Compare commits
4 Commits
docs/azure
...
copilot/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c31309a1db | ||
|
|
3c4c4d53dd | ||
|
|
9fe4554a70 | ||
|
|
c1dcabae20 |
14
.env.example
14
.env.example
@@ -254,10 +254,6 @@ AZURE_AI_SEARCH_SEARCH_OPTION_SELECT=
|
||||
|
||||
# OpenAI Image Tools Customization
|
||||
#----------------
|
||||
# IMAGE_GEN_OAI_API_KEY= # Create or reuse OpenAI API key for image generation tool
|
||||
# IMAGE_GEN_OAI_BASEURL= # Custom OpenAI base URL for image generation tool
|
||||
# IMAGE_GEN_OAI_AZURE_API_VERSION= # Custom Azure OpenAI deployments
|
||||
# IMAGE_GEN_OAI_DESCRIPTION=
|
||||
# IMAGE_GEN_OAI_DESCRIPTION_WITH_FILES=Custom description for image generation tool when files are present
|
||||
# IMAGE_GEN_OAI_DESCRIPTION_NO_FILES=Custom description for image generation tool when no files are present
|
||||
# IMAGE_EDIT_OAI_DESCRIPTION=Custom description for image editing tool
|
||||
@@ -706,16 +702,6 @@ HELP_AND_FAQ_URL=https://librechat.ai
|
||||
# Comma-separated list of CacheKeys (e.g., ROLES,MESSAGES)
|
||||
# FORCED_IN_MEMORY_CACHE_NAMESPACES=ROLES,MESSAGES
|
||||
|
||||
# Leader Election Configuration (for multi-instance deployments with Redis)
|
||||
# Duration in seconds that the leader lease is valid before it expires (default: 25)
|
||||
# LEADER_LEASE_DURATION=25
|
||||
# Interval in seconds at which the leader renews its lease (default: 10)
|
||||
# LEADER_RENEW_INTERVAL=10
|
||||
# Maximum number of retry attempts when renewing the lease fails (default: 3)
|
||||
# LEADER_RENEW_ATTEMPTS=3
|
||||
# Delay in seconds between retry attempts when renewing the lease (default: 0.5)
|
||||
# LEADER_RENEW_RETRY_DELAY=0.5
|
||||
|
||||
#==================================================#
|
||||
# Others #
|
||||
#==================================================#
|
||||
|
||||
22
.github/workflows/cache-integration-tests.yml
vendored
22
.github/workflows/cache-integration-tests.yml
vendored
@@ -8,14 +8,12 @@ on:
|
||||
- release/*
|
||||
paths:
|
||||
- 'packages/api/src/cache/**'
|
||||
- 'packages/api/src/cluster/**'
|
||||
- 'packages/api/src/mcp/**'
|
||||
- 'redis-config/**'
|
||||
- '.github/workflows/cache-integration-tests.yml'
|
||||
|
||||
jobs:
|
||||
cache_integration_tests:
|
||||
name: Integration Tests that use actual Redis Cache
|
||||
name: Run Cache Integration Tests
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -68,23 +66,7 @@ jobs:
|
||||
USE_REDIS: true
|
||||
REDIS_URI: redis://127.0.0.1:6379
|
||||
REDIS_CLUSTER_URI: redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003
|
||||
run: npm run test:cache-integration:core
|
||||
|
||||
- name: Run cluster integration tests
|
||||
working-directory: packages/api
|
||||
env:
|
||||
NODE_ENV: test
|
||||
USE_REDIS: true
|
||||
REDIS_URI: redis://127.0.0.1:6379
|
||||
run: npm run test:cache-integration:cluster
|
||||
|
||||
- name: Run mcp integration tests
|
||||
working-directory: packages/api
|
||||
env:
|
||||
NODE_ENV: test
|
||||
USE_REDIS: true
|
||||
REDIS_URI: redis://127.0.0.1:6379
|
||||
run: npm run test:cache-integration:mcp
|
||||
run: npm run test:cache:integration
|
||||
|
||||
- name: Stop Redis Cluster
|
||||
if: always()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# v0.8.1-rc1
|
||||
# v0.8.0
|
||||
|
||||
# Base node image
|
||||
FROM node:20-alpine AS node
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Dockerfile.multi
|
||||
# v0.8.1-rc1
|
||||
# v0.8.0
|
||||
|
||||
# Base for all builds
|
||||
FROM node:20-alpine AS base-min
|
||||
|
||||
@@ -2,7 +2,7 @@ const { z } = require('zod');
|
||||
const axios = require('axios');
|
||||
const { Ollama } = require('ollama');
|
||||
const { sleep } = require('@librechat/agents');
|
||||
const { resolveHeaders } = require('@librechat/api');
|
||||
const { logAxiosError } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { Constants } = require('librechat-data-provider');
|
||||
const { deriveBaseURL } = require('~/utils');
|
||||
@@ -44,7 +44,6 @@ class OllamaClient {
|
||||
constructor(options = {}) {
|
||||
const host = deriveBaseURL(options.baseURL ?? 'http://localhost:11434');
|
||||
this.streamRate = options.streamRate ?? Constants.DEFAULT_STREAM_RATE;
|
||||
this.headers = options.headers ?? {};
|
||||
/** @type {Ollama} */
|
||||
this.client = new Ollama({ host });
|
||||
}
|
||||
@@ -52,32 +51,27 @@ class OllamaClient {
|
||||
/**
|
||||
* Fetches Ollama models from the specified base API path.
|
||||
* @param {string} baseURL
|
||||
* @param {Object} [options] - Optional configuration
|
||||
* @param {Partial<IUser>} [options.user] - User object for header resolution
|
||||
* @param {Record<string, string>} [options.headers] - Headers to include in the request
|
||||
* @returns {Promise<string[]>} The Ollama models.
|
||||
* @throws {Error} Throws if the Ollama API request fails
|
||||
*/
|
||||
static async fetchModels(baseURL, options = {}) {
|
||||
static async fetchModels(baseURL) {
|
||||
let models = [];
|
||||
if (!baseURL) {
|
||||
return models;
|
||||
}
|
||||
try {
|
||||
const ollamaEndpoint = deriveBaseURL(baseURL);
|
||||
/** @type {Promise<AxiosResponse<OllamaListResponse>>} */
|
||||
const response = await axios.get(`${ollamaEndpoint}/api/tags`, {
|
||||
timeout: 5000,
|
||||
});
|
||||
models = response.data.models.map((tag) => tag.name);
|
||||
return models;
|
||||
} catch (error) {
|
||||
const logMessage =
|
||||
"Failed to fetch models from Ollama API. If you are not using Ollama directly, and instead, through some aggregator or reverse proxy that handles fetching via OpenAI spec, ensure the name of the endpoint doesn't start with `ollama` (case-insensitive).";
|
||||
logAxiosError({ message: logMessage, error });
|
||||
return [];
|
||||
}
|
||||
|
||||
const ollamaEndpoint = deriveBaseURL(baseURL);
|
||||
|
||||
const resolvedHeaders = resolveHeaders({
|
||||
headers: options.headers,
|
||||
user: options.user,
|
||||
});
|
||||
|
||||
/** @type {Promise<AxiosResponse<OllamaListResponse>>} */
|
||||
const response = await axios.get(`${ollamaEndpoint}/api/tags`, {
|
||||
headers: resolvedHeaders,
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
const models = response.data.models.map((tag) => tag.name);
|
||||
return models;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,7 +5,6 @@ const FormData = require('form-data');
|
||||
const { ProxyAgent } = require('undici');
|
||||
const { tool } = require('@langchain/core/tools');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
const { logAxiosError, oaiToolkit } = require('@librechat/api');
|
||||
const { ContentTypes, EImageOutputType } = require('librechat-data-provider');
|
||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||
@@ -349,7 +348,16 @@ Error Message: ${error.message}`);
|
||||
};
|
||||
|
||||
if (process.env.PROXY) {
|
||||
axiosConfig.httpsAgent = new HttpsProxyAgent(process.env.PROXY);
|
||||
try {
|
||||
const url = new URL(process.env.PROXY);
|
||||
axiosConfig.proxy = {
|
||||
host: url.hostname.replace(/^\[|\]$/g, ''),
|
||||
port: url.port ? parseInt(url.port, 10) : undefined,
|
||||
protocol: url.protocol.replace(':', ''),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error parsing proxy URL:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.IMAGE_GEN_OAI_AZURE_API_VERSION && process.env.IMAGE_GEN_OAI_BASEURL) {
|
||||
|
||||
@@ -327,7 +327,7 @@ const loadTools = async ({
|
||||
const { onSearchResults, onGetHighlights } = options?.[Tools.web_search] ?? {};
|
||||
requestedTools[tool] = async () => {
|
||||
toolContextMap[tool] = `# \`${tool}\`:
|
||||
Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })}
|
||||
Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })}${options.req?.body?.timezone ? `\nLocal Date & Time: ${replaceSpecialVars({ text: '{{local_datetime}}', timezone: options.req.body.timezone })}` : ''}
|
||||
1. **Execute immediately without preface** when using \`${tool}\`.
|
||||
2. **After the search, begin with a brief summary** that directly addresses the query without headers or explaining your process.
|
||||
3. **Structure your response clearly** using Markdown formatting (Level 2 headers for sections, lists for multiple points, tables for comparisons).
|
||||
@@ -448,7 +448,7 @@ Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })}
|
||||
}
|
||||
if (!availableTools) {
|
||||
try {
|
||||
availableTools = await getMCPServerTools(safeUser.id, serverName);
|
||||
availableTools = await getMCPServerTools(serverName);
|
||||
} catch (error) {
|
||||
logger.error(`Error fetching available tools for MCP server ${serverName}:`, error);
|
||||
}
|
||||
|
||||
@@ -62,38 +62,25 @@ const getAgents = async (searchParameter) => await Agent.find(searchParameter).l
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {ServerRequest} params.req
|
||||
* @param {string} params.spec
|
||||
* @param {string} params.agent_id
|
||||
* @param {string} params.endpoint
|
||||
* @param {import('@librechat/agents').ClientOptions} [params.model_parameters]
|
||||
* @returns {Promise<Agent|null>} The agent document as a plain object, or null if not found.
|
||||
*/
|
||||
const loadEphemeralAgent = async ({ req, spec, agent_id, endpoint, model_parameters: _m }) => {
|
||||
const loadEphemeralAgent = async ({ req, agent_id, endpoint, model_parameters: _m }) => {
|
||||
const { model, ...model_parameters } = _m;
|
||||
const modelSpecs = req.config?.modelSpecs?.list;
|
||||
/** @type {TModelSpec | null} */
|
||||
let modelSpec = null;
|
||||
if (spec != null && spec !== '') {
|
||||
modelSpec = modelSpecs?.find((s) => s.name === spec) || null;
|
||||
}
|
||||
/** @type {TEphemeralAgent | null} */
|
||||
const ephemeralAgent = req.body.ephemeralAgent;
|
||||
const mcpServers = new Set(ephemeralAgent?.mcp);
|
||||
const userId = req.user?.id; // note: userId cannot be undefined at runtime
|
||||
if (modelSpec?.mcpServers) {
|
||||
for (const mcpServer of modelSpec.mcpServers) {
|
||||
mcpServers.add(mcpServer);
|
||||
}
|
||||
}
|
||||
/** @type {string[]} */
|
||||
const tools = [];
|
||||
if (ephemeralAgent?.execute_code === true || modelSpec?.executeCode === true) {
|
||||
if (ephemeralAgent?.execute_code === true) {
|
||||
tools.push(Tools.execute_code);
|
||||
}
|
||||
if (ephemeralAgent?.file_search === true || modelSpec?.fileSearch === true) {
|
||||
if (ephemeralAgent?.file_search === true) {
|
||||
tools.push(Tools.file_search);
|
||||
}
|
||||
if (ephemeralAgent?.web_search === true || modelSpec?.webSearch === true) {
|
||||
if (ephemeralAgent?.web_search === true) {
|
||||
tools.push(Tools.web_search);
|
||||
}
|
||||
|
||||
@@ -103,7 +90,7 @@ const loadEphemeralAgent = async ({ req, spec, agent_id, endpoint, model_paramet
|
||||
if (addedServers.has(mcpServer)) {
|
||||
continue;
|
||||
}
|
||||
const serverTools = await getMCPServerTools(userId, mcpServer);
|
||||
const serverTools = await getMCPServerTools(mcpServer);
|
||||
if (!serverTools) {
|
||||
tools.push(`${mcp_all}${mcp_delimiter}${mcpServer}`);
|
||||
addedServers.add(mcpServer);
|
||||
@@ -135,18 +122,17 @@ const loadEphemeralAgent = async ({ req, spec, agent_id, endpoint, model_paramet
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {ServerRequest} params.req
|
||||
* @param {string} params.spec
|
||||
* @param {string} params.agent_id
|
||||
* @param {string} params.endpoint
|
||||
* @param {import('@librechat/agents').ClientOptions} [params.model_parameters]
|
||||
* @returns {Promise<Agent|null>} The agent document as a plain object, or null if not found.
|
||||
*/
|
||||
const loadAgent = async ({ req, spec, agent_id, endpoint, model_parameters }) => {
|
||||
const loadAgent = async ({ req, agent_id, endpoint, model_parameters }) => {
|
||||
if (!agent_id) {
|
||||
return null;
|
||||
}
|
||||
if (agent_id === EPHEMERAL_AGENT_ID) {
|
||||
return await loadEphemeralAgent({ req, spec, agent_id, endpoint, model_parameters });
|
||||
return await loadEphemeralAgent({ req, agent_id, endpoint, model_parameters });
|
||||
}
|
||||
const agent = await getAgent({
|
||||
id: agent_id,
|
||||
|
||||
@@ -1931,7 +1931,7 @@ describe('models/Agent', () => {
|
||||
});
|
||||
|
||||
// Mock getMCPServerTools to return tools for each server
|
||||
getMCPServerTools.mockImplementation(async (_userId, server) => {
|
||||
getMCPServerTools.mockImplementation(async (server) => {
|
||||
if (server === 'server1') {
|
||||
return { tool1_mcp_server1: {} };
|
||||
} else if (server === 'server2') {
|
||||
@@ -2125,7 +2125,7 @@ describe('models/Agent', () => {
|
||||
getCachedTools.mockResolvedValue(availableTools);
|
||||
|
||||
// Mock getMCPServerTools to return all tools for server1
|
||||
getMCPServerTools.mockImplementation(async (_userId, server) => {
|
||||
getMCPServerTools.mockImplementation(async (server) => {
|
||||
if (server === 'server1') {
|
||||
return availableTools; // All 100 tools belong to server1
|
||||
}
|
||||
@@ -2674,7 +2674,7 @@ describe('models/Agent', () => {
|
||||
});
|
||||
|
||||
// Mock getMCPServerTools to return only tools matching the server
|
||||
getMCPServerTools.mockImplementation(async (_userId, server) => {
|
||||
getMCPServerTools.mockImplementation(async (server) => {
|
||||
if (server === 'server1') {
|
||||
// Only return tool that correctly matches server1 format
|
||||
return { tool_mcp_server1: {} };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@librechat/backend",
|
||||
"version": "v0.8.1-rc1",
|
||||
"version": "v0.8.0",
|
||||
"description": "",
|
||||
"scripts": {
|
||||
"start": "echo 'please run this from the root directory'",
|
||||
@@ -48,7 +48,7 @@
|
||||
"@langchain/google-genai": "^0.2.13",
|
||||
"@langchain/google-vertexai": "^0.2.13",
|
||||
"@langchain/textsplitters": "^0.1.0",
|
||||
"@librechat/agents": "^2.4.90",
|
||||
"@librechat/agents": "^2.4.85",
|
||||
"@librechat/api": "*",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||
|
||||
@@ -28,7 +28,6 @@ const { getMCPManager, getFlowStateManager } = require('~/config');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
const { deleteToolCalls } = require('~/models/ToolCall');
|
||||
const { getLogStores } = require('~/cache');
|
||||
const { mcpServersRegistry } = require('@librechat/api');
|
||||
|
||||
const getUserController = async (req, res) => {
|
||||
const appConfig = await getAppConfig({ role: req.user?.role });
|
||||
@@ -199,7 +198,7 @@ const updateUserPluginsController = async (req, res) => {
|
||||
// If auth was updated successfully, disconnect MCP sessions as they might use these credentials
|
||||
if (pluginKey.startsWith(Constants.mcp_prefix)) {
|
||||
try {
|
||||
const mcpManager = getMCPManager();
|
||||
const mcpManager = getMCPManager(user.id);
|
||||
if (mcpManager) {
|
||||
// Extract server name from pluginKey (format: "mcp_<serverName>")
|
||||
const serverName = pluginKey.replace(Constants.mcp_prefix, '');
|
||||
@@ -296,11 +295,10 @@ const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => {
|
||||
}
|
||||
|
||||
const serverName = pluginKey.replace(Constants.mcp_prefix, '');
|
||||
const serverConfig =
|
||||
(await mcpServersRegistry.getServerConfig(serverName, userId)) ??
|
||||
appConfig?.mcpServers?.[serverName];
|
||||
const oauthServers = await mcpServersRegistry.getOAuthServers();
|
||||
if (!oauthServers.has(serverName)) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ const {
|
||||
Tokenizer,
|
||||
checkAccess,
|
||||
logAxiosError,
|
||||
sanitizeTitle,
|
||||
resolveHeaders,
|
||||
getBalanceConfig,
|
||||
memoryInstructions,
|
||||
@@ -776,7 +775,6 @@ class AgentClient extends BaseClient {
|
||||
const agentsEConfig = appConfig.endpoints?.[EModelEndpoint.agents];
|
||||
|
||||
config = {
|
||||
runName: 'AgentRun',
|
||||
configurable: {
|
||||
thread_id: this.conversationId,
|
||||
last_agent_index: this.agentConfigs?.size ?? 0,
|
||||
@@ -1235,10 +1233,6 @@ class AgentClient extends BaseClient {
|
||||
handleLLMEnd,
|
||||
},
|
||||
],
|
||||
configurable: {
|
||||
thread_id: this.conversationId,
|
||||
user_id: this.user ?? this.options.req.user?.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1276,7 +1270,7 @@ class AgentClient extends BaseClient {
|
||||
);
|
||||
});
|
||||
|
||||
return sanitizeTitle(titleResult.title);
|
||||
return titleResult.title;
|
||||
} catch (err) {
|
||||
logger.error('[api/server/controllers/agents/client.js #titleConvo] Error', err);
|
||||
return;
|
||||
|
||||
@@ -10,10 +10,6 @@ jest.mock('@librechat/agents', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
...jest.requireActual('@librechat/api'),
|
||||
}));
|
||||
|
||||
describe('AgentClient - titleConvo', () => {
|
||||
let client;
|
||||
let mockRun;
|
||||
@@ -256,38 +252,6 @@ describe('AgentClient - titleConvo', () => {
|
||||
expect(result).toBe('Generated Title');
|
||||
});
|
||||
|
||||
it('should sanitize the generated title by removing think blocks', async () => {
|
||||
const titleWithThinkBlock = '<think>reasoning about the title</think> User Hi Greeting';
|
||||
mockRun.generateTitle.mockResolvedValue({
|
||||
title: titleWithThinkBlock,
|
||||
});
|
||||
|
||||
const text = 'Test conversation text';
|
||||
const abortController = new AbortController();
|
||||
|
||||
const result = await client.titleConvo({ text, abortController });
|
||||
|
||||
// Should remove the <think> block and return only the clean title
|
||||
expect(result).toBe('User Hi Greeting');
|
||||
expect(result).not.toContain('<think>');
|
||||
expect(result).not.toContain('</think>');
|
||||
});
|
||||
|
||||
it('should return fallback title when sanitization results in empty string', async () => {
|
||||
const titleOnlyThinkBlock = '<think>only reasoning no actual title</think>';
|
||||
mockRun.generateTitle.mockResolvedValue({
|
||||
title: titleOnlyThinkBlock,
|
||||
});
|
||||
|
||||
const text = 'Test conversation text';
|
||||
const abortController = new AbortController();
|
||||
|
||||
const result = await client.titleConvo({ text, abortController });
|
||||
|
||||
// Should return the fallback title since sanitization would result in empty string
|
||||
expect(result).toBe('Untitled Conversation');
|
||||
});
|
||||
|
||||
it('should handle errors gracefully and return undefined', async () => {
|
||||
mockRun.generateTitle.mockRejectedValue(new Error('Title generation failed'));
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ const {
|
||||
getAppConfig,
|
||||
} = require('~/server/services/Config');
|
||||
const { getMCPManager } = require('~/config');
|
||||
const { mcpServersRegistry } = require('@librechat/api');
|
||||
|
||||
/**
|
||||
* Get all MCP tools available to the user
|
||||
@@ -33,7 +32,7 @@ const getMCPTools = async (req, res) => {
|
||||
const mcpServers = {};
|
||||
|
||||
const cachePromises = configuredServers.map((serverName) =>
|
||||
getMCPServerTools(userId, serverName).then((tools) => ({ serverName, tools })),
|
||||
getMCPServerTools(serverName).then((tools) => ({ serverName, tools })),
|
||||
);
|
||||
const cacheResults = await Promise.all(cachePromises);
|
||||
|
||||
@@ -53,7 +52,7 @@ const getMCPTools = async (req, res) => {
|
||||
|
||||
if (Object.keys(serverTools).length > 0) {
|
||||
// Cache asynchronously without blocking
|
||||
cacheMCPServerTools({ userId, serverName, serverTools }).catch((err) =>
|
||||
cacheMCPServerTools({ serverName, serverTools }).catch((err) =>
|
||||
logger.error(`[getMCPTools] Failed to cache tools for ${serverName}:`, err),
|
||||
);
|
||||
}
|
||||
@@ -66,7 +65,7 @@ const getMCPTools = async (req, res) => {
|
||||
|
||||
// Get server config once
|
||||
const serverConfig = appConfig.mcpConfig[serverName];
|
||||
const rawServerConfig = await mcpServersRegistry.getServerConfig(serverName, userId);
|
||||
const rawServerConfig = mcpManager.getRawConfig(serverName);
|
||||
|
||||
// Initialize server object with all server-level data
|
||||
const server = {
|
||||
|
||||
@@ -15,10 +15,6 @@ jest.mock('@librechat/api', () => ({
|
||||
storeTokens: jest.fn(),
|
||||
},
|
||||
getUserMCPAuthMap: jest.fn(),
|
||||
mcpServersRegistry: {
|
||||
getServerConfig: jest.fn(),
|
||||
getOAuthServers: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
@@ -51,7 +47,6 @@ jest.mock('~/models', () => ({
|
||||
jest.mock('~/server/services/Config', () => ({
|
||||
setCachedTools: jest.fn(),
|
||||
getCachedTools: jest.fn(),
|
||||
getMCPServerTools: jest.fn(),
|
||||
loadCustomConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
@@ -119,7 +114,7 @@ describe('MCP Routes', () => {
|
||||
});
|
||||
|
||||
describe('GET /:serverName/oauth/initiate', () => {
|
||||
const { MCPOAuthHandler, mcpServersRegistry } = require('@librechat/api');
|
||||
const { MCPOAuthHandler } = require('@librechat/api');
|
||||
const { getLogStores } = require('~/cache');
|
||||
|
||||
it('should initiate OAuth flow successfully', async () => {
|
||||
@@ -132,9 +127,13 @@ describe('MCP Routes', () => {
|
||||
}),
|
||||
};
|
||||
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue({}),
|
||||
};
|
||||
|
||||
getLogStores.mockReturnValue({});
|
||||
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
||||
mcpServersRegistry.getServerConfig.mockResolvedValue({});
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
|
||||
MCPOAuthHandler.initiateOAuthFlow.mockResolvedValue({
|
||||
authorizationUrl: 'https://oauth.example.com/auth',
|
||||
@@ -288,7 +287,6 @@ describe('MCP Routes', () => {
|
||||
});
|
||||
|
||||
it('should handle OAuth callback successfully', async () => {
|
||||
const { mcpServersRegistry } = require('@librechat/api');
|
||||
const mockFlowManager = {
|
||||
completeFlow: jest.fn().mockResolvedValue(),
|
||||
deleteFlow: jest.fn().mockResolvedValue(true),
|
||||
@@ -308,7 +306,6 @@ describe('MCP Routes', () => {
|
||||
MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
|
||||
MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
|
||||
MCPTokenStorage.storeTokens.mockResolvedValue();
|
||||
mcpServersRegistry.getServerConfig.mockResolvedValue({});
|
||||
getLogStores.mockReturnValue({});
|
||||
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
||||
|
||||
@@ -323,6 +320,7 @@ describe('MCP Routes', () => {
|
||||
};
|
||||
const mockMcpManager = {
|
||||
getUserConnection: jest.fn().mockResolvedValue(mockUserConnection),
|
||||
getRawConfig: jest.fn().mockReturnValue({}),
|
||||
};
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
|
||||
@@ -380,7 +378,6 @@ describe('MCP Routes', () => {
|
||||
});
|
||||
|
||||
it('should handle system-level OAuth completion', async () => {
|
||||
const { mcpServersRegistry } = require('@librechat/api');
|
||||
const mockFlowManager = {
|
||||
completeFlow: jest.fn().mockResolvedValue(),
|
||||
deleteFlow: jest.fn().mockResolvedValue(true),
|
||||
@@ -400,10 +397,14 @@ describe('MCP Routes', () => {
|
||||
MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
|
||||
MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
|
||||
MCPTokenStorage.storeTokens.mockResolvedValue();
|
||||
mcpServersRegistry.getServerConfig.mockResolvedValue({});
|
||||
getLogStores.mockReturnValue({});
|
||||
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
||||
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue({}),
|
||||
};
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
|
||||
const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
|
||||
code: 'test-auth-code',
|
||||
state: 'test-flow-id',
|
||||
@@ -415,7 +416,6 @@ describe('MCP Routes', () => {
|
||||
});
|
||||
|
||||
it('should handle reconnection failure after OAuth', async () => {
|
||||
const { mcpServersRegistry } = require('@librechat/api');
|
||||
const mockFlowManager = {
|
||||
completeFlow: jest.fn().mockResolvedValue(),
|
||||
deleteFlow: jest.fn().mockResolvedValue(true),
|
||||
@@ -435,12 +435,12 @@ describe('MCP Routes', () => {
|
||||
MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
|
||||
MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
|
||||
MCPTokenStorage.storeTokens.mockResolvedValue();
|
||||
mcpServersRegistry.getServerConfig.mockResolvedValue({});
|
||||
getLogStores.mockReturnValue({});
|
||||
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
||||
|
||||
const mockMcpManager = {
|
||||
getUserConnection: jest.fn().mockRejectedValue(new Error('Reconnection failed')),
|
||||
getRawConfig: jest.fn().mockReturnValue({}),
|
||||
};
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
|
||||
@@ -460,7 +460,6 @@ describe('MCP Routes', () => {
|
||||
});
|
||||
|
||||
it('should redirect to error page if token storage fails', async () => {
|
||||
const { mcpServersRegistry } = require('@librechat/api');
|
||||
const mockFlowManager = {
|
||||
completeFlow: jest.fn().mockResolvedValue(),
|
||||
deleteFlow: jest.fn().mockResolvedValue(true),
|
||||
@@ -480,7 +479,6 @@ describe('MCP Routes', () => {
|
||||
MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
|
||||
MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
|
||||
MCPTokenStorage.storeTokens.mockRejectedValue(new Error('store failed'));
|
||||
mcpServersRegistry.getServerConfig.mockResolvedValue({});
|
||||
getLogStores.mockReturnValue({});
|
||||
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
||||
|
||||
@@ -731,14 +729,12 @@ describe('MCP Routes', () => {
|
||||
});
|
||||
|
||||
describe('POST /:serverName/reinitialize', () => {
|
||||
const { mcpServersRegistry } = require('@librechat/api');
|
||||
|
||||
it('should return 404 when server is not found in configuration', async () => {
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue(null),
|
||||
disconnectUserConnection: jest.fn().mockResolvedValue(),
|
||||
};
|
||||
|
||||
mcpServersRegistry.getServerConfig.mockResolvedValue(null);
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
require('~/config').getFlowStateManager.mockReturnValue({});
|
||||
require('~/cache').getLogStores.mockReturnValue({});
|
||||
@@ -753,6 +749,9 @@ describe('MCP Routes', () => {
|
||||
|
||||
it('should handle OAuth requirement during reinitialize', async () => {
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue({
|
||||
customUserVars: {},
|
||||
}),
|
||||
disconnectUserConnection: jest.fn().mockResolvedValue(),
|
||||
mcpConfigs: {},
|
||||
getUserConnection: jest.fn().mockImplementation(async ({ oauthStart }) => {
|
||||
@@ -763,9 +762,6 @@ describe('MCP Routes', () => {
|
||||
}),
|
||||
};
|
||||
|
||||
mcpServersRegistry.getServerConfig.mockResolvedValue({
|
||||
customUserVars: {},
|
||||
});
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
require('~/config').getFlowStateManager.mockReturnValue({});
|
||||
require('~/cache').getLogStores.mockReturnValue({});
|
||||
@@ -791,12 +787,12 @@ describe('MCP Routes', () => {
|
||||
|
||||
it('should return 500 when reinitialize fails with non-OAuth error', async () => {
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue({}),
|
||||
disconnectUserConnection: jest.fn().mockResolvedValue(),
|
||||
mcpConfigs: {},
|
||||
getUserConnection: jest.fn().mockRejectedValue(new Error('Connection failed')),
|
||||
};
|
||||
|
||||
mcpServersRegistry.getServerConfig.mockResolvedValue({});
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
require('~/config').getFlowStateManager.mockReturnValue({});
|
||||
require('~/cache').getLogStores.mockReturnValue({});
|
||||
@@ -812,12 +808,11 @@ describe('MCP Routes', () => {
|
||||
|
||||
it('should return 500 when unexpected error occurs', async () => {
|
||||
const mockMcpManager = {
|
||||
disconnectUserConnection: jest.fn(),
|
||||
getRawConfig: jest.fn().mockImplementation(() => {
|
||||
throw new Error('Config loading failed');
|
||||
}),
|
||||
};
|
||||
|
||||
mcpServersRegistry.getServerConfig.mockImplementation(() => {
|
||||
throw new Error('Config loading failed');
|
||||
});
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
|
||||
const response = await request(app).post('/api/mcp/test-server/reinitialize');
|
||||
@@ -850,11 +845,11 @@ describe('MCP Routes', () => {
|
||||
};
|
||||
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue({ endpoint: 'http://test-server.com' }),
|
||||
disconnectUserConnection: jest.fn().mockResolvedValue(),
|
||||
getUserConnection: jest.fn().mockResolvedValue(mockUserConnection),
|
||||
};
|
||||
|
||||
mcpServersRegistry.getServerConfig.mockResolvedValue({ endpoint: 'http://test-server.com' });
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
require('~/config').getFlowStateManager.mockReturnValue({});
|
||||
require('~/cache').getLogStores.mockReturnValue({});
|
||||
@@ -895,16 +890,16 @@ describe('MCP Routes', () => {
|
||||
};
|
||||
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue({
|
||||
endpoint: 'http://test-server.com',
|
||||
customUserVars: {
|
||||
API_KEY: 'some-env-var',
|
||||
},
|
||||
}),
|
||||
disconnectUserConnection: jest.fn().mockResolvedValue(),
|
||||
getUserConnection: jest.fn().mockResolvedValue(mockUserConnection),
|
||||
};
|
||||
|
||||
mcpServersRegistry.getServerConfig.mockResolvedValue({
|
||||
endpoint: 'http://test-server.com',
|
||||
customUserVars: {
|
||||
API_KEY: 'some-env-var',
|
||||
},
|
||||
});
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
require('~/config').getFlowStateManager.mockReturnValue({});
|
||||
require('~/cache').getLogStores.mockReturnValue({});
|
||||
@@ -1109,17 +1104,17 @@ describe('MCP Routes', () => {
|
||||
|
||||
describe('GET /:serverName/auth-values', () => {
|
||||
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
|
||||
const { mcpServersRegistry } = require('@librechat/api');
|
||||
|
||||
it('should return auth value flags for server', async () => {
|
||||
const mockMcpManager = {};
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue({
|
||||
customUserVars: {
|
||||
API_KEY: 'some-env-var',
|
||||
SECRET_TOKEN: 'another-env-var',
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
mcpServersRegistry.getServerConfig.mockResolvedValue({
|
||||
customUserVars: {
|
||||
API_KEY: 'some-env-var',
|
||||
SECRET_TOKEN: 'another-env-var',
|
||||
},
|
||||
});
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
getUserPluginAuthValue.mockResolvedValueOnce('some-api-key-value').mockResolvedValueOnce('');
|
||||
|
||||
@@ -1139,9 +1134,10 @@ describe('MCP Routes', () => {
|
||||
});
|
||||
|
||||
it('should return 404 when server is not found in configuration', async () => {
|
||||
const mockMcpManager = {};
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue(null),
|
||||
};
|
||||
|
||||
mcpServersRegistry.getServerConfig.mockResolvedValue(null);
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
|
||||
const response = await request(app).get('/api/mcp/non-existent-server/auth-values');
|
||||
@@ -1153,13 +1149,14 @@ describe('MCP Routes', () => {
|
||||
});
|
||||
|
||||
it('should handle errors when checking auth values', async () => {
|
||||
const mockMcpManager = {};
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue({
|
||||
customUserVars: {
|
||||
API_KEY: 'some-env-var',
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
mcpServersRegistry.getServerConfig.mockResolvedValue({
|
||||
customUserVars: {
|
||||
API_KEY: 'some-env-var',
|
||||
},
|
||||
});
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
getUserPluginAuthValue.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
@@ -1176,11 +1173,12 @@ describe('MCP Routes', () => {
|
||||
});
|
||||
|
||||
it('should return 500 when auth values check throws unexpected error', async () => {
|
||||
const mockMcpManager = {};
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockImplementation(() => {
|
||||
throw new Error('Config loading failed');
|
||||
}),
|
||||
};
|
||||
|
||||
mcpServersRegistry.getServerConfig.mockImplementation(() => {
|
||||
throw new Error('Config loading failed');
|
||||
});
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
|
||||
const response = await request(app).get('/api/mcp/test-server/auth-values');
|
||||
@@ -1190,11 +1188,12 @@ describe('MCP Routes', () => {
|
||||
});
|
||||
|
||||
it('should handle customUserVars that is not an object', async () => {
|
||||
const mockMcpManager = {};
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue({
|
||||
customUserVars: 'not-an-object',
|
||||
}),
|
||||
};
|
||||
|
||||
mcpServersRegistry.getServerConfig.mockResolvedValue({
|
||||
customUserVars: 'not-an-object',
|
||||
});
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
|
||||
const response = await request(app).get('/api/mcp/test-server/auth-values');
|
||||
@@ -1221,7 +1220,7 @@ describe('MCP Routes', () => {
|
||||
|
||||
describe('GET /:serverName/oauth/callback - Edge Cases', () => {
|
||||
it('should handle OAuth callback without toolFlowId (falsy toolFlowId)', async () => {
|
||||
const { MCPOAuthHandler, MCPTokenStorage, mcpServersRegistry } = require('@librechat/api');
|
||||
const { MCPOAuthHandler, MCPTokenStorage } = require('@librechat/api');
|
||||
const mockTokens = {
|
||||
access_token: 'edge-access-token',
|
||||
refresh_token: 'edge-refresh-token',
|
||||
@@ -1239,7 +1238,6 @@ describe('MCP Routes', () => {
|
||||
});
|
||||
MCPOAuthHandler.completeOAuthFlow = jest.fn().mockResolvedValue(mockTokens);
|
||||
MCPTokenStorage.storeTokens.mockResolvedValue();
|
||||
mcpServersRegistry.getServerConfig.mockResolvedValue({});
|
||||
|
||||
const mockFlowManager = {
|
||||
completeFlow: jest.fn(),
|
||||
@@ -1250,6 +1248,7 @@ describe('MCP Routes', () => {
|
||||
getUserConnection: jest.fn().mockResolvedValue({
|
||||
fetchTools: jest.fn().mockResolvedValue([]),
|
||||
}),
|
||||
getRawConfig: jest.fn().mockReturnValue({}),
|
||||
};
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
|
||||
@@ -1264,7 +1263,7 @@ describe('MCP Routes', () => {
|
||||
it('should handle null cached tools in OAuth callback (triggers || {} fallback)', async () => {
|
||||
const { getCachedTools } = require('~/server/services/Config');
|
||||
getCachedTools.mockResolvedValue(null);
|
||||
const { MCPOAuthHandler, MCPTokenStorage, mcpServersRegistry } = require('@librechat/api');
|
||||
const { MCPOAuthHandler, MCPTokenStorage } = require('@librechat/api');
|
||||
const mockTokens = {
|
||||
access_token: 'edge-access-token',
|
||||
refresh_token: 'edge-refresh-token',
|
||||
@@ -1290,7 +1289,6 @@ describe('MCP Routes', () => {
|
||||
});
|
||||
MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
|
||||
MCPTokenStorage.storeTokens.mockResolvedValue();
|
||||
mcpServersRegistry.getServerConfig.mockResolvedValue({});
|
||||
|
||||
const mockMcpManager = {
|
||||
getUserConnection: jest.fn().mockResolvedValue({
|
||||
@@ -1298,6 +1296,7 @@ describe('MCP Routes', () => {
|
||||
.fn()
|
||||
.mockResolvedValue([{ name: 'test-tool', description: 'Test tool' }]),
|
||||
}),
|
||||
getRawConfig: jest.fn().mockReturnValue({}),
|
||||
};
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ const { getAppConfig } = require('~/server/services/Config/app');
|
||||
const { getProjectByName } = require('~/models/Project');
|
||||
const { getMCPManager } = require('~/config');
|
||||
const { getLogStores } = require('~/cache');
|
||||
const { mcpServersRegistry } = require('@librechat/api');
|
||||
|
||||
const router = express.Router();
|
||||
const emailLoginEnabled =
|
||||
@@ -126,7 +125,7 @@ router.get('/', async function (req, res) {
|
||||
payload.minPasswordLength = minPasswordLength;
|
||||
}
|
||||
|
||||
const getMCPServers = async () => {
|
||||
const getMCPServers = () => {
|
||||
try {
|
||||
if (appConfig?.mcpConfig == null) {
|
||||
return;
|
||||
@@ -135,8 +134,9 @@ router.get('/', async function (req, res) {
|
||||
if (!mcpManager) {
|
||||
return;
|
||||
}
|
||||
const mcpServers = await mcpServersRegistry.getAllServerConfigs();
|
||||
const mcpServers = mcpManager.getAllServers();
|
||||
if (!mcpServers) return;
|
||||
const oauthServers = mcpManager.getOAuthServers();
|
||||
for (const serverName in mcpServers) {
|
||||
if (!payload.mcpServers) {
|
||||
payload.mcpServers = {};
|
||||
@@ -145,7 +145,7 @@ router.get('/', async function (req, res) {
|
||||
payload.mcpServers[serverName] = removeNullishValues({
|
||||
startup: serverConfig?.startup,
|
||||
chatMenu: serverConfig?.chatMenu,
|
||||
isOAuth: serverConfig.requiresOAuth,
|
||||
isOAuth: oauthServers?.has(serverName),
|
||||
customUserVars: serverConfig?.customUserVars,
|
||||
});
|
||||
}
|
||||
@@ -154,7 +154,7 @@ router.get('/', async function (req, res) {
|
||||
}
|
||||
};
|
||||
|
||||
await getMCPServers();
|
||||
getMCPServers();
|
||||
const webSearchConfig = appConfig?.webSearch;
|
||||
if (
|
||||
webSearchConfig != null &&
|
||||
|
||||
@@ -6,7 +6,6 @@ const {
|
||||
MCPOAuthHandler,
|
||||
MCPTokenStorage,
|
||||
getUserMCPAuthMap,
|
||||
mcpServersRegistry,
|
||||
} = require('@librechat/api');
|
||||
const { getMCPManager, getFlowStateManager, getOAuthReconnectionManager } = require('~/config');
|
||||
const { getMCPSetupData, getServerConnectionStatus } = require('~/server/services/MCP');
|
||||
@@ -62,12 +61,11 @@ router.get('/:serverName/oauth/initiate', requireJwtAuth, async (req, res) => {
|
||||
return res.status(400).json({ error: 'Invalid flow state' });
|
||||
}
|
||||
|
||||
const oauthHeaders = await getOAuthHeaders(serverName, userId);
|
||||
const { authorizationUrl, flowId: oauthFlowId } = await MCPOAuthHandler.initiateOAuthFlow(
|
||||
serverName,
|
||||
serverUrl,
|
||||
userId,
|
||||
oauthHeaders,
|
||||
getOAuthHeaders(serverName),
|
||||
oauthConfig,
|
||||
);
|
||||
|
||||
@@ -135,8 +133,12 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
|
||||
});
|
||||
|
||||
logger.debug('[MCP OAuth] Completing OAuth flow');
|
||||
const oauthHeaders = await getOAuthHeaders(serverName, flowState.userId);
|
||||
const tokens = await MCPOAuthHandler.completeOAuthFlow(flowId, code, flowManager, oauthHeaders);
|
||||
const tokens = await MCPOAuthHandler.completeOAuthFlow(
|
||||
flowId,
|
||||
code,
|
||||
flowManager,
|
||||
getOAuthHeaders(serverName),
|
||||
);
|
||||
logger.info('[MCP OAuth] OAuth flow completed, tokens received in callback route');
|
||||
|
||||
/** Persist tokens immediately so reconnection uses fresh credentials */
|
||||
@@ -203,7 +205,6 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
|
||||
|
||||
const tools = await userConnection.fetchTools();
|
||||
await updateMCPServerTools({
|
||||
userId: flowState.userId,
|
||||
serverName,
|
||||
tools,
|
||||
});
|
||||
@@ -354,7 +355,7 @@ router.post('/:serverName/reinitialize', requireJwtAuth, async (req, res) => {
|
||||
logger.info(`[MCP Reinitialize] Reinitializing server: ${serverName}`);
|
||||
|
||||
const mcpManager = getMCPManager();
|
||||
const serverConfig = await mcpServersRegistry.getServerConfig(serverName, user.id);
|
||||
const serverConfig = mcpManager.getRawConfig(serverName);
|
||||
if (!serverConfig) {
|
||||
return res.status(404).json({
|
||||
error: `MCP server '${serverName}' not found in configuration`,
|
||||
@@ -503,7 +504,8 @@ router.get('/:serverName/auth-values', requireJwtAuth, async (req, res) => {
|
||||
return res.status(401).json({ error: 'User not authenticated' });
|
||||
}
|
||||
|
||||
const serverConfig = await mcpServersRegistry.getServerConfig(serverName, user.id);
|
||||
const mcpManager = getMCPManager();
|
||||
const serverConfig = mcpManager.getRawConfig(serverName);
|
||||
if (!serverConfig) {
|
||||
return res.status(404).json({
|
||||
error: `MCP server '${serverName}' not found in configuration`,
|
||||
@@ -542,8 +544,9 @@ router.get('/:serverName/auth-values', requireJwtAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
async function getOAuthHeaders(serverName, userId) {
|
||||
const serverConfig = await mcpServersRegistry.getServerConfig(serverName, userId);
|
||||
function getOAuthHeaders(serverName) {
|
||||
const mcpManager = getMCPManager();
|
||||
const serverConfig = mcpManager.getRawConfig(serverName);
|
||||
return serverConfig?.oauth_headers ?? {};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
const { ToolCacheKeys } = require('../getCachedTools');
|
||||
|
||||
describe('getCachedTools - Cache Isolation Security', () => {
|
||||
describe('ToolCacheKeys.MCP_SERVER', () => {
|
||||
it('should generate cache keys that include userId', () => {
|
||||
const key = ToolCacheKeys.MCP_SERVER('user123', 'github');
|
||||
expect(key).toBe('tools:mcp:user123:github');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,25 +7,24 @@ const getLogStores = require('~/cache/getLogStores');
|
||||
const ToolCacheKeys = {
|
||||
/** Global tools available to all users */
|
||||
GLOBAL: 'tools:global',
|
||||
/** MCP tools cached by user ID and server name */
|
||||
MCP_SERVER: (userId, serverName) => `tools:mcp:${userId}:${serverName}`,
|
||||
/** MCP tools cached by server name */
|
||||
MCP_SERVER: (serverName) => `tools:mcp:${serverName}`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves available tools from cache
|
||||
* @function getCachedTools
|
||||
* @param {Object} options - Options for retrieving tools
|
||||
* @param {string} [options.userId] - User ID for user-specific MCP tools
|
||||
* @param {string} [options.serverName] - MCP server name to get cached tools for
|
||||
* @returns {Promise<LCAvailableTools|null>} The available tools object or null if not cached
|
||||
*/
|
||||
async function getCachedTools(options = {}) {
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
const { userId, serverName } = options;
|
||||
const { serverName } = options;
|
||||
|
||||
// Return MCP server-specific tools if requested
|
||||
if (serverName && userId) {
|
||||
return await cache.get(ToolCacheKeys.MCP_SERVER(userId, serverName));
|
||||
if (serverName) {
|
||||
return await cache.get(ToolCacheKeys.MCP_SERVER(serverName));
|
||||
}
|
||||
|
||||
// Default to global tools
|
||||
@@ -37,18 +36,17 @@ async function getCachedTools(options = {}) {
|
||||
* @function setCachedTools
|
||||
* @param {Object} tools - The tools object to cache
|
||||
* @param {Object} options - Options for caching tools
|
||||
* @param {string} [options.userId] - User ID for user-specific MCP tools
|
||||
* @param {string} [options.serverName] - MCP server name for server-specific tools
|
||||
* @param {number} [options.ttl] - Time to live in milliseconds
|
||||
* @returns {Promise<boolean>} Whether the operation was successful
|
||||
*/
|
||||
async function setCachedTools(tools, options = {}) {
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
const { userId, serverName, ttl } = options;
|
||||
const { serverName, ttl } = options;
|
||||
|
||||
// Cache by MCP server if specified (requires userId)
|
||||
if (serverName && userId) {
|
||||
return await cache.set(ToolCacheKeys.MCP_SERVER(userId, serverName), tools, ttl);
|
||||
// Cache by MCP server if specified
|
||||
if (serverName) {
|
||||
return await cache.set(ToolCacheKeys.MCP_SERVER(serverName), tools, ttl);
|
||||
}
|
||||
|
||||
// Default to global cache
|
||||
@@ -59,14 +57,13 @@ async function setCachedTools(tools, options = {}) {
|
||||
* Invalidates cached tools
|
||||
* @function invalidateCachedTools
|
||||
* @param {Object} options - Options for invalidating tools
|
||||
* @param {string} [options.userId] - User ID for user-specific MCP tools
|
||||
* @param {string} [options.serverName] - MCP server name to invalidate
|
||||
* @param {boolean} [options.invalidateGlobal=false] - Whether to invalidate global tools
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function invalidateCachedTools(options = {}) {
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
const { userId, serverName, invalidateGlobal = false } = options;
|
||||
const { serverName, invalidateGlobal = false } = options;
|
||||
|
||||
const keysToDelete = [];
|
||||
|
||||
@@ -74,23 +71,22 @@ async function invalidateCachedTools(options = {}) {
|
||||
keysToDelete.push(ToolCacheKeys.GLOBAL);
|
||||
}
|
||||
|
||||
if (serverName && userId) {
|
||||
keysToDelete.push(ToolCacheKeys.MCP_SERVER(userId, serverName));
|
||||
if (serverName) {
|
||||
keysToDelete.push(ToolCacheKeys.MCP_SERVER(serverName));
|
||||
}
|
||||
|
||||
await Promise.all(keysToDelete.map((key) => cache.delete(key)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets MCP tools for a specific server from cache
|
||||
* Gets MCP tools for a specific server from cache or merges with global tools
|
||||
* @function getMCPServerTools
|
||||
* @param {string} userId - The user ID
|
||||
* @param {string} serverName - The MCP server name
|
||||
* @returns {Promise<LCAvailableTools|null>} The available tools for the server
|
||||
*/
|
||||
async function getMCPServerTools(userId, serverName) {
|
||||
async function getMCPServerTools(serverName) {
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
const serverTools = await cache.get(ToolCacheKeys.MCP_SERVER(userId, serverName));
|
||||
const serverTools = await cache.get(ToolCacheKeys.MCP_SERVER(serverName));
|
||||
|
||||
if (serverTools) {
|
||||
return serverTools;
|
||||
|
||||
@@ -57,7 +57,7 @@ async function loadConfigModels(req) {
|
||||
|
||||
for (let i = 0; i < customEndpoints.length; i++) {
|
||||
const endpoint = customEndpoints[i];
|
||||
const { models, name: configName, baseURL, apiKey, headers: endpointHeaders } = endpoint;
|
||||
const { models, name: configName, baseURL, apiKey } = endpoint;
|
||||
const name = normalizeEndpointName(configName);
|
||||
endpointsMap[name] = endpoint;
|
||||
|
||||
@@ -76,8 +76,6 @@ async function loadConfigModels(req) {
|
||||
apiKey: API_KEY,
|
||||
baseURL: BASE_URL,
|
||||
user: req.user.id,
|
||||
userObject: req.user,
|
||||
headers: endpointHeaders,
|
||||
direct: endpoint.directEndpoint,
|
||||
userIdQuery: models.userIdQuery,
|
||||
});
|
||||
|
||||
@@ -6,12 +6,11 @@ const { getLogStores } = require('~/cache');
|
||||
/**
|
||||
* Updates MCP tools in the cache for a specific server
|
||||
* @param {Object} params - Parameters for updating MCP tools
|
||||
* @param {string} params.userId - User ID for user-specific caching
|
||||
* @param {string} params.serverName - MCP server name
|
||||
* @param {Array} params.tools - Array of tool objects from MCP server
|
||||
* @returns {Promise<LCAvailableTools>}
|
||||
*/
|
||||
async function updateMCPServerTools({ userId, serverName, tools }) {
|
||||
async function updateMCPServerTools({ serverName, tools }) {
|
||||
try {
|
||||
const serverTools = {};
|
||||
const mcpDelimiter = Constants.mcp_delimiter;
|
||||
@@ -28,16 +27,14 @@ async function updateMCPServerTools({ userId, serverName, tools }) {
|
||||
};
|
||||
}
|
||||
|
||||
await setCachedTools(serverTools, { userId, serverName });
|
||||
await setCachedTools(serverTools, { serverName });
|
||||
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
await cache.delete(CacheKeys.TOOLS);
|
||||
logger.debug(
|
||||
`[MCP Cache] Updated ${tools.length} tools for server ${serverName} (user: ${userId})`,
|
||||
);
|
||||
logger.debug(`[MCP Cache] Updated ${tools.length} tools for server ${serverName}`);
|
||||
return serverTools;
|
||||
} catch (error) {
|
||||
logger.error(`[MCP Cache] Failed to update tools for ${serverName} (user: ${userId}):`, error);
|
||||
logger.error(`[MCP Cache] Failed to update tools for ${serverName}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -68,22 +65,21 @@ async function mergeAppTools(appTools) {
|
||||
/**
|
||||
* Caches MCP server tools (no longer merges with global)
|
||||
* @param {object} params
|
||||
* @param {string} params.userId - User ID for user-specific caching
|
||||
* @param {string} params.serverName
|
||||
* @param {import('@librechat/api').LCAvailableTools} params.serverTools
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function cacheMCPServerTools({ userId, serverName, serverTools }) {
|
||||
async function cacheMCPServerTools({ serverName, serverTools }) {
|
||||
try {
|
||||
const count = Object.keys(serverTools).length;
|
||||
if (!count) {
|
||||
return;
|
||||
}
|
||||
// Only cache server-specific tools, no merging with global
|
||||
await setCachedTools(serverTools, { userId, serverName });
|
||||
logger.debug(`Cached ${count} MCP server tools for ${serverName} (user: ${userId})`);
|
||||
await setCachedTools(serverTools, { serverName });
|
||||
logger.debug(`Cached ${count} MCP server tools for ${serverName}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to cache MCP server tools for ${serverName} (user: ${userId}):`, error);
|
||||
logger.error(`Failed to cache MCP server tools for ${serverName}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,10 +134,10 @@ const initializeAgent = async ({
|
||||
});
|
||||
|
||||
const tokensModel =
|
||||
agent.provider === EModelEndpoint.azureOpenAI ? agent.model : options.llmConfig?.model;
|
||||
const maxOutputTokens = optionalChainWithEmptyCheck(
|
||||
options.llmConfig?.maxOutputTokens,
|
||||
options.llmConfig?.maxTokens,
|
||||
agent.provider === EModelEndpoint.azureOpenAI ? agent.model : modelOptions.model;
|
||||
const maxTokens = optionalChainWithEmptyCheck(
|
||||
modelOptions.maxOutputTokens,
|
||||
modelOptions.maxTokens,
|
||||
0,
|
||||
);
|
||||
const agentMaxContextTokens = optionalChainWithEmptyCheck(
|
||||
@@ -185,6 +185,7 @@ const initializeAgent = async ({
|
||||
agent.instructions = replaceSpecialVars({
|
||||
text: agent.instructions,
|
||||
user: req.user,
|
||||
timezone: req.body?.timezone,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -203,7 +204,7 @@ const initializeAgent = async ({
|
||||
userMCPAuthMap,
|
||||
toolContextMap,
|
||||
useLegacyContent: !!options.useLegacyContent,
|
||||
maxContextTokens: Math.round((agentMaxContextTokens - maxOutputTokens) * 0.9),
|
||||
maxContextTokens: Math.round((agentMaxContextTokens - maxTokens) * 0.9),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -3,10 +3,9 @@ const { isAgentsEndpoint, removeNullishValues, Constants } = require('librechat-
|
||||
const { loadAgent } = require('~/models/Agent');
|
||||
|
||||
const buildOptions = (req, endpoint, parsedBody, endpointType) => {
|
||||
const { spec, iconURL, agent_id, ...model_parameters } = parsedBody;
|
||||
const { spec, iconURL, agent_id, instructions, ...model_parameters } = parsedBody;
|
||||
const agentPromise = loadAgent({
|
||||
req,
|
||||
spec,
|
||||
agent_id: isAgentsEndpoint(endpoint) ? agent_id : Constants.EPHEMERAL_AGENT_ID,
|
||||
endpoint,
|
||||
model_parameters,
|
||||
@@ -21,6 +20,7 @@ const buildOptions = (req, endpoint, parsedBody, endpointType) => {
|
||||
endpoint,
|
||||
agent_id,
|
||||
endpointType,
|
||||
instructions,
|
||||
model_parameters,
|
||||
agent: agentPromise,
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { Providers } = require('@librechat/agents');
|
||||
const {
|
||||
resolveHeaders,
|
||||
isUserProvided,
|
||||
@@ -142,27 +143,39 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid
|
||||
|
||||
if (optionsOnly) {
|
||||
const modelOptions = endpointOption?.model_parameters ?? {};
|
||||
clientOptions = Object.assign(
|
||||
{
|
||||
modelOptions,
|
||||
},
|
||||
clientOptions,
|
||||
);
|
||||
clientOptions.modelOptions.user = req.user.id;
|
||||
const options = getOpenAIConfig(apiKey, clientOptions, endpoint);
|
||||
if (options != null) {
|
||||
options.useLegacyContent = true;
|
||||
options.endpointTokenConfig = endpointTokenConfig;
|
||||
}
|
||||
if (!clientOptions.streamRate) {
|
||||
if (endpoint !== Providers.OLLAMA) {
|
||||
clientOptions = Object.assign(
|
||||
{
|
||||
modelOptions,
|
||||
},
|
||||
clientOptions,
|
||||
);
|
||||
clientOptions.modelOptions.user = req.user.id;
|
||||
const options = getOpenAIConfig(apiKey, clientOptions, endpoint);
|
||||
if (options != null) {
|
||||
options.useLegacyContent = true;
|
||||
options.endpointTokenConfig = endpointTokenConfig;
|
||||
}
|
||||
if (!clientOptions.streamRate) {
|
||||
return options;
|
||||
}
|
||||
options.llmConfig.callbacks = [
|
||||
{
|
||||
handleLLMNewToken: createHandleLLMNewToken(clientOptions.streamRate),
|
||||
},
|
||||
];
|
||||
return options;
|
||||
}
|
||||
options.llmConfig.callbacks = [
|
||||
{
|
||||
handleLLMNewToken: createHandleLLMNewToken(clientOptions.streamRate),
|
||||
},
|
||||
];
|
||||
return options;
|
||||
|
||||
if (clientOptions.reverseProxyUrl) {
|
||||
modelOptions.baseUrl = clientOptions.reverseProxyUrl.split('/v1')[0];
|
||||
delete clientOptions.reverseProxyUrl;
|
||||
}
|
||||
|
||||
return {
|
||||
useLegacyContent: true,
|
||||
llmConfig: modelOptions,
|
||||
};
|
||||
}
|
||||
|
||||
const client = new OpenAIClient(apiKey, clientOptions);
|
||||
|
||||
@@ -143,7 +143,7 @@ const initializeClient = async ({
|
||||
modelOptions.model = modelName;
|
||||
clientOptions = Object.assign({ modelOptions }, clientOptions);
|
||||
clientOptions.modelOptions.user = req.user.id;
|
||||
const options = getOpenAIConfig(apiKey, clientOptions, endpoint);
|
||||
const options = getOpenAIConfig(apiKey, clientOptions);
|
||||
if (options != null && serverless === true) {
|
||||
options.useLegacyContent = true;
|
||||
}
|
||||
|
||||
@@ -227,6 +227,7 @@ class STTService {
|
||||
}
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
...(apiKey && { 'api-key': apiKey }),
|
||||
};
|
||||
|
||||
|
||||
@@ -42,26 +42,18 @@ async function getCustomConfigSpeech(req, res) {
|
||||
settings.advancedMode = speechTab.advancedMode;
|
||||
}
|
||||
|
||||
if (speechTab.speechToText !== undefined) {
|
||||
if (typeof speechTab.speechToText === 'boolean') {
|
||||
settings.speechToText = speechTab.speechToText;
|
||||
} else {
|
||||
for (const key in speechTab.speechToText) {
|
||||
if (speechTab.speechToText[key] !== undefined) {
|
||||
settings[key] = speechTab.speechToText[key];
|
||||
}
|
||||
if (speechTab.speechToText) {
|
||||
for (const key in speechTab.speechToText) {
|
||||
if (speechTab.speechToText[key] !== undefined) {
|
||||
settings[key] = speechTab.speechToText[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (speechTab.textToSpeech !== undefined) {
|
||||
if (typeof speechTab.textToSpeech === 'boolean') {
|
||||
settings.textToSpeech = speechTab.textToSpeech;
|
||||
} else {
|
||||
for (const key in speechTab.textToSpeech) {
|
||||
if (speechTab.textToSpeech[key] !== undefined) {
|
||||
settings[key] = speechTab.textToSpeech[key];
|
||||
}
|
||||
if (speechTab.textToSpeech) {
|
||||
for (const key in speechTab.textToSpeech) {
|
||||
if (speechTab.textToSpeech[key] !== undefined) {
|
||||
settings[key] = speechTab.textToSpeech[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ const searchEntraIdPrincipals = async (accessToken, sub, query, type = 'all', li
|
||||
|
||||
/**
|
||||
* Get current user's Entra ID group memberships from Microsoft Graph
|
||||
* Uses /me/getMemberGroups endpoint to get transitive groups the user is a member of
|
||||
* Uses /me/memberOf endpoint to get groups the user is a member of
|
||||
* @param {string} accessToken - OpenID Connect access token
|
||||
* @param {string} sub - Subject identifier
|
||||
* @returns {Promise<Array<string>>} Array of group ID strings (GUIDs)
|
||||
@@ -167,12 +167,10 @@ const searchEntraIdPrincipals = async (accessToken, sub, query, type = 'all', li
|
||||
const getUserEntraGroups = async (accessToken, sub) => {
|
||||
try {
|
||||
const graphClient = await createGraphClient(accessToken, sub);
|
||||
const response = await graphClient
|
||||
.api('/me/getMemberGroups')
|
||||
.post({ securityEnabledOnly: false });
|
||||
|
||||
const groupIds = Array.isArray(response?.value) ? response.value : [];
|
||||
return [...new Set(groupIds.map((groupId) => String(groupId)))];
|
||||
const groupsResponse = await graphClient.api('/me/memberOf').select('id').get();
|
||||
|
||||
return (groupsResponse.value || []).map((group) => group.id);
|
||||
} catch (error) {
|
||||
logger.error('[getUserEntraGroups] Error fetching user groups:', error);
|
||||
return [];
|
||||
@@ -189,22 +187,13 @@ const getUserEntraGroups = async (accessToken, sub) => {
|
||||
const getUserOwnedEntraGroups = async (accessToken, sub) => {
|
||||
try {
|
||||
const graphClient = await createGraphClient(accessToken, sub);
|
||||
const allGroupIds = [];
|
||||
let nextLink = '/me/ownedObjects/microsoft.graph.group';
|
||||
|
||||
while (nextLink) {
|
||||
const response = await graphClient.api(nextLink).select('id').top(999).get();
|
||||
const groups = response?.value || [];
|
||||
allGroupIds.push(...groups.map((group) => group.id));
|
||||
const groupsResponse = await graphClient
|
||||
.api('/me/ownedObjects/microsoft.graph.group')
|
||||
.select('id')
|
||||
.get();
|
||||
|
||||
nextLink = response['@odata.nextLink']
|
||||
? response['@odata.nextLink']
|
||||
.replace(/^https:\/\/graph\.microsoft\.com\/v1\.0/, '')
|
||||
.trim() || null
|
||||
: null;
|
||||
}
|
||||
|
||||
return allGroupIds;
|
||||
return (groupsResponse.value || []).map((group) => group.id);
|
||||
} catch (error) {
|
||||
logger.error('[getUserOwnedEntraGroups] Error fetching user owned groups:', error);
|
||||
return [];
|
||||
@@ -222,27 +211,21 @@ const getUserOwnedEntraGroups = async (accessToken, sub) => {
|
||||
const getGroupMembers = async (accessToken, sub, groupId) => {
|
||||
try {
|
||||
const graphClient = await createGraphClient(accessToken, sub);
|
||||
const allMembers = new Set();
|
||||
let nextLink = `/groups/${groupId}/transitiveMembers`;
|
||||
const allMembers = [];
|
||||
let nextLink = `/groups/${groupId}/members`;
|
||||
|
||||
while (nextLink) {
|
||||
const membersResponse = await graphClient.api(nextLink).select('id').top(999).get();
|
||||
|
||||
const members = membersResponse?.value || [];
|
||||
members.forEach((member) => {
|
||||
if (typeof member?.id === 'string' && member['@odata.type'] === '#microsoft.graph.user') {
|
||||
allMembers.add(member.id);
|
||||
}
|
||||
});
|
||||
const members = membersResponse.value || [];
|
||||
allMembers.push(...members.map((member) => member.id));
|
||||
|
||||
nextLink = membersResponse['@odata.nextLink']
|
||||
? membersResponse['@odata.nextLink']
|
||||
.replace(/^https:\/\/graph\.microsoft\.com\/v1\.0/, '')
|
||||
.trim() || null
|
||||
? membersResponse['@odata.nextLink'].split('/v1.0')[1]
|
||||
: null;
|
||||
}
|
||||
|
||||
return Array.from(allMembers);
|
||||
return allMembers;
|
||||
} catch (error) {
|
||||
logger.error('[getGroupMembers] Error fetching group members:', error);
|
||||
return [];
|
||||
|
||||
@@ -73,7 +73,6 @@ describe('GraphApiService', () => {
|
||||
header: jest.fn().mockReturnThis(),
|
||||
top: jest.fn().mockReturnThis(),
|
||||
get: jest.fn(),
|
||||
post: jest.fn(),
|
||||
};
|
||||
|
||||
Client.init.mockReturnValue(mockGraphClient);
|
||||
@@ -515,33 +514,31 @@ describe('GraphApiService', () => {
|
||||
});
|
||||
|
||||
describe('getUserEntraGroups', () => {
|
||||
it('should fetch user groups using getMemberGroups endpoint', async () => {
|
||||
it('should fetch user groups from memberOf endpoint', async () => {
|
||||
const mockGroupsResponse = {
|
||||
value: ['group-1', 'group-2'],
|
||||
value: [
|
||||
{
|
||||
id: 'group-1',
|
||||
},
|
||||
{
|
||||
id: 'group-2',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockGraphClient.post.mockResolvedValue(mockGroupsResponse);
|
||||
mockGraphClient.get.mockResolvedValue(mockGroupsResponse);
|
||||
|
||||
const result = await GraphApiService.getUserEntraGroups('token', 'user');
|
||||
|
||||
expect(mockGraphClient.api).toHaveBeenCalledWith('/me/getMemberGroups');
|
||||
expect(mockGraphClient.post).toHaveBeenCalledWith({ securityEnabledOnly: false });
|
||||
|
||||
expect(result).toEqual(['group-1', 'group-2']);
|
||||
});
|
||||
|
||||
it('should deduplicate returned group ids', async () => {
|
||||
mockGraphClient.post.mockResolvedValue({
|
||||
value: ['group-1', 'group-2', 'group-1'],
|
||||
});
|
||||
|
||||
const result = await GraphApiService.getUserEntraGroups('token', 'user');
|
||||
expect(mockGraphClient.api).toHaveBeenCalledWith('/me/memberOf');
|
||||
expect(mockGraphClient.select).toHaveBeenCalledWith('id');
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toEqual(['group-1', 'group-2']);
|
||||
});
|
||||
|
||||
it('should return empty array on error', async () => {
|
||||
mockGraphClient.post.mockRejectedValue(new Error('API error'));
|
||||
mockGraphClient.get.mockRejectedValue(new Error('API error'));
|
||||
|
||||
const result = await GraphApiService.getUserEntraGroups('token', 'user');
|
||||
|
||||
@@ -553,7 +550,7 @@ describe('GraphApiService', () => {
|
||||
value: [],
|
||||
};
|
||||
|
||||
mockGraphClient.post.mockResolvedValue(mockGroupsResponse);
|
||||
mockGraphClient.get.mockResolvedValue(mockGroupsResponse);
|
||||
|
||||
const result = await GraphApiService.getUserEntraGroups('token', 'user');
|
||||
|
||||
@@ -561,7 +558,7 @@ describe('GraphApiService', () => {
|
||||
});
|
||||
|
||||
it('should handle missing value property', async () => {
|
||||
mockGraphClient.post.mockResolvedValue({});
|
||||
mockGraphClient.get.mockResolvedValue({});
|
||||
|
||||
const result = await GraphApiService.getUserEntraGroups('token', 'user');
|
||||
|
||||
@@ -569,89 +566,6 @@ describe('GraphApiService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserOwnedEntraGroups', () => {
|
||||
it('should fetch owned groups with pagination support', async () => {
|
||||
const firstPage = {
|
||||
value: [
|
||||
{
|
||||
id: 'owned-group-1',
|
||||
},
|
||||
],
|
||||
'@odata.nextLink':
|
||||
'https://graph.microsoft.com/v1.0/me/ownedObjects/microsoft.graph.group?$skiptoken=xyz',
|
||||
};
|
||||
|
||||
const secondPage = {
|
||||
value: [
|
||||
{
|
||||
id: 'owned-group-2',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockGraphClient.get.mockResolvedValueOnce(firstPage).mockResolvedValueOnce(secondPage);
|
||||
|
||||
const result = await GraphApiService.getUserOwnedEntraGroups('token', 'user');
|
||||
|
||||
expect(mockGraphClient.api).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/me/ownedObjects/microsoft.graph.group',
|
||||
);
|
||||
expect(mockGraphClient.api).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/me/ownedObjects/microsoft.graph.group?$skiptoken=xyz',
|
||||
);
|
||||
expect(mockGraphClient.top).toHaveBeenCalledWith(999);
|
||||
expect(mockGraphClient.get).toHaveBeenCalledTimes(2);
|
||||
|
||||
expect(result).toEqual(['owned-group-1', 'owned-group-2']);
|
||||
});
|
||||
|
||||
it('should return empty array on error', async () => {
|
||||
mockGraphClient.get.mockRejectedValue(new Error('API error'));
|
||||
|
||||
const result = await GraphApiService.getUserOwnedEntraGroups('token', 'user');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGroupMembers', () => {
|
||||
it('should fetch transitive members and include only users', async () => {
|
||||
const firstPage = {
|
||||
value: [
|
||||
{ id: 'user-1', '@odata.type': '#microsoft.graph.user' },
|
||||
{ id: 'child-group', '@odata.type': '#microsoft.graph.group' },
|
||||
],
|
||||
'@odata.nextLink':
|
||||
'https://graph.microsoft.com/v1.0/groups/group-id/transitiveMembers?$skiptoken=abc',
|
||||
};
|
||||
const secondPage = {
|
||||
value: [{ id: 'user-2', '@odata.type': '#microsoft.graph.user' }],
|
||||
};
|
||||
|
||||
mockGraphClient.get.mockResolvedValueOnce(firstPage).mockResolvedValueOnce(secondPage);
|
||||
|
||||
const result = await GraphApiService.getGroupMembers('token', 'user', 'group-id');
|
||||
|
||||
expect(mockGraphClient.api).toHaveBeenNthCalledWith(1, '/groups/group-id/transitiveMembers');
|
||||
expect(mockGraphClient.api).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/groups/group-id/transitiveMembers?$skiptoken=abc',
|
||||
);
|
||||
expect(mockGraphClient.top).toHaveBeenCalledWith(999);
|
||||
expect(result).toEqual(['user-1', 'user-2']);
|
||||
});
|
||||
|
||||
it('should return empty array on error', async () => {
|
||||
mockGraphClient.get.mockRejectedValue(new Error('API error'));
|
||||
|
||||
const result = await GraphApiService.getGroupMembers('token', 'user', 'group-id');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('testGraphApiAccess', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
@@ -25,7 +25,6 @@ const { findToken, createToken, updateToken } = require('~/models');
|
||||
const { reinitMCPServer } = require('./Tools/mcp');
|
||||
const { getAppConfig } = require('./Config');
|
||||
const { getLogStores } = require('~/cache');
|
||||
const { mcpServersRegistry } = require('@librechat/api');
|
||||
|
||||
/**
|
||||
* @param {object} params
|
||||
@@ -451,7 +450,7 @@ async function getMCPSetupData(userId) {
|
||||
logger.error(`[MCP][User: ${userId}] Error getting app connections:`, error);
|
||||
}
|
||||
const userConnections = mcpManager.getUserConnections(userId) || new Map();
|
||||
const oauthServers = await mcpServersRegistry.getOAuthServers();
|
||||
const oauthServers = mcpManager.getOAuthServers();
|
||||
|
||||
return {
|
||||
mcpConfig,
|
||||
|
||||
@@ -50,9 +50,6 @@ jest.mock('@librechat/api', () => ({
|
||||
sendEvent: jest.fn(),
|
||||
normalizeServerName: jest.fn((name) => name),
|
||||
convertWithResolvedRefs: jest.fn((params) => params),
|
||||
mcpServersRegistry: {
|
||||
getOAuthServers: jest.fn(() => Promise.resolve(new Set())),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
@@ -103,7 +100,6 @@ describe('tests for the new helper functions used by the MCP connection status e
|
||||
let mockGetFlowStateManager;
|
||||
let mockGetLogStores;
|
||||
let mockGetOAuthReconnectionManager;
|
||||
let mockMcpServersRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -112,7 +108,6 @@ describe('tests for the new helper functions used by the MCP connection status e
|
||||
mockGetFlowStateManager = require('~/config').getFlowStateManager;
|
||||
mockGetLogStores = require('~/cache').getLogStores;
|
||||
mockGetOAuthReconnectionManager = require('~/config').getOAuthReconnectionManager;
|
||||
mockMcpServersRegistry = require('@librechat/api').mcpServersRegistry;
|
||||
});
|
||||
|
||||
describe('getMCPSetupData', () => {
|
||||
@@ -130,8 +125,8 @@ describe('tests for the new helper functions used by the MCP connection status e
|
||||
mockGetMCPManager.mockReturnValue({
|
||||
appConnections: { getAll: jest.fn(() => new Map()) },
|
||||
getUserConnections: jest.fn(() => new Map()),
|
||||
getOAuthServers: jest.fn(() => new Set()),
|
||||
});
|
||||
mockMcpServersRegistry.getOAuthServers.mockResolvedValue(new Set());
|
||||
});
|
||||
|
||||
it('should successfully return MCP setup data', async () => {
|
||||
@@ -144,9 +139,9 @@ describe('tests for the new helper functions used by the MCP connection status e
|
||||
const mockMCPManager = {
|
||||
appConnections: { getAll: jest.fn(() => mockAppConnections) },
|
||||
getUserConnections: jest.fn(() => mockUserConnections),
|
||||
getOAuthServers: jest.fn(() => mockOAuthServers),
|
||||
};
|
||||
mockGetMCPManager.mockReturnValue(mockMCPManager);
|
||||
mockMcpServersRegistry.getOAuthServers.mockResolvedValue(mockOAuthServers);
|
||||
|
||||
const result = await getMCPSetupData(mockUserId);
|
||||
|
||||
@@ -154,7 +149,7 @@ describe('tests for the new helper functions used by the MCP connection status e
|
||||
expect(mockGetMCPManager).toHaveBeenCalledWith(mockUserId);
|
||||
expect(mockMCPManager.appConnections.getAll).toHaveBeenCalled();
|
||||
expect(mockMCPManager.getUserConnections).toHaveBeenCalledWith(mockUserId);
|
||||
expect(mockMcpServersRegistry.getOAuthServers).toHaveBeenCalled();
|
||||
expect(mockMCPManager.getOAuthServers).toHaveBeenCalled();
|
||||
|
||||
expect(result).toEqual({
|
||||
mcpConfig: mockConfig.mcpServers,
|
||||
@@ -175,9 +170,9 @@ describe('tests for the new helper functions used by the MCP connection status e
|
||||
const mockMCPManager = {
|
||||
appConnections: { getAll: jest.fn(() => null) },
|
||||
getUserConnections: jest.fn(() => null),
|
||||
getOAuthServers: jest.fn(() => new Set()),
|
||||
};
|
||||
mockGetMCPManager.mockReturnValue(mockMCPManager);
|
||||
mockMcpServersRegistry.getOAuthServers.mockResolvedValue(new Set());
|
||||
|
||||
const result = await getMCPSetupData(mockUserId);
|
||||
|
||||
|
||||
@@ -39,8 +39,6 @@ const { openAIApiKey, userProvidedOpenAI } = require('./Config/EndpointService')
|
||||
* @param {boolean} [params.userIdQuery=false] - Whether to send the user ID as a query parameter.
|
||||
* @param {boolean} [params.createTokenConfig=true] - Whether to create a token configuration from the API response.
|
||||
* @param {string} [params.tokenKey] - The cache key to save the token configuration. Uses `name` if omitted.
|
||||
* @param {Record<string, string>} [params.headers] - Optional headers for the request.
|
||||
* @param {Partial<IUser>} [params.userObject] - Optional user object for header resolution.
|
||||
* @returns {Promise<string[]>} A promise that resolves to an array of model identifiers.
|
||||
* @async
|
||||
*/
|
||||
@@ -54,8 +52,6 @@ const fetchModels = async ({
|
||||
userIdQuery = false,
|
||||
createTokenConfig = true,
|
||||
tokenKey,
|
||||
headers,
|
||||
userObject,
|
||||
}) => {
|
||||
let models = [];
|
||||
const baseURL = direct ? extractBaseURL(_baseURL) : _baseURL;
|
||||
@@ -69,13 +65,7 @@ const fetchModels = async ({
|
||||
}
|
||||
|
||||
if (name && name.toLowerCase().startsWith(Providers.OLLAMA)) {
|
||||
try {
|
||||
return await OllamaClient.fetchModels(baseURL, { headers, user: userObject });
|
||||
} catch (ollamaError) {
|
||||
const logMessage =
|
||||
'Failed to fetch models from Ollama API. Attempting to fetch via OpenAI-compatible endpoint.';
|
||||
logAxiosError({ message: logMessage, error: ollamaError });
|
||||
}
|
||||
return await OllamaClient.fetchModels(baseURL);
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const axios = require('axios');
|
||||
const { logAxiosError, resolveHeaders } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { EModelEndpoint, defaultModels } = require('librechat-data-provider');
|
||||
|
||||
const {
|
||||
@@ -18,8 +18,6 @@ jest.mock('@librechat/api', () => {
|
||||
processModelData: jest.fn((...args) => {
|
||||
return originalUtils.processModelData(...args);
|
||||
}),
|
||||
logAxiosError: jest.fn(),
|
||||
resolveHeaders: jest.fn((options) => options?.headers || {}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -279,51 +277,12 @@ describe('fetchModels with Ollama specific logic', () => {
|
||||
|
||||
expect(models).toEqual(['Ollama-Base', 'Ollama-Advanced']);
|
||||
expect(axios.get).toHaveBeenCalledWith('https://api.ollama.test.com/api/tags', {
|
||||
headers: {},
|
||||
timeout: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass headers and user object to Ollama fetchModels', async () => {
|
||||
const customHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer custom-token',
|
||||
};
|
||||
const userObject = {
|
||||
id: 'user789',
|
||||
email: 'test@example.com',
|
||||
};
|
||||
|
||||
resolveHeaders.mockReturnValueOnce(customHeaders);
|
||||
|
||||
const models = await fetchModels({
|
||||
user: 'user789',
|
||||
apiKey: 'testApiKey',
|
||||
baseURL: 'https://api.ollama.test.com',
|
||||
name: 'ollama',
|
||||
headers: customHeaders,
|
||||
userObject,
|
||||
});
|
||||
|
||||
expect(models).toEqual(['Ollama-Base', 'Ollama-Advanced']);
|
||||
expect(resolveHeaders).toHaveBeenCalledWith({
|
||||
headers: customHeaders,
|
||||
user: userObject,
|
||||
});
|
||||
expect(axios.get).toHaveBeenCalledWith('https://api.ollama.test.com/api/tags', {
|
||||
headers: customHeaders,
|
||||
timeout: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors gracefully when fetching Ollama models fails and fallback to OpenAI-compatible fetch', async () => {
|
||||
axios.get.mockRejectedValueOnce(new Error('Ollama API error'));
|
||||
axios.get.mockResolvedValueOnce({
|
||||
data: {
|
||||
data: [{ id: 'fallback-model-1' }, { id: 'fallback-model-2' }],
|
||||
},
|
||||
});
|
||||
|
||||
it('should handle errors gracefully when fetching Ollama models fails', async () => {
|
||||
axios.get.mockRejectedValue(new Error('Network error'));
|
||||
const models = await fetchModels({
|
||||
user: 'user789',
|
||||
apiKey: 'testApiKey',
|
||||
@@ -331,13 +290,8 @@ describe('fetchModels with Ollama specific logic', () => {
|
||||
name: 'OllamaAPI',
|
||||
});
|
||||
|
||||
expect(models).toEqual(['fallback-model-1', 'fallback-model-2']);
|
||||
expect(logAxiosError).toHaveBeenCalledWith({
|
||||
message:
|
||||
'Failed to fetch models from Ollama API. Attempting to fetch via OpenAI-compatible endpoint.',
|
||||
error: expect.any(Error),
|
||||
});
|
||||
expect(axios.get).toHaveBeenCalledTimes(2);
|
||||
expect(models).toEqual([]);
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return an empty array if no baseURL is provided', async () => {
|
||||
|
||||
@@ -98,7 +98,6 @@ async function reinitMCPServer({
|
||||
if (connection && !oauthRequired) {
|
||||
tools = await connection.fetchTools();
|
||||
availableTools = await updateMCPServerTools({
|
||||
userId: user.id,
|
||||
serverName,
|
||||
tools,
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ async function initializeMCPs() {
|
||||
const mcpManager = await createMCPManager(mcpServers);
|
||||
|
||||
try {
|
||||
const mcpTools = (await mcpManager.getAppToolFunctions()) || {};
|
||||
const mcpTools = mcpManager.getAppToolFunctions() || {};
|
||||
await mergeAppTools(mcpTools);
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -304,7 +304,6 @@ describe('Apple Login Strategy', () => {
|
||||
fileStrategy: 'local',
|
||||
balance: { enabled: false },
|
||||
}),
|
||||
'jane.doe@example.com',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -357,18 +357,16 @@ async function setupOpenId() {
|
||||
};
|
||||
|
||||
const appConfig = await getAppConfig();
|
||||
/** Azure AD sometimes doesn't return email, use preferred_username as fallback */
|
||||
const email = userinfo.email || userinfo.preferred_username || userinfo.upn;
|
||||
if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
|
||||
if (!isEmailDomainAllowed(userinfo.email, appConfig?.registration?.allowedDomains)) {
|
||||
logger.error(
|
||||
`[OpenID Strategy] Authentication blocked - email domain not allowed [Email: ${email}]`,
|
||||
`[OpenID Strategy] Authentication blocked - email domain not allowed [Email: ${userinfo.email}]`,
|
||||
);
|
||||
return done(null, false, { message: 'Email domain not allowed' });
|
||||
}
|
||||
|
||||
const result = await findOpenIDUser({
|
||||
findUser,
|
||||
email: email,
|
||||
email: claims.email,
|
||||
openidId: claims.sub,
|
||||
idOnTheSource: claims.oid,
|
||||
strategyName: 'openidStrategy',
|
||||
@@ -435,7 +433,7 @@ async function setupOpenId() {
|
||||
provider: 'openid',
|
||||
openidId: userinfo.sub,
|
||||
username,
|
||||
email: email || '',
|
||||
email: userinfo.email || '',
|
||||
emailVerified: userinfo.email_verified || false,
|
||||
name: fullName,
|
||||
idOnTheSource: userinfo.oid,
|
||||
@@ -449,8 +447,8 @@ async function setupOpenId() {
|
||||
user.username = username;
|
||||
user.name = fullName;
|
||||
user.idOnTheSource = userinfo.oid;
|
||||
if (email && email !== user.email) {
|
||||
user.email = email;
|
||||
if (userinfo.email && userinfo.email !== user.email) {
|
||||
user.email = userinfo.email;
|
||||
user.emailVerified = userinfo.email_verified || false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,25 +5,22 @@ const { resizeAvatar } = require('~/server/services/Files/images/avatar');
|
||||
const { updateUser, createUser, getUserById } = require('~/models');
|
||||
|
||||
/**
|
||||
* Updates the avatar URL and email of an existing user. If the user's avatar URL does not include the query parameter
|
||||
* Updates the avatar URL of an existing user. If the user's avatar URL does not include the query parameter
|
||||
* '?manual=true', it updates the user's avatar with the provided URL. For local file storage, it directly updates
|
||||
* the avatar URL, while for other storage types, it processes the avatar URL using the specified file strategy.
|
||||
* Also updates the email if it has changed (e.g., when a Google Workspace email is updated).
|
||||
*
|
||||
* @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.
|
||||
* @param {string} [email] - Optional. The new email address to update if it has changed.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
* The function updates the user's avatar and/or email and saves the user object. It does not return any value.
|
||||
* 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, email) => {
|
||||
const handleExistingUser = async (oldUser, avatarUrl, appConfig) => {
|
||||
const fileStrategy = appConfig?.fileStrategy ?? process.env.CDN_PROVIDER;
|
||||
const isLocal = fileStrategy === FileSources.local;
|
||||
const updates = {};
|
||||
|
||||
let updatedAvatar = false;
|
||||
const hasManualFlag =
|
||||
@@ -42,16 +39,7 @@ const handleExistingUser = async (oldUser, avatarUrl, appConfig, email) => {
|
||||
}
|
||||
|
||||
if (updatedAvatar) {
|
||||
updates.avatar = updatedAvatar;
|
||||
}
|
||||
|
||||
/** Update email if it has changed */
|
||||
if (email && email.trim() !== oldUser.email) {
|
||||
updates.email = email.trim();
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await updateUser(oldUser._id, updates);
|
||||
await updateUser(oldUser._id, { avatar: updatedAvatar });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -167,76 +167,4 @@ describe('handleExistingUser', () => {
|
||||
// This should throw an error when trying to access oldUser._id
|
||||
await expect(handleExistingUser(null, avatarUrl)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should update email when it has changed', async () => {
|
||||
const oldUser = {
|
||||
_id: 'user123',
|
||||
email: 'old@example.com',
|
||||
avatar: 'https://example.com/avatar.png?manual=true',
|
||||
};
|
||||
const avatarUrl = 'https://example.com/avatar.png';
|
||||
const newEmail = 'new@example.com';
|
||||
|
||||
await handleExistingUser(oldUser, avatarUrl, {}, newEmail);
|
||||
|
||||
expect(updateUser).toHaveBeenCalledWith('user123', { email: 'new@example.com' });
|
||||
});
|
||||
|
||||
it('should update both avatar and email when both have changed', async () => {
|
||||
const oldUser = {
|
||||
_id: 'user123',
|
||||
email: 'old@example.com',
|
||||
avatar: null,
|
||||
};
|
||||
const avatarUrl = 'https://example.com/new-avatar.png';
|
||||
const newEmail = 'new@example.com';
|
||||
|
||||
await handleExistingUser(oldUser, avatarUrl, {}, newEmail);
|
||||
|
||||
expect(updateUser).toHaveBeenCalledWith('user123', {
|
||||
avatar: avatarUrl,
|
||||
email: 'new@example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not update email when it has not changed', async () => {
|
||||
const oldUser = {
|
||||
_id: 'user123',
|
||||
email: 'same@example.com',
|
||||
avatar: 'https://example.com/avatar.png?manual=true',
|
||||
};
|
||||
const avatarUrl = 'https://example.com/avatar.png';
|
||||
const sameEmail = 'same@example.com';
|
||||
|
||||
await handleExistingUser(oldUser, avatarUrl, {}, sameEmail);
|
||||
|
||||
expect(updateUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should trim email before comparison and update', async () => {
|
||||
const oldUser = {
|
||||
_id: 'user123',
|
||||
email: 'test@example.com',
|
||||
avatar: 'https://example.com/avatar.png?manual=true',
|
||||
};
|
||||
const avatarUrl = 'https://example.com/avatar.png';
|
||||
const newEmailWithSpaces = ' newemail@example.com ';
|
||||
|
||||
await handleExistingUser(oldUser, avatarUrl, {}, newEmailWithSpaces);
|
||||
|
||||
expect(updateUser).toHaveBeenCalledWith('user123', { email: 'newemail@example.com' });
|
||||
});
|
||||
|
||||
it('should not update when email parameter is not provided', async () => {
|
||||
const oldUser = {
|
||||
_id: 'user123',
|
||||
email: 'test@example.com',
|
||||
avatar: 'https://example.com/avatar.png?manual=true',
|
||||
};
|
||||
const avatarUrl = 'https://example.com/avatar.png';
|
||||
|
||||
await handleExistingUser(oldUser, avatarUrl, {});
|
||||
|
||||
expect(updateUser).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,24 +25,10 @@ const socialLogin =
|
||||
return cb(error);
|
||||
}
|
||||
|
||||
const providerKey = `${provider}Id`;
|
||||
let existingUser = null;
|
||||
|
||||
/** First try to find user by provider ID (e.g., googleId, facebookId) */
|
||||
if (id && typeof id === 'string') {
|
||||
existingUser = await findUser({ [providerKey]: id });
|
||||
}
|
||||
|
||||
/** If not found by provider ID, try finding by email */
|
||||
if (!existingUser) {
|
||||
existingUser = await findUser({ email: email?.trim() });
|
||||
if (existingUser) {
|
||||
logger.warn(`[${provider}Login] User found by email: ${email} but not by ${providerKey}`);
|
||||
}
|
||||
}
|
||||
const existingUser = await findUser({ email: email.trim() });
|
||||
|
||||
if (existingUser?.provider === provider) {
|
||||
await handleExistingUser(existingUser, avatarUrl, appConfig, email);
|
||||
await handleExistingUser(existingUser, avatarUrl, appConfig);
|
||||
return cb(null, existingUser);
|
||||
} else if (existingUser) {
|
||||
logger.info(
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { ErrorTypes } = require('librechat-data-provider');
|
||||
const { createSocialUser, handleExistingUser } = require('./process');
|
||||
const socialLogin = require('./socialLogin');
|
||||
const { findUser } = require('~/models');
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => {
|
||||
const actualModule = jest.requireActual('@librechat/data-schemas');
|
||||
return {
|
||||
...actualModule,
|
||||
logger: {
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('./process', () => ({
|
||||
createSocialUser: jest.fn(),
|
||||
handleExistingUser: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
...jest.requireActual('@librechat/api'),
|
||||
isEnabled: jest.fn().mockReturnValue(true),
|
||||
isEmailDomainAllowed: jest.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
jest.mock('~/models', () => ({
|
||||
findUser: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Config', () => ({
|
||||
getAppConfig: jest.fn().mockResolvedValue({
|
||||
fileStrategy: 'local',
|
||||
balance: { enabled: false },
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('socialLogin', () => {
|
||||
const mockGetProfileDetails = ({ profile }) => ({
|
||||
email: profile.emails[0].value,
|
||||
id: profile.id,
|
||||
avatarUrl: profile.photos?.[0]?.value || null,
|
||||
username: profile.name?.givenName || 'user',
|
||||
name: `${profile.name?.givenName || ''} ${profile.name?.familyName || ''}`.trim(),
|
||||
emailVerified: profile.emails[0].verified || false,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Finding users by provider ID', () => {
|
||||
it('should find user by provider ID (googleId) when email has changed', async () => {
|
||||
const provider = 'google';
|
||||
const googleId = 'google-user-123';
|
||||
const oldEmail = 'old@example.com';
|
||||
const newEmail = 'new@example.com';
|
||||
|
||||
const existingUser = {
|
||||
_id: 'user123',
|
||||
email: oldEmail,
|
||||
provider: 'google',
|
||||
googleId: googleId,
|
||||
};
|
||||
|
||||
/** Mock findUser to return user on first call (by googleId), null on second call */
|
||||
findUser
|
||||
.mockResolvedValueOnce(existingUser) // First call: finds by googleId
|
||||
.mockResolvedValueOnce(null); // Second call would be by email, but won't be reached
|
||||
|
||||
const mockProfile = {
|
||||
id: googleId,
|
||||
emails: [{ value: newEmail, verified: true }],
|
||||
photos: [{ value: 'https://example.com/avatar.png' }],
|
||||
name: { givenName: 'John', familyName: 'Doe' },
|
||||
};
|
||||
|
||||
const loginFn = socialLogin(provider, mockGetProfileDetails);
|
||||
const callback = jest.fn();
|
||||
|
||||
await loginFn(null, null, null, mockProfile, callback);
|
||||
|
||||
/** Verify it searched by googleId first */
|
||||
expect(findUser).toHaveBeenNthCalledWith(1, { googleId: googleId });
|
||||
|
||||
/** Verify it did NOT search by email (because it found user by googleId) */
|
||||
expect(findUser).toHaveBeenCalledTimes(1);
|
||||
|
||||
/** Verify handleExistingUser was called with the new email */
|
||||
expect(handleExistingUser).toHaveBeenCalledWith(
|
||||
existingUser,
|
||||
'https://example.com/avatar.png',
|
||||
expect.any(Object),
|
||||
newEmail,
|
||||
);
|
||||
|
||||
/** Verify callback was called with success */
|
||||
expect(callback).toHaveBeenCalledWith(null, existingUser);
|
||||
});
|
||||
|
||||
it('should find user by provider ID (facebookId) when using Facebook', async () => {
|
||||
const provider = 'facebook';
|
||||
const facebookId = 'fb-user-456';
|
||||
const email = 'user@example.com';
|
||||
|
||||
const existingUser = {
|
||||
_id: 'user456',
|
||||
email: email,
|
||||
provider: 'facebook',
|
||||
facebookId: facebookId,
|
||||
};
|
||||
|
||||
findUser.mockResolvedValue(existingUser); // Always returns user
|
||||
|
||||
const mockProfile = {
|
||||
id: facebookId,
|
||||
emails: [{ value: email, verified: true }],
|
||||
photos: [{ value: 'https://example.com/fb-avatar.png' }],
|
||||
name: { givenName: 'Jane', familyName: 'Smith' },
|
||||
};
|
||||
|
||||
const loginFn = socialLogin(provider, mockGetProfileDetails);
|
||||
const callback = jest.fn();
|
||||
|
||||
await loginFn(null, null, null, mockProfile, callback);
|
||||
|
||||
/** Verify it searched by facebookId first */
|
||||
expect(findUser).toHaveBeenCalledWith({ facebookId: facebookId });
|
||||
expect(findUser.mock.calls[0]).toEqual([{ facebookId: facebookId }]);
|
||||
|
||||
expect(handleExistingUser).toHaveBeenCalledWith(
|
||||
existingUser,
|
||||
'https://example.com/fb-avatar.png',
|
||||
expect.any(Object),
|
||||
email,
|
||||
);
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(null, existingUser);
|
||||
});
|
||||
|
||||
it('should fallback to finding user by email if not found by provider ID', async () => {
|
||||
const provider = 'google';
|
||||
const googleId = 'google-user-789';
|
||||
const email = 'user@example.com';
|
||||
|
||||
const existingUser = {
|
||||
_id: 'user789',
|
||||
email: email,
|
||||
provider: 'google',
|
||||
googleId: 'old-google-id', // Different googleId (edge case)
|
||||
};
|
||||
|
||||
/** First call (by googleId) returns null, second call (by email) returns user */
|
||||
findUser
|
||||
.mockResolvedValueOnce(null) // By googleId
|
||||
.mockResolvedValueOnce(existingUser); // By email
|
||||
|
||||
const mockProfile = {
|
||||
id: googleId,
|
||||
emails: [{ value: email, verified: true }],
|
||||
photos: [{ value: 'https://example.com/avatar.png' }],
|
||||
name: { givenName: 'Bob', familyName: 'Johnson' },
|
||||
};
|
||||
|
||||
const loginFn = socialLogin(provider, mockGetProfileDetails);
|
||||
const callback = jest.fn();
|
||||
|
||||
await loginFn(null, null, null, mockProfile, callback);
|
||||
|
||||
/** Verify both searches happened */
|
||||
expect(findUser).toHaveBeenNthCalledWith(1, { googleId: googleId });
|
||||
expect(findUser).toHaveBeenNthCalledWith(2, { email: email });
|
||||
expect(findUser).toHaveBeenCalledTimes(2);
|
||||
|
||||
/** Verify warning log */
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
`[${provider}Login] User found by email: ${email} but not by ${provider}Id`,
|
||||
);
|
||||
|
||||
expect(handleExistingUser).toHaveBeenCalled();
|
||||
expect(callback).toHaveBeenCalledWith(null, existingUser);
|
||||
});
|
||||
|
||||
it('should create new user if not found by provider ID or email', async () => {
|
||||
const provider = 'google';
|
||||
const googleId = 'google-new-user';
|
||||
const email = 'newuser@example.com';
|
||||
|
||||
const newUser = {
|
||||
_id: 'newuser123',
|
||||
email: email,
|
||||
provider: 'google',
|
||||
googleId: googleId,
|
||||
};
|
||||
|
||||
/** Both searches return null */
|
||||
findUser.mockResolvedValue(null);
|
||||
createSocialUser.mockResolvedValue(newUser);
|
||||
|
||||
const mockProfile = {
|
||||
id: googleId,
|
||||
emails: [{ value: email, verified: true }],
|
||||
photos: [{ value: 'https://example.com/avatar.png' }],
|
||||
name: { givenName: 'New', familyName: 'User' },
|
||||
};
|
||||
|
||||
const loginFn = socialLogin(provider, mockGetProfileDetails);
|
||||
const callback = jest.fn();
|
||||
|
||||
await loginFn(null, null, null, mockProfile, callback);
|
||||
|
||||
/** Verify both searches happened */
|
||||
expect(findUser).toHaveBeenCalledTimes(2);
|
||||
|
||||
/** Verify createSocialUser was called */
|
||||
expect(createSocialUser).toHaveBeenCalledWith({
|
||||
email: email,
|
||||
avatarUrl: 'https://example.com/avatar.png',
|
||||
provider: provider,
|
||||
providerKey: 'googleId',
|
||||
providerId: googleId,
|
||||
username: 'New',
|
||||
name: 'New User',
|
||||
emailVerified: true,
|
||||
appConfig: expect.any(Object),
|
||||
});
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(null, newUser);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('should return error if user exists with different provider', async () => {
|
||||
const provider = 'google';
|
||||
const googleId = 'google-user-123';
|
||||
const email = 'user@example.com';
|
||||
|
||||
const existingUser = {
|
||||
_id: 'user123',
|
||||
email: email,
|
||||
provider: 'local', // Different provider
|
||||
};
|
||||
|
||||
findUser
|
||||
.mockResolvedValueOnce(null) // By googleId
|
||||
.mockResolvedValueOnce(existingUser); // By email
|
||||
|
||||
const mockProfile = {
|
||||
id: googleId,
|
||||
emails: [{ value: email, verified: true }],
|
||||
photos: [{ value: 'https://example.com/avatar.png' }],
|
||||
name: { givenName: 'John', familyName: 'Doe' },
|
||||
};
|
||||
|
||||
const loginFn = socialLogin(provider, mockGetProfileDetails);
|
||||
const callback = jest.fn();
|
||||
|
||||
await loginFn(null, null, null, mockProfile, callback);
|
||||
|
||||
/** Verify error callback */
|
||||
expect(callback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
code: ErrorTypes.AUTH_FAILED,
|
||||
provider: 'local',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
`[${provider}Login] User ${email} already exists with provider local`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
/** v0.8.1-rc1 */
|
||||
/** v0.8.0 */
|
||||
module.exports = {
|
||||
roots: ['<rootDir>/src'],
|
||||
testEnvironment: 'jsdom',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@librechat/frontend",
|
||||
"version": "v0.8.1-rc1",
|
||||
"version": "v0.8.0",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -149,7 +149,7 @@
|
||||
"tailwindcss": "^3.4.1",
|
||||
"ts-jest": "^29.2.5",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^6.4.1",
|
||||
"vite": "^6.3.6",
|
||||
"vite-plugin-compression2": "^2.2.1",
|
||||
"vite-plugin-node-polyfills": "^0.23.0",
|
||||
"vite-plugin-pwa": "^0.21.2"
|
||||
|
||||
@@ -35,7 +35,9 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
|
||||
enabled: !isEphemeralAgent(agent_id),
|
||||
});
|
||||
|
||||
const { data: regularTools } = useAvailableToolsQuery(EModelEndpoint.agents);
|
||||
const { data: regularTools } = useAvailableToolsQuery(EModelEndpoint.agents, {
|
||||
enabled: !isEphemeralAgent(agent_id),
|
||||
});
|
||||
|
||||
const { data: mcpData } = useMCPToolsQuery({
|
||||
enabled: !isEphemeralAgent(agent_id) && startupConfig?.mcpServers != null,
|
||||
|
||||
@@ -11,9 +11,9 @@ import {
|
||||
AgentListResponse,
|
||||
} from 'librechat-data-provider';
|
||||
import type t from 'librechat-data-provider';
|
||||
import { renderAgentAvatar, clearMessagesCache } from '~/utils';
|
||||
import { useLocalize, useDefaultConvo } from '~/hooks';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import { renderAgentAvatar } from '~/utils';
|
||||
|
||||
interface SupportContact {
|
||||
name?: string;
|
||||
@@ -56,7 +56,10 @@ const AgentDetail: React.FC<AgentDetailProps> = ({ agent, isOpen, onClose }) =>
|
||||
|
||||
localStorage.setItem(`${LocalStorageKeys.AGENT_ID_PREFIX}0`, agent.id);
|
||||
|
||||
clearMessagesCache(queryClient, conversation?.conversationId);
|
||||
queryClient.setQueryData<t.TMessage[]>(
|
||||
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
|
||||
[],
|
||||
);
|
||||
queryClient.invalidateQueries([QueryKeys.messages]);
|
||||
|
||||
/** Template with agent configuration */
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useOutletContext } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useSearchParams, useParams, useNavigate } from 'react-router-dom';
|
||||
import { TooltipAnchor, Button, NewChatIcon, useMediaQuery } from '@librechat/client';
|
||||
import { PermissionTypes, Permissions, QueryKeys } from 'librechat-data-provider';
|
||||
import { PermissionTypes, Permissions, QueryKeys, Constants } from 'librechat-data-provider';
|
||||
import type t from 'librechat-data-provider';
|
||||
import type { ContextType } from '~/common';
|
||||
import { useDocumentTitle, useHasAccess, useLocalize, TranslationKeys } from '~/hooks';
|
||||
@@ -13,11 +13,11 @@ import MarketplaceAdminSettings from './MarketplaceAdminSettings';
|
||||
import { SidePanelProvider, useChatContext } from '~/Providers';
|
||||
import { SidePanelGroup } from '~/components/SidePanel';
|
||||
import { OpenSidebar } from '~/components/Chat/Menus';
|
||||
import { cn, clearMessagesCache } from '~/utils';
|
||||
import CategoryTabs from './CategoryTabs';
|
||||
import AgentDetail from './AgentDetail';
|
||||
import SearchBar from './SearchBar';
|
||||
import AgentGrid from './AgentGrid';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
interface AgentMarketplaceProps {
|
||||
@@ -224,7 +224,10 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||
window.open('/c/new', '_blank');
|
||||
return;
|
||||
}
|
||||
clearMessagesCache(queryClient, conversation?.conversationId);
|
||||
queryClient.setQueryData<t.TMessage[]>(
|
||||
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
|
||||
[],
|
||||
);
|
||||
queryClient.invalidateQueries([QueryKeys.messages]);
|
||||
newConversation();
|
||||
};
|
||||
|
||||
@@ -58,7 +58,6 @@ const LabelController: React.FC<LabelControllerProps> = ({
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
value={field.value.toString()}
|
||||
aria-label={label}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -194,7 +194,7 @@ describe('Virtual Scrolling Performance', () => {
|
||||
|
||||
// Performance check: rendering should be fast
|
||||
const renderTime = endTime - startTime;
|
||||
expect(renderTime).toBeLessThan(740);
|
||||
expect(renderTime).toBeLessThan(720);
|
||||
|
||||
console.log(`Rendered 1000 agents in ${renderTime.toFixed(2)}ms`);
|
||||
console.log(`Only ${renderedCards.length} DOM nodes created for 1000 agents`);
|
||||
|
||||
89
client/src/components/Artifacts/Thinking.tsx
Normal file
89
client/src/components/Artifacts/Thinking.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useState, useMemo, memo, useCallback } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Atom, ChevronDown } from 'lucide-react';
|
||||
import type { MouseEvent, FC } from 'react';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
const BUTTON_STYLES = {
|
||||
base: 'group mt-3 flex w-fit items-center justify-center rounded-xl bg-surface-tertiary px-3 py-2 text-xs leading-[18px] animate-thinking-appear',
|
||||
icon: 'icon-sm ml-1.5 transform-gpu text-text-primary transition-transform duration-200',
|
||||
} as const;
|
||||
|
||||
const CONTENT_STYLES = {
|
||||
wrapper: 'relative pl-3 text-text-secondary',
|
||||
border:
|
||||
'absolute left-0 h-[calc(100%-10px)] border-l-2 border-border-medium dark:border-border-heavy',
|
||||
partBorder:
|
||||
'absolute left-0 h-[calc(100%)] border-l-2 border-border-medium dark:border-border-heavy',
|
||||
text: 'whitespace-pre-wrap leading-[26px]',
|
||||
} as const;
|
||||
|
||||
export const ThinkingContent: FC<{ children: React.ReactNode; isPart?: boolean }> = memo(
|
||||
({ isPart, children }) => (
|
||||
<div className={CONTENT_STYLES.wrapper}>
|
||||
<div className={isPart === true ? CONTENT_STYLES.partBorder : CONTENT_STYLES.border} />
|
||||
<p className={CONTENT_STYLES.text}>{children}</p>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
|
||||
export const ThinkingButton = memo(
|
||||
({
|
||||
isExpanded,
|
||||
onClick,
|
||||
label,
|
||||
}: {
|
||||
isExpanded: boolean;
|
||||
onClick: (e: MouseEvent<HTMLButtonElement>) => void;
|
||||
label: string;
|
||||
}) => (
|
||||
<button type="button" onClick={onClick} className={BUTTON_STYLES.base}>
|
||||
<Atom size={14} className="mr-1.5 text-text-secondary" />
|
||||
{label}
|
||||
<ChevronDown className={`${BUTTON_STYLES.icon} ${isExpanded ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
),
|
||||
);
|
||||
|
||||
const Thinking: React.ElementType = memo(({ children }: { children: React.ReactNode }) => {
|
||||
const localize = useLocalize();
|
||||
const showThinking = useRecoilValue<boolean>(store.showThinking);
|
||||
const [isExpanded, setIsExpanded] = useState(showThinking);
|
||||
|
||||
const handleClick = useCallback((e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
setIsExpanded((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const label = useMemo(() => localize('com_ui_thoughts'), [localize]);
|
||||
|
||||
if (children == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-5">
|
||||
<ThinkingButton isExpanded={isExpanded} onClick={handleClick} label={label} />
|
||||
</div>
|
||||
<div
|
||||
className={cn('grid transition-all duration-300 ease-out', isExpanded && 'mb-8')}
|
||||
style={{
|
||||
gridTemplateRows: isExpanded ? '1fr' : '0fr',
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<ThinkingContent isPart={true}>{children}</ThinkingContent>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
ThinkingButton.displayName = 'ThinkingButton';
|
||||
ThinkingContent.displayName = 'ThinkingContent';
|
||||
Thinking.displayName = 'Thinking';
|
||||
|
||||
export default memo(Thinking);
|
||||
@@ -129,11 +129,7 @@ const BookmarkForm = ({
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid w-full items-center gap-2">
|
||||
<Label
|
||||
id="bookmark-description-label"
|
||||
htmlFor="bookmark-description"
|
||||
className="text-left text-sm font-medium"
|
||||
>
|
||||
<Label htmlFor="bookmark-description" className="text-left text-sm font-medium">
|
||||
{localize('com_ui_bookmarks_description')}
|
||||
</Label>
|
||||
<TextareaAutosize
|
||||
@@ -151,7 +147,6 @@ const BookmarkForm = ({
|
||||
className={cn(
|
||||
'flex h-10 max-h-[250px] min-h-[100px] w-full resize-none rounded-lg border border-input bg-transparent px-3 py-2 text-sm ring-offset-background focus-visible:outline-none',
|
||||
)}
|
||||
aria-labelledby="bookmark-description-label"
|
||||
/>
|
||||
</div>
|
||||
{conversationId != null && conversationId && (
|
||||
@@ -166,7 +161,6 @@ const BookmarkForm = ({
|
||||
onCheckedChange={field.onChange}
|
||||
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
|
||||
value={field.value?.toString()}
|
||||
aria-label={localize('com_ui_bookmarks_add_to_conversation')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import {
|
||||
useTextarea,
|
||||
useAutoSave,
|
||||
useLocalize,
|
||||
useRequiresKey,
|
||||
useHandleKeyUp,
|
||||
useQueryParams,
|
||||
@@ -39,7 +38,6 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
||||
const submitButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||
useFocusChatEffect(textAreaRef);
|
||||
const localize = useLocalize();
|
||||
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [, setIsScrollable] = useState(false);
|
||||
@@ -222,7 +220,6 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
||||
<div className={cn('flex w-full items-center', isRTL && 'flex-row-reverse')}>
|
||||
{showPlusPopover && !isAssistantsEndpoint(endpoint) && (
|
||||
<Mention
|
||||
conversation={conversation}
|
||||
setShowMentionPopover={setShowPlusPopover}
|
||||
newConversation={generateConversation}
|
||||
textAreaRef={textAreaRef}
|
||||
@@ -233,7 +230,6 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
||||
)}
|
||||
{showMentionPopover && (
|
||||
<Mention
|
||||
conversation={conversation}
|
||||
setShowMentionPopover={setShowMentionPopover}
|
||||
newConversation={newConversation}
|
||||
textAreaRef={textAreaRef}
|
||||
@@ -281,7 +277,6 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
||||
setIsTextAreaFocused(true);
|
||||
}}
|
||||
onBlur={setIsTextAreaFocused.bind(null, false)}
|
||||
aria-label={localize('com_ui_message_input')}
|
||||
onClick={handleFocusOrClick}
|
||||
style={{ height: 44, overflowY: 'auto' }}
|
||||
className={cn(
|
||||
|
||||
@@ -117,10 +117,8 @@ const AttachFileMenu = ({
|
||||
const items: MenuItemProps[] = [];
|
||||
|
||||
const currentProvider = provider || endpoint;
|
||||
if (
|
||||
isDocumentSupportedProvider(endpointType) ||
|
||||
isDocumentSupportedProvider(currentProvider)
|
||||
) {
|
||||
|
||||
if (isDocumentSupportedProvider(currentProvider || endpointType)) {
|
||||
items.push({
|
||||
label: localize('com_ui_upload_provider'),
|
||||
onClick: () => {
|
||||
|
||||
@@ -57,7 +57,7 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD
|
||||
const currentProvider = provider || endpoint;
|
||||
|
||||
// Check if provider supports document upload
|
||||
if (isDocumentSupportedProvider(endpointType) || isDocumentSupportedProvider(currentProvider)) {
|
||||
if (isDocumentSupportedProvider(currentProvider || endpointType)) {
|
||||
const isGoogleProvider = currentProvider === EModelEndpoint.google;
|
||||
const validFileTypes = isGoogleProvider
|
||||
? files.every(
|
||||
|
||||
@@ -133,7 +133,7 @@ export default function FileRow({
|
||||
>
|
||||
{isImage ? (
|
||||
<Image
|
||||
url={file.progress === 1 ? file.filepath : (file.preview ?? file.filepath)}
|
||||
url={file.preview ?? file.filepath}
|
||||
onDelete={handleDelete}
|
||||
progress={file.progress}
|
||||
source={file.source}
|
||||
|
||||
@@ -62,28 +62,17 @@ const FileUpload: React.FC<FileUploadProps> = ({
|
||||
statusText = invalidText ?? localize('com_ui_upload_invalid');
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
const fileInput = document.getElementById(`file-upload-${id}`) as HTMLInputElement;
|
||||
if (fileInput) {
|
||||
fileInput.click();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
'mr-1 flex h-auto cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-normal transition-colors hover:bg-gray-100 hover:text-green-600 focus:ring-ring dark:bg-transparent dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-green-500',
|
||||
statusColor,
|
||||
containerClassName,
|
||||
)}
|
||||
aria-label={statusText}
|
||||
>
|
||||
<FileUp className="mr-1 flex w-[22px] items-center stroke-1" aria-hidden="true" />
|
||||
<span className="flex text-xs">{statusText}</span>
|
||||
</button>
|
||||
<label
|
||||
htmlFor={`file-upload-${id}`}
|
||||
className={cn(
|
||||
'mr-1 flex h-auto cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-normal transition-colors hover:bg-gray-100 hover:text-green-600 dark:bg-transparent dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-green-500',
|
||||
statusColor,
|
||||
containerClassName,
|
||||
)}
|
||||
>
|
||||
<FileUp className="mr-1 flex w-[22px] items-center stroke-1" />
|
||||
<span className="flex text-xs">{statusText}</span>
|
||||
<input
|
||||
id={`file-upload-${id}`}
|
||||
value=""
|
||||
@@ -91,9 +80,8 @@ const FileUpload: React.FC<FileUploadProps> = ({
|
||||
className={cn('hidden', className)}
|
||||
accept=".json"
|
||||
onChange={handleFileChange}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -122,11 +122,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
aria-label={localize('com_files_filter_by')}
|
||||
className={cn('min-w-[40px]', isSmallScreen && 'px-2 py-1')}
|
||||
>
|
||||
<Button variant="outline" className={cn('min-w-[40px]', isSmallScreen && 'px-2 py-1')}>
|
||||
<ListFilter className="size-3.5 sm:size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -1,602 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import AttachFileMenu from '../AttachFileMenu';
|
||||
|
||||
// Mock all the hooks
|
||||
jest.mock('~/hooks', () => ({
|
||||
useAgentToolPermissions: jest.fn(),
|
||||
useAgentCapabilities: jest.fn(),
|
||||
useGetAgentsConfig: jest.fn(),
|
||||
useFileHandling: jest.fn(),
|
||||
useLocalize: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks/Files/useSharePointFileHandling', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useGetStartupConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/components/SharePoint', () => ({
|
||||
SharePointPickerDialog: jest.fn(() => null),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/client', () => {
|
||||
const React = jest.requireActual('react');
|
||||
return {
|
||||
FileUpload: React.forwardRef(({ children, handleFileChange }: any, ref: any) => (
|
||||
<div data-testid="file-upload">
|
||||
<input ref={ref} type="file" onChange={handleFileChange} data-testid="file-input" />
|
||||
{children}
|
||||
</div>
|
||||
)),
|
||||
TooltipAnchor: ({ render }: any) => render,
|
||||
DropdownPopup: ({ trigger, items, isOpen, setIsOpen }: any) => {
|
||||
const handleTriggerClick = () => {
|
||||
if (setIsOpen) {
|
||||
setIsOpen(!isOpen);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div onClick={handleTriggerClick}>{trigger}</div>
|
||||
{isOpen && (
|
||||
<div data-testid="dropdown-menu">
|
||||
{items.map((item: any, idx: number) => (
|
||||
<button key={idx} onClick={item.onClick} data-testid={`menu-item-${idx}`}>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
AttachmentIcon: () => <span data-testid="attachment-icon">📎</span>,
|
||||
SharePointIcon: () => <span data-testid="sharepoint-icon">SP</span>,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@ariakit/react', () => ({
|
||||
MenuButton: ({ children, onClick, disabled, ...props }: any) => (
|
||||
<button onClick={onClick} disabled={disabled} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockUseAgentToolPermissions = jest.requireMock('~/hooks').useAgentToolPermissions;
|
||||
const mockUseAgentCapabilities = jest.requireMock('~/hooks').useAgentCapabilities;
|
||||
const mockUseGetAgentsConfig = jest.requireMock('~/hooks').useGetAgentsConfig;
|
||||
const mockUseFileHandling = jest.requireMock('~/hooks').useFileHandling;
|
||||
const mockUseLocalize = jest.requireMock('~/hooks').useLocalize;
|
||||
const mockUseSharePointFileHandling = jest.requireMock(
|
||||
'~/hooks/Files/useSharePointFileHandling',
|
||||
).default;
|
||||
const mockUseGetStartupConfig = jest.requireMock('~/data-provider').useGetStartupConfig;
|
||||
|
||||
describe('AttachFileMenu', () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
const mockHandleFileChange = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Default mock implementations
|
||||
mockUseLocalize.mockReturnValue((key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
com_ui_upload_provider: 'Upload to Provider',
|
||||
com_ui_upload_image_input: 'Upload Image',
|
||||
com_ui_upload_ocr_text: 'Upload OCR Text',
|
||||
com_ui_upload_file_search: 'Upload for File Search',
|
||||
com_ui_upload_code_files: 'Upload Code Files',
|
||||
com_sidepanel_attach_files: 'Attach Files',
|
||||
com_files_upload_sharepoint: 'Upload from SharePoint',
|
||||
};
|
||||
return translations[key] || key;
|
||||
});
|
||||
|
||||
mockUseAgentCapabilities.mockReturnValue({
|
||||
contextEnabled: false,
|
||||
fileSearchEnabled: false,
|
||||
codeEnabled: false,
|
||||
});
|
||||
|
||||
mockUseGetAgentsConfig.mockReturnValue({
|
||||
agentsConfig: {
|
||||
capabilities: {
|
||||
contextEnabled: false,
|
||||
fileSearchEnabled: false,
|
||||
codeEnabled: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mockUseFileHandling.mockReturnValue({
|
||||
handleFileChange: mockHandleFileChange,
|
||||
});
|
||||
|
||||
mockUseSharePointFileHandling.mockReturnValue({
|
||||
handleSharePointFiles: jest.fn(),
|
||||
isProcessing: false,
|
||||
downloadProgress: 0,
|
||||
});
|
||||
|
||||
mockUseGetStartupConfig.mockReturnValue({
|
||||
data: {
|
||||
sharePointFilePickerEnabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
mockUseAgentToolPermissions.mockReturnValue({
|
||||
fileSearchAllowedByAgent: false,
|
||||
codeAllowedByAgent: false,
|
||||
provider: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
const renderAttachFileMenu = (props: any = {}) => {
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RecoilRoot>
|
||||
<AttachFileMenu conversationId="test-conversation" {...props} />
|
||||
</RecoilRoot>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('Basic Rendering', () => {
|
||||
it('should render the attachment button', () => {
|
||||
renderAttachFileMenu();
|
||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should be disabled when disabled prop is true', () => {
|
||||
renderAttachFileMenu({ disabled: true });
|
||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should not be disabled when disabled prop is false', () => {
|
||||
renderAttachFileMenu({ disabled: false });
|
||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||
expect(button).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Provider Detection Fix - endpointType Priority', () => {
|
||||
it('should prioritize endpointType over currentProvider for LiteLLM gateway', () => {
|
||||
mockUseAgentToolPermissions.mockReturnValue({
|
||||
fileSearchAllowedByAgent: false,
|
||||
codeAllowedByAgent: false,
|
||||
provider: 'litellm', // Custom gateway name NOT in documentSupportedProviders
|
||||
});
|
||||
|
||||
renderAttachFileMenu({
|
||||
endpoint: 'litellm',
|
||||
endpointType: EModelEndpoint.openAI, // Backend override IS in documentSupportedProviders
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
// With the fix, should show "Upload to Provider" because endpointType is checked first
|
||||
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Upload Image')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show Upload to Provider for custom endpoints with OpenAI endpointType', () => {
|
||||
mockUseAgentToolPermissions.mockReturnValue({
|
||||
fileSearchAllowedByAgent: false,
|
||||
codeAllowedByAgent: false,
|
||||
provider: 'my-custom-gateway',
|
||||
});
|
||||
|
||||
renderAttachFileMenu({
|
||||
endpoint: 'my-custom-gateway',
|
||||
endpointType: EModelEndpoint.openAI,
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show Upload Image when neither endpointType nor provider support documents', () => {
|
||||
mockUseAgentToolPermissions.mockReturnValue({
|
||||
fileSearchAllowedByAgent: false,
|
||||
codeAllowedByAgent: false,
|
||||
provider: 'unsupported-provider',
|
||||
});
|
||||
|
||||
renderAttachFileMenu({
|
||||
endpoint: 'unsupported-provider',
|
||||
endpointType: 'unsupported-endpoint' as any,
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.getByText('Upload Image')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Upload to Provider')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should fallback to currentProvider when endpointType is undefined', () => {
|
||||
mockUseAgentToolPermissions.mockReturnValue({
|
||||
fileSearchAllowedByAgent: false,
|
||||
codeAllowedByAgent: false,
|
||||
provider: EModelEndpoint.openAI,
|
||||
});
|
||||
|
||||
renderAttachFileMenu({
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
endpointType: undefined,
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should fallback to currentProvider when endpointType is null', () => {
|
||||
mockUseAgentToolPermissions.mockReturnValue({
|
||||
fileSearchAllowedByAgent: false,
|
||||
codeAllowedByAgent: false,
|
||||
provider: EModelEndpoint.anthropic,
|
||||
});
|
||||
|
||||
renderAttachFileMenu({
|
||||
endpoint: EModelEndpoint.anthropic,
|
||||
endpointType: null,
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Supported Providers', () => {
|
||||
const supportedProviders = [
|
||||
{ name: 'OpenAI', endpoint: EModelEndpoint.openAI },
|
||||
{ name: 'Anthropic', endpoint: EModelEndpoint.anthropic },
|
||||
{ name: 'Google', endpoint: EModelEndpoint.google },
|
||||
{ name: 'Azure OpenAI', endpoint: EModelEndpoint.azureOpenAI },
|
||||
{ name: 'Custom', endpoint: EModelEndpoint.custom },
|
||||
];
|
||||
|
||||
supportedProviders.forEach(({ name, endpoint }) => {
|
||||
it(`should show Upload to Provider for ${name}`, () => {
|
||||
mockUseAgentToolPermissions.mockReturnValue({
|
||||
fileSearchAllowedByAgent: false,
|
||||
codeAllowedByAgent: false,
|
||||
provider: endpoint,
|
||||
});
|
||||
|
||||
renderAttachFileMenu({
|
||||
endpoint,
|
||||
endpointType: endpoint,
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Agent Capabilities', () => {
|
||||
it('should show OCR Text option when context is enabled', () => {
|
||||
mockUseAgentCapabilities.mockReturnValue({
|
||||
contextEnabled: true,
|
||||
fileSearchEnabled: false,
|
||||
codeEnabled: false,
|
||||
});
|
||||
|
||||
renderAttachFileMenu({
|
||||
endpointType: EModelEndpoint.openAI,
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.getByText('Upload OCR Text')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show File Search option when enabled and allowed by agent', () => {
|
||||
mockUseAgentCapabilities.mockReturnValue({
|
||||
contextEnabled: false,
|
||||
fileSearchEnabled: true,
|
||||
codeEnabled: false,
|
||||
});
|
||||
|
||||
mockUseAgentToolPermissions.mockReturnValue({
|
||||
fileSearchAllowedByAgent: true,
|
||||
codeAllowedByAgent: false,
|
||||
provider: undefined,
|
||||
});
|
||||
|
||||
renderAttachFileMenu({
|
||||
endpointType: EModelEndpoint.openAI,
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.getByText('Upload for File Search')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should NOT show File Search when enabled but not allowed by agent', () => {
|
||||
mockUseAgentCapabilities.mockReturnValue({
|
||||
contextEnabled: false,
|
||||
fileSearchEnabled: true,
|
||||
codeEnabled: false,
|
||||
});
|
||||
|
||||
mockUseAgentToolPermissions.mockReturnValue({
|
||||
fileSearchAllowedByAgent: false,
|
||||
codeAllowedByAgent: false,
|
||||
provider: undefined,
|
||||
});
|
||||
|
||||
renderAttachFileMenu({
|
||||
endpointType: EModelEndpoint.openAI,
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.queryByText('Upload for File Search')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show Code Files option when enabled and allowed by agent', () => {
|
||||
mockUseAgentCapabilities.mockReturnValue({
|
||||
contextEnabled: false,
|
||||
fileSearchEnabled: false,
|
||||
codeEnabled: true,
|
||||
});
|
||||
|
||||
mockUseAgentToolPermissions.mockReturnValue({
|
||||
fileSearchAllowedByAgent: false,
|
||||
codeAllowedByAgent: true,
|
||||
provider: undefined,
|
||||
});
|
||||
|
||||
renderAttachFileMenu({
|
||||
endpointType: EModelEndpoint.openAI,
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.getByText('Upload Code Files')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show all options when all capabilities are enabled', () => {
|
||||
mockUseAgentCapabilities.mockReturnValue({
|
||||
contextEnabled: true,
|
||||
fileSearchEnabled: true,
|
||||
codeEnabled: true,
|
||||
});
|
||||
|
||||
mockUseAgentToolPermissions.mockReturnValue({
|
||||
fileSearchAllowedByAgent: true,
|
||||
codeAllowedByAgent: true,
|
||||
provider: undefined,
|
||||
});
|
||||
|
||||
renderAttachFileMenu({
|
||||
endpointType: EModelEndpoint.openAI,
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
|
||||
expect(screen.getByText('Upload OCR Text')).toBeInTheDocument();
|
||||
expect(screen.getByText('Upload for File Search')).toBeInTheDocument();
|
||||
expect(screen.getByText('Upload Code Files')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SharePoint Integration', () => {
|
||||
it('should show SharePoint option when enabled', () => {
|
||||
mockUseGetStartupConfig.mockReturnValue({
|
||||
data: {
|
||||
sharePointFilePickerEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
renderAttachFileMenu({
|
||||
endpointType: EModelEndpoint.openAI,
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.getByText('Upload from SharePoint')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should NOT show SharePoint option when disabled', () => {
|
||||
mockUseGetStartupConfig.mockReturnValue({
|
||||
data: {
|
||||
sharePointFilePickerEnabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
renderAttachFileMenu({
|
||||
endpointType: EModelEndpoint.openAI,
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.queryByText('Upload from SharePoint')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined endpoint and provider gracefully', () => {
|
||||
mockUseAgentToolPermissions.mockReturnValue({
|
||||
fileSearchAllowedByAgent: false,
|
||||
codeAllowedByAgent: false,
|
||||
provider: undefined,
|
||||
});
|
||||
|
||||
renderAttachFileMenu({
|
||||
endpoint: undefined,
|
||||
endpointType: undefined,
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
fireEvent.click(button);
|
||||
|
||||
// Should show Upload Image as fallback
|
||||
expect(screen.getByText('Upload Image')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle null endpoint and provider gracefully', () => {
|
||||
mockUseAgentToolPermissions.mockReturnValue({
|
||||
fileSearchAllowedByAgent: false,
|
||||
codeAllowedByAgent: false,
|
||||
provider: null,
|
||||
});
|
||||
|
||||
renderAttachFileMenu({
|
||||
endpoint: null,
|
||||
endpointType: null,
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle missing agentId gracefully', () => {
|
||||
renderAttachFileMenu({
|
||||
agentId: undefined,
|
||||
endpointType: EModelEndpoint.openAI,
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle empty string agentId', () => {
|
||||
renderAttachFileMenu({
|
||||
agentId: '',
|
||||
endpointType: EModelEndpoint.openAI,
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Google Provider Special Case', () => {
|
||||
it('should use google_multimodal file type for Google provider', () => {
|
||||
mockUseAgentToolPermissions.mockReturnValue({
|
||||
fileSearchAllowedByAgent: false,
|
||||
codeAllowedByAgent: false,
|
||||
provider: EModelEndpoint.google,
|
||||
});
|
||||
|
||||
renderAttachFileMenu({
|
||||
endpoint: EModelEndpoint.google,
|
||||
endpointType: EModelEndpoint.google,
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
const uploadProviderButton = screen.getByText('Upload to Provider');
|
||||
expect(uploadProviderButton).toBeInTheDocument();
|
||||
|
||||
// Click the upload to provider option
|
||||
fireEvent.click(uploadProviderButton);
|
||||
|
||||
// The file input should have been clicked (indirectly tested through the implementation)
|
||||
});
|
||||
|
||||
it('should use multimodal file type for non-Google providers', () => {
|
||||
mockUseAgentToolPermissions.mockReturnValue({
|
||||
fileSearchAllowedByAgent: false,
|
||||
codeAllowedByAgent: false,
|
||||
provider: EModelEndpoint.openAI,
|
||||
});
|
||||
|
||||
renderAttachFileMenu({
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
endpointType: EModelEndpoint.openAI,
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
const uploadProviderButton = screen.getByText('Upload to Provider');
|
||||
expect(uploadProviderButton).toBeInTheDocument();
|
||||
fireEvent.click(uploadProviderButton);
|
||||
|
||||
// Implementation detail - multimodal type is used
|
||||
});
|
||||
});
|
||||
|
||||
describe('Regression Tests', () => {
|
||||
it('should not break the previous behavior for direct provider attachments', () => {
|
||||
// When using a direct supported provider (not through a gateway)
|
||||
mockUseAgentToolPermissions.mockReturnValue({
|
||||
fileSearchAllowedByAgent: false,
|
||||
codeAllowedByAgent: false,
|
||||
provider: EModelEndpoint.anthropic,
|
||||
});
|
||||
|
||||
renderAttachFileMenu({
|
||||
endpoint: EModelEndpoint.anthropic,
|
||||
endpointType: EModelEndpoint.anthropic,
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should maintain correct priority when both are supported', () => {
|
||||
// Both endpointType and provider are supported, endpointType should be checked first
|
||||
mockUseAgentToolPermissions.mockReturnValue({
|
||||
fileSearchAllowedByAgent: false,
|
||||
codeAllowedByAgent: false,
|
||||
provider: EModelEndpoint.google,
|
||||
});
|
||||
|
||||
renderAttachFileMenu({
|
||||
endpoint: EModelEndpoint.google,
|
||||
endpointType: EModelEndpoint.openAI, // Different but both supported
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
// Should still work because endpointType (openAI) is supported
|
||||
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
import { EModelEndpoint, isDocumentSupportedProvider } from 'librechat-data-provider';
|
||||
|
||||
describe('DragDropModal - Provider Detection', () => {
|
||||
describe('endpointType priority over currentProvider', () => {
|
||||
it('should show upload option for LiteLLM with OpenAI endpointType', () => {
|
||||
const currentProvider = 'litellm'; // NOT in documentSupportedProviders
|
||||
const endpointType = EModelEndpoint.openAI; // IS in documentSupportedProviders
|
||||
|
||||
// With fix: endpointType checked
|
||||
const withFix =
|
||||
isDocumentSupportedProvider(endpointType) || isDocumentSupportedProvider(currentProvider);
|
||||
expect(withFix).toBe(true);
|
||||
|
||||
// Without fix: only currentProvider checked = false
|
||||
const withoutFix = isDocumentSupportedProvider(currentProvider || endpointType);
|
||||
expect(withoutFix).toBe(false);
|
||||
});
|
||||
|
||||
it('should show upload option for any custom gateway with OpenAI endpointType', () => {
|
||||
const currentProvider = 'my-custom-gateway';
|
||||
const endpointType = EModelEndpoint.openAI;
|
||||
|
||||
const result =
|
||||
isDocumentSupportedProvider(endpointType) || isDocumentSupportedProvider(currentProvider);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should fallback to currentProvider when endpointType is undefined', () => {
|
||||
const currentProvider = EModelEndpoint.openAI;
|
||||
const endpointType = undefined;
|
||||
|
||||
const result =
|
||||
isDocumentSupportedProvider(endpointType) || isDocumentSupportedProvider(currentProvider);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should fallback to currentProvider when endpointType is null', () => {
|
||||
const currentProvider = EModelEndpoint.anthropic;
|
||||
const endpointType = null;
|
||||
|
||||
const result =
|
||||
isDocumentSupportedProvider(endpointType as any) ||
|
||||
isDocumentSupportedProvider(currentProvider);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when neither provider supports documents', () => {
|
||||
const currentProvider = 'unsupported-provider';
|
||||
const endpointType = 'unsupported-endpoint' as any;
|
||||
|
||||
const result =
|
||||
isDocumentSupportedProvider(endpointType) || isDocumentSupportedProvider(currentProvider);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('supported providers', () => {
|
||||
const supportedProviders = [
|
||||
{ name: 'OpenAI', value: EModelEndpoint.openAI },
|
||||
{ name: 'Anthropic', value: EModelEndpoint.anthropic },
|
||||
{ name: 'Google', value: EModelEndpoint.google },
|
||||
{ name: 'Azure OpenAI', value: EModelEndpoint.azureOpenAI },
|
||||
{ name: 'Custom', value: EModelEndpoint.custom },
|
||||
];
|
||||
|
||||
supportedProviders.forEach(({ name, value }) => {
|
||||
it(`should recognize ${name} as supported`, () => {
|
||||
expect(isDocumentSupportedProvider(value)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('real-world scenarios', () => {
|
||||
it('should handle LiteLLM gateway pointing to OpenAI', () => {
|
||||
const scenario = {
|
||||
currentProvider: 'litellm',
|
||||
endpointType: EModelEndpoint.openAI,
|
||||
};
|
||||
|
||||
expect(
|
||||
isDocumentSupportedProvider(scenario.endpointType) ||
|
||||
isDocumentSupportedProvider(scenario.currentProvider),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle direct OpenAI connection', () => {
|
||||
const scenario = {
|
||||
currentProvider: EModelEndpoint.openAI,
|
||||
endpointType: EModelEndpoint.openAI,
|
||||
};
|
||||
|
||||
expect(
|
||||
isDocumentSupportedProvider(scenario.endpointType) ||
|
||||
isDocumentSupportedProvider(scenario.currentProvider),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle unsupported custom endpoint without override', () => {
|
||||
const scenario = {
|
||||
currentProvider: 'my-unsupported-endpoint',
|
||||
endpointType: undefined,
|
||||
};
|
||||
|
||||
expect(
|
||||
isDocumentSupportedProvider(scenario.endpointType) ||
|
||||
isDocumentSupportedProvider(scenario.currentProvider),
|
||||
).toBe(false);
|
||||
});
|
||||
it('should handle agents endpoints with document supported providers', () => {
|
||||
const scenario = {
|
||||
currentProvider: EModelEndpoint.google,
|
||||
endpointType: EModelEndpoint.agents,
|
||||
};
|
||||
|
||||
expect(
|
||||
isDocumentSupportedProvider(scenario.endpointType) ||
|
||||
isDocumentSupportedProvider(scenario.currentProvider),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,347 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { FileSources } from 'librechat-data-provider';
|
||||
import type { ExtendedFile } from '~/common';
|
||||
import FileRow from '../FileRow';
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useDeleteFilesMutation: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks/Files', () => ({
|
||||
useFileDeletion: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/utils', () => ({
|
||||
logger: {
|
||||
log: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../Image', () => {
|
||||
return function MockImage({ url, progress, source }: any) {
|
||||
return (
|
||||
<div data-testid="mock-image">
|
||||
<span data-testid="image-url">{url}</span>
|
||||
<span data-testid="image-progress">{progress}</span>
|
||||
<span data-testid="image-source">{source}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../FileContainer', () => {
|
||||
return function MockFileContainer({ file }: any) {
|
||||
return (
|
||||
<div data-testid="mock-file-container">
|
||||
<span data-testid="file-name">{file.filename}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const mockUseLocalize = jest.requireMock('~/hooks').useLocalize;
|
||||
const mockUseDeleteFilesMutation = jest.requireMock('~/data-provider').useDeleteFilesMutation;
|
||||
const mockUseFileDeletion = jest.requireMock('~/hooks/Files').useFileDeletion;
|
||||
|
||||
describe('FileRow', () => {
|
||||
const mockSetFiles = jest.fn();
|
||||
const mockSetFilesLoading = jest.fn();
|
||||
const mockDeleteFile = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockUseLocalize.mockReturnValue((key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
com_ui_deleting_file: 'Deleting file...',
|
||||
};
|
||||
return translations[key] || key;
|
||||
});
|
||||
|
||||
mockUseDeleteFilesMutation.mockReturnValue({
|
||||
mutateAsync: jest.fn(),
|
||||
});
|
||||
|
||||
mockUseFileDeletion.mockReturnValue({
|
||||
deleteFile: mockDeleteFile,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a mock ExtendedFile with sensible defaults
|
||||
*/
|
||||
const createMockFile = (overrides: Partial<ExtendedFile> = {}): ExtendedFile => ({
|
||||
file_id: 'test-file-id',
|
||||
type: 'image/png',
|
||||
preview: 'blob:http://localhost:3080/preview-blob-url',
|
||||
filepath: '/images/user123/test-file-id__image.png',
|
||||
filename: 'test-image.png',
|
||||
progress: 1,
|
||||
size: 1024,
|
||||
source: FileSources.local,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const renderFileRow = (files: Map<string, ExtendedFile>) => {
|
||||
return render(
|
||||
<FileRow files={files} setFiles={mockSetFiles} setFilesLoading={mockSetFilesLoading} />,
|
||||
);
|
||||
};
|
||||
|
||||
describe('Image URL Selection Logic', () => {
|
||||
it('should use filepath instead of preview when progress is 1 (upload complete)', () => {
|
||||
const file = createMockFile({
|
||||
file_id: 'uploaded-file',
|
||||
preview: 'blob:http://localhost:3080/temp-preview',
|
||||
filepath: '/images/user123/uploaded-file__image.png',
|
||||
progress: 1,
|
||||
});
|
||||
|
||||
const filesMap = new Map<string, ExtendedFile>();
|
||||
filesMap.set(file.file_id, file);
|
||||
|
||||
renderFileRow(filesMap);
|
||||
|
||||
const imageUrl = screen.getByTestId('image-url').textContent;
|
||||
expect(imageUrl).toBe('/images/user123/uploaded-file__image.png');
|
||||
expect(imageUrl).not.toContain('blob:');
|
||||
});
|
||||
|
||||
it('should use preview when progress is less than 1 (uploading)', () => {
|
||||
const file = createMockFile({
|
||||
file_id: 'uploading-file',
|
||||
preview: 'blob:http://localhost:3080/temp-preview',
|
||||
filepath: undefined,
|
||||
progress: 0.5,
|
||||
});
|
||||
|
||||
const filesMap = new Map<string, ExtendedFile>();
|
||||
filesMap.set(file.file_id, file);
|
||||
|
||||
renderFileRow(filesMap);
|
||||
|
||||
const imageUrl = screen.getByTestId('image-url').textContent;
|
||||
expect(imageUrl).toBe('blob:http://localhost:3080/temp-preview');
|
||||
});
|
||||
|
||||
it('should fallback to filepath when preview is undefined and progress is less than 1', () => {
|
||||
const file = createMockFile({
|
||||
file_id: 'file-without-preview',
|
||||
preview: undefined,
|
||||
filepath: '/images/user123/file-without-preview__image.png',
|
||||
progress: 0.7,
|
||||
});
|
||||
|
||||
const filesMap = new Map<string, ExtendedFile>();
|
||||
filesMap.set(file.file_id, file);
|
||||
|
||||
renderFileRow(filesMap);
|
||||
|
||||
const imageUrl = screen.getByTestId('image-url').textContent;
|
||||
expect(imageUrl).toBe('/images/user123/file-without-preview__image.png');
|
||||
});
|
||||
|
||||
it('should use filepath when both preview and filepath exist and progress is exactly 1', () => {
|
||||
const file = createMockFile({
|
||||
file_id: 'complete-file',
|
||||
preview: 'blob:http://localhost:3080/old-blob',
|
||||
filepath: '/images/user123/complete-file__image.png',
|
||||
progress: 1.0,
|
||||
});
|
||||
|
||||
const filesMap = new Map<string, ExtendedFile>();
|
||||
filesMap.set(file.file_id, file);
|
||||
|
||||
renderFileRow(filesMap);
|
||||
|
||||
const imageUrl = screen.getByTestId('image-url').textContent;
|
||||
expect(imageUrl).toBe('/images/user123/complete-file__image.png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Progress States', () => {
|
||||
it('should pass correct progress value during upload', () => {
|
||||
const file = createMockFile({
|
||||
progress: 0.65,
|
||||
});
|
||||
|
||||
const filesMap = new Map<string, ExtendedFile>();
|
||||
filesMap.set(file.file_id, file);
|
||||
|
||||
renderFileRow(filesMap);
|
||||
|
||||
const progress = screen.getByTestId('image-progress').textContent;
|
||||
expect(progress).toBe('0.65');
|
||||
});
|
||||
|
||||
it('should pass progress value of 1 when upload is complete', () => {
|
||||
const file = createMockFile({
|
||||
progress: 1,
|
||||
});
|
||||
|
||||
const filesMap = new Map<string, ExtendedFile>();
|
||||
filesMap.set(file.file_id, file);
|
||||
|
||||
renderFileRow(filesMap);
|
||||
|
||||
const progress = screen.getByTestId('image-progress').textContent;
|
||||
expect(progress).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('File Source', () => {
|
||||
it('should pass local source to Image component', () => {
|
||||
const file = createMockFile({
|
||||
source: FileSources.local,
|
||||
});
|
||||
|
||||
const filesMap = new Map<string, ExtendedFile>();
|
||||
filesMap.set(file.file_id, file);
|
||||
|
||||
renderFileRow(filesMap);
|
||||
|
||||
const source = screen.getByTestId('image-source').textContent;
|
||||
expect(source).toBe(FileSources.local);
|
||||
});
|
||||
|
||||
it('should pass openai source to Image component', () => {
|
||||
const file = createMockFile({
|
||||
source: FileSources.openai,
|
||||
});
|
||||
|
||||
const filesMap = new Map<string, ExtendedFile>();
|
||||
filesMap.set(file.file_id, file);
|
||||
|
||||
renderFileRow(filesMap);
|
||||
|
||||
const source = screen.getByTestId('image-source').textContent;
|
||||
expect(source).toBe(FileSources.openai);
|
||||
});
|
||||
});
|
||||
|
||||
describe('File Type Detection', () => {
|
||||
it('should render Image component for image files', () => {
|
||||
const file = createMockFile({
|
||||
type: 'image/jpeg',
|
||||
});
|
||||
|
||||
const filesMap = new Map<string, ExtendedFile>();
|
||||
filesMap.set(file.file_id, file);
|
||||
|
||||
renderFileRow(filesMap);
|
||||
|
||||
expect(screen.getByTestId('mock-image')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('mock-file-container')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render FileContainer for non-image files', () => {
|
||||
const file = createMockFile({
|
||||
type: 'application/pdf',
|
||||
filename: 'document.pdf',
|
||||
});
|
||||
|
||||
const filesMap = new Map<string, ExtendedFile>();
|
||||
filesMap.set(file.file_id, file);
|
||||
|
||||
renderFileRow(filesMap);
|
||||
|
||||
expect(screen.getByTestId('mock-file-container')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('mock-image')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiple Files', () => {
|
||||
it('should render multiple image files with correct URLs based on their progress', () => {
|
||||
const filesMap = new Map<string, ExtendedFile>();
|
||||
|
||||
const uploadingFile = createMockFile({
|
||||
file_id: 'file-1',
|
||||
preview: 'blob:http://localhost:3080/preview-1',
|
||||
filepath: undefined,
|
||||
progress: 0.3,
|
||||
});
|
||||
|
||||
const completedFile = createMockFile({
|
||||
file_id: 'file-2',
|
||||
preview: 'blob:http://localhost:3080/preview-2',
|
||||
filepath: '/images/user123/file-2__image.png',
|
||||
progress: 1,
|
||||
});
|
||||
|
||||
filesMap.set(uploadingFile.file_id, uploadingFile);
|
||||
filesMap.set(completedFile.file_id, completedFile);
|
||||
|
||||
renderFileRow(filesMap);
|
||||
|
||||
const images = screen.getAllByTestId('mock-image');
|
||||
expect(images).toHaveLength(2);
|
||||
|
||||
const urls = screen.getAllByTestId('image-url').map((el) => el.textContent);
|
||||
expect(urls).toContain('blob:http://localhost:3080/preview-1');
|
||||
expect(urls).toContain('/images/user123/file-2__image.png');
|
||||
});
|
||||
|
||||
it('should deduplicate files with the same file_id', () => {
|
||||
const filesMap = new Map<string, ExtendedFile>();
|
||||
|
||||
const file1 = createMockFile({ file_id: 'duplicate-id' });
|
||||
const file2 = createMockFile({ file_id: 'duplicate-id' });
|
||||
|
||||
filesMap.set('key-1', file1);
|
||||
filesMap.set('key-2', file2);
|
||||
|
||||
renderFileRow(filesMap);
|
||||
|
||||
const images = screen.getAllByTestId('mock-image');
|
||||
expect(images).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Empty State', () => {
|
||||
it('should render nothing when files map is empty', () => {
|
||||
const filesMap = new Map<string, ExtendedFile>();
|
||||
|
||||
const { container } = renderFileRow(filesMap);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('should render nothing when files is undefined', () => {
|
||||
const { container } = render(
|
||||
<FileRow files={undefined} setFiles={mockSetFiles} setFilesLoading={mockSetFilesLoading} />,
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Regression: Blob URL Bug Fix', () => {
|
||||
it('should NOT use revoked blob URL after upload completes', () => {
|
||||
const file = createMockFile({
|
||||
file_id: 'regression-test',
|
||||
preview: 'blob:http://localhost:3080/d25f730c-152d-41f7-8d79-c9fa448f606b',
|
||||
filepath:
|
||||
'/images/68c98b26901ebe2d87c193a2/c0fe1b93-ba3d-456c-80be-9a492bfd9ed0__image.png',
|
||||
progress: 1,
|
||||
});
|
||||
|
||||
const filesMap = new Map<string, ExtendedFile>();
|
||||
filesMap.set(file.file_id, file);
|
||||
|
||||
renderFileRow(filesMap);
|
||||
|
||||
const imageUrl = screen.getByTestId('image-url').textContent;
|
||||
|
||||
expect(imageUrl).not.toContain('blob:');
|
||||
expect(imageUrl).toBe(
|
||||
'/images/68c98b26901ebe2d87c193a2/c0fe1b93-ba3d-456c-80be-9a492bfd9ed0__image.png',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import { useState, useRef, useEffect } from 'react';
|
||||
import { useCombobox } from '@librechat/client';
|
||||
import { AutoSizer, List } from 'react-virtualized';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { TConversation } from 'librechat-data-provider';
|
||||
import type { MentionOption, ConvoGenerator } from '~/common';
|
||||
import type { SetterOrUpdater } from 'recoil';
|
||||
import useSelectMention from '~/hooks/Input/useSelectMention';
|
||||
@@ -15,7 +14,6 @@ import MentionItem from './MentionItem';
|
||||
const ROW_HEIGHT = 40;
|
||||
|
||||
export default function Mention({
|
||||
conversation,
|
||||
setShowMentionPopover,
|
||||
newConversation,
|
||||
textAreaRef,
|
||||
@@ -23,7 +21,6 @@ export default function Mention({
|
||||
placeholder = 'com_ui_mention',
|
||||
includeAssistants = true,
|
||||
}: {
|
||||
conversation: TConversation | null;
|
||||
setShowMentionPopover: SetterOrUpdater<boolean>;
|
||||
newConversation: ConvoGenerator;
|
||||
textAreaRef: React.MutableRefObject<HTMLTextAreaElement | null>;
|
||||
@@ -45,7 +42,6 @@ export default function Mention({
|
||||
const { onSelectMention } = useSelectMention({
|
||||
presets,
|
||||
modelSpecs,
|
||||
conversation,
|
||||
assistantsMap,
|
||||
endpointsConfig,
|
||||
newConversation,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { createContext, useContext, useMemo } from 'react';
|
||||
import type { EModelEndpoint, TConversation } from 'librechat-data-provider';
|
||||
import type { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { useChatContext } from '~/Providers/ChatContext';
|
||||
|
||||
interface ModelSelectorChatContextValue {
|
||||
@@ -8,7 +8,6 @@ interface ModelSelectorChatContextValue {
|
||||
spec?: string | null;
|
||||
agent_id?: string | null;
|
||||
assistant_id?: string | null;
|
||||
conversation: TConversation | null;
|
||||
newConversation: ReturnType<typeof useChatContext>['newConversation'];
|
||||
}
|
||||
|
||||
@@ -27,10 +26,16 @@ export function ModelSelectorChatProvider({ children }: { children: React.ReactN
|
||||
spec: conversation?.spec,
|
||||
agent_id: conversation?.agent_id,
|
||||
assistant_id: conversation?.assistant_id,
|
||||
conversation,
|
||||
newConversation,
|
||||
}),
|
||||
[conversation, newConversation],
|
||||
[
|
||||
conversation?.endpoint,
|
||||
conversation?.model,
|
||||
conversation?.spec,
|
||||
conversation?.agent_id,
|
||||
conversation?.assistant_id,
|
||||
newConversation,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -57,7 +57,7 @@ export function ModelSelectorProvider({ children, startupConfig }: ModelSelector
|
||||
const agentsMap = useAgentsMapContext();
|
||||
const assistantsMap = useAssistantsMapContext();
|
||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||
const { endpoint, model, spec, agent_id, assistant_id, conversation, newConversation } =
|
||||
const { endpoint, model, spec, agent_id, assistant_id, newConversation } =
|
||||
useModelSelectorChatContext();
|
||||
const modelSpecs = useMemo(() => {
|
||||
const specs = startupConfig?.modelSpecs?.list ?? [];
|
||||
@@ -96,7 +96,6 @@ export function ModelSelectorProvider({ children, startupConfig }: ModelSelector
|
||||
const { onSelectEndpoint, onSelectSpec } = useSelectMention({
|
||||
// presets,
|
||||
modelSpecs,
|
||||
conversation,
|
||||
assistantsMap,
|
||||
endpointsConfig,
|
||||
newConversation,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { QueryKeys } from 'librechat-data-provider';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { QueryKeys, Constants } from 'librechat-data-provider';
|
||||
import { TooltipAnchor, Button, NewChatIcon } from '@librechat/client';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import { clearMessagesCache } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export default function HeaderNewChat() {
|
||||
@@ -15,7 +15,10 @@ export default function HeaderNewChat() {
|
||||
window.open('/c/new', '_blank');
|
||||
return;
|
||||
}
|
||||
clearMessagesCache(queryClient, conversation?.conversationId);
|
||||
queryClient.setQueryData<TMessage[]>(
|
||||
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
|
||||
[],
|
||||
);
|
||||
queryClient.invalidateQueries([QueryKeys.messages]);
|
||||
newConversation();
|
||||
};
|
||||
|
||||
@@ -59,10 +59,9 @@ const PresetItems: FC<{
|
||||
</label>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="mr-1 flex h-[32px] cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-red-700 focus:ring-ring dark:bg-transparent dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-red-700"
|
||||
aria-label={localize('com_ui_clear') + ' ' + localize('com_ui_all')}
|
||||
<label
|
||||
htmlFor="file-upload"
|
||||
className="mr-1 flex h-[32px] cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-red-700 dark:bg-transparent dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-red-700"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
@@ -71,12 +70,11 @@ const PresetItems: FC<{
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="mr-1 flex w-[22px] items-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M9.293 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V4.707A1 1 0 0 0 13.707 4L10 .293A1 1 0 0 0 9.293 0M9.5 3.5v-2l3 3h-2a1 1 0 0 1-1-1M6.854 7.146 8 8.293l1.146-1.147a.5.5 0 1 1 .708.708L8.707 9l1.147 1.146a.5.5 0 0 1-.708.708L8 9.707l-1.146 1.147a.5.5 0 0 1-.708-.708L7.293 9 6.146 7.854a.5.5 0 1 1 .708-.708"></path>
|
||||
</svg>
|
||||
{localize('com_ui_clear')} {localize('com_ui_all')}
|
||||
</button>
|
||||
</label>
|
||||
</DialogTrigger>
|
||||
<DialogTemplate
|
||||
showCloseButton={false}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { memo, useMemo, useState } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import type {
|
||||
TMessageContentParts,
|
||||
@@ -6,11 +7,14 @@ import type {
|
||||
TAttachment,
|
||||
Agents,
|
||||
} from 'librechat-data-provider';
|
||||
import { ThinkingButton } from '~/components/Artifacts/Thinking';
|
||||
import { MessageContext, SearchContext } from '~/Providers';
|
||||
import MemoryArtifacts from './MemoryArtifacts';
|
||||
import Sources from '~/components/Web/Sources';
|
||||
import { mapAttachments } from '~/utils/map';
|
||||
import { EditTextPart } from './Parts';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import store from '~/store';
|
||||
import Part from './Part';
|
||||
|
||||
type ContentPartsProps = {
|
||||
@@ -48,10 +52,32 @@ const ContentParts = memo(
|
||||
siblingIdx,
|
||||
setSiblingIdx,
|
||||
}: ContentPartsProps) => {
|
||||
const localize = useLocalize();
|
||||
const [showThinking, setShowThinking] = useRecoilState<boolean>(store.showThinking);
|
||||
const [isExpanded, setIsExpanded] = useState(showThinking);
|
||||
const attachmentMap = useMemo(() => mapAttachments(attachments ?? []), [attachments]);
|
||||
|
||||
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
|
||||
|
||||
const hasReasoningParts = useMemo(() => {
|
||||
const hasThinkPart = content?.some((part) => part?.type === ContentTypes.THINK) ?? false;
|
||||
const allThinkPartsHaveContent =
|
||||
content?.every((part) => {
|
||||
if (part?.type !== ContentTypes.THINK) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof part.think === 'string') {
|
||||
const cleanedContent = part.think.replace(/<\/?think>/g, '').trim();
|
||||
return cleanedContent.length > 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}) ?? false;
|
||||
|
||||
return hasThinkPart && allThinkPartsHaveContent;
|
||||
}, [content]);
|
||||
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
@@ -100,40 +126,57 @@ const ContentParts = memo(
|
||||
<SearchContext.Provider value={{ searchResults }}>
|
||||
<MemoryArtifacts attachments={attachments} />
|
||||
<Sources messageId={messageId} conversationId={conversationId || undefined} />
|
||||
{content.map((part, idx) => {
|
||||
if (!part) {
|
||||
return null;
|
||||
}
|
||||
{hasReasoningParts && (
|
||||
<div className="mb-5">
|
||||
<ThinkingButton
|
||||
isExpanded={isExpanded}
|
||||
onClick={() =>
|
||||
setIsExpanded((prev) => {
|
||||
const val = !prev;
|
||||
setShowThinking(val);
|
||||
return val;
|
||||
})
|
||||
}
|
||||
label={
|
||||
effectiveIsSubmitting && isLast
|
||||
? localize('com_ui_thinking')
|
||||
: localize('com_ui_thoughts')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{content
|
||||
.filter((part) => part)
|
||||
.map((part, idx) => {
|
||||
const toolCallId =
|
||||
(part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? '';
|
||||
const attachments = attachmentMap[toolCallId];
|
||||
|
||||
const toolCallId =
|
||||
(part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? '';
|
||||
const partAttachments = attachmentMap[toolCallId];
|
||||
|
||||
return (
|
||||
<MessageContext.Provider
|
||||
key={`provider-${messageId}-${idx}`}
|
||||
value={{
|
||||
messageId,
|
||||
isExpanded: true,
|
||||
conversationId,
|
||||
partIndex: idx,
|
||||
nextType: content[idx + 1]?.type,
|
||||
isSubmitting: effectiveIsSubmitting,
|
||||
isLatestMessage,
|
||||
}}
|
||||
>
|
||||
<Part
|
||||
part={part}
|
||||
attachments={partAttachments}
|
||||
isSubmitting={effectiveIsSubmitting}
|
||||
key={`part-${messageId}-${idx}`}
|
||||
isCreatedByUser={isCreatedByUser}
|
||||
isLast={idx === content.length - 1}
|
||||
showCursor={idx === content.length - 1 && isLast}
|
||||
/>
|
||||
</MessageContext.Provider>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<MessageContext.Provider
|
||||
key={`provider-${messageId}-${idx}`}
|
||||
value={{
|
||||
messageId,
|
||||
isExpanded,
|
||||
conversationId,
|
||||
partIndex: idx,
|
||||
nextType: content[idx + 1]?.type,
|
||||
isSubmitting: effectiveIsSubmitting,
|
||||
isLatestMessage,
|
||||
}}
|
||||
>
|
||||
<Part
|
||||
part={part}
|
||||
attachments={attachments}
|
||||
isSubmitting={effectiveIsSubmitting}
|
||||
key={`part-${messageId}-${idx}`}
|
||||
isCreatedByUser={isCreatedByUser}
|
||||
isLast={idx === content.length - 1}
|
||||
showCursor={idx === content.length - 1 && isLast}
|
||||
/>
|
||||
</MessageContext.Provider>
|
||||
);
|
||||
})}
|
||||
</SearchContext.Provider>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -151,7 +151,7 @@ const EditMessage = ({
|
||||
|
||||
return (
|
||||
<Container message={message}>
|
||||
<div className="bg-token-main-surface-primary relative mt-2 flex w-full flex-grow flex-col overflow-hidden rounded-2xl border border-border-medium text-text-primary [&:has(textarea:focus)]:border-border-heavy [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)]">
|
||||
<div className="bg-token-main-surface-primary relative flex w-full flex-grow flex-col overflow-hidden rounded-2xl border border-border-medium text-text-primary [&:has(textarea:focus)]:border-border-heavy [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)]">
|
||||
<TextareaAutosize
|
||||
{...registerProps}
|
||||
ref={(e) => {
|
||||
@@ -168,7 +168,6 @@ const EditMessage = ({
|
||||
'max-h-[65vh] pr-3 md:max-h-[75vh] md:pr-4',
|
||||
removeFocusRings,
|
||||
)}
|
||||
aria-label={localize('com_ui_message_input')}
|
||||
dir={isRTL ? 'rtl' : 'ltr'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -4,89 +4,67 @@ import { DelayedRender } from '@librechat/client';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import type { TMessageContentProps, TDisplayProps } from '~/common';
|
||||
import Error from '~/components/Messages/Content/Error';
|
||||
import Thinking from '~/components/Artifacts/Thinking';
|
||||
import { useMessageContext } from '~/Providers';
|
||||
import MarkdownLite from './MarkdownLite';
|
||||
import EditMessage from './EditMessage';
|
||||
import Thinking from './Parts/Thinking';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import Container from './Container';
|
||||
import Markdown from './Markdown';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
const ERROR_CONNECTION_TEXT = 'Error connecting to server, try refreshing the page.';
|
||||
const DELAYED_ERROR_TIMEOUT = 5500;
|
||||
const UNFINISHED_DELAY = 250;
|
||||
|
||||
const parseThinkingContent = (text: string) => {
|
||||
const thinkingMatch = text.match(/:::thinking([\s\S]*?):::/);
|
||||
return {
|
||||
thinkingContent: thinkingMatch ? thinkingMatch[1].trim() : '',
|
||||
regularContent: thinkingMatch ? text.replace(/:::thinking[\s\S]*?:::/, '').trim() : text,
|
||||
};
|
||||
};
|
||||
|
||||
const LoadingFallback = () => (
|
||||
<div className="text-message mb-[0.625rem] flex min-h-[20px] flex-col items-start gap-3 overflow-visible">
|
||||
<div className="markdown prose dark:prose-invert light w-full break-words dark:text-gray-100">
|
||||
<div className="absolute">
|
||||
<p className="submitting relative">
|
||||
<span className="result-thinking" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ErrorBox = ({
|
||||
children,
|
||||
className = '',
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className={cn(
|
||||
'rounded-xl border border-red-500/20 bg-red-500/5 px-3 py-2 text-sm text-gray-600 dark:text-gray-200',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const ConnectionError = ({ message }: { message?: TMessage }) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<DelayedRender delay={DELAYED_ERROR_TIMEOUT}>
|
||||
<Container message={message}>
|
||||
<div className="mt-2 rounded-xl border border-red-500/20 bg-red-50/50 px-4 py-3 text-sm text-red-700 shadow-sm transition-all dark:bg-red-950/30 dark:text-red-100">
|
||||
{localize('com_ui_error_connection')}
|
||||
</div>
|
||||
</Container>
|
||||
</DelayedRender>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export const ErrorMessage = ({
|
||||
text,
|
||||
message,
|
||||
className = '',
|
||||
}: Pick<TDisplayProps, 'text' | 'className'> & { message?: TMessage }) => {
|
||||
if (text === ERROR_CONNECTION_TEXT) {
|
||||
return <ConnectionError message={message} />;
|
||||
}: Pick<TDisplayProps, 'text' | 'className'> & {
|
||||
message?: TMessage;
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
if (text === 'Error connecting to server, try refreshing the page.') {
|
||||
console.log('error message', message);
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="text-message mb-[0.625rem] flex min-h-[20px] flex-col items-start gap-3 overflow-visible">
|
||||
<div className="markdown prose dark:prose-invert light w-full break-words dark:text-gray-100">
|
||||
<div className="absolute">
|
||||
<p className="submitting relative">
|
||||
<span className="result-thinking" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DelayedRender delay={5500}>
|
||||
<Container message={message}>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-md border border-red-500 bg-red-500/10 px-3 py-2 text-sm text-gray-600 dark:text-gray-200',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{localize('com_ui_error_connection')}
|
||||
</div>
|
||||
</Container>
|
||||
</DelayedRender>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container message={message}>
|
||||
<ErrorBox className={className}>
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className={cn(
|
||||
'rounded-xl border border-red-500/20 bg-red-500/5 px-3 py-2 text-sm text-gray-600 dark:text-gray-200',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Error text={text} />
|
||||
</ErrorBox>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
@@ -94,29 +72,27 @@ export const ErrorMessage = ({
|
||||
const DisplayMessage = ({ text, isCreatedByUser, message, showCursor }: TDisplayProps) => {
|
||||
const { isSubmitting = false, isLatestMessage = false } = useMessageContext();
|
||||
const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown);
|
||||
|
||||
const showCursorState = useMemo(
|
||||
() => showCursor === true && isSubmitting,
|
||||
[showCursor, isSubmitting],
|
||||
);
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (!isCreatedByUser) {
|
||||
return <Markdown content={text} isLatestMessage={isLatestMessage} />;
|
||||
}
|
||||
if (enableUserMsgMarkdown) {
|
||||
return <MarkdownLite content={text} />;
|
||||
}
|
||||
return <>{text}</>;
|
||||
}, [isCreatedByUser, enableUserMsgMarkdown, text, isLatestMessage]);
|
||||
let content: React.ReactElement;
|
||||
if (!isCreatedByUser) {
|
||||
content = <Markdown content={text} isLatestMessage={isLatestMessage} />;
|
||||
} else if (enableUserMsgMarkdown) {
|
||||
content = <MarkdownLite content={text} />;
|
||||
} else {
|
||||
content = <>{text}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container message={message}>
|
||||
<div
|
||||
className={cn(
|
||||
isSubmitting ? 'submitting' : '',
|
||||
showCursorState && !!text.length ? 'result-streaming' : '',
|
||||
'markdown prose message-content dark:prose-invert light w-full break-words',
|
||||
isSubmitting && 'submitting',
|
||||
showCursorState && text.length > 0 && 'result-streaming',
|
||||
isCreatedByUser && !enableUserMsgMarkdown && 'whitespace-pre-wrap',
|
||||
isCreatedByUser ? 'dark:text-gray-20' : 'dark:text-gray-100',
|
||||
)}
|
||||
@@ -127,6 +103,7 @@ const DisplayMessage = ({ text, isCreatedByUser, message, showCursor }: TDisplay
|
||||
);
|
||||
};
|
||||
|
||||
// Unfinished Message Component
|
||||
export const UnfinishedMessage = ({ message }: { message: TMessage }) => (
|
||||
<ErrorMessage
|
||||
message={message}
|
||||
@@ -146,14 +123,21 @@ const MessageContent = ({
|
||||
const { message } = props;
|
||||
const { messageId } = message;
|
||||
|
||||
const { thinkingContent, regularContent } = useMemo(() => parseThinkingContent(text), [text]);
|
||||
const { thinkingContent, regularContent } = useMemo(() => {
|
||||
const thinkingMatch = text.match(/:::thinking([\s\S]*?):::/);
|
||||
return {
|
||||
thinkingContent: thinkingMatch ? thinkingMatch[1].trim() : '',
|
||||
regularContent: thinkingMatch ? text.replace(/:::thinking[\s\S]*?:::/, '').trim() : text,
|
||||
};
|
||||
}, [text]);
|
||||
|
||||
const showRegularCursor = useMemo(() => isLast && isSubmitting, [isLast, isSubmitting]);
|
||||
|
||||
const unfinishedMessage = useMemo(
|
||||
() =>
|
||||
!isSubmitting && unfinished ? (
|
||||
<Suspense>
|
||||
<DelayedRender delay={UNFINISHED_DELAY}>
|
||||
<DelayedRender delay={250}>
|
||||
<UnfinishedMessage message={message} />
|
||||
</DelayedRender>
|
||||
</Suspense>
|
||||
@@ -162,10 +146,8 @@ const MessageContent = ({
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return <ErrorMessage message={message} text={text} />;
|
||||
}
|
||||
|
||||
if (edit) {
|
||||
return <ErrorMessage message={props.message} text={text} />;
|
||||
} else if (edit) {
|
||||
return <EditMessage text={text} isSubmitting={isSubmitting} {...props} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -65,10 +65,6 @@ const Part = memo(
|
||||
if (part.tool_call_ids != null && !text) {
|
||||
return null;
|
||||
}
|
||||
/** Skip rendering if text is only whitespace to avoid empty Container */
|
||||
if (!isLast && text.length > 0 && /^\s*$/.test(text)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Container>
|
||||
<Text text={text} isCreatedByUser={isCreatedByUser} showCursor={showCursor} />
|
||||
@@ -79,7 +75,7 @@ const Part = memo(
|
||||
if (typeof reasoning !== 'string') {
|
||||
return null;
|
||||
}
|
||||
return <Reasoning reasoning={reasoning} isLast={isLast ?? false} />;
|
||||
return <Reasoning reasoning={reasoning} />;
|
||||
} else if (part.type === ContentTypes.TOOL_CALL) {
|
||||
const toolCall = part[ContentTypes.TOOL_CALL];
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useForm } from 'react-hook-form';
|
||||
import { TextareaAutosize } from '@librechat/client';
|
||||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { Lightbulb, MessageSquare } from 'lucide-react';
|
||||
import { useUpdateMessageContentMutation } from 'librechat-data-provider/react-query';
|
||||
import type { Agents } from 'librechat-data-provider';
|
||||
import type { TEditProps } from '~/common';
|
||||
@@ -154,22 +153,6 @@ const EditTextPart = ({
|
||||
|
||||
return (
|
||||
<Container message={message}>
|
||||
{part.type === ContentTypes.THINK && (
|
||||
<div className="mt-2 flex items-center gap-1.5 text-xs text-text-secondary">
|
||||
<span className="flex gap-2 rounded-lg bg-surface-tertiary px-1.5 py-1 font-medium">
|
||||
<Lightbulb className="size-3.5" />
|
||||
{localize('com_ui_thoughts')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{part.type !== ContentTypes.THINK && (
|
||||
<div className="mt-2 flex items-center gap-1.5 text-xs text-text-secondary">
|
||||
<span className="flex gap-2 rounded-lg bg-surface-tertiary px-1.5 py-1 font-medium">
|
||||
<MessageSquare className="size-3.5" />
|
||||
{localize('com_ui_response')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-token-main-surface-primary relative flex w-full flex-grow flex-col overflow-hidden rounded-2xl border border-border-medium text-text-primary [&:has(textarea:focus)]:border-border-heavy [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)]">
|
||||
<TextareaAutosize
|
||||
{...registerProps}
|
||||
@@ -187,7 +170,6 @@ const EditTextPart = ({
|
||||
'max-h-[65vh] pr-3 md:max-h-[75vh] md:pr-4',
|
||||
removeFocusRings,
|
||||
)}
|
||||
aria-label={localize('com_ui_editable_message')}
|
||||
dir={isRTL ? 'rtl' : 'ltr'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,47 +1,15 @@
|
||||
import { memo, useMemo, useState, useCallback } from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import type { MouseEvent } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import { ThinkingContent, ThinkingButton } from './Thinking';
|
||||
import { showThinkingAtom } from '~/store/showThinking';
|
||||
import { ThinkingContent } from '~/components/Artifacts/Thinking';
|
||||
import { useMessageContext } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
type ReasoningProps = {
|
||||
reasoning: string;
|
||||
isLast: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reasoning Component (MODERN SYSTEM)
|
||||
*
|
||||
* Used for structured content parts with ContentTypes.THINK type.
|
||||
* This handles modern message format where content is an array of typed parts.
|
||||
*
|
||||
* Pattern: `{ content: [{ type: "think", think: "<think>content</think>" }, ...] }`
|
||||
*
|
||||
* Used by:
|
||||
* - ContentParts.tsx → Part.tsx for structured messages
|
||||
* - Agent/Assistant responses (OpenAI Assistants, custom agents)
|
||||
* - O-series models (o1, o3) with reasoning capabilities
|
||||
* - Modern Claude responses with thinking blocks
|
||||
*
|
||||
* Key differences from legacy Thinking.tsx:
|
||||
* - Works with content parts array instead of plain text
|
||||
* - Strips `<think>` tags instead of `:::thinking:::` markers
|
||||
* - Each THINK part has its own independent toggle button
|
||||
* - Can be interleaved with other content types
|
||||
*
|
||||
* For legacy text-based messages, see Thinking.tsx component.
|
||||
*/
|
||||
const Reasoning = memo(({ reasoning, isLast }: ReasoningProps) => {
|
||||
const localize = useLocalize();
|
||||
const [showThinking] = useAtom(showThinkingAtom);
|
||||
const [isExpanded, setIsExpanded] = useState(showThinking);
|
||||
const { isSubmitting, isLatestMessage, nextType } = useMessageContext();
|
||||
|
||||
// Strip <think> tags from the reasoning content (modern format)
|
||||
const Reasoning = memo(({ reasoning }: ReasoningProps) => {
|
||||
const { isExpanded, nextType } = useMessageContext();
|
||||
const reasoningText = useMemo(() => {
|
||||
return reasoning
|
||||
.replace(/^<think>\s*/, '')
|
||||
@@ -49,45 +17,22 @@ const Reasoning = memo(({ reasoning, isLast }: ReasoningProps) => {
|
||||
.trim();
|
||||
}, [reasoning]);
|
||||
|
||||
const handleClick = useCallback((e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
setIsExpanded((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
|
||||
|
||||
const label = useMemo(
|
||||
() =>
|
||||
effectiveIsSubmitting && isLast ? localize('com_ui_thinking') : localize('com_ui_thoughts'),
|
||||
[effectiveIsSubmitting, localize, isLast],
|
||||
);
|
||||
|
||||
if (!reasoningText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group/reasoning">
|
||||
<div className="sticky top-0 z-10 mb-2 bg-surface-secondary pb-2 pt-2">
|
||||
<ThinkingButton
|
||||
isExpanded={isExpanded}
|
||||
onClick={handleClick}
|
||||
label={label}
|
||||
content={reasoningText}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'grid transition-all duration-300 ease-out',
|
||||
nextType !== ContentTypes.THINK && isExpanded && 'mb-4',
|
||||
)}
|
||||
style={{
|
||||
gridTemplateRows: isExpanded ? '1fr' : '0fr',
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<ThinkingContent>{reasoningText}</ThinkingContent>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'grid transition-all duration-300 ease-out',
|
||||
nextType !== ContentTypes.THINK && isExpanded && 'mb-8',
|
||||
)}
|
||||
style={{
|
||||
gridTemplateRows: isExpanded ? '1fr' : '0fr',
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<ThinkingContent isPart={true}>{reasoningText}</ThinkingContent>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
import { useState, useMemo, memo, useCallback } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Lightbulb, ChevronDown } from 'lucide-react';
|
||||
import { Clipboard, CheckMark } from '@librechat/client';
|
||||
import type { MouseEvent, FC } from 'react';
|
||||
import { showThinkingAtom } from '~/store/showThinking';
|
||||
import { fontSizeAtom } from '~/store/fontSize';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
/**
|
||||
* ThinkingContent - Displays the actual thinking/reasoning content
|
||||
* Used by both legacy text-based messages and modern content parts
|
||||
*/
|
||||
export const ThinkingContent: FC<{
|
||||
children: React.ReactNode;
|
||||
}> = memo(({ children }) => {
|
||||
const fontSize = useAtomValue(fontSizeAtom);
|
||||
|
||||
return (
|
||||
<div className="relative rounded-3xl border border-border-medium bg-surface-tertiary p-4 text-text-secondary">
|
||||
<p className={cn('whitespace-pre-wrap leading-[26px]', fontSize)}>{children}</p>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* ThinkingButton - Toggle button for expanding/collapsing thinking content
|
||||
* Shows lightbulb icon by default, chevron on hover
|
||||
* Shared between legacy Thinking component and modern ContentParts
|
||||
*/
|
||||
export const ThinkingButton = memo(
|
||||
({
|
||||
isExpanded,
|
||||
onClick,
|
||||
label,
|
||||
content,
|
||||
}: {
|
||||
isExpanded: boolean;
|
||||
onClick: (e: MouseEvent<HTMLButtonElement>) => void;
|
||||
label: string;
|
||||
content?: string;
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
const fontSize = useAtomValue(fontSizeAtom);
|
||||
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
const handleCopy = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
if (content) {
|
||||
navigator.clipboard.writeText(content);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
}
|
||||
},
|
||||
[content],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'group/button flex flex-1 items-center justify-start rounded-lg leading-[18px]',
|
||||
fontSize,
|
||||
)}
|
||||
>
|
||||
<span className="relative mr-1.5 inline-flex h-[18px] w-[18px] items-center justify-center">
|
||||
<Lightbulb className="icon-sm absolute text-text-secondary opacity-100 transition-opacity group-hover/button:opacity-0" />
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'icon-sm absolute transform-gpu text-text-primary opacity-0 transition-all duration-300 group-hover/button:opacity-100',
|
||||
isExpanded && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
{label}
|
||||
</button>
|
||||
{content && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
title={
|
||||
isCopied
|
||||
? localize('com_ui_copied_to_clipboard')
|
||||
: localize('com_ui_copy_thoughts_to_clipboard')
|
||||
}
|
||||
className={cn(
|
||||
'rounded-lg p-1.5 text-text-secondary-alt transition-colors duration-200',
|
||||
'hover:bg-surface-hover hover:text-text-primary',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white',
|
||||
)}
|
||||
>
|
||||
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard size="19" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Thinking Component (LEGACY SYSTEM)
|
||||
*
|
||||
* Used for simple text-based messages with `:::thinking:::` markers.
|
||||
* This handles the old message format where text contains embedded thinking blocks.
|
||||
*
|
||||
* Pattern: `:::thinking\n{content}\n:::\n{response}`
|
||||
*
|
||||
* Used by:
|
||||
* - MessageContent.tsx for plain text messages
|
||||
* - Legacy message format compatibility
|
||||
* - User messages when manually adding thinking content
|
||||
*
|
||||
* For modern structured content (agents/assistants), see Reasoning.tsx component.
|
||||
*/
|
||||
const Thinking: React.ElementType = memo(({ children }: { children: React.ReactNode }) => {
|
||||
const localize = useLocalize();
|
||||
const showThinking = useAtomValue(showThinkingAtom);
|
||||
const [isExpanded, setIsExpanded] = useState(showThinking);
|
||||
|
||||
const handleClick = useCallback((e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
setIsExpanded((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const label = useMemo(() => localize('com_ui_thoughts'), [localize]);
|
||||
|
||||
// Extract text content for copy functionality
|
||||
const textContent = useMemo(() => {
|
||||
if (typeof children === 'string') {
|
||||
return children;
|
||||
}
|
||||
return '';
|
||||
}, [children]);
|
||||
|
||||
if (children == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sticky top-0 z-10 mb-4 bg-surface-primary pb-2 pt-2">
|
||||
<ThinkingButton
|
||||
isExpanded={isExpanded}
|
||||
onClick={handleClick}
|
||||
label={label}
|
||||
content={textContent}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn('grid transition-all duration-300 ease-out', isExpanded && 'mb-8')}
|
||||
style={{
|
||||
gridTemplateRows: isExpanded ? '1fr' : '0fr',
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<ThinkingContent>{children}</ThinkingContent>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
ThinkingButton.displayName = 'ThinkingButton';
|
||||
ThinkingContent.displayName = 'ThinkingContent';
|
||||
Thinking.displayName = 'Thinking';
|
||||
|
||||
export default memo(Thinking);
|
||||
@@ -201,6 +201,7 @@ const Conversations: FC<ConversationsProps> = ({
|
||||
overscanRowCount={10}
|
||||
className="outline-none"
|
||||
style={{ outline: 'none' }}
|
||||
role="list"
|
||||
aria-label="Conversations"
|
||||
onRowsRendered={handleRowsRendered}
|
||||
tabIndex={-1}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { QueryKeys } from 'librechat-data-provider';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
@@ -82,7 +82,7 @@ export function DeleteConversationDialog({
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle>{localize('com_ui_delete_conversation')}</OGDialogTitle>
|
||||
</OGDialogHeader>
|
||||
<div className="w-full truncate">
|
||||
<div>
|
||||
{localize('com_ui_delete_confirm')} <strong>{title}</strong> ?
|
||||
</div>
|
||||
<div className="flex justify-end gap-4 pt-4">
|
||||
|
||||
@@ -77,13 +77,7 @@ export default function ShareButton({
|
||||
<div className="relative items-center rounded-lg p-2">
|
||||
{showQR && (
|
||||
<div className="mb-4 flex flex-col items-center">
|
||||
<QRCodeSVG
|
||||
value={sharedLink}
|
||||
size={200}
|
||||
marginSize={2}
|
||||
className="rounded-2xl"
|
||||
title={localize('com_ui_share_qr_code_description')}
|
||||
/>
|
||||
<QRCodeSVG value={sharedLink} size={200} marginSize={2} className="rounded-2xl" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -93,7 +87,6 @@ export default function ShareButton({
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
aria-label={localize('com_ui_copy_link')}
|
||||
onClick={() => {
|
||||
if (isCopying) {
|
||||
return;
|
||||
|
||||
@@ -34,8 +34,6 @@ const RenameForm: React.FC<RenameFormProps> = ({
|
||||
case 'Enter':
|
||||
onSubmit(titleInput);
|
||||
break;
|
||||
case 'Tab':
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -52,23 +50,22 @@ const RenameForm: React.FC<RenameFormProps> = ({
|
||||
value={titleInput}
|
||||
onChange={(e) => setTitleInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => onSubmit(titleInput)}
|
||||
maxLength={100}
|
||||
aria-label={localize('com_ui_new_conversation_title')}
|
||||
/>
|
||||
<div className="flex gap-1" role="toolbar">
|
||||
<button
|
||||
onClick={() => onCancel()}
|
||||
className="rounded-md p-1 hover:opacity-70 focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="p-1 hover:opacity-70 focus:outline-none focus:ring-2"
|
||||
aria-label={localize('com_ui_cancel')}
|
||||
type="button"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSubmit(titleInput)}
|
||||
className="rounded-md p-1 hover:opacity-70 focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="p-1 hover:opacity-70 focus:outline-none focus:ring-2"
|
||||
aria-label={localize('com_ui_save')}
|
||||
type="button"
|
||||
>
|
||||
<Check className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
@@ -151,7 +151,6 @@ export default function Settings({
|
||||
min={0}
|
||||
step={0.01}
|
||||
className="flex h-4 w-full"
|
||||
aria-labelledby="temp-int"
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<OptionHover endpoint={optionEndpoint ?? ''} type="temp" side={ESide.Left} />
|
||||
@@ -161,9 +160,7 @@ export default function Settings({
|
||||
<div className="flex justify-between">
|
||||
<Label htmlFor="top-p-int" className="text-left text-sm font-medium">
|
||||
{localize('com_endpoint_top_p')}{' '}
|
||||
<small className="opacity-40">
|
||||
({localize('com_endpoint_default_with_num', { 0: '1' })})
|
||||
</small>
|
||||
<small className="opacity-40">({localize('com_endpoint_default')}: 1)</small>
|
||||
</Label>
|
||||
<InputNumber
|
||||
id="top-p-int"
|
||||
@@ -192,7 +189,6 @@ export default function Settings({
|
||||
min={0}
|
||||
step={0.01}
|
||||
className="flex h-4 w-full"
|
||||
aria-labelledby="top-p-int"
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<OptionHover endpoint={optionEndpoint ?? ''} type="topp" side={ESide.Left} />
|
||||
@@ -203,9 +199,7 @@ export default function Settings({
|
||||
<div className="flex justify-between">
|
||||
<Label htmlFor="freq-penalty-int" className="text-left text-sm font-medium">
|
||||
{localize('com_endpoint_frequency_penalty')}{' '}
|
||||
<small className="opacity-40">
|
||||
({localize('com_endpoint_default_with_num', { 0: '0' })})
|
||||
</small>
|
||||
<small className="opacity-40">({localize('com_endpoint_default')}: 0)</small>
|
||||
</Label>
|
||||
<InputNumber
|
||||
id="freq-penalty-int"
|
||||
@@ -234,7 +228,6 @@ export default function Settings({
|
||||
min={-2}
|
||||
step={0.01}
|
||||
className="flex h-4 w-full"
|
||||
aria-labelledby="freq-penalty-int"
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<OptionHover endpoint={optionEndpoint ?? ''} type="freq" side={ESide.Left} />
|
||||
@@ -245,9 +238,7 @@ export default function Settings({
|
||||
<div className="flex justify-between">
|
||||
<Label htmlFor="pres-penalty-int" className="text-left text-sm font-medium">
|
||||
{localize('com_endpoint_presence_penalty')}{' '}
|
||||
<small className="opacity-40">
|
||||
({localize('com_endpoint_default_with_num', { 0: '0' })})
|
||||
</small>
|
||||
<small className="opacity-40">({localize('com_endpoint_default')}: 0)</small>
|
||||
</Label>
|
||||
<InputNumber
|
||||
id="pres-penalty-int"
|
||||
@@ -276,7 +267,6 @@ export default function Settings({
|
||||
min={-2}
|
||||
step={0.01}
|
||||
className="flex h-4 w-full"
|
||||
aria-labelledby="pres-penalty-int"
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<OptionHover endpoint={optionEndpoint ?? ''} type="pres" side={ESide.Left} />
|
||||
@@ -316,7 +306,6 @@ export default function Settings({
|
||||
onCheckedChange={(checked: boolean) => setResendFiles(checked)}
|
||||
disabled={readonly}
|
||||
className="flex"
|
||||
aria-label={localize('com_endpoint_plug_resend_files')}
|
||||
/>
|
||||
<OptionHover endpoint={optionEndpoint ?? ''} type="resend" side={ESide.Bottom} />
|
||||
</HoverCardTrigger>
|
||||
@@ -334,7 +323,6 @@ export default function Settings({
|
||||
max={2}
|
||||
min={0}
|
||||
step={1}
|
||||
aria-label={localize('com_endpoint_plug_image_detail')}
|
||||
/>
|
||||
<OptionHover endpoint={optionEndpoint ?? ''} type="detail" side={ESide.Bottom} />
|
||||
</HoverCardTrigger>
|
||||
|
||||
@@ -53,9 +53,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
|
||||
<div className="flex justify-between">
|
||||
<Label htmlFor="temp-int" className="text-left text-sm font-medium">
|
||||
{localize('com_endpoint_temperature')}{' '}
|
||||
<small className="opacity-40">
|
||||
({localize('com_endpoint_default_with_num', { 0: '0' })})
|
||||
</small>
|
||||
<small className="opacity-40">({localize('com_endpoint_default')}: 0)</small>
|
||||
</Label>
|
||||
<InputNumber
|
||||
id="temp-int"
|
||||
@@ -84,7 +82,6 @@ export default function Settings({ conversation, setOption, models, readonly }:
|
||||
min={0}
|
||||
step={0.01}
|
||||
className="flex h-4 w-full"
|
||||
aria-labelledby="temp-int"
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<OptionHover endpoint={conversation.endpoint ?? ''} type="temp" side={ESide.Left} />
|
||||
@@ -104,7 +101,6 @@ export default function Settings({ conversation, setOption, models, readonly }:
|
||||
onCheckedChange={onCheckedChangeAgent}
|
||||
disabled={readonly}
|
||||
className="ml-4 mt-2"
|
||||
aria-label={localize('com_endpoint_plug_use_functions')}
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<OptionHover endpoint={conversation.endpoint ?? ''} type="func" side={ESide.Bottom} />
|
||||
@@ -123,7 +119,6 @@ export default function Settings({ conversation, setOption, models, readonly }:
|
||||
onCheckedChange={onCheckedChangeSkip}
|
||||
disabled={readonly}
|
||||
className="ml-4 mt-2"
|
||||
aria-label={localize('com_endpoint_plug_skip_completion')}
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<OptionHover endpoint={conversation.endpoint ?? ''} type="skip" side={ESide.Bottom} />
|
||||
|
||||
@@ -171,7 +171,6 @@ export default function Settings({ conversation, setOption, models, readonly }:
|
||||
min={google.temperature.min}
|
||||
step={google.temperature.step}
|
||||
className="flex h-4 w-full"
|
||||
aria-labelledby="temp-int"
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<OptionHover endpoint={conversation.endpoint ?? ''} type="temp" side={ESide.Left} />
|
||||
@@ -212,7 +211,6 @@ export default function Settings({ conversation, setOption, models, readonly }:
|
||||
min={google.topP.min}
|
||||
step={google.topP.step}
|
||||
className="flex h-4 w-full"
|
||||
aria-labelledby="top-p-int"
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<OptionHover endpoint={conversation.endpoint ?? ''} type="topp" side={ESide.Left} />
|
||||
@@ -254,7 +252,6 @@ export default function Settings({ conversation, setOption, models, readonly }:
|
||||
min={google.topK.min}
|
||||
step={google.topK.step}
|
||||
className="flex h-4 w-full"
|
||||
aria-labelledby="top-k-int"
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<OptionHover endpoint={conversation.endpoint ?? ''} type="topk" side={ESide.Left} />
|
||||
@@ -299,7 +296,6 @@ export default function Settings({ conversation, setOption, models, readonly }:
|
||||
min={google.maxOutputTokens.min}
|
||||
step={google.maxOutputTokens.step}
|
||||
className="flex h-4 w-full"
|
||||
aria-labelledby="max-tokens-int"
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<OptionHover
|
||||
|
||||
@@ -256,7 +256,6 @@ export default function Settings({
|
||||
min={0}
|
||||
step={0.01}
|
||||
className="flex h-4 w-full"
|
||||
aria-labelledby="temp-int"
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<OptionHover endpoint={conversation.endpoint ?? ''} type="temp" side={ESide.Left} />
|
||||
@@ -297,7 +296,6 @@ export default function Settings({
|
||||
min={0}
|
||||
step={0.01}
|
||||
className="flex h-4 w-full"
|
||||
aria-labelledby="top-p-int"
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<OptionHover endpoint={conversation.endpoint ?? ''} type="topp" side={ESide.Left} />
|
||||
@@ -339,7 +337,6 @@ export default function Settings({
|
||||
min={-2}
|
||||
step={0.01}
|
||||
className="flex h-4 w-full"
|
||||
aria-labelledby="freq-penalty-int"
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<OptionHover endpoint={conversation.endpoint ?? ''} type="freq" side={ESide.Left} />
|
||||
@@ -381,7 +378,6 @@ export default function Settings({
|
||||
min={-2}
|
||||
step={0.01}
|
||||
className="flex h-4 w-full"
|
||||
aria-labelledby="pres-penalty-int"
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<OptionHover endpoint={conversation.endpoint ?? ''} type="pres" side={ESide.Left} />
|
||||
|
||||
@@ -124,15 +124,13 @@ export default function ExportModal({
|
||||
disabled={!exportOptionsSupport}
|
||||
checked={includeOptions}
|
||||
onCheckedChange={setIncludeOptions}
|
||||
aria-labelledby="includeOptions-label"
|
||||
/>
|
||||
<label
|
||||
id="includeOptions-label"
|
||||
htmlFor="includeOptions"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-gray-50"
|
||||
>
|
||||
{exportOptionsSupport
|
||||
? localize('com_nav_export_include_endpoint_options')
|
||||
? localize('com_nav_enabled')
|
||||
: localize('com_nav_not_supported')}
|
||||
</label>
|
||||
</div>
|
||||
@@ -148,15 +146,13 @@ export default function ExportModal({
|
||||
disabled={!exportBranchesSupport}
|
||||
checked={exportBranches}
|
||||
onCheckedChange={setExportBranches}
|
||||
aria-labelledby="exportBranches-label"
|
||||
/>
|
||||
<label
|
||||
id="exportBranches-label"
|
||||
htmlFor="exportBranches"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-gray-50"
|
||||
>
|
||||
{exportBranchesSupport
|
||||
? localize('com_nav_export_all_message_branches')
|
||||
? localize('com_nav_enabled')
|
||||
: localize('com_nav_not_supported')}
|
||||
</label>
|
||||
</div>
|
||||
@@ -167,14 +163,8 @@ export default function ExportModal({
|
||||
{localize('com_nav_export_recursive_or_sequential')}
|
||||
</Label>
|
||||
<div className="flex h-[40px] w-full items-center space-x-3">
|
||||
<Checkbox
|
||||
id="recursive"
|
||||
checked={recursive}
|
||||
onCheckedChange={setRecursive}
|
||||
aria-labelledby="recursive-label"
|
||||
/>
|
||||
<Checkbox id="recursive" checked={recursive} onCheckedChange={setRecursive} />
|
||||
<label
|
||||
id="recursive-label"
|
||||
htmlFor="recursive"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-gray-50"
|
||||
>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { QueryKeys, Constants } from 'librechat-data-provider';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useLocalize, useNewConvo } from '~/hooks';
|
||||
import { clearMessagesCache } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
export default function MobileNav({
|
||||
@@ -58,7 +57,10 @@ export default function MobileNav({
|
||||
aria-label={localize('com_ui_new_chat')}
|
||||
className="m-1 inline-flex size-10 items-center justify-center rounded-full hover:bg-surface-hover"
|
||||
onClick={() => {
|
||||
clearMessagesCache(queryClient, conversation?.conversationId);
|
||||
queryClient.setQueryData<TMessage[]>(
|
||||
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
|
||||
[],
|
||||
);
|
||||
queryClient.invalidateQueries([QueryKeys.messages]);
|
||||
newConversation();
|
||||
}}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { QueryKeys, Constants } from 'librechat-data-provider';
|
||||
import { TooltipAnchor, NewChatIcon, MobileSidebar, Sidebar, Button } from '@librechat/client';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import { useLocalize, useNewConvo } from '~/hooks';
|
||||
import { clearMessagesCache } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
export default function NewChat({
|
||||
@@ -34,7 +33,10 @@ export default function NewChat({
|
||||
window.open('/c/new', '_blank');
|
||||
return;
|
||||
}
|
||||
clearMessagesCache(queryClient, conversation?.conversationId);
|
||||
queryClient.setQueryData<TMessage[]>(
|
||||
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
|
||||
[],
|
||||
);
|
||||
queryClient.invalidateQueries([QueryKeys.messages]);
|
||||
newConvo();
|
||||
navigate('/c/new', { state: { focusChat: true } });
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { memo } from 'react';
|
||||
import { showThinkingAtom } from '~/store/showThinking';
|
||||
import FontSizeSelector from './FontSizeSelector';
|
||||
import { ForkSettings } from './ForkSettings';
|
||||
import ChatDirection from './ChatDirection';
|
||||
@@ -29,7 +28,7 @@ const toggleSwitchConfigs = [
|
||||
key: 'centerFormOnLanding',
|
||||
},
|
||||
{
|
||||
stateAtom: showThinkingAtom,
|
||||
stateAtom: store.showThinking,
|
||||
localizationKey: 'com_nav_show_thinking',
|
||||
switchId: 'showThinking',
|
||||
hoverCardText: undefined,
|
||||
|
||||
@@ -30,7 +30,6 @@ export default function SaveBadgesState({
|
||||
onCheckedChange={handleCheckedChange}
|
||||
className="ml-4"
|
||||
data-testid="saveBadgesState"
|
||||
aria-label={localize('com_nav_save_badges_state')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { useAtom } from 'jotai';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { Switch, InfoHoverCard, ESide } from '@librechat/client';
|
||||
import { showThinkingAtom } from '~/store/showThinking';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
||||
export default function SaveDraft({
|
||||
onCheckedChange,
|
||||
}: {
|
||||
onCheckedChange?: (value: boolean) => void;
|
||||
}) {
|
||||
const [showThinking, setShowThinking] = useAtom(showThinkingAtom);
|
||||
const [showThinking, setSaveDrafts] = useRecoilState<boolean>(store.showThinking);
|
||||
const localize = useLocalize();
|
||||
|
||||
const handleCheckedChange = (value: boolean) => {
|
||||
setShowThinking(value);
|
||||
setSaveDrafts(value);
|
||||
if (onCheckedChange) {
|
||||
onCheckedChange(value);
|
||||
}
|
||||
@@ -30,7 +30,6 @@ export default function SaveDraft({
|
||||
onCheckedChange={handleCheckedChange}
|
||||
className="ml-4"
|
||||
data-testid="showThinking"
|
||||
aria-label={localize('com_nav_show_thinking')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,10 +9,12 @@ import { useLocalize } from '~/hooks';
|
||||
import { cn, logger } from '~/utils';
|
||||
|
||||
function ImportConversations() {
|
||||
const localize = useLocalize();
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToastContext();
|
||||
const startupConfig = queryClient.getQueryData<TStartupConfig>([QueryKeys.startupConfig]);
|
||||
const localize = useLocalize();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { showToast } = useToastContext();
|
||||
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const handleSuccess = useCallback(() => {
|
||||
@@ -51,8 +53,7 @@ function ImportConversations() {
|
||||
const handleFileUpload = useCallback(
|
||||
async (file: File) => {
|
||||
try {
|
||||
const startupConfig = queryClient.getQueryData<TStartupConfig>([QueryKeys.startupConfig]);
|
||||
const maxFileSize = startupConfig?.conversationImportMaxFileSize;
|
||||
const maxFileSize = (startupConfig as any)?.conversationImportMaxFileSize;
|
||||
if (maxFileSize && file.size > maxFileSize) {
|
||||
const size = (maxFileSize / (1024 * 1024)).toFixed(2);
|
||||
showToast({
|
||||
@@ -75,7 +76,7 @@ function ImportConversations() {
|
||||
});
|
||||
}
|
||||
},
|
||||
[uploadFile, showToast, localize, queryClient],
|
||||
[uploadFile, showToast, localize, startupConfig],
|
||||
);
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
useMediaQuery,
|
||||
OGDialogHeader,
|
||||
OGDialogTitle,
|
||||
TooltipAnchor,
|
||||
DataTable,
|
||||
Spinner,
|
||||
Button,
|
||||
@@ -245,27 +246,37 @@ export default function SharedLinks() {
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 hover:bg-surface-hover"
|
||||
onClick={() => {
|
||||
window.open(`/c/${row.original.conversationId}`, '_blank');
|
||||
}}
|
||||
aria-label={`${localize('com_ui_view_source')} - ${row.original.title || localize('com_ui_untitled')}`}
|
||||
>
|
||||
<MessageSquare className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 hover:bg-surface-hover"
|
||||
onClick={() => {
|
||||
setDeleteRow(row.original);
|
||||
setIsDeleteOpen(true);
|
||||
}}
|
||||
aria-label={`${localize('com_ui_delete')} - ${row.original.title || localize('com_ui_untitled')}`}
|
||||
>
|
||||
<TrashIcon className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_view_source')}
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 hover:bg-surface-hover"
|
||||
onClick={() => {
|
||||
window.open(`/c/${row.original.conversationId}`, '_blank');
|
||||
}}
|
||||
title={localize('com_ui_view_source')}
|
||||
>
|
||||
<MessageSquare className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_delete')}
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 hover:bg-surface-hover"
|
||||
onClick={() => {
|
||||
setDeleteRow(row.original);
|
||||
setIsDeleteOpen(true);
|
||||
}}
|
||||
title={localize('com_ui_delete')}
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { WritableAtom, useAtom } from 'jotai';
|
||||
import { RecoilState, useRecoilState } from 'recoil';
|
||||
import { Switch, InfoHoverCard, ESide } from '@librechat/client';
|
||||
import { useLocalize } from '~/hooks';
|
||||
@@ -7,7 +6,7 @@ type LocalizeFn = ReturnType<typeof useLocalize>;
|
||||
type LocalizeKey = Parameters<LocalizeFn>[0];
|
||||
|
||||
interface ToggleSwitchProps {
|
||||
stateAtom: RecoilState<boolean> | WritableAtom<boolean, [boolean], void>;
|
||||
stateAtom: RecoilState<boolean>;
|
||||
localizationKey: LocalizeKey;
|
||||
hoverCardText?: LocalizeKey;
|
||||
switchId: string;
|
||||
@@ -17,18 +16,13 @@ interface ToggleSwitchProps {
|
||||
strongLabel?: boolean;
|
||||
}
|
||||
|
||||
function isRecoilState<T>(atom: unknown): atom is RecoilState<T> {
|
||||
return atom != null && typeof atom === 'object' && 'key' in atom;
|
||||
}
|
||||
|
||||
const RecoilToggle: React.FC<
|
||||
Omit<ToggleSwitchProps, 'stateAtom'> & { stateAtom: RecoilState<boolean> }
|
||||
> = ({
|
||||
const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
|
||||
stateAtom,
|
||||
localizationKey,
|
||||
hoverCardText,
|
||||
switchId,
|
||||
onCheckedChange,
|
||||
showSwitch = true,
|
||||
disabled = false,
|
||||
strongLabel = false,
|
||||
}) => {
|
||||
@@ -42,83 +36,29 @@ const RecoilToggle: React.FC<
|
||||
|
||||
const labelId = `${switchId}-label`;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div id={labelId}>
|
||||
{strongLabel ? <strong>{localize(localizationKey)}</strong> : localize(localizationKey)}
|
||||
</div>
|
||||
{hoverCardText && <InfoHoverCard side={ESide.Bottom} text={localize(hoverCardText)} />}
|
||||
</div>
|
||||
<Switch
|
||||
id={switchId}
|
||||
checked={switchState}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
disabled={disabled}
|
||||
className="ml-4"
|
||||
data-testid={switchId}
|
||||
aria-labelledby={labelId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const JotaiToggle: React.FC<
|
||||
Omit<ToggleSwitchProps, 'stateAtom'> & { stateAtom: WritableAtom<boolean, [boolean], void> }
|
||||
> = ({
|
||||
stateAtom,
|
||||
localizationKey,
|
||||
hoverCardText,
|
||||
switchId,
|
||||
onCheckedChange,
|
||||
disabled = false,
|
||||
strongLabel = false,
|
||||
}) => {
|
||||
const [switchState, setSwitchState] = useAtom(stateAtom);
|
||||
const localize = useLocalize();
|
||||
|
||||
const handleCheckedChange = (value: boolean) => {
|
||||
setSwitchState(value);
|
||||
onCheckedChange?.(value);
|
||||
};
|
||||
|
||||
const labelId = `${switchId}-label`;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div id={labelId}>
|
||||
{strongLabel ? <strong>{localize(localizationKey)}</strong> : localize(localizationKey)}
|
||||
</div>
|
||||
{hoverCardText && <InfoHoverCard side={ESide.Bottom} text={localize(hoverCardText)} />}
|
||||
</div>
|
||||
<Switch
|
||||
id={switchId}
|
||||
checked={switchState}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
disabled={disabled}
|
||||
className="ml-4"
|
||||
data-testid={switchId}
|
||||
aria-labelledby={labelId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ToggleSwitch: React.FC<ToggleSwitchProps> = (props) => {
|
||||
const { stateAtom, showSwitch = true } = props;
|
||||
|
||||
if (!showSwitch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isRecoil = isRecoilState(stateAtom);
|
||||
|
||||
if (isRecoil) {
|
||||
return <RecoilToggle {...props} stateAtom={stateAtom as RecoilState<boolean>} />;
|
||||
}
|
||||
|
||||
return <JotaiToggle {...props} stateAtom={stateAtom as WritableAtom<boolean, [boolean], void>} />;
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div id={labelId}>
|
||||
{strongLabel ? <strong>{localize(localizationKey)}</strong> : localize(localizationKey)}
|
||||
</div>
|
||||
{hoverCardText && <InfoHoverCard side={ESide.Bottom} text={localize(hoverCardText)} />}
|
||||
</div>
|
||||
<Switch
|
||||
id={switchId}
|
||||
checked={switchState}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
className="ml-4"
|
||||
data-testid={switchId}
|
||||
aria-labelledby={labelId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToggleSwitch;
|
||||
|
||||
@@ -53,7 +53,6 @@ const LabelController: React.FC<LabelControllerProps> = ({
|
||||
}
|
||||
}}
|
||||
value={field.value.toString()}
|
||||
aria-label={label}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -217,12 +216,7 @@ const AdminSettings = () => {
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || isLoading}
|
||||
variant="submit"
|
||||
aria-label={localize('com_ui_save')}
|
||||
>
|
||||
<Button type="submit" disabled={isSubmitting || isLoading} variant="submit">
|
||||
{localize('com_ui_save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function AlwaysMakeProd({
|
||||
checked={alwaysMakeProd}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
data-testid="alwaysMakeProd"
|
||||
aria-label={localize('com_nav_always_make_prod')}
|
||||
aria-label="Always make prompt production"
|
||||
/>
|
||||
<div>{localize('com_nav_always_make_prod')} </div>
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function AutoSendPrompt({
|
||||
>
|
||||
<div> {localize('com_nav_auto_send_prompts')} </div>
|
||||
<Switch
|
||||
aria-label={localize('com_nav_auto_send_prompts')}
|
||||
aria-label="toggle-auto-send-prompts"
|
||||
id="autoSendPrompts"
|
||||
checked={autoSendPrompts}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
|
||||
@@ -102,9 +102,6 @@ function ChatGroupItem({
|
||||
e.stopPropagation();
|
||||
setPreviewDialogOpen(true);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="w-full cursor-pointer rounded-lg text-text-primary hover:bg-surface-hover focus:bg-surface-hover disabled:cursor-not-allowed"
|
||||
>
|
||||
<TextSearch className="mr-2 h-4 w-4 text-text-primary" aria-hidden="true" />
|
||||
@@ -119,9 +116,6 @@ function ChatGroupItem({
|
||||
e.stopPropagation();
|
||||
onEditClick(e);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<EditIcon className="mr-2 h-4 w-4 text-text-primary" aria-hidden="true" />
|
||||
<span>{localize('com_ui_edit')}</span>
|
||||
|
||||
@@ -151,7 +151,6 @@ const CreatePromptForm = ({
|
||||
className="w-full rounded border border-border-medium px-2 py-1 focus:outline-none dark:bg-transparent dark:text-gray-200"
|
||||
minRows={6}
|
||||
tabIndex={0}
|
||||
aria-label={localize('com_ui_prompt_input_field')}
|
||||
/>
|
||||
<div
|
||||
className={`mt-1 text-sm text-red-500 ${
|
||||
|
||||
@@ -34,7 +34,6 @@ export default function List({
|
||||
variant="outline"
|
||||
className={`w-full bg-transparent ${isChatRoute ? '' : 'mx-2'}`}
|
||||
onClick={() => navigate('/d/prompts/new')}
|
||||
aria-label={localize('com_ui_create_prompt')}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden />
|
||||
{localize('com_ui_create_prompt')}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Label } from '@librechat/client';
|
||||
import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export default function ListCard({
|
||||
category,
|
||||
@@ -16,7 +15,6 @@ export default function ListCard({
|
||||
onClick?: React.MouseEventHandler<HTMLDivElement | HTMLButtonElement>;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement | HTMLButtonElement>) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
@@ -33,7 +31,7 @@ export default function ListCard({
|
||||
tabIndex={0}
|
||||
aria-labelledby={`card-title-${name}`}
|
||||
aria-describedby={`card-snippet-${name}`}
|
||||
aria-label={`${name} Prompt, ${category ? `${localize('com_ui_category')}: ${category}` : ''}`}
|
||||
aria-label={`Card for ${name}`}
|
||||
>
|
||||
<div className="flex w-full justify-between gap-2">
|
||||
<div className="flex flex-row gap-2">
|
||||
|
||||
@@ -17,7 +17,6 @@ export default function NoPromptGroup() {
|
||||
onClick={() => {
|
||||
navigate('/d/prompts');
|
||||
}}
|
||||
aria-label={localize('com_ui_back_to_prompts')}
|
||||
>
|
||||
{localize('com_ui_back_to_prompts')}
|
||||
</Button>
|
||||
|
||||
@@ -73,7 +73,8 @@ export default function VariableForm({
|
||||
|
||||
const mainText = useMemo(() => {
|
||||
const initialText = group.productionPrompt?.prompt ?? '';
|
||||
return replaceSpecialVars({ text: initialText, user });
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
return replaceSpecialVars({ text: initialText, user, timezone });
|
||||
}, [group.productionPrompt?.prompt, user]);
|
||||
|
||||
const { allVariables, uniqueVariables, variableIndexMap } = useMemo(
|
||||
@@ -193,7 +194,6 @@ export default function VariableForm({
|
||||
)}
|
||||
placeholder={field.config.variable}
|
||||
maxRows={8}
|
||||
aria-label={field.config.variable}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
@@ -202,7 +202,7 @@ export default function VariableForm({
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" variant="submit" aria-label={localize('com_ui_submit')}>
|
||||
<Button type="submit" variant="submit">
|
||||
{localize('com_ui_submit')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,8 @@ const PromptDetails = ({ group }: { group?: TPromptGroup }) => {
|
||||
|
||||
const mainText = useMemo(() => {
|
||||
const initialText = group?.productionPrompt?.prompt ?? '';
|
||||
return replaceSpecialVars({ text: initialText, user });
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
return replaceSpecialVars({ text: initialText, user, timezone });
|
||||
}, [group?.productionPrompt?.prompt, user]);
|
||||
|
||||
if (!group) {
|
||||
|
||||
@@ -118,7 +118,6 @@ const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
|
||||
setIsEditing(false);
|
||||
}
|
||||
}}
|
||||
aria-label={localize('com_ui_prompt_input')}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
|
||||
@@ -370,11 +370,7 @@ export default function GenericGrantAccessDialog({
|
||||
<div className="flex gap-2">
|
||||
<PeoplePickerAdminSettings />
|
||||
<OGDialogClose asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
aria-label={localize('com_ui_cancel')}
|
||||
>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
{localize('com_ui_cancel')}
|
||||
</Button>
|
||||
</OGDialogClose>
|
||||
@@ -386,7 +382,6 @@ export default function GenericGrantAccessDialog({
|
||||
(hasChanges && !hasAtLeastOneOwner)
|
||||
}
|
||||
className="min-w-[120px]"
|
||||
aria-label={localize('com_ui_save_changes')}
|
||||
>
|
||||
{updatePermissionsMutation.isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user