Compare commits
21 Commits
chart-1.9.
...
feat/agent
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f87bc231c5 | ||
|
|
611411e712 | ||
|
|
6235ad21fe | ||
|
|
25afe0c4c9 | ||
|
|
dd72c32a84 | ||
|
|
613be5103b | ||
|
|
a92843a54d | ||
|
|
451dcbff83 | ||
|
|
e6baecb985 | ||
|
|
6e0e47d5dd | ||
|
|
6d91fa1fe5 | ||
|
|
88717fcb81 | ||
|
|
8cb7f7dea5 | ||
|
|
3f1224e23e | ||
|
|
4464b333e9 | ||
|
|
3784c702aa | ||
|
|
6d0ce0ac2a | ||
|
|
3b15944448 | ||
|
|
02454bf502 | ||
|
|
cfffd43184 | ||
|
|
317a5b5310 |
@@ -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**:
|
||||
|
||||
@@ -10,17 +10,7 @@ const {
|
||||
validateVisionModel,
|
||||
} = require('librechat-data-provider');
|
||||
const { SplitStreamHandler: _Handler } = require('@librechat/agents');
|
||||
const {
|
||||
Tokenizer,
|
||||
createFetch,
|
||||
matchModelName,
|
||||
getClaudeHeaders,
|
||||
getModelMaxTokens,
|
||||
configureReasoning,
|
||||
checkPromptCacheSupport,
|
||||
getModelMaxOutputTokens,
|
||||
createStreamEventHandlers,
|
||||
} = require('@librechat/api');
|
||||
const { Tokenizer, createFetch, createStreamEventHandlers } = require('@librechat/api');
|
||||
const {
|
||||
truncateText,
|
||||
formatMessage,
|
||||
@@ -29,6 +19,12 @@ const {
|
||||
parseParamFromPrompt,
|
||||
createContextHandlers,
|
||||
} = require('./prompts');
|
||||
const {
|
||||
getClaudeHeaders,
|
||||
configureReasoning,
|
||||
checkPromptCacheSupport,
|
||||
} = require('~/server/services/Endpoints/anthropic/helpers');
|
||||
const { getModelMaxTokens, getModelMaxOutputTokens, matchModelName } = require('~/utils');
|
||||
const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
|
||||
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
|
||||
const { sleep } = require('~/server/utils');
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
const { google } = require('googleapis');
|
||||
const { getModelMaxTokens } = require('@librechat/api');
|
||||
const { concat } = require('@langchain/core/utils/stream');
|
||||
const { ChatVertexAI } = require('@langchain/google-vertexai');
|
||||
const { Tokenizer, getSafetySettings } = require('@librechat/api');
|
||||
@@ -22,6 +21,7 @@ const {
|
||||
} = require('librechat-data-provider');
|
||||
const { encodeAndFormat } = require('~/server/services/Files/images');
|
||||
const { spendTokens } = require('~/models/spendTokens');
|
||||
const { getModelMaxTokens } = require('~/utils');
|
||||
const { sleep } = require('~/server/utils');
|
||||
const { logger } = require('~/config');
|
||||
const {
|
||||
|
||||
@@ -7,9 +7,7 @@ const {
|
||||
createFetch,
|
||||
resolveHeaders,
|
||||
constructAzureURL,
|
||||
getModelMaxTokens,
|
||||
genAzureChatCompletion,
|
||||
getModelMaxOutputTokens,
|
||||
createStreamEventHandlers,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
@@ -33,13 +31,13 @@ const {
|
||||
titleInstruction,
|
||||
createContextHandlers,
|
||||
} = require('./prompts');
|
||||
const { extractBaseURL, getModelMaxTokens, getModelMaxOutputTokens } = require('~/utils');
|
||||
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
|
||||
const { addSpaceIfNeeded, sleep } = require('~/server/utils');
|
||||
const { spendTokens } = require('~/models/spendTokens');
|
||||
const { handleOpenAIErrors } = require('./tools/util');
|
||||
const { summaryBuffer } = require('./memory');
|
||||
const { runTitleChain } = require('./chains');
|
||||
const { extractBaseURL } = require('~/utils');
|
||||
const { tokenSplit } = require('./document');
|
||||
const BaseClient = require('./BaseClient');
|
||||
const { createLLM } = require('./llm');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const { getModelMaxTokens } = require('@librechat/api');
|
||||
const BaseClient = require('../BaseClient');
|
||||
const { getModelMaxTokens } = require('../../../utils');
|
||||
|
||||
class FakeClient extends BaseClient {
|
||||
constructor(apiKey, options = {}) {
|
||||
|
||||
@@ -71,10 +71,9 @@ const primeFiles = async (options) => {
|
||||
* @param {ServerRequest} options.req
|
||||
* @param {Array<{ file_id: string; filename: string }>} options.files
|
||||
* @param {string} [options.entity_id]
|
||||
* @param {boolean} [options.fileCitations=false] - Whether to include citation instructions
|
||||
* @returns
|
||||
*/
|
||||
const createFileSearchTool = async ({ req, files, entity_id, fileCitations = false }) => {
|
||||
const createFileSearchTool = async ({ req, files, entity_id }) => {
|
||||
return tool(
|
||||
async ({ query }) => {
|
||||
if (files.length === 0) {
|
||||
@@ -143,9 +142,9 @@ const createFileSearchTool = async ({ req, files, entity_id, fileCitations = fal
|
||||
const formattedString = formattedResults
|
||||
.map(
|
||||
(result, index) =>
|
||||
`File: ${result.filename}${
|
||||
fileCitations ? `\nAnchor: \\ue202turn0file${index} (${result.filename})` : ''
|
||||
}\nRelevance: ${(1.0 - result.distance).toFixed(4)}\nContent: ${result.content}\n`,
|
||||
`File: ${result.filename}\nAnchor: \\ue202turn0file${index} (${result.filename})\nRelevance: ${(1.0 - result.distance).toFixed(4)}\nContent: ${
|
||||
result.content
|
||||
}\n`,
|
||||
)
|
||||
.join('\n---\n');
|
||||
|
||||
@@ -159,14 +158,12 @@ const createFileSearchTool = async ({ req, files, entity_id, fileCitations = fal
|
||||
pageRelevance: result.page ? { [result.page]: 1.0 - result.distance } : {},
|
||||
}));
|
||||
|
||||
return [formattedString, { [Tools.file_search]: { sources, fileCitations } }];
|
||||
return [formattedString, { [Tools.file_search]: { sources } }];
|
||||
},
|
||||
{
|
||||
name: Tools.file_search,
|
||||
responseFormat: 'content_and_artifact',
|
||||
description: `Performs semantic search across attached "${Tools.file_search}" documents using natural language queries. This tool analyzes the content of uploaded files to find relevant information, quotes, and passages that best match your query. Use this to extract specific information or find relevant sections within the available documents.${
|
||||
fileCitations
|
||||
? `
|
||||
description: `Performs semantic search across attached "${Tools.file_search}" documents using natural language queries. This tool analyzes the content of uploaded files to find relevant information, quotes, and passages that best match your query. Use this to extract specific information or find relevant sections within the available documents.
|
||||
|
||||
**CITE FILE SEARCH RESULTS:**
|
||||
Use anchor markers immediately after statements derived from file content. Reference the filename in your text:
|
||||
@@ -174,9 +171,7 @@ Use anchor markers immediately after statements derived from file content. Refer
|
||||
- Page reference: "According to report.docx... \\ue202turn0file1"
|
||||
- Multi-file: "Multiple sources confirm... \\ue200\\ue202turn0file0\\ue202turn0file1\\ue201"
|
||||
|
||||
**ALWAYS mention the filename in your text before the citation marker. NEVER use markdown links or footnotes.**`
|
||||
: ''
|
||||
}`,
|
||||
**ALWAYS mention the filename in your text before the citation marker. NEVER use markdown links or footnotes.**`,
|
||||
schema: z.object({
|
||||
query: z
|
||||
.string()
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { SerpAPI } = require('@langchain/community/tools/serpapi');
|
||||
const { Calculator } = require('@langchain/community/tools/calculator');
|
||||
const { mcpToolPattern, loadWebSearchAuth, checkAccess } = require('@librechat/api');
|
||||
const { mcpToolPattern, loadWebSearchAuth } = require('@librechat/api');
|
||||
const { EnvVar, createCodeExecutionTool, createSearchTool } = require('@librechat/agents');
|
||||
const {
|
||||
Tools,
|
||||
Constants,
|
||||
Permissions,
|
||||
EToolResources,
|
||||
PermissionTypes,
|
||||
replaceSpecialVars,
|
||||
} = require('librechat-data-provider');
|
||||
const { Tools, Constants, EToolResources, replaceSpecialVars } = require('librechat-data-provider');
|
||||
const {
|
||||
availableTools,
|
||||
manifestToolMap,
|
||||
@@ -34,7 +27,6 @@ const { getUserPluginAuthValue } = require('~/server/services/PluginService');
|
||||
const { createMCPTool, createMCPTools } = require('~/server/services/MCP');
|
||||
const { loadAuthValues } = require('~/server/services/Tools/credentials');
|
||||
const { getCachedTools } = require('~/server/services/Config');
|
||||
const { getRoleByName } = require('~/models/Role');
|
||||
|
||||
/**
|
||||
* Validates the availability and authentication of tools for a user based on environment variables or user-specific plugin authentication values.
|
||||
@@ -289,29 +281,7 @@ const loadTools = async ({
|
||||
if (toolContext) {
|
||||
toolContextMap[tool] = toolContext;
|
||||
}
|
||||
|
||||
/** @type {boolean | undefined} Check if user has FILE_CITATIONS permission */
|
||||
let fileCitations;
|
||||
if (fileCitations == null && options.req?.user != null) {
|
||||
try {
|
||||
fileCitations = await checkAccess({
|
||||
user: options.req.user,
|
||||
permissionType: PermissionTypes.FILE_CITATIONS,
|
||||
permissions: [Permissions.USE],
|
||||
getRoleByName,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[handleTools] FILE_CITATIONS permission check failed:', error);
|
||||
fileCitations = false;
|
||||
}
|
||||
}
|
||||
|
||||
return createFileSearchTool({
|
||||
req: options.req,
|
||||
files,
|
||||
entity_id: agent?.id,
|
||||
fileCitations,
|
||||
});
|
||||
return createFileSearchTool({ req: options.req, files, entity_id: agent?.id });
|
||||
};
|
||||
continue;
|
||||
} else if (tool === Tools.web_search) {
|
||||
|
||||
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),
|
||||
|
||||
@@ -189,15 +189,11 @@ async function createAutoRefillTransaction(txData) {
|
||||
* @param {txData} _txData - Transaction data.
|
||||
*/
|
||||
async function createTransaction(_txData) {
|
||||
const { balance, transactions, ...txData } = _txData;
|
||||
const { balance, ...txData } = _txData;
|
||||
if (txData.rawAmount != null && isNaN(txData.rawAmount)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (transactions?.enabled === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
const transaction = new Transaction(txData);
|
||||
transaction.endpointTokenConfig = txData.endpointTokenConfig;
|
||||
calculateTokenValue(transaction);
|
||||
@@ -226,11 +222,7 @@ async function createTransaction(_txData) {
|
||||
* @param {txData} _txData - Transaction data.
|
||||
*/
|
||||
async function createStructuredTransaction(_txData) {
|
||||
const { balance, transactions, ...txData } = _txData;
|
||||
if (transactions?.enabled === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { balance, ...txData } = _txData;
|
||||
const transaction = new Transaction({
|
||||
...txData,
|
||||
endpointTokenConfig: txData.endpointTokenConfig,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
const mongoose = require('mongoose');
|
||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||
const { spendTokens, spendStructuredTokens } = require('./spendTokens');
|
||||
|
||||
const { getMultiplier, getCacheMultiplier } = require('./tx');
|
||||
const { createTransaction, createStructuredTransaction } = require('./Transaction');
|
||||
const { Balance, Transaction } = require('~/db/models');
|
||||
const { createTransaction } = require('./Transaction');
|
||||
const { Balance } = require('~/db/models');
|
||||
|
||||
let mongoServer;
|
||||
beforeAll(async () => {
|
||||
@@ -379,188 +380,3 @@ describe('NaN Handling Tests', () => {
|
||||
expect(balance.tokenCredits).toBe(initialBalance);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Transactions Config Tests', () => {
|
||||
test('createTransaction should not save when transactions.enabled is false', async () => {
|
||||
// Arrange
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const initialBalance = 10000000;
|
||||
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
||||
|
||||
const model = 'gpt-3.5-turbo';
|
||||
const txData = {
|
||||
user: userId,
|
||||
conversationId: 'test-conversation-id',
|
||||
model,
|
||||
context: 'test',
|
||||
endpointTokenConfig: null,
|
||||
rawAmount: -100,
|
||||
tokenType: 'prompt',
|
||||
transactions: { enabled: false },
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = await createTransaction(txData);
|
||||
|
||||
// Assert: No transaction should be created
|
||||
expect(result).toBeUndefined();
|
||||
const transactions = await Transaction.find({ user: userId });
|
||||
expect(transactions).toHaveLength(0);
|
||||
const balance = await Balance.findOne({ user: userId });
|
||||
expect(balance.tokenCredits).toBe(initialBalance);
|
||||
});
|
||||
|
||||
test('createTransaction should save when transactions.enabled is true', async () => {
|
||||
// Arrange
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const initialBalance = 10000000;
|
||||
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
||||
|
||||
const model = 'gpt-3.5-turbo';
|
||||
const txData = {
|
||||
user: userId,
|
||||
conversationId: 'test-conversation-id',
|
||||
model,
|
||||
context: 'test',
|
||||
endpointTokenConfig: null,
|
||||
rawAmount: -100,
|
||||
tokenType: 'prompt',
|
||||
transactions: { enabled: true },
|
||||
balance: { enabled: true },
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = await createTransaction(txData);
|
||||
|
||||
// Assert: Transaction should be created
|
||||
expect(result).toBeDefined();
|
||||
expect(result.balance).toBeLessThan(initialBalance);
|
||||
const transactions = await Transaction.find({ user: userId });
|
||||
expect(transactions).toHaveLength(1);
|
||||
expect(transactions[0].rawAmount).toBe(-100);
|
||||
});
|
||||
|
||||
test('createTransaction should save when balance.enabled is true even if transactions config is missing', async () => {
|
||||
// Arrange
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const initialBalance = 10000000;
|
||||
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
||||
|
||||
const model = 'gpt-3.5-turbo';
|
||||
const txData = {
|
||||
user: userId,
|
||||
conversationId: 'test-conversation-id',
|
||||
model,
|
||||
context: 'test',
|
||||
endpointTokenConfig: null,
|
||||
rawAmount: -100,
|
||||
tokenType: 'prompt',
|
||||
balance: { enabled: true },
|
||||
// No transactions config provided
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = await createTransaction(txData);
|
||||
|
||||
// Assert: Transaction should be created (backward compatibility)
|
||||
expect(result).toBeDefined();
|
||||
expect(result.balance).toBeLessThan(initialBalance);
|
||||
const transactions = await Transaction.find({ user: userId });
|
||||
expect(transactions).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('createTransaction should save transaction but not update balance when balance is disabled but transactions enabled', async () => {
|
||||
// Arrange
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const initialBalance = 10000000;
|
||||
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
||||
|
||||
const model = 'gpt-3.5-turbo';
|
||||
const txData = {
|
||||
user: userId,
|
||||
conversationId: 'test-conversation-id',
|
||||
model,
|
||||
context: 'test',
|
||||
endpointTokenConfig: null,
|
||||
rawAmount: -100,
|
||||
tokenType: 'prompt',
|
||||
transactions: { enabled: true },
|
||||
balance: { enabled: false },
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = await createTransaction(txData);
|
||||
|
||||
// Assert: Transaction should be created but balance unchanged
|
||||
expect(result).toBeUndefined();
|
||||
const transactions = await Transaction.find({ user: userId });
|
||||
expect(transactions).toHaveLength(1);
|
||||
expect(transactions[0].rawAmount).toBe(-100);
|
||||
const balance = await Balance.findOne({ user: userId });
|
||||
expect(balance.tokenCredits).toBe(initialBalance);
|
||||
});
|
||||
|
||||
test('createStructuredTransaction should not save when transactions.enabled is false', async () => {
|
||||
// Arrange
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const initialBalance = 10000000;
|
||||
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
||||
|
||||
const model = 'claude-3-5-sonnet';
|
||||
const txData = {
|
||||
user: userId,
|
||||
conversationId: 'test-conversation-id',
|
||||
model,
|
||||
context: 'message',
|
||||
tokenType: 'prompt',
|
||||
inputTokens: -10,
|
||||
writeTokens: -100,
|
||||
readTokens: -5,
|
||||
transactions: { enabled: false },
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = await createStructuredTransaction(txData);
|
||||
|
||||
// Assert: No transaction should be created
|
||||
expect(result).toBeUndefined();
|
||||
const transactions = await Transaction.find({ user: userId });
|
||||
expect(transactions).toHaveLength(0);
|
||||
const balance = await Balance.findOne({ user: userId });
|
||||
expect(balance.tokenCredits).toBe(initialBalance);
|
||||
});
|
||||
|
||||
test('createStructuredTransaction should save transaction but not update balance when balance is disabled but transactions enabled', async () => {
|
||||
// Arrange
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const initialBalance = 10000000;
|
||||
await Balance.create({ user: userId, tokenCredits: initialBalance });
|
||||
|
||||
const model = 'claude-3-5-sonnet';
|
||||
const txData = {
|
||||
user: userId,
|
||||
conversationId: 'test-conversation-id',
|
||||
model,
|
||||
context: 'message',
|
||||
tokenType: 'prompt',
|
||||
inputTokens: -10,
|
||||
writeTokens: -100,
|
||||
readTokens: -5,
|
||||
transactions: { enabled: true },
|
||||
balance: { enabled: false },
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = await createStructuredTransaction(txData);
|
||||
|
||||
// Assert: Transaction should be created but balance unchanged
|
||||
expect(result).toBeUndefined();
|
||||
const transactions = await Transaction.find({ user: userId });
|
||||
expect(transactions).toHaveLength(1);
|
||||
expect(transactions[0].inputTokens).toBe(-10);
|
||||
expect(transactions[0].writeTokens).toBe(-100);
|
||||
expect(transactions[0].readTokens).toBe(-5);
|
||||
const balance = await Balance.findOne({ user: userId });
|
||||
expect(balance.tokenCredits).toBe(initialBalance);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { matchModelName } = require('@librechat/api');
|
||||
const { matchModelName } = require('../utils/tokens');
|
||||
const defaultRate = 6;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'",
|
||||
@@ -44,19 +44,19 @@
|
||||
"@googleapis/youtube": "^20.0.0",
|
||||
"@keyv/redis": "^4.3.3",
|
||||
"@langchain/community": "^0.3.47",
|
||||
"@langchain/core": "^0.3.62",
|
||||
"@langchain/core": "^0.3.72",
|
||||
"@langchain/google-genai": "^0.2.13",
|
||||
"@langchain/google-vertexai": "^0.2.13",
|
||||
"@langchain/openai": "^0.5.18",
|
||||
"@langchain/textsplitters": "^0.1.0",
|
||||
"@librechat/agents": "^2.4.79",
|
||||
"@librechat/agents": "^3.0.0-rc10",
|
||||
"@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",
|
||||
|
||||
@@ -75,7 +75,7 @@ const refreshController = async (req, res) => {
|
||||
if (!user) {
|
||||
return res.status(401).redirect('/login');
|
||||
}
|
||||
const token = setOpenIDAuthTokens(tokenset, res, user._id.toString());
|
||||
const token = setOpenIDAuthTokens(tokenset, res);
|
||||
return res.status(200).send({ token, user });
|
||||
} catch (error) {
|
||||
logger.error('[refreshController] OpenID token refresh error', error);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -95,6 +95,19 @@ class ModelEndHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Agent Chain helper
|
||||
* @param {string | undefined} [last_agent_id]
|
||||
* @param {string | undefined} [langgraph_node]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function checkIfLastAgent(last_agent_id, langgraph_node) {
|
||||
if (!last_agent_id || !langgraph_node) {
|
||||
return false;
|
||||
}
|
||||
return langgraph_node?.endsWith(last_agent_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default handlers for stream events.
|
||||
* @param {Object} options - The options object.
|
||||
@@ -125,7 +138,7 @@ function getDefaultHandlers({ res, aggregateContent, toolEndCallback, collectedU
|
||||
handle: (event, data, metadata) => {
|
||||
if (data?.stepDetails.type === StepTypes.TOOL_CALLS) {
|
||||
sendEvent(res, { event, data });
|
||||
} else if (metadata?.last_agent_index === metadata?.agent_index) {
|
||||
} else if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) {
|
||||
sendEvent(res, { event, data });
|
||||
} else if (!metadata?.hide_sequential_outputs) {
|
||||
sendEvent(res, { event, data });
|
||||
@@ -154,7 +167,7 @@ function getDefaultHandlers({ res, aggregateContent, toolEndCallback, collectedU
|
||||
handle: (event, data, metadata) => {
|
||||
if (data?.delta.type === StepTypes.TOOL_CALLS) {
|
||||
sendEvent(res, { event, data });
|
||||
} else if (metadata?.last_agent_index === metadata?.agent_index) {
|
||||
} else if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) {
|
||||
sendEvent(res, { event, data });
|
||||
} else if (!metadata?.hide_sequential_outputs) {
|
||||
sendEvent(res, { event, data });
|
||||
@@ -172,7 +185,7 @@ function getDefaultHandlers({ res, aggregateContent, toolEndCallback, collectedU
|
||||
handle: (event, data, metadata) => {
|
||||
if (data?.result != null) {
|
||||
sendEvent(res, { event, data });
|
||||
} else if (metadata?.last_agent_index === metadata?.agent_index) {
|
||||
} else if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) {
|
||||
sendEvent(res, { event, data });
|
||||
} else if (!metadata?.hide_sequential_outputs) {
|
||||
sendEvent(res, { event, data });
|
||||
@@ -188,7 +201,7 @@ function getDefaultHandlers({ res, aggregateContent, toolEndCallback, collectedU
|
||||
* @param {GraphRunnableConfig['configurable']} [metadata] The runnable metadata.
|
||||
*/
|
||||
handle: (event, data, metadata) => {
|
||||
if (metadata?.last_agent_index === metadata?.agent_index) {
|
||||
if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) {
|
||||
sendEvent(res, { event, data });
|
||||
} else if (!metadata?.hide_sequential_outputs) {
|
||||
sendEvent(res, { event, data });
|
||||
@@ -204,7 +217,7 @@ function getDefaultHandlers({ res, aggregateContent, toolEndCallback, collectedU
|
||||
* @param {GraphRunnableConfig['configurable']} [metadata] The runnable metadata.
|
||||
*/
|
||||
handle: (event, data, metadata) => {
|
||||
if (metadata?.last_agent_index === metadata?.agent_index) {
|
||||
if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) {
|
||||
sendEvent(res, { event, data });
|
||||
} else if (!metadata?.hide_sequential_outputs) {
|
||||
sendEvent(res, { event, data });
|
||||
@@ -265,30 +278,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 () => {
|
||||
|
||||
@@ -3,22 +3,17 @@ const { logger } = require('@librechat/data-schemas');
|
||||
const { DynamicStructuredTool } = require('@langchain/core/tools');
|
||||
const { getBufferString, HumanMessage } = require('@langchain/core/messages');
|
||||
const {
|
||||
sendEvent,
|
||||
createRun,
|
||||
Tokenizer,
|
||||
checkAccess,
|
||||
logAxiosError,
|
||||
resolveHeaders,
|
||||
getBalanceConfig,
|
||||
memoryInstructions,
|
||||
formatContentStrings,
|
||||
getTransactionsConfig,
|
||||
createMemoryProcessor,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
Callback,
|
||||
Providers,
|
||||
GraphEvents,
|
||||
TitleMethod,
|
||||
formatMessage,
|
||||
formatAgentMessages,
|
||||
@@ -37,12 +32,12 @@ const {
|
||||
bedrockInputSchema,
|
||||
removeNullishValues,
|
||||
} = require('librechat-data-provider');
|
||||
const { addCacheControl, createContextHandlers } = require('~/app/clients/prompts');
|
||||
const { initializeAgent } = require('~/server/services/Endpoints/agents/agent');
|
||||
const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
|
||||
const { getFormattedMemories, deleteMemory, setMemory } = require('~/models');
|
||||
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
|
||||
const { getProviderConfig } = require('~/server/services/Endpoints');
|
||||
const { createContextHandlers } = require('~/app/clients/prompts');
|
||||
const { checkCapability } = require('~/server/services/Config');
|
||||
const BaseClient = require('~/app/clients/BaseClient');
|
||||
const { getRoleByName } = require('~/models/Role');
|
||||
@@ -79,8 +74,6 @@ const payloadParser = ({ req, agent, endpoint }) => {
|
||||
return req.body.endpointOption.model_parameters;
|
||||
};
|
||||
|
||||
const noSystemModelRegex = [/\b(o1-preview|o1-mini|amazon\.titan-text)\b/gi];
|
||||
|
||||
function createTokenCounter(encoding) {
|
||||
return function (message) {
|
||||
const countTokens = (text) => Tokenizer.getTokenCount(text, encoding);
|
||||
@@ -89,10 +82,11 @@ function createTokenCounter(encoding) {
|
||||
}
|
||||
|
||||
function logToolError(graph, error, toolId) {
|
||||
logAxiosError({
|
||||
logger.error(
|
||||
'[api/server/controllers/agents/client.js #chatCompletion] Tool Error',
|
||||
error,
|
||||
message: `[api/server/controllers/agents/client.js #chatCompletion] Tool Error "${toolId}"`,
|
||||
});
|
||||
toolId,
|
||||
);
|
||||
}
|
||||
|
||||
class AgentClient extends BaseClient {
|
||||
@@ -624,13 +618,11 @@ class AgentClient extends BaseClient {
|
||||
* @param {string} [params.model]
|
||||
* @param {string} [params.context='message']
|
||||
* @param {AppConfig['balance']} [params.balance]
|
||||
* @param {AppConfig['transactions']} [params.transactions]
|
||||
* @param {UsageMetadata[]} [params.collectedUsage=this.collectedUsage]
|
||||
*/
|
||||
async recordCollectedUsage({
|
||||
model,
|
||||
balance,
|
||||
transactions,
|
||||
context = 'message',
|
||||
collectedUsage = this.collectedUsage,
|
||||
}) {
|
||||
@@ -656,7 +648,6 @@ class AgentClient extends BaseClient {
|
||||
const txMetadata = {
|
||||
context,
|
||||
balance,
|
||||
transactions,
|
||||
conversationId: this.conversationId,
|
||||
user: this.user ?? this.options.req.user?.id,
|
||||
endpointTokenConfig: this.options.endpointTokenConfig,
|
||||
@@ -805,137 +796,81 @@ class AgentClient extends BaseClient {
|
||||
);
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Agent} agent
|
||||
* @param {BaseMessage[]} messages
|
||||
* @param {number} [i]
|
||||
* @param {TMessageContentParts[]} [contentData]
|
||||
* @param {Record<string, number>} [currentIndexCountMap]
|
||||
*/
|
||||
const runAgent = async (agent, _messages, i = 0, contentData = [], _currentIndexCountMap) => {
|
||||
config.configurable.model = agent.model_parameters.model;
|
||||
const currentIndexCountMap = _currentIndexCountMap ?? indexTokenCountMap;
|
||||
if (i > 0) {
|
||||
this.model = agent.model_parameters.model;
|
||||
const runAgents = async (messages) => {
|
||||
const agents = [this.options.agent];
|
||||
if (
|
||||
this.agentConfigs &&
|
||||
this.agentConfigs.size > 0 &&
|
||||
((this.options.agent.edges?.length ?? 0) > 0 ||
|
||||
(await checkCapability(this.options.req, AgentCapabilities.chain)))
|
||||
) {
|
||||
agents.push(...this.agentConfigs.values());
|
||||
}
|
||||
if (i > 0 && config.signal == null) {
|
||||
config.signal = abortController.signal;
|
||||
}
|
||||
if (agent.recursion_limit && typeof agent.recursion_limit === 'number') {
|
||||
config.recursionLimit = agent.recursion_limit;
|
||||
|
||||
if (agents[0].recursion_limit && typeof agents[0].recursion_limit === 'number') {
|
||||
config.recursionLimit = agents[0].recursion_limit;
|
||||
}
|
||||
|
||||
if (
|
||||
agentsEConfig?.maxRecursionLimit &&
|
||||
config.recursionLimit > agentsEConfig?.maxRecursionLimit
|
||||
) {
|
||||
config.recursionLimit = agentsEConfig?.maxRecursionLimit;
|
||||
}
|
||||
config.configurable.agent_id = agent.id;
|
||||
config.configurable.name = agent.name;
|
||||
config.configurable.agent_index = i;
|
||||
const noSystemMessages = noSystemModelRegex.some((regex) =>
|
||||
agent.model_parameters.model.match(regex),
|
||||
);
|
||||
|
||||
const systemMessage = Object.values(agent.toolContextMap ?? {})
|
||||
.join('\n')
|
||||
.trim();
|
||||
// TODO: needs to be added as part of AgentContext initialization
|
||||
// const noSystemModelRegex = [/\b(o1-preview|o1-mini|amazon\.titan-text)\b/gi];
|
||||
// const noSystemMessages = noSystemModelRegex.some((regex) =>
|
||||
// agent.model_parameters.model.match(regex),
|
||||
// );
|
||||
// if (noSystemMessages === true && systemContent?.length) {
|
||||
// const latestMessageContent = _messages.pop().content;
|
||||
// if (typeof latestMessageContent !== 'string') {
|
||||
// latestMessageContent[0].text = [systemContent, latestMessageContent[0].text].join('\n');
|
||||
// _messages.push(new HumanMessage({ content: latestMessageContent }));
|
||||
// } else {
|
||||
// const text = [systemContent, latestMessageContent].join('\n');
|
||||
// _messages.push(new HumanMessage(text));
|
||||
// }
|
||||
// }
|
||||
// let messages = _messages;
|
||||
// if (agent.useLegacyContent === true) {
|
||||
// messages = formatContentStrings(messages);
|
||||
// }
|
||||
// if (
|
||||
// agent.model_parameters?.clientOptions?.defaultHeaders?.['anthropic-beta']?.includes(
|
||||
// 'prompt-caching',
|
||||
// )
|
||||
// ) {
|
||||
// messages = addCacheControl(messages);
|
||||
// }
|
||||
|
||||
let systemContent = [
|
||||
systemMessage,
|
||||
agent.instructions ?? '',
|
||||
i !== 0 ? (agent.additional_instructions ?? '') : '',
|
||||
]
|
||||
.join('\n')
|
||||
.trim();
|
||||
|
||||
if (noSystemMessages === true) {
|
||||
agent.instructions = undefined;
|
||||
agent.additional_instructions = undefined;
|
||||
} else {
|
||||
agent.instructions = systemContent;
|
||||
agent.additional_instructions = undefined;
|
||||
}
|
||||
|
||||
if (noSystemMessages === true && systemContent?.length) {
|
||||
const latestMessageContent = _messages.pop().content;
|
||||
if (typeof latestMessageContent !== 'string') {
|
||||
latestMessageContent[0].text = [systemContent, latestMessageContent[0].text].join('\n');
|
||||
_messages.push(new HumanMessage({ content: latestMessageContent }));
|
||||
} else {
|
||||
const text = [systemContent, latestMessageContent].join('\n');
|
||||
_messages.push(new HumanMessage(text));
|
||||
}
|
||||
}
|
||||
|
||||
let messages = _messages;
|
||||
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')) {
|
||||
messages = addCacheControl(messages);
|
||||
}
|
||||
|
||||
if (i === 0) {
|
||||
memoryPromise = this.runMemory(messages);
|
||||
}
|
||||
|
||||
/** Resolve request-based headers for Custom Endpoints. Note: if this is added to
|
||||
* non-custom endpoints, needs consideration of varying provider header configs.
|
||||
*/
|
||||
if (agent.model_parameters?.configuration?.defaultHeaders != null) {
|
||||
agent.model_parameters.configuration.defaultHeaders = resolveHeaders({
|
||||
headers: agent.model_parameters.configuration.defaultHeaders,
|
||||
body: config.configurable.requestBody,
|
||||
});
|
||||
}
|
||||
memoryPromise = this.runMemory(messages);
|
||||
|
||||
run = await createRun({
|
||||
agent,
|
||||
req: this.options.req,
|
||||
agents,
|
||||
indexTokenCountMap,
|
||||
runId: this.responseMessageId,
|
||||
signal: abortController.signal,
|
||||
customHandlers: this.options.eventHandlers,
|
||||
requestBody: config.configurable.requestBody,
|
||||
tokenCounter: createTokenCounter(this.getEncoding()),
|
||||
});
|
||||
|
||||
if (!run) {
|
||||
throw new Error('Failed to create run');
|
||||
}
|
||||
|
||||
if (i === 0) {
|
||||
this.run = run;
|
||||
}
|
||||
|
||||
if (contentData.length) {
|
||||
const agentUpdate = {
|
||||
type: ContentTypes.AGENT_UPDATE,
|
||||
[ContentTypes.AGENT_UPDATE]: {
|
||||
index: contentData.length,
|
||||
runId: this.responseMessageId,
|
||||
agentId: agent.id,
|
||||
},
|
||||
};
|
||||
const streamData = {
|
||||
event: GraphEvents.ON_AGENT_UPDATE,
|
||||
data: agentUpdate,
|
||||
};
|
||||
this.options.aggregateContent(streamData);
|
||||
sendEvent(this.options.res, streamData);
|
||||
contentData.push(agentUpdate);
|
||||
run.Graph.contentData = contentData;
|
||||
}
|
||||
|
||||
this.run = run;
|
||||
if (userMCPAuthMap != null) {
|
||||
config.configurable.userMCPAuthMap = userMCPAuthMap;
|
||||
}
|
||||
|
||||
/** @deprecated Agent Chain */
|
||||
config.configurable.last_agent_id = agents[agents.length - 1].id;
|
||||
await run.processStream({ messages }, config, {
|
||||
keepContent: i !== 0,
|
||||
tokenCounter: createTokenCounter(this.getEncoding()),
|
||||
indexTokenCountMap: currentIndexCountMap,
|
||||
maxContextTokens: agent.maxContextTokens,
|
||||
callbacks: {
|
||||
[Callback.TOOL_ERROR]: logToolError,
|
||||
},
|
||||
@@ -944,109 +879,22 @@ class AgentClient extends BaseClient {
|
||||
config.signal = null;
|
||||
};
|
||||
|
||||
await runAgent(this.options.agent, initialMessages);
|
||||
let finalContentStart = 0;
|
||||
if (
|
||||
this.agentConfigs &&
|
||||
this.agentConfigs.size > 0 &&
|
||||
(await checkCapability(this.options.req, AgentCapabilities.chain))
|
||||
) {
|
||||
const windowSize = 5;
|
||||
let latestMessage = initialMessages.pop().content;
|
||||
if (typeof latestMessage !== 'string') {
|
||||
latestMessage = latestMessage[0].text;
|
||||
}
|
||||
let i = 1;
|
||||
let runMessages = [];
|
||||
|
||||
const windowIndexCountMap = {};
|
||||
const windowMessages = initialMessages.slice(-windowSize);
|
||||
let currentIndex = 4;
|
||||
for (let i = initialMessages.length - 1; i >= 0; i--) {
|
||||
windowIndexCountMap[currentIndex] = indexTokenCountMap[i];
|
||||
currentIndex--;
|
||||
if (currentIndex < 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const encoding = this.getEncoding();
|
||||
const tokenCounter = createTokenCounter(encoding);
|
||||
for (const [agentId, agent] of this.agentConfigs) {
|
||||
if (abortController.signal.aborted === true) {
|
||||
break;
|
||||
}
|
||||
const currentRun = await run;
|
||||
|
||||
if (
|
||||
i === this.agentConfigs.size &&
|
||||
config.configurable.hide_sequential_outputs === true
|
||||
) {
|
||||
const content = this.contentParts.filter(
|
||||
(part) => part.type === ContentTypes.TOOL_CALL,
|
||||
);
|
||||
|
||||
this.options.res.write(
|
||||
`event: message\ndata: ${JSON.stringify({
|
||||
event: 'on_content_update',
|
||||
data: {
|
||||
runId: this.responseMessageId,
|
||||
content,
|
||||
},
|
||||
})}\n\n`,
|
||||
);
|
||||
}
|
||||
const _runMessages = currentRun.Graph.getRunMessages();
|
||||
finalContentStart = this.contentParts.length;
|
||||
runMessages = runMessages.concat(_runMessages);
|
||||
const contentData = currentRun.Graph.contentData.slice();
|
||||
const bufferString = getBufferString([new HumanMessage(latestMessage), ...runMessages]);
|
||||
if (i === this.agentConfigs.size) {
|
||||
logger.debug(`SEQUENTIAL AGENTS: Last buffer string:\n${bufferString}`);
|
||||
}
|
||||
try {
|
||||
const contextMessages = [];
|
||||
const runIndexCountMap = {};
|
||||
for (let i = 0; i < windowMessages.length; i++) {
|
||||
const message = windowMessages[i];
|
||||
const messageType = message._getType();
|
||||
if (
|
||||
(!agent.tools || agent.tools.length === 0) &&
|
||||
(messageType === 'tool' || (message.tool_calls?.length ?? 0) > 0)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
runIndexCountMap[contextMessages.length] = windowIndexCountMap[i];
|
||||
contextMessages.push(message);
|
||||
}
|
||||
const bufferMessage = new HumanMessage(bufferString);
|
||||
runIndexCountMap[contextMessages.length] = tokenCounter(bufferMessage);
|
||||
const currentMessages = [...contextMessages, bufferMessage];
|
||||
await runAgent(agent, currentMessages, i, contentData, runIndexCountMap);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`[api/server/controllers/agents/client.js #chatCompletion] Error running agent ${agentId} (${i})`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
await runAgents(initialMessages);
|
||||
/** @deprecated Agent Chain */
|
||||
if (config.configurable.hide_sequential_outputs) {
|
||||
this.contentParts = this.contentParts.filter((part, index) => {
|
||||
// Include parts that are either:
|
||||
// 1. At or after the finalContentStart index
|
||||
// 2. Of type tool_call
|
||||
// 3. Have tool_call_ids property
|
||||
return (
|
||||
index >= this.contentParts.length - 1 ||
|
||||
part.type === ContentTypes.TOOL_CALL ||
|
||||
part.tool_call_ids
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Note: not implemented */
|
||||
if (config.configurable.hide_sequential_outputs !== true) {
|
||||
finalContentStart = 0;
|
||||
}
|
||||
|
||||
this.contentParts = this.contentParts.filter((part, index) => {
|
||||
// Include parts that are either:
|
||||
// 1. At or after the finalContentStart index
|
||||
// 2. Of type tool_call
|
||||
// 3. Have tool_call_ids property
|
||||
return (
|
||||
index >= finalContentStart || part.type === ContentTypes.TOOL_CALL || part.tool_call_ids
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
const attachments = await this.awaitMemoryWithTimeout(memoryPromise);
|
||||
if (attachments && attachments.length > 0) {
|
||||
@@ -1054,12 +902,7 @@ class AgentClient extends BaseClient {
|
||||
}
|
||||
|
||||
const balanceConfig = getBalanceConfig(appConfig);
|
||||
const transactionsConfig = getTransactionsConfig(appConfig);
|
||||
await this.recordCollectedUsage({
|
||||
context: 'message',
|
||||
balance: balanceConfig,
|
||||
transactions: transactionsConfig,
|
||||
});
|
||||
await this.recordCollectedUsage({ context: 'message', balance: balanceConfig });
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
'[api/server/controllers/agents/client.js #chatCompletion] Error recording collected usage',
|
||||
@@ -1253,13 +1096,11 @@ class AgentClient extends BaseClient {
|
||||
});
|
||||
|
||||
const balanceConfig = getBalanceConfig(appConfig);
|
||||
const transactionsConfig = getTransactionsConfig(appConfig);
|
||||
await this.recordCollectedUsage({
|
||||
collectedUsage,
|
||||
context: 'title',
|
||||
model: clientOptions.model,
|
||||
balance: balanceConfig,
|
||||
transactions: transactionsConfig,
|
||||
}).catch((err) => {
|
||||
logger.error(
|
||||
'[api/server/controllers/agents/client.js #titleConvo] Error recording collected usage',
|
||||
|
||||
@@ -237,9 +237,6 @@ describe('AgentClient - titleConvo', () => {
|
||||
balance: {
|
||||
enabled: false,
|
||||
},
|
||||
transactions: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const { v4 } = require('uuid');
|
||||
const { sleep } = require('@librechat/agents');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { sendEvent, getBalanceConfig, getModelMaxTokens } = require('@librechat/api');
|
||||
const { sendEvent, getBalanceConfig } = require('@librechat/api');
|
||||
const {
|
||||
Time,
|
||||
Constants,
|
||||
@@ -34,6 +34,7 @@ const { checkBalance } = require('~/models/balanceMethods');
|
||||
const { getConvo } = require('~/models/Conversation');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
const { countTokens } = require('~/server/utils');
|
||||
const { getModelMaxTokens } = require('~/utils');
|
||||
const { getOpenAIClient } = require('./helpers');
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const { v4 } = require('uuid');
|
||||
const { sleep } = require('@librechat/agents');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { sendEvent, getBalanceConfig, getModelMaxTokens } = require('@librechat/api');
|
||||
const { sendEvent, getBalanceConfig } = require('@librechat/api');
|
||||
const {
|
||||
Time,
|
||||
Constants,
|
||||
@@ -31,6 +31,7 @@ const { checkBalance } = require('~/models/balanceMethods');
|
||||
const { getConvo } = require('~/models/Conversation');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
const { countTokens } = require('~/server/utils');
|
||||
const { getModelMaxTokens } = require('~/utils');
|
||||
const { getOpenAIClient } = require('./helpers');
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,7 @@ const { logger } = require('@librechat/data-schemas');
|
||||
const mongoSanitize = require('express-mongo-sanitize');
|
||||
const { isEnabled, ErrorController } = require('@librechat/api');
|
||||
const { connectDb, indexSync } = require('~/db');
|
||||
const createValidateImageRequest = require('./middleware/validateImageRequest');
|
||||
const validateImageRequest = require('./middleware/validateImageRequest');
|
||||
const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies');
|
||||
const { updateInterfacePermissions } = require('~/models/interface');
|
||||
const { checkMigrations } = require('./services/start/migration');
|
||||
@@ -126,7 +126,7 @@ const startServer = async () => {
|
||||
app.use('/api/config', routes.config);
|
||||
app.use('/api/assistants', routes.assistants);
|
||||
app.use('/api/files', await routes.files.initialize());
|
||||
app.use('/images/', createValidateImageRequest(appConfig.secureImageLinks), routes.staticRoute);
|
||||
app.use('/images/', validateImageRequest, routes.staticRoute);
|
||||
app.use('/api/share', routes.share);
|
||||
app.use('/api/roles', routes.roles);
|
||||
app.use('/api/agents', routes.agents);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const validatePasswordReset = require('./validatePasswordReset');
|
||||
const validateRegistration = require('./validateRegistration');
|
||||
const validateImageRequest = require('./validateImageRequest');
|
||||
const buildEndpointOption = require('./buildEndpointOption');
|
||||
const validateMessageReq = require('./validateMessageReq');
|
||||
const checkDomainAllowed = require('./checkDomainAllowed');
|
||||
@@ -49,5 +50,6 @@ module.exports = {
|
||||
validateMessageReq,
|
||||
buildEndpointOption,
|
||||
validateRegistration,
|
||||
validateImageRequest,
|
||||
validatePasswordReset,
|
||||
};
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { isEnabled } = require('@librechat/api');
|
||||
const createValidateImageRequest = require('~/server/middleware/validateImageRequest');
|
||||
const validateImageRequest = require('~/server/middleware/validateImageRequest');
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
isEnabled: jest.fn(),
|
||||
jest.mock('~/server/services/Config/app', () => ({
|
||||
getAppConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('validateImageRequest middleware', () => {
|
||||
let req, res, next, validateImageRequest;
|
||||
let req, res, next;
|
||||
const validObjectId = '65cfb246f7ecadb8b1e8036b';
|
||||
const { getAppConfig } = require('~/server/services/Config/app');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -22,278 +22,116 @@ describe('validateImageRequest middleware', () => {
|
||||
};
|
||||
next = jest.fn();
|
||||
process.env.JWT_REFRESH_SECRET = 'test-secret';
|
||||
process.env.OPENID_REUSE_TOKENS = 'false';
|
||||
|
||||
// Default: OpenID token reuse disabled
|
||||
isEnabled.mockReturnValue(false);
|
||||
// Mock getAppConfig to return secureImageLinks: true by default
|
||||
getAppConfig.mockResolvedValue({
|
||||
secureImageLinks: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Factory function', () => {
|
||||
test('should return a pass-through middleware if secureImageLinks is false', async () => {
|
||||
const middleware = createValidateImageRequest(false);
|
||||
await middleware(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should return validation middleware if secureImageLinks is true', async () => {
|
||||
validateImageRequest = createValidateImageRequest(true);
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.send).toHaveBeenCalledWith('Unauthorized');
|
||||
test('should call next() if secureImageLinks is false', async () => {
|
||||
getAppConfig.mockResolvedValue({
|
||||
secureImageLinks: false,
|
||||
});
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('Standard LibreChat token flow', () => {
|
||||
beforeEach(() => {
|
||||
validateImageRequest = createValidateImageRequest(true);
|
||||
});
|
||||
|
||||
test('should return 401 if refresh token is not provided', async () => {
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.send).toHaveBeenCalledWith('Unauthorized');
|
||||
});
|
||||
|
||||
test('should return 403 if refresh token is invalid', async () => {
|
||||
req.headers.cookie = 'refreshToken=invalid-token';
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith('Access Denied');
|
||||
});
|
||||
|
||||
test('should return 403 if refresh token is expired', async () => {
|
||||
const expiredToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) - 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${expiredToken}`;
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith('Access Denied');
|
||||
});
|
||||
|
||||
test('should call next() for valid image path', async () => {
|
||||
const validToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = `/images/${validObjectId}/example.jpg`;
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should return 403 for invalid image path', async () => {
|
||||
const validToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = '/images/65cfb246f7ecadb8b1e8036c/example.jpg'; // Different ObjectId
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith('Access Denied');
|
||||
});
|
||||
|
||||
test('should allow agent avatar pattern for any valid ObjectId', async () => {
|
||||
const validToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = '/images/65cfb246f7ecadb8b1e8036c/agent-avatar-12345.png';
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should prevent file traversal attempts', async () => {
|
||||
const validToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
|
||||
const traversalAttempts = [
|
||||
`/images/${validObjectId}/../../../etc/passwd`,
|
||||
`/images/${validObjectId}/..%2F..%2F..%2Fetc%2Fpasswd`,
|
||||
`/images/${validObjectId}/image.jpg/../../../etc/passwd`,
|
||||
`/images/${validObjectId}/%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd`,
|
||||
];
|
||||
|
||||
for (const attempt of traversalAttempts) {
|
||||
req.originalUrl = attempt;
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith('Access Denied');
|
||||
jest.clearAllMocks();
|
||||
// Reset mocks for next iteration
|
||||
res.status = jest.fn().mockReturnThis();
|
||||
res.send = jest.fn();
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle URL encoded characters in valid paths', async () => {
|
||||
const validToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = `/images/${validObjectId}/image%20with%20spaces.jpg`;
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
test('should return 401 if refresh token is not provided', async () => {
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.send).toHaveBeenCalledWith('Unauthorized');
|
||||
});
|
||||
|
||||
describe('OpenID token flow', () => {
|
||||
beforeEach(() => {
|
||||
validateImageRequest = createValidateImageRequest(true);
|
||||
// Enable OpenID token reuse
|
||||
isEnabled.mockReturnValue(true);
|
||||
process.env.OPENID_REUSE_TOKENS = 'true';
|
||||
});
|
||||
|
||||
test('should return 403 if no OpenID user ID cookie when token_provider is openid', async () => {
|
||||
req.headers.cookie = 'refreshToken=dummy-token; token_provider=openid';
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith('Access Denied');
|
||||
});
|
||||
|
||||
test('should validate JWT-signed user ID for OpenID flow', async () => {
|
||||
const signedUserId = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=dummy-token; token_provider=openid; openid_user_id=${signedUserId}`;
|
||||
req.originalUrl = `/images/${validObjectId}/example.jpg`;
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should return 403 for invalid JWT-signed user ID', async () => {
|
||||
req.headers.cookie =
|
||||
'refreshToken=dummy-token; token_provider=openid; openid_user_id=invalid-jwt';
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith('Access Denied');
|
||||
});
|
||||
|
||||
test('should return 403 for expired JWT-signed user ID', async () => {
|
||||
const expiredSignedUserId = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) - 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=dummy-token; token_provider=openid; openid_user_id=${expiredSignedUserId}`;
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith('Access Denied');
|
||||
});
|
||||
|
||||
test('should validate image path against JWT-signed user ID', async () => {
|
||||
const signedUserId = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
const differentObjectId = '65cfb246f7ecadb8b1e8036c';
|
||||
req.headers.cookie = `refreshToken=dummy-token; token_provider=openid; openid_user_id=${signedUserId}`;
|
||||
req.originalUrl = `/images/${differentObjectId}/example.jpg`;
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith('Access Denied');
|
||||
});
|
||||
|
||||
test('should allow agent avatars in OpenID flow', async () => {
|
||||
const signedUserId = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=dummy-token; token_provider=openid; openid_user_id=${signedUserId}`;
|
||||
req.originalUrl = '/images/65cfb246f7ecadb8b1e8036c/agent-avatar-12345.png';
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
test('should return 403 if refresh token is invalid', async () => {
|
||||
req.headers.cookie = 'refreshToken=invalid-token';
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith('Access Denied');
|
||||
});
|
||||
|
||||
describe('Security edge cases', () => {
|
||||
let validToken;
|
||||
test('should return 403 if refresh token is expired', async () => {
|
||||
const expiredToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) - 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${expiredToken}`;
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith('Access Denied');
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
validateImageRequest = createValidateImageRequest(true);
|
||||
validToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
});
|
||||
test('should call next() for valid image path', async () => {
|
||||
const validToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = `/images/${validObjectId}/example.jpg`;
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should handle very long image filenames', async () => {
|
||||
const longFilename = 'a'.repeat(1000) + '.jpg';
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = `/images/${validObjectId}/${longFilename}`;
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
test('should return 403 for invalid image path', async () => {
|
||||
const validToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = '/images/65cfb246f7ecadb8b1e8036c/example.jpg'; // Different ObjectId
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith('Access Denied');
|
||||
});
|
||||
|
||||
test('should handle URLs with maximum practical length', async () => {
|
||||
// Most browsers support URLs up to ~2000 characters
|
||||
const longFilename = 'x'.repeat(1900) + '.jpg';
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = `/images/${validObjectId}/${longFilename}`;
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
test('should return 403 for invalid ObjectId format', async () => {
|
||||
const validToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = '/images/123/example.jpg'; // Invalid ObjectId
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith('Access Denied');
|
||||
});
|
||||
|
||||
test('should accept URLs just under the 2048 limit', async () => {
|
||||
// Create a URL exactly 2047 characters long
|
||||
const baseLength = `/images/${validObjectId}/`.length + '.jpg'.length;
|
||||
const filenameLength = 2047 - baseLength;
|
||||
const filename = 'a'.repeat(filenameLength) + '.jpg';
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = `/images/${validObjectId}/${filename}`;
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
// File traversal tests
|
||||
test('should prevent file traversal attempts', async () => {
|
||||
const validToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
|
||||
test('should handle malformed URL encoding gracefully', async () => {
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = `/images/${validObjectId}/test%ZZinvalid.jpg`;
|
||||
const traversalAttempts = [
|
||||
`/images/${validObjectId}/../../../etc/passwd`,
|
||||
`/images/${validObjectId}/..%2F..%2F..%2Fetc%2Fpasswd`,
|
||||
`/images/${validObjectId}/image.jpg/../../../etc/passwd`,
|
||||
`/images/${validObjectId}/%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd`,
|
||||
];
|
||||
|
||||
for (const attempt of traversalAttempts) {
|
||||
req.originalUrl = attempt;
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith('Access Denied');
|
||||
});
|
||||
jest.clearAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
test('should reject URLs with null bytes', async () => {
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = `/images/${validObjectId}/test\x00.jpg`;
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith('Access Denied');
|
||||
});
|
||||
|
||||
test('should handle URLs with repeated slashes', async () => {
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = `/images/${validObjectId}//test.jpg`;
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith('Access Denied');
|
||||
});
|
||||
|
||||
test('should reject extremely long URLs as potential DoS', async () => {
|
||||
// Create a URL longer than 2048 characters
|
||||
const baseLength = `/images/${validObjectId}/`.length + '.jpg'.length;
|
||||
const filenameLength = 2049 - baseLength; // Ensure total length exceeds 2048
|
||||
const extremelyLongFilename = 'x'.repeat(filenameLength) + '.jpg';
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = `/images/${validObjectId}/${extremelyLongFilename}`;
|
||||
// Verify our test URL is actually too long
|
||||
expect(req.originalUrl.length).toBeGreaterThan(2048);
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith('Access Denied');
|
||||
});
|
||||
test('should handle URL encoded characters in valid paths', async () => {
|
||||
const validToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = `/images/${validObjectId}/image%20with%20spaces.jpg`;
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const cookies = require('cookie');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { isEnabled } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { getAppConfig } = require('~/server/services/Config/app');
|
||||
|
||||
const OBJECT_ID_LENGTH = 24;
|
||||
const OBJECT_ID_PATTERN = /^[0-9a-f]{24}$/i;
|
||||
@@ -22,129 +22,50 @@ function isValidObjectId(id) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a LibreChat refresh token
|
||||
* @param {string} refreshToken - The refresh token to validate
|
||||
* @returns {{valid: boolean, userId?: string, error?: string}} - Validation result
|
||||
* Middleware to validate image request.
|
||||
* Must be set by `secureImageLinks` via custom config file.
|
||||
*/
|
||||
function validateToken(refreshToken) {
|
||||
async function validateImageRequest(req, res, next) {
|
||||
const appConfig = await getAppConfig({ role: req.user?.role });
|
||||
if (!appConfig.secureImageLinks) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const refreshToken = req.headers.cookie ? cookies.parse(req.headers.cookie).refreshToken : null;
|
||||
if (!refreshToken) {
|
||||
logger.warn('[validateImageRequest] Refresh token not provided');
|
||||
return res.status(401).send('Unauthorized');
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
|
||||
|
||||
if (!isValidObjectId(payload.id)) {
|
||||
return { valid: false, error: 'Invalid User ID' };
|
||||
}
|
||||
|
||||
const currentTimeInSeconds = Math.floor(Date.now() / 1000);
|
||||
if (payload.exp < currentTimeInSeconds) {
|
||||
return { valid: false, error: 'Refresh token expired' };
|
||||
}
|
||||
|
||||
return { valid: true, userId: payload.id };
|
||||
payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
|
||||
} catch (err) {
|
||||
logger.warn('[validateToken]', err);
|
||||
return { valid: false, error: 'Invalid token' };
|
||||
logger.warn('[validateImageRequest]', err);
|
||||
return res.status(403).send('Access Denied');
|
||||
}
|
||||
|
||||
if (!isValidObjectId(payload.id)) {
|
||||
logger.warn('[validateImageRequest] Invalid User ID');
|
||||
return res.status(403).send('Access Denied');
|
||||
}
|
||||
|
||||
const currentTimeInSeconds = Math.floor(Date.now() / 1000);
|
||||
if (payload.exp < currentTimeInSeconds) {
|
||||
logger.warn('[validateImageRequest] Refresh token expired');
|
||||
return res.status(403).send('Access Denied');
|
||||
}
|
||||
|
||||
const fullPath = decodeURIComponent(req.originalUrl);
|
||||
const pathPattern = new RegExp(`^/images/${payload.id}/[^/]+$`);
|
||||
|
||||
if (pathPattern.test(fullPath)) {
|
||||
logger.debug('[validateImageRequest] Image request validated');
|
||||
next();
|
||||
} else {
|
||||
logger.warn('[validateImageRequest] Invalid image path');
|
||||
res.status(403).send('Access Denied');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory to create the `validateImageRequest` middleware with configured secureImageLinks
|
||||
* @param {boolean} [secureImageLinks] - Whether secure image links are enabled
|
||||
*/
|
||||
function createValidateImageRequest(secureImageLinks) {
|
||||
if (!secureImageLinks) {
|
||||
return (_req, _res, next) => next();
|
||||
}
|
||||
/**
|
||||
* Middleware to validate image request.
|
||||
* Supports both LibreChat refresh tokens and OpenID JWT tokens.
|
||||
* Must be set by `secureImageLinks` via custom config file.
|
||||
*/
|
||||
return async function validateImageRequest(req, res, next) {
|
||||
try {
|
||||
const cookieHeader = req.headers.cookie;
|
||||
if (!cookieHeader) {
|
||||
logger.warn('[validateImageRequest] No cookies provided');
|
||||
return res.status(401).send('Unauthorized');
|
||||
}
|
||||
|
||||
const parsedCookies = cookies.parse(cookieHeader);
|
||||
const refreshToken = parsedCookies.refreshToken;
|
||||
|
||||
if (!refreshToken) {
|
||||
logger.warn('[validateImageRequest] Token not provided');
|
||||
return res.status(401).send('Unauthorized');
|
||||
}
|
||||
|
||||
const tokenProvider = parsedCookies.token_provider;
|
||||
let userIdForPath;
|
||||
|
||||
if (tokenProvider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS)) {
|
||||
const openidUserId = parsedCookies.openid_user_id;
|
||||
if (!openidUserId) {
|
||||
logger.warn('[validateImageRequest] No OpenID user ID cookie found');
|
||||
return res.status(403).send('Access Denied');
|
||||
}
|
||||
|
||||
const validationResult = validateToken(openidUserId);
|
||||
if (!validationResult.valid) {
|
||||
logger.warn(`[validateImageRequest] ${validationResult.error}`);
|
||||
return res.status(403).send('Access Denied');
|
||||
}
|
||||
userIdForPath = validationResult.userId;
|
||||
} else {
|
||||
const validationResult = validateToken(refreshToken);
|
||||
if (!validationResult.valid) {
|
||||
logger.warn(`[validateImageRequest] ${validationResult.error}`);
|
||||
return res.status(403).send('Access Denied');
|
||||
}
|
||||
userIdForPath = validationResult.userId;
|
||||
}
|
||||
|
||||
if (!userIdForPath) {
|
||||
logger.warn('[validateImageRequest] No user ID available for path validation');
|
||||
return res.status(403).send('Access Denied');
|
||||
}
|
||||
|
||||
const MAX_URL_LENGTH = 2048;
|
||||
if (req.originalUrl.length > MAX_URL_LENGTH) {
|
||||
logger.warn('[validateImageRequest] URL too long');
|
||||
return res.status(403).send('Access Denied');
|
||||
}
|
||||
|
||||
if (req.originalUrl.includes('\x00')) {
|
||||
logger.warn('[validateImageRequest] URL contains null byte');
|
||||
return res.status(403).send('Access Denied');
|
||||
}
|
||||
|
||||
let fullPath;
|
||||
try {
|
||||
fullPath = decodeURIComponent(req.originalUrl);
|
||||
} catch {
|
||||
logger.warn('[validateImageRequest] Invalid URL encoding');
|
||||
return res.status(403).send('Access Denied');
|
||||
}
|
||||
|
||||
const agentAvatarPattern = /^\/images\/[a-f0-9]{24}\/agent-[^/]*$/;
|
||||
if (agentAvatarPattern.test(fullPath)) {
|
||||
logger.debug('[validateImageRequest] Image request validated');
|
||||
return next();
|
||||
}
|
||||
|
||||
const escapedUserId = userIdForPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const pathPattern = new RegExp(`^/images/${escapedUserId}/[^/]+$`);
|
||||
|
||||
if (pathPattern.test(fullPath)) {
|
||||
logger.debug('[validateImageRequest] Image request validated');
|
||||
next();
|
||||
} else {
|
||||
logger.warn('[validateImageRequest] Invalid image path');
|
||||
res.status(403).send('Access Denied');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[validateImageRequest] Error:', error);
|
||||
res.status(500).send('Internal Server Error');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = createValidateImageRequest;
|
||||
module.exports = validateImageRequest;
|
||||
|
||||
@@ -122,6 +122,7 @@ router.get('/', async function (req, res) {
|
||||
payload.minPasswordLength = minPasswordLength;
|
||||
}
|
||||
|
||||
payload.mcpServers = {};
|
||||
const getMCPServers = () => {
|
||||
try {
|
||||
if (appConfig?.mcpConfig == null) {
|
||||
@@ -135,9 +136,6 @@ router.get('/', async function (req, res) {
|
||||
if (!mcpServers) return;
|
||||
const oauthServers = mcpManager.getOAuthServers();
|
||||
for (const serverName in mcpServers) {
|
||||
if (!payload.mcpServers) {
|
||||
payload.mcpServers = {};
|
||||
}
|
||||
const serverConfig = mcpServers[serverName];
|
||||
payload.mcpServers[serverName] = removeNullishValues({
|
||||
startup: serverConfig?.startup,
|
||||
|
||||
@@ -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;
|
||||
@@ -42,14 +39,13 @@ const oauthHandler = async (req, res, next) => {
|
||||
isEnabled(process.env.OPENID_REUSE_TOKENS) === true
|
||||
) {
|
||||
await syncUserEntraGroupMemberships(req.user, req.user.tokenset.access_token);
|
||||
setOpenIDAuthTokens(req.user.tokenset, res, req.user._id.toString());
|
||||
setOpenIDAuthTokens(req.user.tokenset, res);
|
||||
} else {
|
||||
await setAuthTokens(req.user._id, res);
|
||||
}
|
||||
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,
|
||||
);
|
||||
|
||||
|
||||
@@ -49,7 +49,6 @@ const AppService = async () => {
|
||||
enabled: isEnabled(process.env.CHECK_BALANCE),
|
||||
startBalance: startBalance ? parseInt(startBalance, 10) : undefined,
|
||||
};
|
||||
const transactions = config.transactions ?? configDefaults.transactions;
|
||||
const imageOutputType = config?.imageOutputType ?? configDefaults.imageOutputType;
|
||||
|
||||
process.env.CDN_PROVIDER = fileStrategy;
|
||||
@@ -85,7 +84,6 @@ const AppService = async () => {
|
||||
memory,
|
||||
speech,
|
||||
balance,
|
||||
transactions,
|
||||
mcpConfig,
|
||||
webSearch,
|
||||
fileStrategy,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -409,10 +402,9 @@ const setAuthTokens = async (userId, res, sessionId = null) => {
|
||||
* @param {import('openid-client').TokenEndpointResponse & import('openid-client').TokenEndpointResponseHelpers} tokenset
|
||||
* - The tokenset object containing access and refresh tokens
|
||||
* @param {Object} res - response object
|
||||
* @param {string} [userId] - Optional MongoDB user ID for image path validation
|
||||
* @returns {String} - access token
|
||||
*/
|
||||
const setOpenIDAuthTokens = (tokenset, res, userId) => {
|
||||
const setOpenIDAuthTokens = (tokenset, res) => {
|
||||
try {
|
||||
if (!tokenset) {
|
||||
logger.error('[setOpenIDAuthTokens] No tokenset found in request');
|
||||
@@ -443,18 +435,6 @@ const setOpenIDAuthTokens = (tokenset, res, userId) => {
|
||||
secure: isProduction,
|
||||
sameSite: 'strict',
|
||||
});
|
||||
if (userId && isEnabled(process.env.OPENID_REUSE_TOKENS)) {
|
||||
/** JWT-signed user ID cookie for image path validation when OPENID_REUSE_TOKENS is enabled */
|
||||
const signedUserId = jwt.sign({ id: userId }, process.env.JWT_REFRESH_SECRET, {
|
||||
expiresIn: expiryInMilliseconds / 1000,
|
||||
});
|
||||
res.cookie('openid_user_id', signedUserId, {
|
||||
expires: expirationDate,
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: 'strict',
|
||||
});
|
||||
}
|
||||
return tokenset.access_token;
|
||||
} catch (error) {
|
||||
logger.error('[setOpenIDAuthTokens] Error in setting authentication tokens:', error);
|
||||
@@ -472,7 +452,7 @@ const setOpenIDAuthTokens = (tokenset, res, userId) => {
|
||||
const resendVerificationEmail = async (req) => {
|
||||
try {
|
||||
const { email } = req.body;
|
||||
await deleteTokens({ email });
|
||||
await deleteTokens(email);
|
||||
const user = await findUser({ email }, 'email _id name');
|
||||
|
||||
if (!user) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const { Providers } = require('@librechat/agents');
|
||||
const {
|
||||
primeResources,
|
||||
getModelMaxTokens,
|
||||
extractLibreChatParams,
|
||||
optionalChainWithEmptyCheck,
|
||||
} = require('@librechat/api');
|
||||
@@ -18,6 +17,7 @@ const { getProviderConfig } = require('~/server/services/Endpoints');
|
||||
const { processFiles } = require('~/server/services/Files/process');
|
||||
const { getFiles, getToolFilesByIds } = require('~/models/File');
|
||||
const { getConvoFiles } = require('~/models/Conversation');
|
||||
const { getModelMaxTokens } = require('~/utils');
|
||||
|
||||
/**
|
||||
* @param {object} params
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { createContentAggregator } = require('@librechat/agents');
|
||||
const { validateAgentModel, getCustomEndpointConfig } = require('@librechat/api');
|
||||
const {
|
||||
validateAgentModel,
|
||||
getCustomEndpointConfig,
|
||||
createSequentialChainEdges,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
Constants,
|
||||
EModelEndpoint,
|
||||
@@ -119,44 +123,90 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
|
||||
|
||||
const agent_ids = primaryConfig.agent_ids;
|
||||
let userMCPAuthMap = primaryConfig.userMCPAuthMap;
|
||||
if (agent_ids?.length) {
|
||||
for (const agentId of agent_ids) {
|
||||
const agent = await getAgent({ id: agentId });
|
||||
if (!agent) {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
|
||||
async function processAgent(agentId) {
|
||||
const agent = await getAgent({ id: agentId });
|
||||
if (!agent) {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
|
||||
const validationResult = await validateAgentModel({
|
||||
req,
|
||||
res,
|
||||
agent,
|
||||
modelsConfig,
|
||||
logViolation,
|
||||
});
|
||||
|
||||
if (!validationResult.isValid) {
|
||||
throw new Error(validationResult.error?.message);
|
||||
}
|
||||
|
||||
const config = await initializeAgent({
|
||||
req,
|
||||
res,
|
||||
agent,
|
||||
loadTools,
|
||||
requestFiles,
|
||||
conversationId,
|
||||
endpointOption,
|
||||
allowedProviders,
|
||||
});
|
||||
if (userMCPAuthMap != null) {
|
||||
Object.assign(userMCPAuthMap, config.userMCPAuthMap ?? {});
|
||||
} else {
|
||||
userMCPAuthMap = config.userMCPAuthMap;
|
||||
}
|
||||
agentConfigs.set(agentId, config);
|
||||
}
|
||||
|
||||
let edges = primaryConfig.edges;
|
||||
const checkAgentInit = (agentId) => agentId === primaryConfig.id || agentConfigs.has(agentId);
|
||||
if ((edges?.length ?? 0) > 0) {
|
||||
for (const edge of edges) {
|
||||
if (Array.isArray(edge.to)) {
|
||||
for (const to of edge.to) {
|
||||
if (checkAgentInit(to)) {
|
||||
continue;
|
||||
}
|
||||
await processAgent(to);
|
||||
}
|
||||
} else if (typeof edge.to === 'string' && checkAgentInit(edge.to)) {
|
||||
continue;
|
||||
} else if (typeof edge.to === 'string') {
|
||||
await processAgent(edge.to);
|
||||
}
|
||||
|
||||
const validationResult = await validateAgentModel({
|
||||
req,
|
||||
res,
|
||||
agent,
|
||||
modelsConfig,
|
||||
logViolation,
|
||||
});
|
||||
|
||||
if (!validationResult.isValid) {
|
||||
throw new Error(validationResult.error?.message);
|
||||
if (Array.isArray(edge.from)) {
|
||||
for (const from of edge.from) {
|
||||
if (checkAgentInit(from)) {
|
||||
continue;
|
||||
}
|
||||
await processAgent(from);
|
||||
}
|
||||
} else if (typeof edge.from === 'string' && checkAgentInit(edge.from)) {
|
||||
continue;
|
||||
} else if (typeof edge.from === 'string') {
|
||||
await processAgent(edge.from);
|
||||
}
|
||||
|
||||
const config = await initializeAgent({
|
||||
req,
|
||||
res,
|
||||
agent,
|
||||
loadTools,
|
||||
requestFiles,
|
||||
conversationId,
|
||||
endpointOption,
|
||||
allowedProviders,
|
||||
});
|
||||
if (userMCPAuthMap != null) {
|
||||
Object.assign(userMCPAuthMap, config.userMCPAuthMap ?? {});
|
||||
} else {
|
||||
userMCPAuthMap = config.userMCPAuthMap;
|
||||
}
|
||||
agentConfigs.set(agentId, config);
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Agent Chain */
|
||||
if (agent_ids?.length) {
|
||||
for (const agentId of agent_ids) {
|
||||
if (checkAgentInit(agentId)) {
|
||||
continue;
|
||||
}
|
||||
await processAgent(agentId);
|
||||
}
|
||||
|
||||
const chain = await createSequentialChainEdges([primaryConfig.id].concat(agent_ids), '{convo}');
|
||||
edges = edges ? edges.concat(chain) : chain;
|
||||
}
|
||||
|
||||
primaryConfig.edges = edges;
|
||||
|
||||
let endpointConfig = appConfig.endpoints?.[primaryConfig.endpoint];
|
||||
if (!isAgentsEndpoint(primaryConfig.endpoint) && !endpointConfig) {
|
||||
try {
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import { AnthropicClientOptions } from '@librechat/agents';
|
||||
import { EModelEndpoint, anthropicSettings } from 'librechat-data-provider';
|
||||
import { matchModelName } from '~/utils/tokens';
|
||||
const { EModelEndpoint, anthropicSettings } = require('librechat-data-provider');
|
||||
const { matchModelName } = require('~/utils');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
/**
|
||||
* @param {string} modelName
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function checkPromptCacheSupport(modelName: string): boolean {
|
||||
const modelMatch = matchModelName(modelName, EModelEndpoint.anthropic) ?? '';
|
||||
function checkPromptCacheSupport(modelName) {
|
||||
const modelMatch = matchModelName(modelName, EModelEndpoint.anthropic);
|
||||
if (
|
||||
modelMatch.includes('claude-3-5-sonnet-latest') ||
|
||||
modelMatch.includes('claude-3.5-sonnet-latest')
|
||||
@@ -32,10 +31,7 @@ function checkPromptCacheSupport(modelName: string): boolean {
|
||||
* @param {boolean} supportsCacheControl Whether the model supports cache control
|
||||
* @returns {AnthropicClientOptions['extendedOptions']['defaultHeaders']|undefined} The headers object or undefined if not applicable
|
||||
*/
|
||||
function getClaudeHeaders(
|
||||
model: string,
|
||||
supportsCacheControl: boolean,
|
||||
): Record<string, string> | undefined {
|
||||
function getClaudeHeaders(model, supportsCacheControl) {
|
||||
if (!supportsCacheControl) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -76,13 +72,9 @@ function getClaudeHeaders(
|
||||
* @param {number|null} extendedOptions.thinkingBudget The token budget for thinking
|
||||
* @returns {Object} Updated request options
|
||||
*/
|
||||
function configureReasoning(
|
||||
anthropicInput: AnthropicClientOptions & { max_tokens?: number },
|
||||
extendedOptions: { thinking?: boolean; thinkingBudget?: number | null } = {},
|
||||
): AnthropicClientOptions & { max_tokens?: number } {
|
||||
function configureReasoning(anthropicInput, extendedOptions = {}) {
|
||||
const updatedOptions = { ...anthropicInput };
|
||||
const currentMaxTokens = updatedOptions.max_tokens ?? updatedOptions.maxTokens;
|
||||
|
||||
if (
|
||||
extendedOptions.thinking &&
|
||||
updatedOptions?.model &&
|
||||
@@ -90,16 +82,11 @@ function configureReasoning(
|
||||
/claude-(?:sonnet|opus|haiku)-[4-9]/.test(updatedOptions.model))
|
||||
) {
|
||||
updatedOptions.thinking = {
|
||||
...updatedOptions.thinking,
|
||||
type: 'enabled',
|
||||
} as { type: 'enabled'; budget_tokens: number };
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
updatedOptions.thinking != null &&
|
||||
extendedOptions.thinkingBudget != null &&
|
||||
updatedOptions.thinking.type === 'enabled'
|
||||
) {
|
||||
if (updatedOptions.thinking != null && extendedOptions.thinkingBudget != null) {
|
||||
updatedOptions.thinking = {
|
||||
...updatedOptions.thinking,
|
||||
budget_tokens: extendedOptions.thinkingBudget,
|
||||
@@ -108,10 +95,9 @@ function configureReasoning(
|
||||
|
||||
if (
|
||||
updatedOptions.thinking != null &&
|
||||
updatedOptions.thinking.type === 'enabled' &&
|
||||
(currentMaxTokens == null || updatedOptions.thinking.budget_tokens > currentMaxTokens)
|
||||
) {
|
||||
const maxTokens = anthropicSettings.maxOutputTokens.reset(updatedOptions.model ?? '');
|
||||
const maxTokens = anthropicSettings.maxOutputTokens.reset(updatedOptions.model);
|
||||
updatedOptions.max_tokens = currentMaxTokens ?? maxTokens;
|
||||
|
||||
logger.warn(
|
||||
@@ -129,4 +115,4 @@ function configureReasoning(
|
||||
return updatedOptions;
|
||||
}
|
||||
|
||||
export { checkPromptCacheSupport, getClaudeHeaders, configureReasoning };
|
||||
module.exports = { checkPromptCacheSupport, getClaudeHeaders, configureReasoning };
|
||||
@@ -1,6 +1,6 @@
|
||||
const { getLLMConfig } = require('@librechat/api');
|
||||
const { EModelEndpoint } = require('librechat-data-provider');
|
||||
const { getUserKey, checkUserKeyExpiry } = require('~/server/services/UserService');
|
||||
const { getLLMConfig } = require('~/server/services/Endpoints/anthropic/llm');
|
||||
const AnthropicClient = require('~/app/clients/AnthropicClient');
|
||||
|
||||
const initializeClient = async ({ req, res, endpointOption, overrideModel, optionsOnly }) => {
|
||||
@@ -27,19 +27,20 @@ const initializeClient = async ({ req, res, endpointOption, overrideModel, optio
|
||||
const anthropicConfig = appConfig.endpoints?.[EModelEndpoint.anthropic];
|
||||
|
||||
if (anthropicConfig) {
|
||||
clientOptions.streamRate = anthropicConfig.streamRate;
|
||||
clientOptions._lc_stream_delay = anthropicConfig.streamRate;
|
||||
clientOptions.titleModel = anthropicConfig.titleModel;
|
||||
}
|
||||
|
||||
const allConfig = appConfig.endpoints?.all;
|
||||
if (allConfig) {
|
||||
clientOptions.streamRate = allConfig.streamRate;
|
||||
clientOptions._lc_stream_delay = allConfig.streamRate;
|
||||
}
|
||||
|
||||
if (optionsOnly) {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
103
api/server/services/Endpoints/anthropic/llm.js
Normal file
103
api/server/services/Endpoints/anthropic/llm.js
Normal file
@@ -0,0 +1,103 @@
|
||||
const { ProxyAgent } = require('undici');
|
||||
const { anthropicSettings, removeNullishValues } = require('librechat-data-provider');
|
||||
const { checkPromptCacheSupport, getClaudeHeaders, configureReasoning } = require('./helpers');
|
||||
|
||||
/**
|
||||
* Generates configuration options for creating an Anthropic language model (LLM) instance.
|
||||
*
|
||||
* @param {string} apiKey - The API key for authentication with Anthropic.
|
||||
* @param {Object} [options={}] - Additional options for configuring the LLM.
|
||||
* @param {Object} [options.modelOptions] - Model-specific options.
|
||||
* @param {string} [options.modelOptions.model] - The name of the model to use.
|
||||
* @param {number} [options.modelOptions.maxOutputTokens] - The maximum number of tokens to generate.
|
||||
* @param {number} [options.modelOptions.temperature] - Controls randomness in output generation.
|
||||
* @param {number} [options.modelOptions.topP] - Controls diversity of output generation.
|
||||
* @param {number} [options.modelOptions.topK] - Controls the number of top tokens to consider.
|
||||
* @param {string[]} [options.modelOptions.stop] - Sequences where the API will stop generating further tokens.
|
||||
* @param {boolean} [options.modelOptions.stream] - Whether to stream the response.
|
||||
* @param {string} options.userId - The user ID for tracking and personalization.
|
||||
* @param {string} [options.proxy] - Proxy server URL.
|
||||
* @param {string} [options.reverseProxyUrl] - URL for a reverse proxy, if used.
|
||||
*
|
||||
* @returns {Object} Configuration options for creating an Anthropic LLM instance, with null and undefined values removed.
|
||||
*/
|
||||
function getLLMConfig(apiKey, options = {}) {
|
||||
const systemOptions = {
|
||||
thinking: options.modelOptions.thinking ?? anthropicSettings.thinking.default,
|
||||
promptCache: options.modelOptions.promptCache ?? anthropicSettings.promptCache.default,
|
||||
thinkingBudget: options.modelOptions.thinkingBudget ?? anthropicSettings.thinkingBudget.default,
|
||||
};
|
||||
for (let key in systemOptions) {
|
||||
delete options.modelOptions[key];
|
||||
}
|
||||
const defaultOptions = {
|
||||
model: anthropicSettings.model.default,
|
||||
maxOutputTokens: anthropicSettings.maxOutputTokens.default,
|
||||
stream: true,
|
||||
};
|
||||
|
||||
const mergedOptions = Object.assign(defaultOptions, options.modelOptions);
|
||||
|
||||
/** @type {AnthropicClientOptions} */
|
||||
let requestOptions = {
|
||||
apiKey,
|
||||
model: mergedOptions.model,
|
||||
stream: mergedOptions.stream,
|
||||
temperature: mergedOptions.temperature,
|
||||
stopSequences: mergedOptions.stop,
|
||||
maxTokens:
|
||||
mergedOptions.maxOutputTokens || anthropicSettings.maxOutputTokens.reset(mergedOptions.model),
|
||||
clientOptions: {},
|
||||
invocationKwargs: {
|
||||
metadata: {
|
||||
user_id: options.userId,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
requestOptions = configureReasoning(requestOptions, systemOptions);
|
||||
|
||||
if (!/claude-3[-.]7/.test(mergedOptions.model)) {
|
||||
requestOptions.topP = mergedOptions.topP;
|
||||
requestOptions.topK = mergedOptions.topK;
|
||||
} else if (requestOptions.thinking == null) {
|
||||
requestOptions.topP = mergedOptions.topP;
|
||||
requestOptions.topK = mergedOptions.topK;
|
||||
}
|
||||
|
||||
const supportsCacheControl =
|
||||
systemOptions.promptCache === true && checkPromptCacheSupport(requestOptions.model);
|
||||
const headers = getClaudeHeaders(requestOptions.model, supportsCacheControl);
|
||||
if (headers) {
|
||||
requestOptions.clientOptions.defaultHeaders = headers;
|
||||
}
|
||||
|
||||
if (options.proxy) {
|
||||
const proxyAgent = new ProxyAgent(options.proxy);
|
||||
requestOptions.clientOptions.fetchOptions = {
|
||||
dispatcher: proxyAgent,
|
||||
};
|
||||
}
|
||||
|
||||
if (options.reverseProxyUrl) {
|
||||
requestOptions.clientOptions.baseURL = options.reverseProxyUrl;
|
||||
requestOptions.anthropicApiUrl = options.reverseProxyUrl;
|
||||
}
|
||||
|
||||
const tools = [];
|
||||
|
||||
if (mergedOptions.web_search) {
|
||||
tools.push({
|
||||
type: 'web_search_20250305',
|
||||
name: 'web_search',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
tools,
|
||||
/** @type {AnthropicClientOptions} */
|
||||
llmConfig: removeNullishValues(requestOptions),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { getLLMConfig };
|
||||
341
api/server/services/Endpoints/anthropic/llm.spec.js
Normal file
341
api/server/services/Endpoints/anthropic/llm.spec.js
Normal file
@@ -0,0 +1,341 @@
|
||||
const { getLLMConfig } = require('~/server/services/Endpoints/anthropic/llm');
|
||||
|
||||
jest.mock('https-proxy-agent', () => ({
|
||||
HttpsProxyAgent: jest.fn().mockImplementation((proxy) => ({ proxy })),
|
||||
}));
|
||||
|
||||
describe('getLLMConfig', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should create a basic configuration with default values', () => {
|
||||
const result = getLLMConfig('test-api-key', { modelOptions: {} });
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('apiKey', 'test-api-key');
|
||||
expect(result.llmConfig).toHaveProperty('model', 'claude-3-5-sonnet-latest');
|
||||
expect(result.llmConfig).toHaveProperty('stream', true);
|
||||
expect(result.llmConfig).toHaveProperty('maxTokens');
|
||||
});
|
||||
|
||||
it('should include proxy settings when provided', () => {
|
||||
const result = getLLMConfig('test-api-key', {
|
||||
modelOptions: {},
|
||||
proxy: 'http://proxy:8080',
|
||||
});
|
||||
|
||||
expect(result.llmConfig.clientOptions).toHaveProperty('fetchOptions');
|
||||
expect(result.llmConfig.clientOptions.fetchOptions).toHaveProperty('dispatcher');
|
||||
expect(result.llmConfig.clientOptions.fetchOptions.dispatcher).toBeDefined();
|
||||
expect(result.llmConfig.clientOptions.fetchOptions.dispatcher.constructor.name).toBe(
|
||||
'ProxyAgent',
|
||||
);
|
||||
});
|
||||
|
||||
it('should include reverse proxy URL when provided', () => {
|
||||
const result = getLLMConfig('test-api-key', {
|
||||
modelOptions: {},
|
||||
reverseProxyUrl: 'http://reverse-proxy',
|
||||
});
|
||||
|
||||
expect(result.llmConfig.clientOptions).toHaveProperty('baseURL', 'http://reverse-proxy');
|
||||
expect(result.llmConfig).toHaveProperty('anthropicApiUrl', 'http://reverse-proxy');
|
||||
});
|
||||
|
||||
it('should include topK and topP for non-Claude-3.7 models', () => {
|
||||
const result = getLLMConfig('test-api-key', {
|
||||
modelOptions: {
|
||||
model: 'claude-3-opus',
|
||||
topK: 10,
|
||||
topP: 0.9,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('topK', 10);
|
||||
expect(result.llmConfig).toHaveProperty('topP', 0.9);
|
||||
});
|
||||
|
||||
it('should include topK and topP for Claude-3.5 models', () => {
|
||||
const result = getLLMConfig('test-api-key', {
|
||||
modelOptions: {
|
||||
model: 'claude-3-5-sonnet',
|
||||
topK: 10,
|
||||
topP: 0.9,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('topK', 10);
|
||||
expect(result.llmConfig).toHaveProperty('topP', 0.9);
|
||||
});
|
||||
|
||||
it('should NOT include topK and topP for Claude-3-7 models with thinking enabled (hyphen notation)', () => {
|
||||
const result = getLLMConfig('test-api-key', {
|
||||
modelOptions: {
|
||||
model: 'claude-3-7-sonnet',
|
||||
topK: 10,
|
||||
topP: 0.9,
|
||||
thinking: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).not.toHaveProperty('topK');
|
||||
expect(result.llmConfig).not.toHaveProperty('topP');
|
||||
expect(result.llmConfig).toHaveProperty('thinking');
|
||||
expect(result.llmConfig.thinking).toHaveProperty('type', 'enabled');
|
||||
// When thinking is enabled, it uses the default thinkingBudget of 2000
|
||||
expect(result.llmConfig.thinking).toHaveProperty('budget_tokens', 2000);
|
||||
});
|
||||
|
||||
it('should add "prompt-caching" and "context-1m" beta headers for claude-sonnet-4 model', () => {
|
||||
const modelOptions = {
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
promptCache: true,
|
||||
};
|
||||
const result = getLLMConfig('test-key', { modelOptions });
|
||||
const clientOptions = result.llmConfig.clientOptions;
|
||||
expect(clientOptions.defaultHeaders).toBeDefined();
|
||||
expect(clientOptions.defaultHeaders).toHaveProperty('anthropic-beta');
|
||||
expect(clientOptions.defaultHeaders['anthropic-beta']).toBe(
|
||||
'prompt-caching-2024-07-31,context-1m-2025-08-07',
|
||||
);
|
||||
});
|
||||
|
||||
it('should add "prompt-caching" and "context-1m" beta headers for claude-sonnet-4 model formats', () => {
|
||||
const modelVariations = [
|
||||
'claude-sonnet-4-20250514',
|
||||
'claude-sonnet-4-latest',
|
||||
'anthropic/claude-sonnet-4-20250514',
|
||||
];
|
||||
|
||||
modelVariations.forEach((model) => {
|
||||
const modelOptions = { model, promptCache: true };
|
||||
const result = getLLMConfig('test-key', { modelOptions });
|
||||
const clientOptions = result.llmConfig.clientOptions;
|
||||
expect(clientOptions.defaultHeaders).toBeDefined();
|
||||
expect(clientOptions.defaultHeaders).toHaveProperty('anthropic-beta');
|
||||
expect(clientOptions.defaultHeaders['anthropic-beta']).toBe(
|
||||
'prompt-caching-2024-07-31,context-1m-2025-08-07',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT include topK and topP for Claude-3.7 models with thinking enabled (decimal notation)', () => {
|
||||
const result = getLLMConfig('test-api-key', {
|
||||
modelOptions: {
|
||||
model: 'claude-3.7-sonnet',
|
||||
topK: 10,
|
||||
topP: 0.9,
|
||||
thinking: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).not.toHaveProperty('topK');
|
||||
expect(result.llmConfig).not.toHaveProperty('topP');
|
||||
expect(result.llmConfig).toHaveProperty('thinking');
|
||||
expect(result.llmConfig.thinking).toHaveProperty('type', 'enabled');
|
||||
// When thinking is enabled, it uses the default thinkingBudget of 2000
|
||||
expect(result.llmConfig.thinking).toHaveProperty('budget_tokens', 2000);
|
||||
});
|
||||
|
||||
it('should handle custom maxOutputTokens', () => {
|
||||
const result = getLLMConfig('test-api-key', {
|
||||
modelOptions: {
|
||||
model: 'claude-3-opus',
|
||||
maxOutputTokens: 2048,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('maxTokens', 2048);
|
||||
});
|
||||
|
||||
it('should handle promptCache setting', () => {
|
||||
const result = getLLMConfig('test-api-key', {
|
||||
modelOptions: {
|
||||
model: 'claude-3-5-sonnet',
|
||||
promptCache: true,
|
||||
},
|
||||
});
|
||||
|
||||
// We're not checking specific header values since that depends on the actual helper function
|
||||
// Just verifying that the promptCache setting is processed
|
||||
expect(result.llmConfig).toBeDefined();
|
||||
});
|
||||
|
||||
it('should include topK and topP for Claude-3.7 models when thinking is not enabled', () => {
|
||||
// Test with thinking explicitly set to null/undefined
|
||||
const result = getLLMConfig('test-api-key', {
|
||||
modelOptions: {
|
||||
model: 'claude-3-7-sonnet',
|
||||
topK: 10,
|
||||
topP: 0.9,
|
||||
thinking: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('topK', 10);
|
||||
expect(result.llmConfig).toHaveProperty('topP', 0.9);
|
||||
|
||||
// Test with thinking explicitly set to false
|
||||
const result2 = getLLMConfig('test-api-key', {
|
||||
modelOptions: {
|
||||
model: 'claude-3-7-sonnet',
|
||||
topK: 10,
|
||||
topP: 0.9,
|
||||
thinking: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result2.llmConfig).toHaveProperty('topK', 10);
|
||||
expect(result2.llmConfig).toHaveProperty('topP', 0.9);
|
||||
|
||||
// Test with decimal notation as well
|
||||
const result3 = getLLMConfig('test-api-key', {
|
||||
modelOptions: {
|
||||
model: 'claude-3.7-sonnet',
|
||||
topK: 10,
|
||||
topP: 0.9,
|
||||
thinking: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result3.llmConfig).toHaveProperty('topK', 10);
|
||||
expect(result3.llmConfig).toHaveProperty('topP', 0.9);
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle missing apiKey', () => {
|
||||
const result = getLLMConfig(undefined, { modelOptions: {} });
|
||||
expect(result.llmConfig).not.toHaveProperty('apiKey');
|
||||
});
|
||||
|
||||
it('should handle empty modelOptions', () => {
|
||||
expect(() => {
|
||||
getLLMConfig('test-api-key', {});
|
||||
}).toThrow("Cannot read properties of undefined (reading 'thinking')");
|
||||
});
|
||||
|
||||
it('should handle no options parameter', () => {
|
||||
expect(() => {
|
||||
getLLMConfig('test-api-key');
|
||||
}).toThrow("Cannot read properties of undefined (reading 'thinking')");
|
||||
});
|
||||
|
||||
it('should handle temperature, stop sequences, and stream settings', () => {
|
||||
const result = getLLMConfig('test-api-key', {
|
||||
modelOptions: {
|
||||
temperature: 0.7,
|
||||
stop: ['\n\n', 'END'],
|
||||
stream: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('temperature', 0.7);
|
||||
expect(result.llmConfig).toHaveProperty('stopSequences', ['\n\n', 'END']);
|
||||
expect(result.llmConfig).toHaveProperty('stream', false);
|
||||
});
|
||||
|
||||
it('should handle maxOutputTokens when explicitly set to falsy value', () => {
|
||||
const result = getLLMConfig('test-api-key', {
|
||||
modelOptions: {
|
||||
model: 'claude-3-opus',
|
||||
maxOutputTokens: null,
|
||||
},
|
||||
});
|
||||
|
||||
// The actual anthropicSettings.maxOutputTokens.reset('claude-3-opus') returns 4096
|
||||
expect(result.llmConfig).toHaveProperty('maxTokens', 4096);
|
||||
});
|
||||
|
||||
it('should handle both proxy and reverseProxyUrl', () => {
|
||||
const result = getLLMConfig('test-api-key', {
|
||||
modelOptions: {},
|
||||
proxy: 'http://proxy:8080',
|
||||
reverseProxyUrl: 'https://reverse-proxy.com',
|
||||
});
|
||||
|
||||
expect(result.llmConfig.clientOptions).toHaveProperty('fetchOptions');
|
||||
expect(result.llmConfig.clientOptions.fetchOptions).toHaveProperty('dispatcher');
|
||||
expect(result.llmConfig.clientOptions.fetchOptions.dispatcher).toBeDefined();
|
||||
expect(result.llmConfig.clientOptions.fetchOptions.dispatcher.constructor.name).toBe(
|
||||
'ProxyAgent',
|
||||
);
|
||||
expect(result.llmConfig.clientOptions).toHaveProperty('baseURL', 'https://reverse-proxy.com');
|
||||
expect(result.llmConfig).toHaveProperty('anthropicApiUrl', 'https://reverse-proxy.com');
|
||||
});
|
||||
|
||||
it('should handle prompt cache with supported model', () => {
|
||||
const result = getLLMConfig('test-api-key', {
|
||||
modelOptions: {
|
||||
model: 'claude-3-5-sonnet',
|
||||
promptCache: true,
|
||||
},
|
||||
});
|
||||
|
||||
// claude-3-5-sonnet supports prompt caching and should get the appropriate headers
|
||||
expect(result.llmConfig.clientOptions.defaultHeaders).toEqual({
|
||||
'anthropic-beta': 'max-tokens-3-5-sonnet-2024-07-15,prompt-caching-2024-07-31',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle thinking and thinkingBudget options', () => {
|
||||
const result = getLLMConfig('test-api-key', {
|
||||
modelOptions: {
|
||||
model: 'claude-3-7-sonnet',
|
||||
thinking: true,
|
||||
thinkingBudget: 10000, // This exceeds the default max_tokens of 8192
|
||||
},
|
||||
});
|
||||
|
||||
// The function should add thinking configuration for claude-3-7 models
|
||||
expect(result.llmConfig).toHaveProperty('thinking');
|
||||
expect(result.llmConfig.thinking).toHaveProperty('type', 'enabled');
|
||||
// With claude-3-7-sonnet, the max_tokens default is 8192
|
||||
// Budget tokens gets adjusted to 90% of max_tokens (8192 * 0.9 = 7372) when it exceeds max_tokens
|
||||
expect(result.llmConfig.thinking).toHaveProperty('budget_tokens', 7372);
|
||||
|
||||
// Test with budget_tokens within max_tokens limit
|
||||
const result2 = getLLMConfig('test-api-key', {
|
||||
modelOptions: {
|
||||
model: 'claude-3-7-sonnet',
|
||||
thinking: true,
|
||||
thinkingBudget: 2000,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result2.llmConfig.thinking).toHaveProperty('budget_tokens', 2000);
|
||||
});
|
||||
|
||||
it('should remove system options from modelOptions', () => {
|
||||
const modelOptions = {
|
||||
model: 'claude-3-opus',
|
||||
thinking: true,
|
||||
promptCache: true,
|
||||
thinkingBudget: 1000,
|
||||
temperature: 0.5,
|
||||
};
|
||||
|
||||
getLLMConfig('test-api-key', { modelOptions });
|
||||
|
||||
expect(modelOptions).not.toHaveProperty('thinking');
|
||||
expect(modelOptions).not.toHaveProperty('promptCache');
|
||||
expect(modelOptions).not.toHaveProperty('thinkingBudget');
|
||||
expect(modelOptions).toHaveProperty('temperature', 0.5);
|
||||
});
|
||||
|
||||
it('should handle all nullish values removal', () => {
|
||||
const result = getLLMConfig('test-api-key', {
|
||||
modelOptions: {
|
||||
temperature: null,
|
||||
topP: undefined,
|
||||
topK: 0,
|
||||
stop: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).not.toHaveProperty('temperature');
|
||||
expect(result.llmConfig).not.toHaveProperty('topP');
|
||||
expect(result.llmConfig).toHaveProperty('topK', 0);
|
||||
expect(result.llmConfig).toHaveProperty('stopSequences', []);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
const { getModelMaxTokens } = require('@librechat/api');
|
||||
const { createContentAggregator } = require('@librechat/agents');
|
||||
const {
|
||||
EModelEndpoint,
|
||||
@@ -8,6 +7,7 @@ const {
|
||||
const { getDefaultHandlers } = require('~/server/controllers/agents/callbacks');
|
||||
const getOptions = require('~/server/services/Endpoints/bedrock/options');
|
||||
const AgentClient = require('~/server/controllers/agents/client');
|
||||
const { getModelMaxTokens } = require('~/utils');
|
||||
|
||||
const initializeClient = async ({ req, res, endpointOption }) => {
|
||||
if (!endpointOption) {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
const { createHandleLLMNewToken } = require('@librechat/api');
|
||||
const {
|
||||
AuthType,
|
||||
Constants,
|
||||
EModelEndpoint,
|
||||
bedrockInputParser,
|
||||
bedrockOutputParser,
|
||||
@@ -11,7 +9,6 @@ const {
|
||||
const { getUserKey, checkUserKeyExpiry } = require('~/server/services/UserService');
|
||||
|
||||
const getOptions = async ({ req, overrideModel, endpointOption }) => {
|
||||
const appConfig = req.config;
|
||||
const {
|
||||
BEDROCK_AWS_SECRET_ACCESS_KEY,
|
||||
BEDROCK_AWS_ACCESS_KEY_ID,
|
||||
@@ -47,10 +44,12 @@ const getOptions = async ({ req, overrideModel, endpointOption }) => {
|
||||
checkUserKeyExpiry(expiresAt, EModelEndpoint.bedrock);
|
||||
}
|
||||
|
||||
/** @type {number} */
|
||||
/*
|
||||
Callback for stream rate no longer awaits and may end the stream prematurely
|
||||
/** @type {number}
|
||||
let streamRate = Constants.DEFAULT_STREAM_RATE;
|
||||
|
||||
/** @type {undefined | TBaseEndpoint} */
|
||||
/** @type {undefined | TBaseEndpoint}
|
||||
const bedrockConfig = appConfig.endpoints?.[EModelEndpoint.bedrock];
|
||||
|
||||
if (bedrockConfig && bedrockConfig.streamRate) {
|
||||
@@ -61,6 +60,7 @@ const getOptions = async ({ req, overrideModel, endpointOption }) => {
|
||||
if (allConfig && allConfig.streamRate) {
|
||||
streamRate = allConfig.streamRate;
|
||||
}
|
||||
*/
|
||||
|
||||
/** @type {BedrockClientOptions} */
|
||||
const requestOptions = {
|
||||
@@ -88,12 +88,6 @@ const getOptions = async ({ req, overrideModel, endpointOption }) => {
|
||||
llmConfig.endpointHost = BEDROCK_REVERSE_PROXY;
|
||||
}
|
||||
|
||||
llmConfig.callbacks = [
|
||||
{
|
||||
handleLLMNewToken: createHandleLLMNewToken(streamRate),
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
/** @type {BedrockClientOptions} */
|
||||
llmConfig,
|
||||
|
||||
@@ -4,7 +4,6 @@ const {
|
||||
isUserProvided,
|
||||
getOpenAIConfig,
|
||||
getCustomEndpointConfig,
|
||||
createHandleLLMNewToken,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
CacheKeys,
|
||||
@@ -159,11 +158,7 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid
|
||||
if (!clientOptions.streamRate) {
|
||||
return options;
|
||||
}
|
||||
options.llmConfig.callbacks = [
|
||||
{
|
||||
handleLLMNewToken: createHandleLLMNewToken(clientOptions.streamRate),
|
||||
},
|
||||
];
|
||||
options.llmConfig._lc_stream_delay = clientOptions.streamRate;
|
||||
return options;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ jest.mock('@librechat/api', () => ({
|
||||
...jest.requireActual('@librechat/api'),
|
||||
resolveHeaders: jest.fn(),
|
||||
getOpenAIConfig: jest.fn(),
|
||||
createHandleLLMNewToken: jest.fn(),
|
||||
getCustomEndpointConfig: jest.fn().mockReturnValue({
|
||||
apiKey: 'test-key',
|
||||
baseURL: 'https://test.com',
|
||||
|
||||
@@ -5,7 +5,6 @@ const {
|
||||
isUserProvided,
|
||||
getOpenAIConfig,
|
||||
getAzureCredentials,
|
||||
createHandleLLMNewToken,
|
||||
} = require('@librechat/api');
|
||||
const { getUserKeyValues, checkUserKeyExpiry } = require('~/server/services/UserService');
|
||||
const OpenAIClient = require('~/app/clients/OpenAIClient');
|
||||
@@ -151,11 +150,7 @@ const initializeClient = async ({
|
||||
if (!streamRate) {
|
||||
return options;
|
||||
}
|
||||
options.llmConfig.callbacks = [
|
||||
{
|
||||
handleLLMNewToken: createHandleLLMNewToken(streamRate),
|
||||
},
|
||||
];
|
||||
options.llmConfig._lc_stream_delay = streamRate;
|
||||
return options;
|
||||
}
|
||||
|
||||
|
||||
@@ -159,11 +159,9 @@ class STTService {
|
||||
* Prepares the request for the OpenAI STT provider.
|
||||
* @param {Object} sttSchema - The STT schema for OpenAI.
|
||||
* @param {Stream} audioReadStream - The audio data to be transcribed.
|
||||
* @param {Object} audioFile - The audio file object (unused in OpenAI provider).
|
||||
* @param {string} language - The language code for the transcription.
|
||||
* @returns {Array} An array containing the URL, data, and headers for the request.
|
||||
*/
|
||||
openAIProvider(sttSchema, audioReadStream, audioFile, language) {
|
||||
openAIProvider(sttSchema, audioReadStream) {
|
||||
const url = sttSchema?.url || 'https://api.openai.com/v1/audio/transcriptions';
|
||||
const apiKey = extractEnvVariable(sttSchema.apiKey) || '';
|
||||
|
||||
@@ -172,12 +170,6 @@ class STTService {
|
||||
model: sttSchema.model,
|
||||
};
|
||||
|
||||
if (language) {
|
||||
/** Converted locale code (e.g., "en-US") to ISO-639-1 format (e.g., "en") */
|
||||
const isoLanguage = language.split('-')[0];
|
||||
data.language = isoLanguage;
|
||||
}
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
...(apiKey && { Authorization: `Bearer ${apiKey}` }),
|
||||
@@ -192,11 +184,10 @@ class STTService {
|
||||
* @param {Object} sttSchema - The STT schema for Azure OpenAI.
|
||||
* @param {Buffer} audioBuffer - The audio data to be transcribed.
|
||||
* @param {Object} audioFile - The audio file object containing originalname, mimetype, and size.
|
||||
* @param {string} language - The language code for the transcription.
|
||||
* @returns {Array} An array containing the URL, data, and headers for the request.
|
||||
* @throws {Error} If the audio file size exceeds 25MB or the audio file format is not accepted.
|
||||
*/
|
||||
azureOpenAIProvider(sttSchema, audioBuffer, audioFile, language) {
|
||||
azureOpenAIProvider(sttSchema, audioBuffer, audioFile) {
|
||||
const url = `${genAzureEndpoint({
|
||||
azureOpenAIApiInstanceName: extractEnvVariable(sttSchema?.instanceName),
|
||||
azureOpenAIApiDeploymentName: extractEnvVariable(sttSchema?.deploymentName),
|
||||
@@ -220,12 +211,6 @@ class STTService {
|
||||
contentType: audioFile.mimetype,
|
||||
});
|
||||
|
||||
if (language) {
|
||||
/** Converted locale code (e.g., "en-US") to ISO-639-1 format (e.g., "en") */
|
||||
const isoLanguage = language.split('-')[0];
|
||||
formData.append('language', isoLanguage);
|
||||
}
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
...(apiKey && { 'api-key': apiKey }),
|
||||
@@ -244,11 +229,10 @@ class STTService {
|
||||
* @param {Object} requestData - The data required for the STT request.
|
||||
* @param {Buffer} requestData.audioBuffer - The audio data to be transcribed.
|
||||
* @param {Object} requestData.audioFile - The audio file object containing originalname, mimetype, and size.
|
||||
* @param {string} requestData.language - The language code for the transcription.
|
||||
* @returns {Promise<string>} A promise that resolves to the transcribed text.
|
||||
* @throws {Error} If the provider is invalid, the response status is not 200, or the response data is missing.
|
||||
*/
|
||||
async sttRequest(provider, sttSchema, { audioBuffer, audioFile, language }) {
|
||||
async sttRequest(provider, sttSchema, { audioBuffer, audioFile }) {
|
||||
const strategy = this.providerStrategies[provider];
|
||||
if (!strategy) {
|
||||
throw new Error('Invalid provider');
|
||||
@@ -259,13 +243,7 @@ class STTService {
|
||||
const audioReadStream = Readable.from(audioBuffer);
|
||||
audioReadStream.path = `audio.${fileExtension}`;
|
||||
|
||||
const [url, data, headers] = strategy.call(
|
||||
this,
|
||||
sttSchema,
|
||||
audioReadStream,
|
||||
audioFile,
|
||||
language,
|
||||
);
|
||||
const [url, data, headers] = strategy.call(this, sttSchema, audioReadStream, audioFile);
|
||||
|
||||
try {
|
||||
const response = await axios.post(url, data, { headers });
|
||||
@@ -306,8 +284,7 @@ class STTService {
|
||||
|
||||
try {
|
||||
const [provider, sttSchema] = await this.getProviderSchema(req);
|
||||
const language = req.body?.language || '';
|
||||
const text = await this.sttRequest(provider, sttSchema, { audioBuffer, audioFile, language });
|
||||
const text = await this.sttRequest(provider, sttSchema, { audioBuffer, audioFile });
|
||||
res.json({ text });
|
||||
} catch (error) {
|
||||
logger.error('An error occurred while processing the audio:', error);
|
||||
|
||||
@@ -17,7 +17,7 @@ const { Files } = require('~/models');
|
||||
* @param {IUser} options.user - The user object
|
||||
* @param {AppConfig} options.appConfig - The app configuration object
|
||||
* @param {GraphRunnableConfig['configurable']} options.metadata - The metadata
|
||||
* @param {{ [Tools.file_search]: { sources: Object[]; fileCitations: boolean } }} options.toolArtifact - The tool artifact containing structured data
|
||||
* @param {any} options.toolArtifact - The tool artifact containing structured data
|
||||
* @param {string} options.toolCallId - The tool call ID
|
||||
* @returns {Promise<Object|null>} The file search attachment or null
|
||||
*/
|
||||
@@ -29,14 +29,12 @@ async function processFileCitations({ user, appConfig, toolArtifact, toolCallId,
|
||||
|
||||
if (user) {
|
||||
try {
|
||||
const hasFileCitationsAccess =
|
||||
toolArtifact?.[Tools.file_search]?.fileCitations ??
|
||||
(await checkAccess({
|
||||
user,
|
||||
permissionType: PermissionTypes.FILE_CITATIONS,
|
||||
permissions: [Permissions.USE],
|
||||
getRoleByName,
|
||||
}));
|
||||
const hasFileCitationsAccess = await checkAccess({
|
||||
user,
|
||||
permissionType: PermissionTypes.FILE_CITATIONS,
|
||||
permissions: [Permissions.USE],
|
||||
getRoleByName,
|
||||
});
|
||||
|
||||
if (!hasFileCitationsAccess) {
|
||||
logger.debug(
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
const axios = require('axios');
|
||||
const { Providers } = require('@librechat/agents');
|
||||
const { logAxiosError } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
const { logAxiosError, inputSchema, processModelData } = require('@librechat/api');
|
||||
const { EModelEndpoint, defaultModels, CacheKeys } = require('librechat-data-provider');
|
||||
const { inputSchema, extractBaseURL, processModelData } = require('~/utils');
|
||||
const { OllamaClient } = require('~/app/clients/OllamaClient');
|
||||
const { isUserProvided } = require('~/server/utils');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
const { extractBaseURL } = require('~/utils');
|
||||
|
||||
/**
|
||||
* Splits a string by commas and trims each resulting value.
|
||||
|
||||
@@ -11,8 +11,8 @@ const {
|
||||
getAnthropicModels,
|
||||
} = require('./ModelService');
|
||||
|
||||
jest.mock('@librechat/api', () => {
|
||||
const originalUtils = jest.requireActual('@librechat/api');
|
||||
jest.mock('~/utils', () => {
|
||||
const originalUtils = jest.requireActual('~/utils');
|
||||
return {
|
||||
...originalUtils,
|
||||
processModelData: jest.fn((...args) => {
|
||||
@@ -108,7 +108,7 @@ describe('fetchModels with createTokenConfig true', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
// Clears the mock's history before each test
|
||||
const _utils = require('@librechat/api');
|
||||
const _utils = require('~/utils');
|
||||
axios.get.mockResolvedValue({ data });
|
||||
});
|
||||
|
||||
@@ -120,7 +120,7 @@ describe('fetchModels with createTokenConfig true', () => {
|
||||
createTokenConfig: true,
|
||||
});
|
||||
|
||||
const { processModelData } = require('@librechat/api');
|
||||
const { processModelData } = require('~/utils');
|
||||
expect(processModelData).toHaveBeenCalled();
|
||||
expect(processModelData).toHaveBeenCalledWith(data);
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const mongoose = require('mongoose');
|
||||
const { isEnabled } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { Strategy: AppleStrategy } = require('passport-apple');
|
||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||
const { createSocialUser, handleExistingUser } = require('./process');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
const socialLogin = require('./socialLogin');
|
||||
const { findUser } = require('~/models');
|
||||
const { User } = require('~/db/models');
|
||||
@@ -17,8 +17,6 @@ jest.mock('@librechat/data-schemas', () => {
|
||||
logger: {
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -26,19 +24,12 @@ jest.mock('./process', () => ({
|
||||
createSocialUser: jest.fn(),
|
||||
handleExistingUser: jest.fn(),
|
||||
}));
|
||||
jest.mock('@librechat/api', () => ({
|
||||
...jest.requireActual('@librechat/api'),
|
||||
jest.mock('~/server/utils', () => ({
|
||||
isEnabled: jest.fn(),
|
||||
}));
|
||||
jest.mock('~/models', () => ({
|
||||
findUser: jest.fn(),
|
||||
}));
|
||||
jest.mock('~/server/services/Config', () => ({
|
||||
getAppConfig: jest.fn().mockResolvedValue({
|
||||
fileStrategy: 'local',
|
||||
balance: { enabled: false },
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Apple Login Strategy', () => {
|
||||
let mongoServer;
|
||||
@@ -297,14 +288,7 @@ describe('Apple Login Strategy', () => {
|
||||
|
||||
expect(mockVerifyCallback).toHaveBeenCalledWith(null, existingUser);
|
||||
expect(existingUser.avatarUrl).toBeNull(); // As per getProfileDetails
|
||||
expect(handleExistingUser).toHaveBeenCalledWith(
|
||||
existingUser,
|
||||
null,
|
||||
expect.objectContaining({
|
||||
fileStrategy: 'local',
|
||||
balance: { enabled: false },
|
||||
}),
|
||||
);
|
||||
expect(handleExistingUser).toHaveBeenCalledWith(existingUser, null);
|
||||
});
|
||||
|
||||
it('should handle missing idToken gracefully', async () => {
|
||||
|
||||
@@ -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');
|
||||
@@ -184,7 +183,7 @@ const getUserInfo = async (config, accessToken, sub) => {
|
||||
const exchangedAccessToken = await exchangeAccessTokenIfNeeded(config, accessToken, sub);
|
||||
return await client.fetchUserInfo(config, exchangedAccessToken, sub);
|
||||
} catch (error) {
|
||||
logger.error('[openidStrategy] getUserInfo: Error fetching user info:', error);
|
||||
logger.warn(`[openidStrategy] getUserInfo: Error fetching user info: ${error}`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -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) {
|
||||
@@ -420,6 +409,7 @@ async function setupOpenId() {
|
||||
idOnTheSource: userinfo.oid,
|
||||
};
|
||||
|
||||
const appConfig = await getAppConfig();
|
||||
const balanceConfig = getBalanceConfig(appConfig);
|
||||
user = await createUser(user, balanceConfig, true, true);
|
||||
} else {
|
||||
@@ -448,9 +438,7 @@ async function setupOpenId() {
|
||||
userinfo.sub,
|
||||
);
|
||||
if (imageBuffer) {
|
||||
const { saveBuffer } = getStrategyFunctions(
|
||||
appConfig?.fileStrategy ?? process.env.CDN_PROVIDER,
|
||||
);
|
||||
const { saveBuffer } = getStrategyFunctions(process.env.CDN_PROVIDER);
|
||||
const imagePath = await saveBuffer({
|
||||
fileName,
|
||||
userId: user._id.toString(),
|
||||
|
||||
@@ -3,6 +3,7 @@ const { FileSources } = require('librechat-data-provider');
|
||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||
const { resizeAvatar } = require('~/server/services/Files/images/avatar');
|
||||
const { updateUser, createUser, getUserById } = require('~/models');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
|
||||
/**
|
||||
* Updates the avatar URL of an existing user. If the user's avatar URL does not include the query parameter
|
||||
@@ -11,15 +12,14 @@ const { updateUser, createUser, getUserById } = require('~/models');
|
||||
*
|
||||
* @param {IUser} oldUser - The existing user object that needs to be updated.
|
||||
* @param {string} avatarUrl - The new avatar URL to be set for the user.
|
||||
* @param {AppConfig} appConfig - The application configuration object.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
* The function updates the user's avatar and saves the user object. It does not return any value.
|
||||
*
|
||||
* @throws {Error} Throws an error if there's an issue saving the updated user object.
|
||||
*/
|
||||
const handleExistingUser = async (oldUser, avatarUrl, appConfig) => {
|
||||
const fileStrategy = appConfig?.fileStrategy ?? process.env.CDN_PROVIDER;
|
||||
const handleExistingUser = async (oldUser, avatarUrl) => {
|
||||
const fileStrategy = process.env.CDN_PROVIDER;
|
||||
const isLocal = fileStrategy === FileSources.local;
|
||||
|
||||
let updatedAvatar = false;
|
||||
@@ -56,7 +56,6 @@ const handleExistingUser = async (oldUser, avatarUrl, appConfig) => {
|
||||
* @param {string} params.providerId - The provider-specific ID of the user.
|
||||
* @param {string} params.username - The username of the new user.
|
||||
* @param {string} params.name - The name of the new user.
|
||||
* @param {AppConfig} appConfig - The application configuration object.
|
||||
* @param {boolean} [params.emailVerified=false] - Optional. Indicates whether the user's email is verified. Defaults to false.
|
||||
*
|
||||
* @returns {Promise<User>}
|
||||
@@ -72,7 +71,6 @@ const createSocialUser = async ({
|
||||
providerId,
|
||||
username,
|
||||
name,
|
||||
appConfig,
|
||||
emailVerified,
|
||||
}) => {
|
||||
const update = {
|
||||
@@ -85,9 +83,10 @@ const createSocialUser = async ({
|
||||
emailVerified,
|
||||
};
|
||||
|
||||
const appConfig = await getAppConfig();
|
||||
const balanceConfig = getBalanceConfig(appConfig);
|
||||
const newUserId = await createUser(update, balanceConfig);
|
||||
const fileStrategy = appConfig?.fileStrategy ?? process.env.CDN_PROVIDER;
|
||||
const fileStrategy = process.env.CDN_PROVIDER;
|
||||
const isLocal = fileStrategy === FileSources.local;
|
||||
|
||||
if (!isLocal) {
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -235,10 +225,11 @@ async function setupSaml() {
|
||||
provider: 'saml',
|
||||
samlId: profile.nameID,
|
||||
username,
|
||||
email: userEmail,
|
||||
email: getEmail(profile) || '',
|
||||
emailVerified: true,
|
||||
name: fullName,
|
||||
};
|
||||
const appConfig = await getAppConfig();
|
||||
const balanceConfig = getBalanceConfig(appConfig);
|
||||
user = await createUser(user, balanceConfig, true, true);
|
||||
} else {
|
||||
@@ -259,9 +250,7 @@ async function setupSaml() {
|
||||
fileName = profile.nameID + '.png';
|
||||
}
|
||||
|
||||
const { saveBuffer } = getStrategyFunctions(
|
||||
appConfig?.fileStrategy ?? process.env.CDN_PROVIDER,
|
||||
);
|
||||
const { saveBuffer } = getStrategyFunctions(process.env.CDN_PROVIDER);
|
||||
const imagePath = await saveBuffer({
|
||||
fileName,
|
||||
userId: user._id.toString(),
|
||||
|
||||
@@ -2,8 +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');
|
||||
|
||||
const socialLogin =
|
||||
@@ -14,22 +12,11 @@ const socialLogin =
|
||||
profile,
|
||||
});
|
||||
|
||||
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);
|
||||
await handleExistingUser(existingUser, avatarUrl);
|
||||
return cb(null, existingUser);
|
||||
} else if (existingUser) {
|
||||
logger.info(
|
||||
@@ -41,29 +28,19 @@ 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,
|
||||
});
|
||||
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,7 +1,7 @@
|
||||
const axios = require('axios');
|
||||
const deriveBaseURL = require('./deriveBaseURL');
|
||||
jest.mock('@librechat/api', () => {
|
||||
const originalUtils = jest.requireActual('@librechat/api');
|
||||
jest.mock('~/utils', () => {
|
||||
const originalUtils = jest.requireActual('~/utils');
|
||||
return {
|
||||
...originalUtils,
|
||||
processModelData: jest.fn((...args) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const tokenHelpers = require('./tokens');
|
||||
const deriveBaseURL = require('./deriveBaseURL');
|
||||
const extractBaseURL = require('./extractBaseURL');
|
||||
const findMessageContent = require('./findMessageContent');
|
||||
@@ -5,5 +6,6 @@ const findMessageContent = require('./findMessageContent');
|
||||
module.exports = {
|
||||
deriveBaseURL,
|
||||
extractBaseURL,
|
||||
...tokenHelpers,
|
||||
findMessageContent,
|
||||
};
|
||||
|
||||
@@ -1,23 +1,5 @@
|
||||
import z from 'zod';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
|
||||
/** Configuration object mapping model keys to their respective prompt, completion rates, and context limit
|
||||
*
|
||||
* Note: the [key: string]: unknown is not in the original JSDoc typedef in /api/typedefs.js, but I've included it since
|
||||
* getModelMaxOutputTokens calls getModelTokenValue with a key of 'output', which was not in the original JSDoc typedef,
|
||||
* but would be referenced in a TokenConfig in the if(matchedPattern) portion of getModelTokenValue.
|
||||
* So in order to preserve functionality for that case and any others which might reference an additional key I'm unaware of,
|
||||
* I've included it here until the interface can be typed more tightly.
|
||||
*/
|
||||
export interface TokenConfig {
|
||||
prompt: number;
|
||||
completion: number;
|
||||
context: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** An endpoint's config object mapping model keys to their respective prompt, completion rates, and context limit */
|
||||
export type EndpointTokenConfig = Record<string, TokenConfig>;
|
||||
const z = require('zod');
|
||||
const { EModelEndpoint } = require('librechat-data-provider');
|
||||
|
||||
const openAIModels = {
|
||||
'o4-mini': 200000,
|
||||
@@ -260,7 +242,7 @@ const aggregateModels = {
|
||||
'gpt-oss-120b': 131000,
|
||||
};
|
||||
|
||||
export const maxTokensMap = {
|
||||
const maxTokensMap = {
|
||||
[EModelEndpoint.azureOpenAI]: openAIModels,
|
||||
[EModelEndpoint.openAI]: aggregateModels,
|
||||
[EModelEndpoint.agents]: aggregateModels,
|
||||
@@ -270,7 +252,7 @@ export const maxTokensMap = {
|
||||
[EModelEndpoint.bedrock]: bedrockModels,
|
||||
};
|
||||
|
||||
export const modelMaxOutputs = {
|
||||
const modelMaxOutputs = {
|
||||
o1: 32268, // -500 from max: 32,768
|
||||
'o1-mini': 65136, // -500 from max: 65,536
|
||||
'o1-preview': 32268, // -500 from max: 32,768
|
||||
@@ -279,7 +261,7 @@ export const modelMaxOutputs = {
|
||||
'gpt-5-nano': 128000,
|
||||
'gpt-oss-20b': 131000,
|
||||
'gpt-oss-120b': 131000,
|
||||
system_default: 32000,
|
||||
system_default: 1024,
|
||||
};
|
||||
|
||||
/** Outputs from https://docs.anthropic.com/en/docs/about-claude/models/all-models#model-names */
|
||||
@@ -295,7 +277,7 @@ const anthropicMaxOutputs = {
|
||||
'claude-3-7-sonnet': 128000,
|
||||
};
|
||||
|
||||
export const maxOutputTokensMap = {
|
||||
const maxOutputTokensMap = {
|
||||
[EModelEndpoint.anthropic]: anthropicMaxOutputs,
|
||||
[EModelEndpoint.azureOpenAI]: modelMaxOutputs,
|
||||
[EModelEndpoint.openAI]: modelMaxOutputs,
|
||||
@@ -305,13 +287,10 @@ export const maxOutputTokensMap = {
|
||||
/**
|
||||
* Finds the first matching pattern in the tokens map.
|
||||
* @param {string} modelName
|
||||
* @param {Record<string, number> | EndpointTokenConfig} tokensMap
|
||||
* @param {Record<string, number>} tokensMap
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function findMatchingPattern(
|
||||
modelName: string,
|
||||
tokensMap: Record<string, number> | EndpointTokenConfig,
|
||||
): string | null {
|
||||
function findMatchingPattern(modelName, tokensMap) {
|
||||
const keys = Object.keys(tokensMap);
|
||||
for (let i = keys.length - 1; i >= 0; i--) {
|
||||
const modelKey = keys[i];
|
||||
@@ -326,79 +305,57 @@ export function findMatchingPattern(
|
||||
/**
|
||||
* Retrieves a token value for a given model name from a tokens map.
|
||||
*
|
||||
* @param modelName - The name of the model to look up.
|
||||
* @param tokensMap - The map of model names to token values.
|
||||
* @param [key='context'] - The key to look up in the tokens map.
|
||||
* @returns The token value for the given model or undefined if no match is found.
|
||||
* @param {string} modelName - The name of the model to look up.
|
||||
* @param {EndpointTokenConfig | Record<string, number>} tokensMap - The map of model names to token values.
|
||||
* @param {string} [key='context'] - The key to look up in the tokens map.
|
||||
* @returns {number|undefined} The token value for the given model or undefined if no match is found.
|
||||
*/
|
||||
export function getModelTokenValue(
|
||||
modelName: string,
|
||||
tokensMap?: EndpointTokenConfig | Record<string, number>,
|
||||
key = 'context' as keyof TokenConfig,
|
||||
): number | undefined {
|
||||
function getModelTokenValue(modelName, tokensMap, key = 'context') {
|
||||
if (typeof modelName !== 'string' || !tokensMap) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const value = tokensMap[modelName];
|
||||
if (typeof value === 'number') {
|
||||
return value;
|
||||
if (tokensMap[modelName]?.context) {
|
||||
return tokensMap[modelName].context;
|
||||
}
|
||||
|
||||
if (value?.context) {
|
||||
return value.context;
|
||||
if (tokensMap[modelName]) {
|
||||
return tokensMap[modelName];
|
||||
}
|
||||
|
||||
const matchedPattern = findMatchingPattern(modelName, tokensMap);
|
||||
|
||||
if (matchedPattern) {
|
||||
const result = tokensMap[matchedPattern];
|
||||
if (typeof result === 'number') {
|
||||
return result;
|
||||
}
|
||||
|
||||
const tokenValue = result?.[key];
|
||||
if (typeof tokenValue === 'number') {
|
||||
return tokenValue;
|
||||
}
|
||||
return tokensMap.system_default as number | undefined;
|
||||
return result?.[key] ?? result ?? tokensMap.system_default;
|
||||
}
|
||||
|
||||
return tokensMap.system_default as number | undefined;
|
||||
return tokensMap.system_default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the maximum tokens for a given model name.
|
||||
*
|
||||
* @param modelName - The name of the model to look up.
|
||||
* @param endpoint - The endpoint (default is 'openAI').
|
||||
* @param [endpointTokenConfig] - Token Config for current endpoint to use for max tokens lookup
|
||||
* @returns The maximum tokens for the given model or undefined if no match is found.
|
||||
* @param {string} modelName - The name of the model to look up.
|
||||
* @param {string} endpoint - The endpoint (default is 'openAI').
|
||||
* @param {EndpointTokenConfig} [endpointTokenConfig] - Token Config for current endpoint to use for max tokens lookup
|
||||
* @returns {number|undefined} The maximum tokens for the given model or undefined if no match is found.
|
||||
*/
|
||||
export function getModelMaxTokens(
|
||||
modelName: string,
|
||||
endpoint = EModelEndpoint.openAI,
|
||||
endpointTokenConfig?: EndpointTokenConfig,
|
||||
): number | undefined {
|
||||
const tokensMap = endpointTokenConfig ?? maxTokensMap[endpoint as keyof typeof maxTokensMap];
|
||||
function getModelMaxTokens(modelName, endpoint = EModelEndpoint.openAI, endpointTokenConfig) {
|
||||
const tokensMap = endpointTokenConfig ?? maxTokensMap[endpoint];
|
||||
return getModelTokenValue(modelName, tokensMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the maximum output tokens for a given model name.
|
||||
*
|
||||
* @param modelName - The name of the model to look up.
|
||||
* @param endpoint - The endpoint (default is 'openAI').
|
||||
* @param [endpointTokenConfig] - Token Config for current endpoint to use for max tokens lookup
|
||||
* @returns The maximum output tokens for the given model or undefined if no match is found.
|
||||
* @param {string} modelName - The name of the model to look up.
|
||||
* @param {string} endpoint - The endpoint (default is 'openAI').
|
||||
* @param {EndpointTokenConfig} [endpointTokenConfig] - Token Config for current endpoint to use for max tokens lookup
|
||||
* @returns {number|undefined} The maximum output tokens for the given model or undefined if no match is found.
|
||||
*/
|
||||
export function getModelMaxOutputTokens(
|
||||
modelName: string,
|
||||
endpoint = EModelEndpoint.openAI,
|
||||
endpointTokenConfig?: EndpointTokenConfig,
|
||||
): number | undefined {
|
||||
const tokensMap =
|
||||
endpointTokenConfig ?? maxOutputTokensMap[endpoint as keyof typeof maxOutputTokensMap];
|
||||
function getModelMaxOutputTokens(modelName, endpoint = EModelEndpoint.openAI, endpointTokenConfig) {
|
||||
const tokensMap = endpointTokenConfig ?? maxOutputTokensMap[endpoint];
|
||||
return getModelTokenValue(modelName, tokensMap, 'output');
|
||||
}
|
||||
|
||||
@@ -406,24 +363,21 @@ export function getModelMaxOutputTokens(
|
||||
* Retrieves the model name key for a given model name input. If the exact model name isn't found,
|
||||
* it searches for partial matches within the model name, checking keys in reverse order.
|
||||
*
|
||||
* @param modelName - The name of the model to look up.
|
||||
* @param endpoint - The endpoint (default is 'openAI').
|
||||
* @returns The model name key for the given model; returns input if no match is found and is string.
|
||||
* @param {string} modelName - The name of the model to look up.
|
||||
* @param {string} endpoint - The endpoint (default is 'openAI').
|
||||
* @returns {string|undefined} The model name key for the given model; returns input if no match is found and is string.
|
||||
*
|
||||
* @example
|
||||
* matchModelName('gpt-4-32k-0613'); // Returns 'gpt-4-32k-0613'
|
||||
* matchModelName('gpt-4-32k-unknown'); // Returns 'gpt-4-32k'
|
||||
* matchModelName('unknown-model'); // Returns undefined
|
||||
*/
|
||||
export function matchModelName(
|
||||
modelName: string,
|
||||
endpoint = EModelEndpoint.openAI,
|
||||
): string | undefined {
|
||||
function matchModelName(modelName, endpoint = EModelEndpoint.openAI) {
|
||||
if (typeof modelName !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const tokensMap: Record<string, number> = maxTokensMap[endpoint as keyof typeof maxTokensMap];
|
||||
const tokensMap = maxTokensMap[endpoint];
|
||||
if (!tokensMap) {
|
||||
return modelName;
|
||||
}
|
||||
@@ -436,7 +390,7 @@ export function matchModelName(
|
||||
return matchedPattern || modelName;
|
||||
}
|
||||
|
||||
export const modelSchema = z.object({
|
||||
const modelSchema = z.object({
|
||||
id: z.string(),
|
||||
pricing: z.object({
|
||||
prompt: z.string(),
|
||||
@@ -445,7 +399,7 @@ export const modelSchema = z.object({
|
||||
context_length: z.number(),
|
||||
});
|
||||
|
||||
export const inputSchema = z.object({
|
||||
const inputSchema = z.object({
|
||||
data: z.array(modelSchema),
|
||||
});
|
||||
|
||||
@@ -454,7 +408,7 @@ export const inputSchema = z.object({
|
||||
* @param {{ data: Array<z.infer<typeof modelSchema>> }} input The input object containing base URL and data fetched from the API.
|
||||
* @returns {EndpointTokenConfig} The processed model data.
|
||||
*/
|
||||
export function processModelData(input: z.infer<typeof inputSchema>): EndpointTokenConfig {
|
||||
function processModelData(input) {
|
||||
const validationResult = inputSchema.safeParse(input);
|
||||
if (!validationResult.success) {
|
||||
throw new Error('Invalid input data');
|
||||
@@ -462,7 +416,7 @@ export function processModelData(input: z.infer<typeof inputSchema>): EndpointTo
|
||||
const { data } = validationResult.data;
|
||||
|
||||
/** @type {EndpointTokenConfig} */
|
||||
const tokenConfig: EndpointTokenConfig = {};
|
||||
const tokenConfig = {};
|
||||
|
||||
for (const model of data) {
|
||||
const modelKey = model.id;
|
||||
@@ -485,7 +439,7 @@ export function processModelData(input: z.infer<typeof inputSchema>): EndpointTo
|
||||
return tokenConfig;
|
||||
}
|
||||
|
||||
export const tiktokenModels = new Set([
|
||||
const tiktokenModels = new Set([
|
||||
'text-davinci-003',
|
||||
'text-davinci-002',
|
||||
'text-davinci-001',
|
||||
@@ -523,3 +477,17 @@ export const tiktokenModels = new Set([
|
||||
'gpt-3.5-turbo',
|
||||
'gpt-3.5-turbo-0301',
|
||||
]);
|
||||
|
||||
module.exports = {
|
||||
inputSchema,
|
||||
modelSchema,
|
||||
maxTokensMap,
|
||||
tiktokenModels,
|
||||
maxOutputTokensMap,
|
||||
matchModelName,
|
||||
processModelData,
|
||||
getModelMaxTokens,
|
||||
getModelTokenValue,
|
||||
findMatchingPattern,
|
||||
getModelMaxOutputTokens,
|
||||
};
|
||||
@@ -1,12 +1,12 @@
|
||||
const { EModelEndpoint } = require('librechat-data-provider');
|
||||
const {
|
||||
maxTokensMap,
|
||||
matchModelName,
|
||||
processModelData,
|
||||
getModelMaxTokens,
|
||||
maxOutputTokensMap,
|
||||
findMatchingPattern,
|
||||
} = require('@librechat/api');
|
||||
getModelMaxTokens,
|
||||
processModelData,
|
||||
matchModelName,
|
||||
maxTokensMap,
|
||||
} = require('./tokens');
|
||||
|
||||
describe('getModelMaxTokens', () => {
|
||||
test('should return correct tokens for exact match', () => {
|
||||
@@ -394,7 +394,7 @@ describe('getModelMaxTokens', () => {
|
||||
});
|
||||
|
||||
test('should return correct max output tokens for GPT-5 models', () => {
|
||||
const { getModelMaxOutputTokens } = require('@librechat/api');
|
||||
const { getModelMaxOutputTokens } = require('./tokens');
|
||||
['gpt-5', 'gpt-5-mini', 'gpt-5-nano'].forEach((model) => {
|
||||
expect(getModelMaxOutputTokens(model)).toBe(maxOutputTokensMap[EModelEndpoint.openAI][model]);
|
||||
expect(getModelMaxOutputTokens(model, EModelEndpoint.openAI)).toBe(
|
||||
@@ -407,7 +407,7 @@ describe('getModelMaxTokens', () => {
|
||||
});
|
||||
|
||||
test('should return correct max output tokens for GPT-OSS models', () => {
|
||||
const { getModelMaxOutputTokens } = require('@librechat/api');
|
||||
const { getModelMaxOutputTokens } = require('./tokens');
|
||||
['gpt-oss-20b', 'gpt-oss-120b'].forEach((model) => {
|
||||
expect(getModelMaxOutputTokens(model)).toBe(maxOutputTokensMap[EModelEndpoint.openAI][model]);
|
||||
expect(getModelMaxOutputTokens(model, EModelEndpoint.openAI)).toBe(
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -140,7 +140,6 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
|
||||
pluginTools,
|
||||
activePanel,
|
||||
agentsConfig,
|
||||
startupConfig,
|
||||
setActivePanel,
|
||||
endpointsConfig,
|
||||
setCurrentAgentId,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { AgentCapabilities, ArtifactModes } from 'librechat-data-provider';
|
||||
import type {
|
||||
Agent,
|
||||
AgentProvider,
|
||||
AgentModelParameters,
|
||||
SupportContact,
|
||||
AgentProvider,
|
||||
GraphEdge,
|
||||
Agent,
|
||||
} from 'librechat-data-provider';
|
||||
import type { OptionWithIcon, ExtendedFile } from './types';
|
||||
|
||||
@@ -33,7 +34,9 @@ export type AgentForm = {
|
||||
model_parameters: AgentModelParameters;
|
||||
tools?: string[];
|
||||
provider?: AgentProvider | OptionWithIcon;
|
||||
/** @deprecated Use edges instead */
|
||||
agent_ids?: string[];
|
||||
edges?: GraphEdge[];
|
||||
[AgentCapabilities.artifacts]?: ArtifactModes | string;
|
||||
recursion_limit?: number;
|
||||
support_contact?: SupportContact;
|
||||
|
||||
@@ -239,7 +239,6 @@ export type AgentPanelContextType = {
|
||||
setActivePanel: React.Dispatch<React.SetStateAction<Panel>>;
|
||||
setCurrentAgentId: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
agent_id?: string;
|
||||
startupConfig?: t.TStartupConfig | null;
|
||||
agentsConfig?: t.TAgentsEndpoint | null;
|
||||
endpointsConfig?: t.TEndpointsConfig | null;
|
||||
/** Pre-computed MCP server information indexed by server key */
|
||||
@@ -642,3 +641,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={
|
||||
|
||||
92
client/src/components/Chat/Messages/Content/AgentHandoff.tsx
Normal file
92
client/src/components/Chat/Messages/Content/AgentHandoff.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { EModelEndpoint, Constants } from 'librechat-data-provider';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import MessageIcon from '~/components/Share/MessageIcon';
|
||||
import { useAgentsMapContext } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface AgentHandoffProps {
|
||||
name: string;
|
||||
args: string | Record<string, unknown>;
|
||||
output?: string | null;
|
||||
}
|
||||
|
||||
const AgentHandoff: React.FC<AgentHandoffProps> = ({ name, args: _args = '' }) => {
|
||||
const localize = useLocalize();
|
||||
const agentsMap = useAgentsMapContext();
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
|
||||
/** Extracted agent ID from tool name (e.g., "lc_transfer_to_agent_gUV0wMb7zHt3y3Xjz-8_4" -> "agent_gUV0wMb7zHt3y3Xjz-8_4") */
|
||||
const targetAgentId = useMemo(() => {
|
||||
if (typeof name !== 'string' || !name.startsWith(Constants.LC_TRANSFER_TO_)) {
|
||||
return null;
|
||||
}
|
||||
return name.replace(Constants.LC_TRANSFER_TO_, '');
|
||||
}, [name]);
|
||||
|
||||
const targetAgent = useMemo(() => {
|
||||
if (!targetAgentId || !agentsMap) {
|
||||
return null;
|
||||
}
|
||||
return agentsMap[targetAgentId];
|
||||
}, [agentsMap, targetAgentId]);
|
||||
|
||||
const args = useMemo(() => {
|
||||
if (typeof _args === 'string') {
|
||||
return _args;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(_args, null, 2);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}, [_args]) as string;
|
||||
|
||||
/** Requires more than 2 characters as can be an empty object: `{}` */
|
||||
const hasInfo = useMemo(() => (args?.trim()?.length ?? 0) > 2, [args]);
|
||||
|
||||
return (
|
||||
<div className="my-3">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2.5 text-sm text-text-secondary',
|
||||
hasInfo && 'cursor-pointer transition-colors hover:text-text-primary',
|
||||
)}
|
||||
onClick={() => hasInfo && setShowInfo(!showInfo)}
|
||||
>
|
||||
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
|
||||
<MessageIcon
|
||||
message={
|
||||
{
|
||||
endpoint: EModelEndpoint.agents,
|
||||
isCreatedByUser: false,
|
||||
} as TMessage
|
||||
}
|
||||
agent={targetAgent || undefined}
|
||||
/>
|
||||
</div>
|
||||
<span className="select-none">{localize('com_ui_transferred_to')}</span>
|
||||
<span className="select-none font-medium text-text-primary">
|
||||
{targetAgent?.name || localize('com_ui_agent')}
|
||||
</span>
|
||||
{hasInfo && (
|
||||
<ChevronDown
|
||||
className={cn('ml-1 h-3 w-3 transition-transform', showInfo && 'rotate-180')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{hasInfo && showInfo && (
|
||||
<div className="ml-8 mt-2 rounded-md bg-surface-secondary p-3 text-xs">
|
||||
<div className="mb-1 font-medium text-text-secondary">
|
||||
{localize('com_ui_handoff_instructions')}:
|
||||
</div>
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap text-text-primary">{args}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentHandoff;
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Tools,
|
||||
Constants,
|
||||
ContentTypes,
|
||||
ToolCallTypes,
|
||||
imageGenTools,
|
||||
@@ -10,6 +11,7 @@ import type { TMessageContentParts, TAttachment } from 'librechat-data-provider'
|
||||
import { OpenAIImageGen, EmptyText, Reasoning, ExecuteCode, AgentUpdate, Text } from './Parts';
|
||||
import { ErrorMessage } from './MessageContent';
|
||||
import RetrievalCall from './RetrievalCall';
|
||||
import AgentHandoff from './AgentHandoff';
|
||||
import CodeAnalyze from './CodeAnalyze';
|
||||
import Container from './Container';
|
||||
import WebSearch from './WebSearch';
|
||||
@@ -118,6 +120,14 @@ const Part = memo(
|
||||
isLast={isLast}
|
||||
/>
|
||||
);
|
||||
} else if (isToolCall && toolCall.name?.startsWith(Constants.LC_TRANSFER_TO_)) {
|
||||
return (
|
||||
<AgentHandoff
|
||||
args={toolCall.args ?? ''}
|
||||
name={toolCall.name || ''}
|
||||
output={toolCall.output ?? ''}
|
||||
/>
|
||||
);
|
||||
} else if (isToolCall) {
|
||||
return (
|
||||
<ToolCall
|
||||
|
||||
@@ -11,8 +11,8 @@ interface AgentUpdateProps {
|
||||
|
||||
const AgentUpdate: React.FC<AgentUpdateProps> = ({ currentAgentId }) => {
|
||||
const localize = useLocalize();
|
||||
const agentsMap = useAgentsMapContext() || {};
|
||||
const currentAgent = useMemo(() => agentsMap[currentAgentId], [agentsMap, currentAgentId]);
|
||||
const agentsMap = useAgentsMapContext();
|
||||
const currentAgent = useMemo(() => agentsMap?.[currentAgentId], [agentsMap, currentAgentId]);
|
||||
if (!currentAgentId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useFormContext, Controller } from 'react-hook-form';
|
||||
import type { AgentForm } from '~/common';
|
||||
import { useAgentPanelContext } from '~/Providers';
|
||||
import MaxAgentSteps from './MaxAgentSteps';
|
||||
import AgentHandoffs from './AgentHandoffs';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import AgentChain from './AgentChain';
|
||||
import { Panel } from '~/common';
|
||||
@@ -41,6 +42,12 @@ export default function AdvancedPanel() {
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 px-2">
|
||||
<MaxAgentSteps />
|
||||
<Controller
|
||||
name="edges"
|
||||
control={control}
|
||||
defaultValue={[]}
|
||||
render={({ field }) => <AgentHandoffs field={field} currentAgentId={currentAgentId} />}
|
||||
/>
|
||||
{chainEnabled && (
|
||||
<Controller
|
||||
name="agent_ids"
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import React, { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { X, Waypoints, PlusCircle, ChevronDown } from 'lucide-react';
|
||||
import {
|
||||
Label,
|
||||
Input,
|
||||
Textarea,
|
||||
HoverCard,
|
||||
CircleHelpIcon,
|
||||
HoverCardPortal,
|
||||
ControlCombobox,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from '@librechat/client';
|
||||
import type { TMessage, GraphEdge } from 'librechat-data-provider';
|
||||
import type { ControllerRenderProps } from 'react-hook-form';
|
||||
import type { AgentForm, OptionWithIcon } from '~/common';
|
||||
import MessageIcon from '~/components/Share/MessageIcon';
|
||||
import { useAgentsMapContext } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { ESide } from '~/common';
|
||||
|
||||
interface AgentHandoffsProps {
|
||||
field: ControllerRenderProps<AgentForm, 'edges'>;
|
||||
currentAgentId: string;
|
||||
}
|
||||
|
||||
/** TODO: make configurable */
|
||||
const MAX_HANDOFFS = 10;
|
||||
|
||||
const AgentHandoffs: React.FC<AgentHandoffsProps> = ({ field, currentAgentId }) => {
|
||||
const localize = useLocalize();
|
||||
const [newAgentId, setNewAgentId] = useState('');
|
||||
const [expandedIndices, setExpandedIndices] = useState<Set<number>>(new Set());
|
||||
const agentsMap = useAgentsMapContext();
|
||||
const edgesValue = field.value;
|
||||
const edges = useMemo(() => edgesValue || [], [edgesValue]);
|
||||
|
||||
const agents = useMemo(() => (agentsMap ? Object.values(agentsMap) : []), [agentsMap]);
|
||||
|
||||
const selectableAgents = useMemo(
|
||||
() =>
|
||||
agents
|
||||
.filter((agent) => agent?.id !== currentAgentId)
|
||||
.map(
|
||||
(agent) =>
|
||||
({
|
||||
label: agent?.name || '',
|
||||
value: agent?.id || '',
|
||||
icon: (
|
||||
<MessageIcon
|
||||
message={
|
||||
{
|
||||
endpoint: EModelEndpoint.agents,
|
||||
isCreatedByUser: false,
|
||||
} as TMessage
|
||||
}
|
||||
agent={agent}
|
||||
/>
|
||||
),
|
||||
}) as OptionWithIcon,
|
||||
),
|
||||
[agents, currentAgentId],
|
||||
);
|
||||
|
||||
const getAgentDetails = useCallback((id: string) => agentsMap?.[id], [agentsMap]);
|
||||
|
||||
useEffect(() => {
|
||||
if (newAgentId && edges.length < MAX_HANDOFFS) {
|
||||
const newEdge: GraphEdge = {
|
||||
from: currentAgentId,
|
||||
to: newAgentId,
|
||||
edgeType: 'handoff',
|
||||
};
|
||||
field.onChange([...edges, newEdge]);
|
||||
setNewAgentId('');
|
||||
}
|
||||
}, [newAgentId, edges, field, currentAgentId]);
|
||||
|
||||
const removeHandoffAt = (index: number) => {
|
||||
field.onChange(edges.filter((_, i) => i !== index));
|
||||
// Also remove from expanded set
|
||||
setExpandedIndices((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(index);
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const updateHandoffAt = (index: number, agentId: string) => {
|
||||
const updated = [...edges];
|
||||
updated[index] = { ...updated[index], to: agentId };
|
||||
field.onChange(updated);
|
||||
};
|
||||
|
||||
const updateHandoffDetailsAt = (index: number, updates: Partial<GraphEdge>) => {
|
||||
const updated = [...edges];
|
||||
updated[index] = { ...updated[index], ...updates };
|
||||
field.onChange(updated);
|
||||
};
|
||||
|
||||
const toggleExpanded = (index: number) => {
|
||||
setExpandedIndices((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(index)) {
|
||||
newSet.delete(index);
|
||||
} else {
|
||||
newSet.add(index);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const getTargetAgentId = (to: string | string[]): string => {
|
||||
return Array.isArray(to) ? to[0] : to;
|
||||
};
|
||||
|
||||
return (
|
||||
<HoverCard openDelay={50}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="font-semibold text-text-primary">
|
||||
{localize('com_ui_agent_handoffs')}
|
||||
</label>
|
||||
<HoverCardTrigger>
|
||||
<CircleHelpIcon className="h-4 w-4 text-text-tertiary" />
|
||||
</HoverCardTrigger>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-full border border-purple-600/40 bg-purple-500/10 px-2 py-0.5 text-xs font-medium text-purple-700 hover:bg-purple-700/10 dark:text-purple-400">
|
||||
{localize('com_ui_beta')}
|
||||
</div>
|
||||
<div className="text-xs text-text-secondary">
|
||||
{edges.length} / {MAX_HANDOFFS}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{edges.map((edge, idx) => {
|
||||
const targetAgentId = getTargetAgentId(edge.to);
|
||||
const isExpanded = expandedIndices.has(idx);
|
||||
|
||||
return (
|
||||
<React.Fragment key={idx}>
|
||||
<div className="space-y-1">
|
||||
<div className="flex h-10 items-center gap-2 rounded-md border border-border-medium bg-surface-tertiary pr-2">
|
||||
<ControlCombobox
|
||||
isCollapsed={false}
|
||||
ariaLabel={localize('com_ui_agent_var', { 0: localize('com_ui_select') })}
|
||||
selectedValue={targetAgentId}
|
||||
setValue={(id) => updateHandoffAt(idx, id)}
|
||||
selectPlaceholder={localize('com_ui_agent_var', {
|
||||
0: localize('com_ui_select'),
|
||||
})}
|
||||
searchPlaceholder={localize('com_ui_agent_var', {
|
||||
0: localize('com_ui_search'),
|
||||
})}
|
||||
items={selectableAgents}
|
||||
displayValue={getAgentDetails(targetAgentId)?.name ?? ''}
|
||||
SelectIcon={
|
||||
<MessageIcon
|
||||
message={
|
||||
{
|
||||
endpoint: EModelEndpoint.agents,
|
||||
isCreatedByUser: false,
|
||||
} as TMessage
|
||||
}
|
||||
agent={targetAgentId && agentsMap ? agentsMap[targetAgentId] : undefined}
|
||||
/>
|
||||
}
|
||||
className="flex-1 border-border-heavy"
|
||||
containerClassName="px-0"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 transition hover:bg-surface-hover"
|
||||
onClick={() => toggleExpanded(idx)}
|
||||
>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`text-text-secondary transition-transform ${
|
||||
isExpanded ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-xl p-1 transition hover:bg-surface-hover"
|
||||
onClick={() => removeHandoffAt(idx)}
|
||||
>
|
||||
<X size={18} className="text-text-secondary" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="space-y-3 rounded-md border border-border-light bg-surface-primary p-3">
|
||||
<div>
|
||||
<Label
|
||||
htmlFor={`handoff-desc-${idx}`}
|
||||
className="text-xs text-text-secondary"
|
||||
>
|
||||
{localize('com_ui_agent_handoff_description')}
|
||||
</Label>
|
||||
<Input
|
||||
id={`handoff-desc-${idx}`}
|
||||
placeholder={localize('com_ui_agent_handoff_description_placeholder')}
|
||||
value={edge.description || ''}
|
||||
onChange={(e) =>
|
||||
updateHandoffDetailsAt(idx, { description: e.target.value })
|
||||
}
|
||||
className="mt-1 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
htmlFor={`handoff-prompt-${idx}`}
|
||||
className="text-xs text-text-secondary"
|
||||
>
|
||||
{localize('com_ui_agent_handoff_prompt')}
|
||||
</Label>
|
||||
<Textarea
|
||||
id={`handoff-prompt-${idx}`}
|
||||
placeholder={localize('com_ui_agent_handoff_prompt_placeholder')}
|
||||
value={typeof edge.prompt === 'string' ? edge.prompt : ''}
|
||||
onChange={(e) => updateHandoffDetailsAt(idx, { prompt: e.target.value })}
|
||||
className="mt-1 h-20 resize-none text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{edge.prompt && (
|
||||
<div>
|
||||
<Label
|
||||
htmlFor={`handoff-promptkey-${idx}`}
|
||||
className="text-xs text-text-secondary"
|
||||
>
|
||||
{localize('com_ui_agent_handoff_prompt_key')}
|
||||
</Label>
|
||||
<Input
|
||||
id={`handoff-promptkey-${idx}`}
|
||||
placeholder={localize('com_ui_agent_handoff_prompt_key_placeholder')}
|
||||
value={edge.promptKey || ''}
|
||||
onChange={(e) =>
|
||||
updateHandoffDetailsAt(idx, { promptKey: e.target.value })
|
||||
}
|
||||
className="mt-1 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{idx < edges.length - 1 && (
|
||||
<Waypoints className="mx-auto text-text-secondary" size={14} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
{edges.length < MAX_HANDOFFS && (
|
||||
<>
|
||||
{edges.length > 0 && <Waypoints className="mx-auto text-text-secondary" size={14} />}
|
||||
<ControlCombobox
|
||||
isCollapsed={false}
|
||||
ariaLabel={localize('com_ui_agent_var', { 0: localize('com_ui_add') })}
|
||||
selectedValue=""
|
||||
setValue={setNewAgentId}
|
||||
selectPlaceholder={localize('com_ui_agent_handoff_add')}
|
||||
searchPlaceholder={localize('com_ui_agent_var', { 0: localize('com_ui_search') })}
|
||||
items={selectableAgents}
|
||||
className="h-10 w-full border-dashed border-border-heavy text-center text-text-secondary hover:text-text-primary"
|
||||
containerClassName="px-0"
|
||||
SelectIcon={<PlusCircle size={16} className="text-text-secondary" />}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{edges.length >= MAX_HANDOFFS && (
|
||||
<p className="pt-1 text-center text-xs italic text-text-tertiary">
|
||||
{localize('com_ui_agent_handoff_max', { 0: MAX_HANDOFFS })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<HoverCardPortal>
|
||||
<HoverCardContent side={ESide.Top} className="w-80">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-text-secondary">{localize('com_ui_agent_handoff_info')}</p>
|
||||
<p className="text-sm text-text-secondary">{localize('com_ui_agent_handoff_info_2')}</p>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCardPortal>
|
||||
</HoverCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentHandoffs;
|
||||
@@ -49,7 +49,6 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
|
||||
actions,
|
||||
setAction,
|
||||
agentsConfig,
|
||||
startupConfig,
|
||||
mcpServersMap,
|
||||
setActivePanel,
|
||||
endpointsConfig,
|
||||
@@ -309,14 +308,6 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
|
||||
{fileSearchEnabled && <FileSearch agent_id={agent_id} files={knowledge_files} />}
|
||||
</div>
|
||||
)}
|
||||
{/* MCP Section */}
|
||||
{startupConfig?.mcpServers != null && (
|
||||
<MCPTools
|
||||
agentId={agent_id}
|
||||
mcpServerNames={mcpServerNames}
|
||||
setShowMCPToolDialog={setShowMCPToolDialog}
|
||||
/>
|
||||
)}
|
||||
{/* Agent Tools & Actions */}
|
||||
<div className="mb-4">
|
||||
<label className={labelClass}>
|
||||
@@ -384,6 +375,12 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* MCP Section */}
|
||||
<MCPTools
|
||||
agentId={agent_id}
|
||||
mcpServerNames={mcpServerNames}
|
||||
setShowMCPToolDialog={setShowMCPToolDialog}
|
||||
/>
|
||||
{/* Support Contact (Optional) */}
|
||||
<div className="mb-4">
|
||||
<div className="mb-1.5 flex items-center gap-2">
|
||||
|
||||
@@ -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,
|
||||
@@ -173,6 +177,7 @@ export default function AgentPanel() {
|
||||
model_parameters,
|
||||
provider: _provider,
|
||||
agent_ids,
|
||||
edges,
|
||||
end_after_tools,
|
||||
hide_sequential_outputs,
|
||||
recursion_limit,
|
||||
@@ -197,6 +202,7 @@ export default function AgentPanel() {
|
||||
provider,
|
||||
model_parameters,
|
||||
agent_ids,
|
||||
edges,
|
||||
end_after_tools,
|
||||
hide_sequential_outputs,
|
||||
recursion_limit,
|
||||
@@ -230,6 +236,7 @@ export default function AgentPanel() {
|
||||
provider,
|
||||
model_parameters,
|
||||
agent_ids,
|
||||
edges,
|
||||
end_after_tools,
|
||||
hide_sequential_outputs,
|
||||
recursion_limit,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user