Compare commits

..

57 Commits

Author SHA1 Message Date
Marco Beretta
014060a11b chore: update @radix-ui/react-accordion to version 1.2.11 2025-07-26 15:03:26 +02:00
Marco Beretta
80d406b629 fix: merge conflicts 2025-07-26 03:07:03 +02:00
Marco Beretta
22035bbf95 style: update itemClassName in ToolsDropdown for improved UI consistency 2025-07-26 01:55:17 +02:00
Marco Beretta
17a164b420 refactor: enhance Menu component to support custom render functions for menu items 2025-07-26 01:55:16 +02:00
Marco Beretta
5b663d0e35 chore: remove unused imports and clean up code in NewChat component 2025-07-26 01:55:16 +02:00
Marco Beretta
c273dfc1f4 refactor: code structure; chore: translations cleanup 2025-07-26 01:55:16 +02:00
Marco Beretta
de69bcdd64 chore: update package version to 0.1.5 and adjust peer dependencies
- Bump version in package.json from 0.1.4 to 0.1.5.
- Update peer dependency for @tanstack/react-query to allow version 5.0.0.
- Add @tanstack/react-table and @tanstack/react-virtual as dependencies.
- Update various dependencies to their latest compatible versions.
- Simplify postcss.config.js by removing unnecessary options.
- Clean up rollup.config.js by removing ignored PostCSS warnings.
- Update CheckboxButton component to cast icon as React JSX element.
- Adjust Combobox component's class names for better styling.
- Change DropdownPopup component to use React's namespace import.
- Modify InputOTP component to use 'any' type for OTPInputContext.
- Ensure displayLabel and value in ModelParameters are converted to strings.
- Update MultiSearch component's placeholder to ensure it's a string.
- Cast selectIcon in MultiSelect as React JSX element for consistency.
- Update OGDialogTemplate to cast selectText as React JSX element.
- Initialize animationRef in PixelCard with undefined for clarity.
- Add TypeScript ignore comments in Select and SelectDropDown components for Radix UI type conflicts.
- Ensure title in SelectDropDown is a string and adjust rendering of options.
- Update useLocalize hook to cast options as any for compatibility.
2025-07-26 01:55:14 +02:00
Marco Beretta
59412c2b36 chore: bump client version to 0.1.4 and update @react-spring dependencies 2025-07-26 01:48:11 +02:00
Marco Beretta
0af8fba7ca chore: bump client version to 0.1.3 and update dependencies 2025-07-26 01:48:11 +02:00
Marco Beretta
d5cf83313b chore: bump client version to 0.1.2 in package-lock.json 2025-07-26 01:48:11 +02:00
Marco Beretta
355da0fc2e chore: bump version to 0.1.2 in package.json 2025-07-26 01:48:11 +02:00
Marco Beretta
8ba5aa6055 refactor: update package dependencies and improve PostCSS and Rollup configurations 2025-07-26 01:48:11 +02:00
Marco Beretta
f1204531a8 refactor: clean up client workflow and update package dependencies 2025-07-26 01:48:11 +02:00
Marco Beretta
a65e33758d refactor: update peer dependencies and improve component typings
- Removed duplicate peer dependencies from package.json and organized them.
- Updated rollup.config.js to disable TypeScript checking during the build process.
- Modified AnimatedTabs component to use React.ReactNode for label and content types, and added TypeScript workarounds for compatibility.
- Enhanced Label and Separator components to accept an optional className prop and improved prop spreading.
- Updated Slider component to include an optional className prop and refined prop handling for better type safety.
2025-07-26 01:48:11 +02:00
Marco Beretta
0855631c54 refactor: remove unused types and interfaces from common files 2025-07-26 01:48:11 +02:00
Marco Beretta
a1e052871f refactor: reorganize exports in index.ts for improved clarity 2025-07-26 01:48:11 +02:00
Marco Beretta
ca18ada9e2 feat: update package.json and rollup.config.js to include jotai and enhance bundling configuration 2025-07-26 01:48:11 +02:00
Marco Beretta
103af99879 feat: update package.json and rollup.config.js to include jotai and enhance bundling configuration 2025-07-26 01:48:11 +02:00
Marco Beretta
412948e025 chore: move dependencies to peerDependencies in package.json 2025-07-26 01:48:11 +02:00
Marco Beretta
4da25826d9 chore: move @tanstack/react-virtual to dependencies and remove recoil from package.json 2025-07-26 01:48:11 +02:00
Marco Beretta
6e7fdeb3a3 feat: integrate Tailwind CSS and update theme context for improved styling 2025-07-26 01:48:11 +02:00
Marco Beretta
1c3f5b972d chore: remove unnecessary logging of sttExternal and ttsExternal in Speech component 2025-07-26 01:48:10 +02:00
Danny Avila
e7aa83e073 chore: update tsconfig.json to exclude additional test files 2025-07-26 01:48:10 +02:00
Danny Avila
0b5155d277 chore: add missing @testing-library/react dependency to client package 2025-07-26 01:48:10 +02:00
Danny Avila
86deb4d19a chore: update GitHub Actions workflow to install and build data-provider and client dependencies 2025-07-26 01:48:10 +02:00
Danny Avila
f741a59ec4 feat: update GitHub Actions workflow to publish @librechat/client 2025-07-26 01:48:10 +02:00
Danny Avila
83477bba34 chore: linting 2025-07-26 01:48:10 +02:00
Danny Avila
5476029bca refactor: remove unused defaultSelectedValues prop from MCPSelect and MultiSelect components 2025-07-26 01:48:09 +02:00
Danny Avila
05a0a1f7cd chore: linting 2025-07-26 01:47:35 +02:00
Danny Avila
55f67212d5 feat: add react-hook-form dependency and update FormInput component to use its types 2025-07-26 01:47:35 +02:00
Danny Avila
63d0c301a0 test: update AgentFooter tests to use document.querySelector for spinner checks
test: mock window.matchMedia in setupTests.js for consistent test environment
2025-07-26 01:47:35 +02:00
Danny Avila
bac6e499b7 chore: include "packages/client" in unused i18next keys detection 2025-07-26 01:47:35 +02:00
Danny Avila
59de92afa9 fix: add missing peer dependency for @radix-ui/react-collapsible 2025-07-26 01:47:35 +02:00
Danny Avila
e297386cee chore: enhance unused package detection for @librechat/client and improve dependency extraction 2025-07-26 01:47:34 +02:00
Danny Avila
e2b1cc607f fix: correct frontend:ci script to include client package build 2025-07-26 01:47:19 +02:00
Danny Avila
55bda03d19 fix: add remaining peer dependencies and match actual versions previously used in client/package.json 2025-07-26 01:47:19 +02:00
Danny Avila
b11ba35790 fix: circular dependencies in DataTable 2025-07-26 01:47:18 +02:00
Danny Avila
8e1b00da2a fix: correct useSprings implementation in SplitText component 2025-07-26 01:47:18 +02:00
Danny Avila
39f5dd47dc fix: update peer dependencies in @librechat/client to prevent bundling them 2025-07-26 01:47:18 +02:00
Marco Beretta
16f83c6e8e chore: fixed formatting issues 2025-07-26 01:47:18 +02:00
Marco Beretta
b71a82d0e9 feat: /client transition to @librechat/client 2025-07-26 01:47:17 +02:00
Danny Avila
63a5902404 feat: export Toast component in client package
- Added export for the Toast component in index.ts to enhance modularity and accessibility of components.
2025-07-26 01:14:16 +02:00
Danny Avila
21c3a831c3 feat: add client package directory to update configuration
- Included the 'client' package directory in the update.js configuration to ensure it is recognized during updates.
2025-07-26 01:14:16 +02:00
Danny Avila
ae43b4eed0 feat: update client package dependencies and Rollup configuration
- Added rollup-plugin-postcss to package.json and updated package-lock.json.
- Enhanced Rollup configuration to include postcss plugin for CSS handling.
- Updated index.ts to export all components from the components directory for better modularity.
2025-07-26 01:14:14 +02:00
Danny Avila
2af4ca5b5c chore: rename package/client build script command 2025-07-26 01:13:50 +02:00
Danny Avila
b6c7b0bc71 feat: enhance Rollup configuration for client package
- Updated terser plugin settings to preserve directives like 'use client'.
- Added custom warning handler to ignore "use client" directive warnings during the build process.
2025-07-26 01:13:50 +02:00
Danny Avila
f8738b207c feat: update client package configuration and dependencies
- Added new dependencies for Rollup plugins and updated existing ones in package.json and package-lock.json.
- Introduced a new Rollup configuration file for building the client package.
- Refactored build scripts to include a dedicated build command for the client.
- Updated TypeScript configuration for improved module resolution and type declaration output.
- Integrated a Toast component from the client package into the main App component.
2025-07-26 01:13:50 +02:00
Danny Avila
6ea1d5eab2 feat: add @librechat/client as a dependency in package.json and package-lock.json 2025-07-26 01:13:50 +02:00
Danny Avila
992911514c fix: move dependencies to peerDependencies in client package 2025-07-26 01:13:50 +02:00
Marco Beretta
9289aeb2ba feat: add back recoil 2025-07-26 01:13:50 +02:00
Marco Beretta
898d273aaf fix: package; refactor: tsconfig 2025-07-26 01:13:50 +02:00
Marco Beretta
5859350bcb fix: cleanup 2025-07-26 01:13:50 +02:00
Marco Beretta
882cca247a feat: cleanup unused types from common/index.ts
- Remove 104 unused type exports from packages/client/src/common/index.ts
- Keep only 7 actually used exports (93% reduction)
- Add cleanup script with enhanced import pattern detection
- Support both named imports and namespace imports (* as t)
- Create automatic backups and comprehensive documentation
- Maintain type safety with build verification
- No breaking changes to existing code

Kept exports:
- TShowToast, Option, OptionWithIcon, DropdownValueSetter
- MentionOption, NotificationSeverity, MenuItemProps

Scripts: cleanup-common-types-safe.js, README-CLEANUP.md
2025-07-26 01:13:50 +02:00
Marco Beretta
0b7dd55797 fix build client package 2025-07-26 01:13:50 +02:00
Marco Beretta
9f270127d3 feat: Add jotai as a peer dependency 2025-07-26 01:13:50 +02:00
Marco Beretta
1380db85cb feat: Add common types and interfaces for accessibility, agents, artifacts, assistants, and tools 2025-07-26 01:13:50 +02:00
Marco Beretta
dcaa5af598 feat: init @librechat/client 2025-07-26 01:13:49 +02:00
185 changed files with 2877 additions and 9191 deletions

View File

@@ -442,8 +442,6 @@ OPENID_REQUIRED_ROLE_PARAMETER_PATH=
OPENID_USERNAME_CLAIM=
# Set to determine which user info property returned from OpenID Provider to store as the User's name
OPENID_NAME_CLAIM=
# Optional audience parameter for OpenID authorization requests
OPENID_AUDIENCE=
OPENID_BUTTON_LABEL=
OPENID_IMAGE_URL=

View File

@@ -22,7 +22,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
node-version: '18.x'
- name: Install dependencies
run: cd packages/data-schemas && npm ci

View File

@@ -48,7 +48,7 @@ jobs:
# 2. Download translation files from locize.
- name: Download Translations from locize
uses: locize/download@v2
uses: locize/download@v1
with:
project-id: ${{ secrets.LOCIZE_PROJECT_ID }}
path: "client/src/locales"

View File

@@ -1,4 +1,4 @@
# v0.8.0-rc1
# v0.7.9
# Base node image
FROM node:20-alpine AS node

View File

@@ -1,5 +1,5 @@
# Dockerfile.multi
# v0.8.0-rc1
# v0.7.9
# Base for all builds
FROM node:20-alpine AS base-min
@@ -16,7 +16,6 @@ COPY package*.json ./
COPY packages/data-provider/package*.json ./packages/data-provider/
COPY packages/api/package*.json ./packages/api/
COPY packages/data-schemas/package*.json ./packages/data-schemas/
COPY packages/client/package*.json ./packages/client/
COPY client/package*.json ./client/
COPY api/package*.json ./api/
@@ -46,19 +45,11 @@ COPY --from=data-provider-build /app/packages/data-provider/dist /app/packages/d
COPY --from=data-schemas-build /app/packages/data-schemas/dist /app/packages/data-schemas/dist
RUN npm run build
# Build `client` package
FROM base AS client-package-build
WORKDIR /app/packages/client
COPY packages/client ./
RUN npm run build
# Client build
FROM base AS client-build
WORKDIR /app/client
COPY client ./
COPY --from=data-provider-build /app/packages/data-provider/dist /app/packages/data-provider/dist
COPY --from=client-package-build /app/packages/client/dist /app/packages/client/dist
COPY --from=client-package-build /app/packages/client/src /app/packages/client/src
ENV NODE_OPTIONS="--max-old-space-size=2048"
RUN npm run build

View File

@@ -3,8 +3,8 @@ const path = require('path');
const OpenAI = require('openai');
const fetch = require('node-fetch');
const { v4: uuidv4 } = require('uuid');
const { ProxyAgent } = require('undici');
const { Tool } = require('@langchain/core/tools');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { FileContext, ContentTypes } = require('librechat-data-provider');
const { getImageBasename } = require('~/server/services/Files/images');
const extractBaseURL = require('~/utils/extractBaseURL');
@@ -46,10 +46,7 @@ class DALLE3 extends Tool {
}
if (process.env.PROXY) {
const proxyAgent = new ProxyAgent(process.env.PROXY);
config.fetchOptions = {
dispatcher: proxyAgent,
};
config.httpAgent = new HttpsProxyAgent(process.env.PROXY);
}
/** @type {OpenAI} */
@@ -166,8 +163,7 @@ Error Message: ${error.message}`);
if (this.isAgent) {
let fetchOptions = {};
if (process.env.PROXY) {
const proxyAgent = new ProxyAgent(process.env.PROXY);
fetchOptions.dispatcher = proxyAgent;
fetchOptions.agent = new HttpsProxyAgent(process.env.PROXY);
}
const imageResponse = await fetch(theImageUrl, fetchOptions);
const arrayBuffer = await imageResponse.arrayBuffer();

View File

@@ -3,10 +3,10 @@ const axios = require('axios');
const { v4 } = require('uuid');
const OpenAI = require('openai');
const FormData = require('form-data');
const { ProxyAgent } = require('undici');
const { tool } = require('@langchain/core/tools');
const { logAxiosError } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { ContentTypes, EImageOutputType } = require('librechat-data-provider');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { extractBaseURL } = require('~/utils');
@@ -189,10 +189,7 @@ function createOpenAIImageTools(fields = {}) {
}
const clientConfig = { ...closureConfig };
if (process.env.PROXY) {
const proxyAgent = new ProxyAgent(process.env.PROXY);
clientConfig.fetchOptions = {
dispatcher: proxyAgent,
};
clientConfig.httpAgent = new HttpsProxyAgent(process.env.PROXY);
}
/** @type {OpenAI} */
@@ -338,10 +335,7 @@ Error Message: ${error.message}`);
const clientConfig = { ...closureConfig };
if (process.env.PROXY) {
const proxyAgent = new ProxyAgent(process.env.PROXY);
clientConfig.fetchOptions = {
dispatcher: proxyAgent,
};
clientConfig.httpAgent = new HttpsProxyAgent(process.env.PROXY);
}
const formData = new FormData();
@@ -453,19 +447,6 @@ Error Message: ${error.message}`);
baseURL,
};
if (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) {
axiosConfig.params = {
'api-version': process.env.IMAGE_GEN_OAI_AZURE_API_VERSION,

View File

@@ -1,94 +0,0 @@
const DALLE3 = require('../DALLE3');
const { ProxyAgent } = require('undici');
const processFileURL = jest.fn();
jest.mock('~/server/services/Files/images', () => ({
getImageBasename: jest.fn().mockImplementation((url) => {
const parts = url.split('/');
const lastPart = parts.pop();
const imageExtensionRegex = /\.(jpg|jpeg|png|gif|bmp|tiff|svg)$/i;
if (imageExtensionRegex.test(lastPart)) {
return lastPart;
}
return '';
}),
}));
jest.mock('fs', () => {
return {
existsSync: jest.fn(),
mkdirSync: jest.fn(),
promises: {
writeFile: jest.fn(),
readFile: jest.fn(),
unlink: jest.fn(),
},
};
});
jest.mock('path', () => {
return {
resolve: jest.fn(),
join: jest.fn(),
relative: jest.fn(),
extname: jest.fn().mockImplementation((filename) => {
return filename.slice(filename.lastIndexOf('.'));
}),
};
});
describe('DALLE3 Proxy Configuration', () => {
let originalEnv;
beforeAll(() => {
originalEnv = { ...process.env };
});
beforeEach(() => {
jest.resetModules();
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
it('should configure ProxyAgent in fetchOptions.dispatcher when PROXY env is set', () => {
// Set proxy environment variable
process.env.PROXY = 'http://proxy.example.com:8080';
process.env.DALLE_API_KEY = 'test-api-key';
// Create instance
const dalleWithProxy = new DALLE3({ processFileURL });
// Check that the openai client exists
expect(dalleWithProxy.openai).toBeDefined();
// Check that _options exists and has fetchOptions with a dispatcher
expect(dalleWithProxy.openai._options).toBeDefined();
expect(dalleWithProxy.openai._options.fetchOptions).toBeDefined();
expect(dalleWithProxy.openai._options.fetchOptions.dispatcher).toBeDefined();
expect(dalleWithProxy.openai._options.fetchOptions.dispatcher).toBeInstanceOf(ProxyAgent);
});
it('should not configure ProxyAgent when PROXY env is not set', () => {
// Ensure PROXY is not set
delete process.env.PROXY;
process.env.DALLE_API_KEY = 'test-api-key';
// Create instance
const dalleWithoutProxy = new DALLE3({ processFileURL });
// Check that the openai client exists
expect(dalleWithoutProxy.openai).toBeDefined();
// Check that _options exists but fetchOptions either doesn't exist or doesn't have a dispatcher
expect(dalleWithoutProxy.openai._options).toBeDefined();
// fetchOptions should either not exist or not have a dispatcher
if (dalleWithoutProxy.openai._options.fetchOptions) {
expect(dalleWithoutProxy.openai._options.fetchOptions.dispatcher).toBeUndefined();
}
});
});

View File

@@ -44,14 +44,6 @@ const cacheConfig = {
REDIS_KEY_PREFIX: process.env[REDIS_KEY_PREFIX_VAR] || REDIS_KEY_PREFIX || '',
REDIS_MAX_LISTENERS: math(process.env.REDIS_MAX_LISTENERS, 40),
REDIS_PING_INTERVAL: math(process.env.REDIS_PING_INTERVAL, 0),
/** Max delay between reconnection attempts in ms */
REDIS_RETRY_MAX_DELAY: math(process.env.REDIS_RETRY_MAX_DELAY, 3000),
/** Max number of reconnection attempts (0 = infinite) */
REDIS_RETRY_MAX_ATTEMPTS: math(process.env.REDIS_RETRY_MAX_ATTEMPTS, 10),
/** Connection timeout in ms */
REDIS_CONNECT_TIMEOUT: math(process.env.REDIS_CONNECT_TIMEOUT, 10000),
/** Queue commands when disconnected */
REDIS_ENABLE_OFFLINE_QUEUE: isEnabled(process.env.REDIS_ENABLE_OFFLINE_QUEUE ?? 'true'),
CI: isEnabled(process.env.CI),
DEBUG_MEMORY_CACHE: isEnabled(process.env.DEBUG_MEMORY_CACHE),

View File

@@ -1,13 +1,12 @@
const KeyvRedis = require('@keyv/redis').default;
const { Keyv } = require('keyv');
const { RedisStore } = require('rate-limit-redis');
const { cacheConfig } = require('./cacheConfig');
const { keyvRedisClient, ioredisClient, GLOBAL_PREFIX_SEPARATOR } = require('./redisClients');
const { Time } = require('librechat-data-provider');
const { logger } = require('@librechat/data-schemas');
const { RedisStore: ConnectRedis } = require('connect-redis');
const MemoryStore = require('memorystore')(require('express-session'));
const { keyvRedisClient, ioredisClient, GLOBAL_PREFIX_SEPARATOR } = require('./redisClients');
const { cacheConfig } = require('./cacheConfig');
const { violationFile } = require('./keyvFiles');
const { RedisStore } = require('rate-limit-redis');
/**
* Creates a cache instance using Redis or a fallback store. Suitable for general caching needs.
@@ -21,21 +20,11 @@ const standardCache = (namespace, ttl = undefined, fallbackStore = undefined) =>
cacheConfig.USE_REDIS &&
!cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES?.includes(namespace)
) {
try {
const keyvRedis = new KeyvRedis(keyvRedisClient);
const cache = new Keyv(keyvRedis, { namespace, ttl });
keyvRedis.namespace = cacheConfig.REDIS_KEY_PREFIX;
keyvRedis.keyPrefixSeparator = GLOBAL_PREFIX_SEPARATOR;
cache.on('error', (err) => {
logger.error(`Cache error in namespace ${namespace}:`, err);
});
return cache;
} catch (err) {
logger.error(`Failed to create Redis cache for namespace ${namespace}:`, err);
throw err;
}
const keyvRedis = new KeyvRedis(keyvRedisClient);
const cache = new Keyv(keyvRedis, { namespace, ttl });
keyvRedis.namespace = cacheConfig.REDIS_KEY_PREFIX;
keyvRedis.keyPrefixSeparator = GLOBAL_PREFIX_SEPARATOR;
return cache;
}
if (fallbackStore) return new Keyv({ store: fallbackStore, namespace, ttl });
return new Keyv({ namespace, ttl });
@@ -61,13 +50,7 @@ const violationCache = (namespace, ttl = undefined) => {
const sessionCache = (namespace, ttl = undefined) => {
namespace = namespace.endsWith(':') ? namespace : `${namespace}:`;
if (!cacheConfig.USE_REDIS) return new MemoryStore({ ttl, checkPeriod: Time.ONE_DAY });
const store = new ConnectRedis({ client: ioredisClient, ttl, prefix: namespace });
if (ioredisClient) {
ioredisClient.on('error', (err) => {
logger.error(`Session store Redis error for namespace ${namespace}:`, err);
});
}
return store;
return new ConnectRedis({ client: ioredisClient, ttl, prefix: namespace });
};
/**
@@ -79,30 +62,8 @@ const limiterCache = (prefix) => {
if (!prefix) throw new Error('prefix is required');
if (!cacheConfig.USE_REDIS) return undefined;
prefix = prefix.endsWith(':') ? prefix : `${prefix}:`;
try {
if (!ioredisClient) {
logger.warn(`Redis client not available for rate limiter with prefix ${prefix}`);
return undefined;
}
return new RedisStore({ sendCommand, prefix });
} catch (err) {
logger.error(`Failed to create Redis rate limiter for prefix ${prefix}:`, err);
return undefined;
}
};
const sendCommand = (...args) => {
if (!ioredisClient) {
logger.warn('Redis client not available for command execution');
return Promise.reject(new Error('Redis client not available'));
}
return ioredisClient.call(...args).catch((err) => {
logger.error('Redis command execution failed:', err);
throw err;
});
return new RedisStore({ sendCommand, prefix });
};
const sendCommand = (...args) => ioredisClient?.call(...args);
module.exports = { standardCache, sessionCache, violationCache, limiterCache };

View File

@@ -6,17 +6,13 @@ const mockKeyvRedis = {
keyPrefixSeparator: '',
};
const mockKeyv = jest.fn().mockReturnValue({
mock: 'keyv',
on: jest.fn(),
});
const mockKeyv = jest.fn().mockReturnValue({ mock: 'keyv' });
const mockConnectRedis = jest.fn().mockReturnValue({ mock: 'connectRedis' });
const mockMemoryStore = jest.fn().mockReturnValue({ mock: 'memoryStore' });
const mockRedisStore = jest.fn().mockReturnValue({ mock: 'redisStore' });
const mockIoredisClient = {
call: jest.fn(),
on: jest.fn(),
};
const mockKeyvRedisClient = {};
@@ -57,14 +53,6 @@ jest.mock('rate-limit-redis', () => ({
RedisStore: mockRedisStore,
}));
jest.mock('@librechat/data-schemas', () => ({
logger: {
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
},
}));
// Import after mocking
const { standardCache, sessionCache, violationCache, limiterCache } = require('./cacheFactory');
const { cacheConfig } = require('./cacheConfig');
@@ -154,28 +142,6 @@ describe('cacheFactory', () => {
expect(require('@keyv/redis').default).toHaveBeenCalledWith(mockKeyvRedisClient);
expect(mockKeyv).toHaveBeenCalledWith(mockKeyvRedis, { namespace, ttl });
});
it('should throw error when Redis cache creation fails', () => {
cacheConfig.USE_REDIS = true;
const namespace = 'test-namespace';
const ttl = 3600;
const testError = new Error('Redis connection failed');
const KeyvRedis = require('@keyv/redis').default;
KeyvRedis.mockImplementationOnce(() => {
throw testError;
});
expect(() => standardCache(namespace, ttl)).toThrow('Redis connection failed');
const { logger } = require('@librechat/data-schemas');
expect(logger.error).toHaveBeenCalledWith(
`Failed to create Redis cache for namespace ${namespace}:`,
testError,
);
expect(mockKeyv).not.toHaveBeenCalled();
});
});
describe('violationCache', () => {
@@ -267,86 +233,6 @@ describe('cacheFactory', () => {
checkPeriod: Time.ONE_DAY,
});
});
it('should throw error when ConnectRedis constructor fails', () => {
cacheConfig.USE_REDIS = true;
const namespace = 'sessions';
const ttl = 86400;
// Mock ConnectRedis to throw an error during construction
const redisError = new Error('Redis connection failed');
mockConnectRedis.mockImplementationOnce(() => {
throw redisError;
});
// The error should propagate up, not be caught
expect(() => sessionCache(namespace, ttl)).toThrow('Redis connection failed');
// Verify that MemoryStore was NOT used as fallback
expect(mockMemoryStore).not.toHaveBeenCalled();
});
it('should register error handler but let errors propagate to Express', () => {
cacheConfig.USE_REDIS = true;
const namespace = 'sessions';
// Create a mock session store with middleware methods
const mockSessionStore = {
get: jest.fn(),
set: jest.fn(),
destroy: jest.fn(),
};
mockConnectRedis.mockReturnValue(mockSessionStore);
const store = sessionCache(namespace);
// Verify error handler was registered
expect(mockIoredisClient.on).toHaveBeenCalledWith('error', expect.any(Function));
// Get the error handler
const errorHandler = mockIoredisClient.on.mock.calls.find((call) => call[0] === 'error')[1];
// Simulate an error from Redis during a session operation
const redisError = new Error('Socket closed unexpectedly');
// The error handler should log but not swallow the error
const { logger } = require('@librechat/data-schemas');
errorHandler(redisError);
expect(logger.error).toHaveBeenCalledWith(
`Session store Redis error for namespace ${namespace}::`,
redisError,
);
// Now simulate what happens when session middleware tries to use the store
const callback = jest.fn();
mockSessionStore.get.mockImplementation((sid, cb) => {
cb(new Error('Redis connection lost'));
});
// Call the store's get method (as Express session would)
store.get('test-session-id', callback);
// The error should be passed to the callback, not swallowed
expect(callback).toHaveBeenCalledWith(new Error('Redis connection lost'));
});
it('should handle null ioredisClient gracefully', () => {
cacheConfig.USE_REDIS = true;
const namespace = 'sessions';
// Temporarily set ioredisClient to null (simulating connection not established)
const originalClient = require('./redisClients').ioredisClient;
require('./redisClients').ioredisClient = null;
// ConnectRedis might accept null client but would fail on first use
// The important thing is it doesn't throw uncaught exceptions during construction
const store = sessionCache(namespace);
expect(store).toBeDefined();
// Restore original client
require('./redisClients').ioredisClient = originalClient;
});
});
describe('limiterCache', () => {
@@ -388,10 +274,8 @@ describe('cacheFactory', () => {
});
});
it('should pass sendCommand function that calls ioredisClient.call', async () => {
it('should pass sendCommand function that calls ioredisClient.call', () => {
cacheConfig.USE_REDIS = true;
mockIoredisClient.call.mockResolvedValue('test-value');
limiterCache('rate-limit');
const sendCommandCall = mockRedisStore.mock.calls[0][0];
@@ -399,29 +283,9 @@ describe('cacheFactory', () => {
// Test that sendCommand properly delegates to ioredisClient.call
const args = ['GET', 'test-key'];
const result = await sendCommand(...args);
sendCommand(...args);
expect(mockIoredisClient.call).toHaveBeenCalledWith(...args);
expect(result).toBe('test-value');
});
it('should handle sendCommand errors properly', async () => {
cacheConfig.USE_REDIS = true;
// Mock the call method to reject with an error
const testError = new Error('Redis error');
mockIoredisClient.call.mockRejectedValue(testError);
limiterCache('rate-limit');
const sendCommandCall = mockRedisStore.mock.calls[0][0];
const sendCommand = sendCommandCall.sendCommand;
// Test that sendCommand properly handles errors
const args = ['GET', 'test-key'];
await expect(sendCommand(...args)).rejects.toThrow('Redis error');
expect(mockIoredisClient.call).toHaveBeenCalledWith(...args);
});
it('should handle undefined prefix', () => {

View File

@@ -13,82 +13,23 @@ const ca = cacheConfig.REDIS_CA;
/** @type {import('ioredis').Redis | import('ioredis').Cluster | null} */
let ioredisClient = null;
if (cacheConfig.USE_REDIS) {
/** @type {import('ioredis').RedisOptions | import('ioredis').ClusterOptions} */
const redisOptions = {
username: username,
password: password,
tls: ca ? { ca } : undefined,
keyPrefix: `${cacheConfig.REDIS_KEY_PREFIX}${GLOBAL_PREFIX_SEPARATOR}`,
maxListeners: cacheConfig.REDIS_MAX_LISTENERS,
retryStrategy: (times) => {
if (
cacheConfig.REDIS_RETRY_MAX_ATTEMPTS > 0 &&
times > cacheConfig.REDIS_RETRY_MAX_ATTEMPTS
) {
logger.error(
`ioredis giving up after ${cacheConfig.REDIS_RETRY_MAX_ATTEMPTS} reconnection attempts`,
);
return null;
}
const delay = Math.min(times * 50, cacheConfig.REDIS_RETRY_MAX_DELAY);
logger.info(`ioredis reconnecting... attempt ${times}, delay ${delay}ms`);
return delay;
},
reconnectOnError: (err) => {
const targetError = 'READONLY';
if (err.message.includes(targetError)) {
logger.warn('ioredis reconnecting due to READONLY error');
return true;
}
return false;
},
enableOfflineQueue: cacheConfig.REDIS_ENABLE_OFFLINE_QUEUE,
connectTimeout: cacheConfig.REDIS_CONNECT_TIMEOUT,
maxRetriesPerRequest: 3,
};
ioredisClient =
urls.length === 1
? new IoRedis(cacheConfig.REDIS_URI, redisOptions)
: new IoRedis.Cluster(cacheConfig.REDIS_URI, {
redisOptions,
clusterRetryStrategy: (times) => {
if (
cacheConfig.REDIS_RETRY_MAX_ATTEMPTS > 0 &&
times > cacheConfig.REDIS_RETRY_MAX_ATTEMPTS
) {
logger.error(
`ioredis cluster giving up after ${cacheConfig.REDIS_RETRY_MAX_ATTEMPTS} reconnection attempts`,
);
return null;
}
const delay = Math.min(times * 100, cacheConfig.REDIS_RETRY_MAX_DELAY);
logger.info(`ioredis cluster reconnecting... attempt ${times}, delay ${delay}ms`);
return delay;
},
enableOfflineQueue: cacheConfig.REDIS_ENABLE_OFFLINE_QUEUE,
});
: new IoRedis.Cluster(cacheConfig.REDIS_URI, { redisOptions });
ioredisClient.on('error', (err) => {
logger.error('ioredis client error:', err);
});
ioredisClient.on('connect', () => {
logger.info('ioredis client connected');
});
ioredisClient.on('ready', () => {
logger.info('ioredis client ready');
});
ioredisClient.on('reconnecting', (delay) => {
logger.info(`ioredis client reconnecting in ${delay}ms`);
});
ioredisClient.on('close', () => {
logger.warn('ioredis client connection closed');
});
/** Ping Interval to keep the Redis server connection alive (if enabled) */
let pingInterval = null;
const clearPingInterval = () => {
@@ -101,9 +42,7 @@ if (cacheConfig.USE_REDIS) {
if (cacheConfig.REDIS_PING_INTERVAL > 0) {
pingInterval = setInterval(() => {
if (ioredisClient && ioredisClient.status === 'ready') {
ioredisClient.ping().catch((err) => {
logger.error('ioredis ping failed:', err);
});
ioredisClient.ping();
}
}, cacheConfig.REDIS_PING_INTERVAL * 1000);
ioredisClient.on('close', clearPingInterval);
@@ -117,32 +56,8 @@ if (cacheConfig.USE_REDIS) {
/**
* ** WARNING ** Keyv Redis client does not support Prefix like ioredis above.
* The prefix feature will be handled by the Keyv-Redis store in cacheFactory.js
* @type {import('@keyv/redis').RedisClientOptions | import('@keyv/redis').RedisClusterOptions}
*/
const redisOptions = {
username,
password,
socket: {
tls: ca != null,
ca,
connectTimeout: cacheConfig.REDIS_CONNECT_TIMEOUT,
reconnectStrategy: (retries) => {
if (
cacheConfig.REDIS_RETRY_MAX_ATTEMPTS > 0 &&
retries > cacheConfig.REDIS_RETRY_MAX_ATTEMPTS
) {
logger.error(
`@keyv/redis client giving up after ${cacheConfig.REDIS_RETRY_MAX_ATTEMPTS} reconnection attempts`,
);
return new Error('Max reconnection attempts reached');
}
const delay = Math.min(retries * 100, cacheConfig.REDIS_RETRY_MAX_DELAY);
logger.info(`@keyv/redis reconnecting... attempt ${retries}, delay ${delay}ms`);
return delay;
},
},
disableOfflineQueue: !cacheConfig.REDIS_ENABLE_OFFLINE_QUEUE,
};
const redisOptions = { username, password, socket: { tls: ca != null, ca } };
keyvRedisClient =
urls.length === 1
@@ -158,27 +73,6 @@ if (cacheConfig.USE_REDIS) {
logger.error('@keyv/redis client error:', err);
});
keyvRedisClient.on('connect', () => {
logger.info('@keyv/redis client connected');
});
keyvRedisClient.on('ready', () => {
logger.info('@keyv/redis client ready');
});
keyvRedisClient.on('reconnecting', () => {
logger.info('@keyv/redis client reconnecting...');
});
keyvRedisClient.on('disconnect', () => {
logger.warn('@keyv/redis client disconnected');
});
keyvRedisClient.connect().catch((err) => {
logger.error('@keyv/redis initial connection failed:', err);
throw err;
});
/** Ping Interval to keep the Redis server connection alive (if enabled) */
let pingInterval = null;
const clearPingInterval = () => {
@@ -191,9 +85,7 @@ if (cacheConfig.USE_REDIS) {
if (cacheConfig.REDIS_PING_INTERVAL > 0) {
pingInterval = setInterval(() => {
if (keyvRedisClient && keyvRedisClient.isReady) {
keyvRedisClient.ping().catch((err) => {
logger.error('@keyv/redis ping failed:', err);
});
keyvRedisClient.ping();
}
}, cacheConfig.REDIS_PING_INTERVAL * 1000);
keyvRedisClient.on('disconnect', clearPingInterval);

View File

@@ -1,6 +1,6 @@
const { logger } = require('@librechat/data-schemas');
const { createTempChatExpirationDate } = require('@librechat/api');
const { getCustomConfig } = require('~/server/services/Config/getCustomConfig');
const getCustomConfig = require('~/server/services/Config/getCustomConfig');
const { getMessages, deleteMessages } = require('./Message');
const { Conversation } = require('~/db/models');

View File

@@ -1,572 +0,0 @@
const mongoose = require('mongoose');
const { v4: uuidv4 } = require('uuid');
const { EModelEndpoint } = require('librechat-data-provider');
const { MongoMemoryServer } = require('mongodb-memory-server');
const {
deleteNullOrEmptyConversations,
searchConversation,
getConvosByCursor,
getConvosQueried,
getConvoFiles,
getConvoTitle,
deleteConvos,
saveConvo,
getConvo,
} = require('./Conversation');
jest.mock('~/server/services/Config/getCustomConfig');
jest.mock('./Message');
const { getCustomConfig } = require('~/server/services/Config/getCustomConfig');
const { getMessages, deleteMessages } = require('./Message');
const { Conversation } = require('~/db/models');
describe('Conversation Operations', () => {
let mongoServer;
let mockReq;
let mockConversationData;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
// Clear database
await Conversation.deleteMany({});
// Reset mocks
jest.clearAllMocks();
// Default mock implementations
getMessages.mockResolvedValue([]);
deleteMessages.mockResolvedValue({ deletedCount: 0 });
mockReq = {
user: { id: 'user123' },
body: {},
};
mockConversationData = {
conversationId: uuidv4(),
title: 'Test Conversation',
endpoint: EModelEndpoint.openAI,
};
});
describe('saveConvo', () => {
it('should save a conversation for an authenticated user', async () => {
const result = await saveConvo(mockReq, mockConversationData);
expect(result.conversationId).toBe(mockConversationData.conversationId);
expect(result.user).toBe('user123');
expect(result.title).toBe('Test Conversation');
expect(result.endpoint).toBe(EModelEndpoint.openAI);
// Verify the conversation was actually saved to the database
const savedConvo = await Conversation.findOne({
conversationId: mockConversationData.conversationId,
user: 'user123',
});
expect(savedConvo).toBeTruthy();
expect(savedConvo.title).toBe('Test Conversation');
});
it('should query messages when saving a conversation', async () => {
// Mock messages as ObjectIds
const mongoose = require('mongoose');
const mockMessages = [new mongoose.Types.ObjectId(), new mongoose.Types.ObjectId()];
getMessages.mockResolvedValue(mockMessages);
await saveConvo(mockReq, mockConversationData);
// Verify that getMessages was called with correct parameters
expect(getMessages).toHaveBeenCalledWith(
{ conversationId: mockConversationData.conversationId },
'_id',
);
});
it('should handle newConversationId when provided', async () => {
const newConversationId = uuidv4();
const result = await saveConvo(mockReq, {
...mockConversationData,
newConversationId,
});
expect(result.conversationId).toBe(newConversationId);
});
it('should handle unsetFields metadata', async () => {
const metadata = {
unsetFields: { someField: 1 },
};
await saveConvo(mockReq, mockConversationData, metadata);
const savedConvo = await Conversation.findOne({
conversationId: mockConversationData.conversationId,
});
expect(savedConvo.someField).toBeUndefined();
});
});
describe('isTemporary conversation handling', () => {
it('should save a conversation with expiredAt when isTemporary is true', async () => {
// Mock custom config with 24 hour retention
getCustomConfig.mockResolvedValue({
interface: {
temporaryChatRetention: 24,
},
});
mockReq.body = { isTemporary: true };
const beforeSave = new Date();
const result = await saveConvo(mockReq, mockConversationData);
const afterSave = new Date();
expect(result.conversationId).toBe(mockConversationData.conversationId);
expect(result.expiredAt).toBeDefined();
expect(result.expiredAt).toBeInstanceOf(Date);
// Verify expiredAt is approximately 24 hours in the future
const expectedExpirationTime = new Date(beforeSave.getTime() + 24 * 60 * 60 * 1000);
const actualExpirationTime = new Date(result.expiredAt);
expect(actualExpirationTime.getTime()).toBeGreaterThanOrEqual(
expectedExpirationTime.getTime() - 1000,
);
expect(actualExpirationTime.getTime()).toBeLessThanOrEqual(
new Date(afterSave.getTime() + 24 * 60 * 60 * 1000 + 1000).getTime(),
);
});
it('should save a conversation without expiredAt when isTemporary is false', async () => {
mockReq.body = { isTemporary: false };
const result = await saveConvo(mockReq, mockConversationData);
expect(result.conversationId).toBe(mockConversationData.conversationId);
expect(result.expiredAt).toBeNull();
});
it('should save a conversation without expiredAt when isTemporary is not provided', async () => {
// No isTemporary in body
mockReq.body = {};
const result = await saveConvo(mockReq, mockConversationData);
expect(result.conversationId).toBe(mockConversationData.conversationId);
expect(result.expiredAt).toBeNull();
});
it('should use custom retention period from config', async () => {
// Mock custom config with 48 hour retention
getCustomConfig.mockResolvedValue({
interface: {
temporaryChatRetention: 48,
},
});
mockReq.body = { isTemporary: true };
const beforeSave = new Date();
const result = await saveConvo(mockReq, mockConversationData);
expect(result.expiredAt).toBeDefined();
// Verify expiredAt is approximately 48 hours in the future
const expectedExpirationTime = new Date(beforeSave.getTime() + 48 * 60 * 60 * 1000);
const actualExpirationTime = new Date(result.expiredAt);
expect(actualExpirationTime.getTime()).toBeGreaterThanOrEqual(
expectedExpirationTime.getTime() - 1000,
);
expect(actualExpirationTime.getTime()).toBeLessThanOrEqual(
expectedExpirationTime.getTime() + 1000,
);
});
it('should handle minimum retention period (1 hour)', async () => {
// Mock custom config with less than minimum retention
getCustomConfig.mockResolvedValue({
interface: {
temporaryChatRetention: 0.5, // Half hour - should be clamped to 1 hour
},
});
mockReq.body = { isTemporary: true };
const beforeSave = new Date();
const result = await saveConvo(mockReq, mockConversationData);
expect(result.expiredAt).toBeDefined();
// Verify expiredAt is approximately 1 hour in the future (minimum)
const expectedExpirationTime = new Date(beforeSave.getTime() + 1 * 60 * 60 * 1000);
const actualExpirationTime = new Date(result.expiredAt);
expect(actualExpirationTime.getTime()).toBeGreaterThanOrEqual(
expectedExpirationTime.getTime() - 1000,
);
expect(actualExpirationTime.getTime()).toBeLessThanOrEqual(
expectedExpirationTime.getTime() + 1000,
);
});
it('should handle maximum retention period (8760 hours)', async () => {
// Mock custom config with more than maximum retention
getCustomConfig.mockResolvedValue({
interface: {
temporaryChatRetention: 10000, // Should be clamped to 8760 hours
},
});
mockReq.body = { isTemporary: true };
const beforeSave = new Date();
const result = await saveConvo(mockReq, mockConversationData);
expect(result.expiredAt).toBeDefined();
// Verify expiredAt is approximately 8760 hours (1 year) in the future
const expectedExpirationTime = new Date(beforeSave.getTime() + 8760 * 60 * 60 * 1000);
const actualExpirationTime = new Date(result.expiredAt);
expect(actualExpirationTime.getTime()).toBeGreaterThanOrEqual(
expectedExpirationTime.getTime() - 1000,
);
expect(actualExpirationTime.getTime()).toBeLessThanOrEqual(
expectedExpirationTime.getTime() + 1000,
);
});
it('should handle getCustomConfig errors gracefully', async () => {
// Mock getCustomConfig to throw an error
getCustomConfig.mockRejectedValue(new Error('Config service unavailable'));
mockReq.body = { isTemporary: true };
const result = await saveConvo(mockReq, mockConversationData);
// Should still save the conversation but with expiredAt as null
expect(result.conversationId).toBe(mockConversationData.conversationId);
expect(result.expiredAt).toBeNull();
});
it('should use default retention when config is not provided', async () => {
// Mock getCustomConfig to return empty config
getCustomConfig.mockResolvedValue({});
mockReq.body = { isTemporary: true };
const beforeSave = new Date();
const result = await saveConvo(mockReq, mockConversationData);
expect(result.expiredAt).toBeDefined();
// Default retention is 30 days (720 hours)
const expectedExpirationTime = new Date(beforeSave.getTime() + 30 * 24 * 60 * 60 * 1000);
const actualExpirationTime = new Date(result.expiredAt);
expect(actualExpirationTime.getTime()).toBeGreaterThanOrEqual(
expectedExpirationTime.getTime() - 1000,
);
expect(actualExpirationTime.getTime()).toBeLessThanOrEqual(
expectedExpirationTime.getTime() + 1000,
);
});
it('should update expiredAt when saving existing temporary conversation', async () => {
// First save a temporary conversation
getCustomConfig.mockResolvedValue({
interface: {
temporaryChatRetention: 24,
},
});
mockReq.body = { isTemporary: true };
const firstSave = await saveConvo(mockReq, mockConversationData);
const originalExpiredAt = firstSave.expiredAt;
// Wait a bit to ensure time difference
await new Promise((resolve) => setTimeout(resolve, 100));
// Save again with same conversationId but different title
const updatedData = { ...mockConversationData, title: 'Updated Title' };
const secondSave = await saveConvo(mockReq, updatedData);
// Should update title and create new expiredAt
expect(secondSave.title).toBe('Updated Title');
expect(secondSave.expiredAt).toBeDefined();
expect(new Date(secondSave.expiredAt).getTime()).toBeGreaterThan(
new Date(originalExpiredAt).getTime(),
);
});
it('should not set expiredAt when updating non-temporary conversation', async () => {
// First save a non-temporary conversation
mockReq.body = { isTemporary: false };
const firstSave = await saveConvo(mockReq, mockConversationData);
expect(firstSave.expiredAt).toBeNull();
// Update without isTemporary flag
mockReq.body = {};
const updatedData = { ...mockConversationData, title: 'Updated Title' };
const secondSave = await saveConvo(mockReq, updatedData);
expect(secondSave.title).toBe('Updated Title');
expect(secondSave.expiredAt).toBeNull();
});
it('should filter out expired conversations in getConvosByCursor', async () => {
// Create some test conversations
const nonExpiredConvo = await Conversation.create({
conversationId: uuidv4(),
user: 'user123',
title: 'Non-expired',
endpoint: EModelEndpoint.openAI,
expiredAt: null,
updatedAt: new Date(),
});
await Conversation.create({
conversationId: uuidv4(),
user: 'user123',
title: 'Future expired',
endpoint: EModelEndpoint.openAI,
expiredAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours from now
updatedAt: new Date(),
});
// Mock Meili search
Conversation.meiliSearch = jest.fn().mockResolvedValue({ hits: [] });
const result = await getConvosByCursor('user123');
// Should only return conversations with null or non-existent expiredAt
expect(result.conversations).toHaveLength(1);
expect(result.conversations[0].conversationId).toBe(nonExpiredConvo.conversationId);
});
it('should filter out expired conversations in getConvosQueried', async () => {
// Create test conversations
const nonExpiredConvo = await Conversation.create({
conversationId: uuidv4(),
user: 'user123',
title: 'Non-expired',
endpoint: EModelEndpoint.openAI,
expiredAt: null,
});
const expiredConvo = await Conversation.create({
conversationId: uuidv4(),
user: 'user123',
title: 'Expired',
endpoint: EModelEndpoint.openAI,
expiredAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
});
const convoIds = [
{ conversationId: nonExpiredConvo.conversationId },
{ conversationId: expiredConvo.conversationId },
];
const result = await getConvosQueried('user123', convoIds);
// Should only return the non-expired conversation
expect(result.conversations).toHaveLength(1);
expect(result.conversations[0].conversationId).toBe(nonExpiredConvo.conversationId);
expect(result.convoMap[nonExpiredConvo.conversationId]).toBeDefined();
expect(result.convoMap[expiredConvo.conversationId]).toBeUndefined();
});
});
describe('searchConversation', () => {
it('should find a conversation by conversationId', async () => {
await Conversation.create({
conversationId: mockConversationData.conversationId,
user: 'user123',
title: 'Test',
endpoint: EModelEndpoint.openAI,
});
const result = await searchConversation(mockConversationData.conversationId);
expect(result).toBeTruthy();
expect(result.conversationId).toBe(mockConversationData.conversationId);
expect(result.user).toBe('user123');
expect(result.title).toBeUndefined(); // Only returns conversationId and user
});
it('should return null if conversation not found', async () => {
const result = await searchConversation('non-existent-id');
expect(result).toBeNull();
});
});
describe('getConvo', () => {
it('should retrieve a conversation for a user', async () => {
await Conversation.create({
conversationId: mockConversationData.conversationId,
user: 'user123',
title: 'Test Conversation',
endpoint: EModelEndpoint.openAI,
});
const result = await getConvo('user123', mockConversationData.conversationId);
expect(result.conversationId).toBe(mockConversationData.conversationId);
expect(result.user).toBe('user123');
expect(result.title).toBe('Test Conversation');
});
it('should return null if conversation not found', async () => {
const result = await getConvo('user123', 'non-existent-id');
expect(result).toBeNull();
});
});
describe('getConvoTitle', () => {
it('should return the conversation title', async () => {
await Conversation.create({
conversationId: mockConversationData.conversationId,
user: 'user123',
title: 'Test Title',
endpoint: EModelEndpoint.openAI,
});
const result = await getConvoTitle('user123', mockConversationData.conversationId);
expect(result).toBe('Test Title');
});
it('should return null if conversation has no title', async () => {
await Conversation.create({
conversationId: mockConversationData.conversationId,
user: 'user123',
title: null,
endpoint: EModelEndpoint.openAI,
});
const result = await getConvoTitle('user123', mockConversationData.conversationId);
expect(result).toBeNull();
});
it('should return "New Chat" if conversation not found', async () => {
const result = await getConvoTitle('user123', 'non-existent-id');
expect(result).toBe('New Chat');
});
});
describe('getConvoFiles', () => {
it('should return conversation files', async () => {
const files = ['file1', 'file2'];
await Conversation.create({
conversationId: mockConversationData.conversationId,
user: 'user123',
endpoint: EModelEndpoint.openAI,
files,
});
const result = await getConvoFiles(mockConversationData.conversationId);
expect(result).toEqual(files);
});
it('should return empty array if no files', async () => {
await Conversation.create({
conversationId: mockConversationData.conversationId,
user: 'user123',
endpoint: EModelEndpoint.openAI,
});
const result = await getConvoFiles(mockConversationData.conversationId);
expect(result).toEqual([]);
});
it('should return empty array if conversation not found', async () => {
const result = await getConvoFiles('non-existent-id');
expect(result).toEqual([]);
});
});
describe('deleteConvos', () => {
it('should delete conversations and associated messages', async () => {
await Conversation.create({
conversationId: mockConversationData.conversationId,
user: 'user123',
title: 'To Delete',
endpoint: EModelEndpoint.openAI,
});
deleteMessages.mockResolvedValue({ deletedCount: 5 });
const result = await deleteConvos('user123', {
conversationId: mockConversationData.conversationId,
});
expect(result.deletedCount).toBe(1);
expect(result.messages.deletedCount).toBe(5);
expect(deleteMessages).toHaveBeenCalledWith({
conversationId: { $in: [mockConversationData.conversationId] },
});
// Verify conversation was deleted
const deletedConvo = await Conversation.findOne({
conversationId: mockConversationData.conversationId,
});
expect(deletedConvo).toBeNull();
});
it('should throw error if no conversations found', async () => {
await expect(deleteConvos('user123', { conversationId: 'non-existent' })).rejects.toThrow(
'Conversation not found or already deleted.',
);
});
});
describe('deleteNullOrEmptyConversations', () => {
it('should delete conversations with null, empty, or missing conversationIds', async () => {
// Since conversationId is required by the schema, we can't create documents with null/missing IDs
// This test should verify the function works when such documents exist (e.g., from data corruption)
// For this test, let's create a valid conversation and verify the function doesn't delete it
await Conversation.create({
conversationId: mockConversationData.conversationId,
user: 'user4',
endpoint: EModelEndpoint.openAI,
});
deleteMessages.mockResolvedValue({ deletedCount: 0 });
const result = await deleteNullOrEmptyConversations();
expect(result.conversations.deletedCount).toBe(0); // No invalid conversations to delete
expect(result.messages.deletedCount).toBe(0);
// Verify valid conversation remains
const remainingConvos = await Conversation.find({});
expect(remainingConvos).toHaveLength(1);
expect(remainingConvos[0].conversationId).toBe(mockConversationData.conversationId);
});
});
describe('Error Handling', () => {
it('should handle database errors in saveConvo', async () => {
// Force a database error by disconnecting
await mongoose.disconnect();
const result = await saveConvo(mockReq, mockConversationData);
expect(result).toEqual({ message: 'Error saving conversation' });
// Reconnect for other tests
await mongoose.connect(mongoServer.getUri());
});
});
});

View File

@@ -1,7 +1,7 @@
const { z } = require('zod');
const { logger } = require('@librechat/data-schemas');
const { createTempChatExpirationDate } = require('@librechat/api');
const { getCustomConfig } = require('~/server/services/Config/getCustomConfig');
const getCustomConfig = require('~/server/services/Config/getCustomConfig');
const { Message } = require('~/db/models');
const idSchema = z.string().uuid();

View File

@@ -1,21 +1,17 @@
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { v4: uuidv4 } = require('uuid');
const { messageSchema } = require('@librechat/data-schemas');
const { MongoMemoryServer } = require('mongodb-memory-server');
const {
saveMessage,
getMessages,
updateMessage,
deleteMessages,
bulkSaveMessages,
updateMessageText,
deleteMessagesSince,
} = require('./Message');
jest.mock('~/server/services/Config/getCustomConfig');
const { getCustomConfig } = require('~/server/services/Config/getCustomConfig');
/**
* @type {import('mongoose').Model<import('@librechat/data-schemas').IMessage>}
*/
@@ -121,21 +117,21 @@ describe('Message Operations', () => {
const conversationId = uuidv4();
// Create multiple messages in the same conversation
await saveMessage(mockReq, {
const message1 = await saveMessage(mockReq, {
messageId: 'msg1',
conversationId,
text: 'First message',
user: 'user123',
});
await saveMessage(mockReq, {
const message2 = await saveMessage(mockReq, {
messageId: 'msg2',
conversationId,
text: 'Second message',
user: 'user123',
});
await saveMessage(mockReq, {
const message3 = await saveMessage(mockReq, {
messageId: 'msg3',
conversationId,
text: 'Third message',
@@ -318,265 +314,4 @@ describe('Message Operations', () => {
expect(messages[0].text).toBe('Victim message');
});
});
describe('isTemporary message handling', () => {
beforeEach(() => {
// Reset mocks before each test
jest.clearAllMocks();
});
it('should save a message with expiredAt when isTemporary is true', async () => {
// Mock custom config with 24 hour retention
getCustomConfig.mockResolvedValue({
interface: {
temporaryChatRetention: 24,
},
});
mockReq.body = { isTemporary: true };
const beforeSave = new Date();
const result = await saveMessage(mockReq, mockMessageData);
const afterSave = new Date();
expect(result.messageId).toBe('msg123');
expect(result.expiredAt).toBeDefined();
expect(result.expiredAt).toBeInstanceOf(Date);
// Verify expiredAt is approximately 24 hours in the future
const expectedExpirationTime = new Date(beforeSave.getTime() + 24 * 60 * 60 * 1000);
const actualExpirationTime = new Date(result.expiredAt);
expect(actualExpirationTime.getTime()).toBeGreaterThanOrEqual(
expectedExpirationTime.getTime() - 1000,
);
expect(actualExpirationTime.getTime()).toBeLessThanOrEqual(
new Date(afterSave.getTime() + 24 * 60 * 60 * 1000 + 1000).getTime(),
);
});
it('should save a message without expiredAt when isTemporary is false', async () => {
mockReq.body = { isTemporary: false };
const result = await saveMessage(mockReq, mockMessageData);
expect(result.messageId).toBe('msg123');
expect(result.expiredAt).toBeNull();
});
it('should save a message without expiredAt when isTemporary is not provided', async () => {
// No isTemporary in body
mockReq.body = {};
const result = await saveMessage(mockReq, mockMessageData);
expect(result.messageId).toBe('msg123');
expect(result.expiredAt).toBeNull();
});
it('should use custom retention period from config', async () => {
// Mock custom config with 48 hour retention
getCustomConfig.mockResolvedValue({
interface: {
temporaryChatRetention: 48,
},
});
mockReq.body = { isTemporary: true };
const beforeSave = new Date();
const result = await saveMessage(mockReq, mockMessageData);
expect(result.expiredAt).toBeDefined();
// Verify expiredAt is approximately 48 hours in the future
const expectedExpirationTime = new Date(beforeSave.getTime() + 48 * 60 * 60 * 1000);
const actualExpirationTime = new Date(result.expiredAt);
expect(actualExpirationTime.getTime()).toBeGreaterThanOrEqual(
expectedExpirationTime.getTime() - 1000,
);
expect(actualExpirationTime.getTime()).toBeLessThanOrEqual(
expectedExpirationTime.getTime() + 1000,
);
});
it('should handle minimum retention period (1 hour)', async () => {
// Mock custom config with less than minimum retention
getCustomConfig.mockResolvedValue({
interface: {
temporaryChatRetention: 0.5, // Half hour - should be clamped to 1 hour
},
});
mockReq.body = { isTemporary: true };
const beforeSave = new Date();
const result = await saveMessage(mockReq, mockMessageData);
expect(result.expiredAt).toBeDefined();
// Verify expiredAt is approximately 1 hour in the future (minimum)
const expectedExpirationTime = new Date(beforeSave.getTime() + 1 * 60 * 60 * 1000);
const actualExpirationTime = new Date(result.expiredAt);
expect(actualExpirationTime.getTime()).toBeGreaterThanOrEqual(
expectedExpirationTime.getTime() - 1000,
);
expect(actualExpirationTime.getTime()).toBeLessThanOrEqual(
expectedExpirationTime.getTime() + 1000,
);
});
it('should handle maximum retention period (8760 hours)', async () => {
// Mock custom config with more than maximum retention
getCustomConfig.mockResolvedValue({
interface: {
temporaryChatRetention: 10000, // Should be clamped to 8760 hours
},
});
mockReq.body = { isTemporary: true };
const beforeSave = new Date();
const result = await saveMessage(mockReq, mockMessageData);
expect(result.expiredAt).toBeDefined();
// Verify expiredAt is approximately 8760 hours (1 year) in the future
const expectedExpirationTime = new Date(beforeSave.getTime() + 8760 * 60 * 60 * 1000);
const actualExpirationTime = new Date(result.expiredAt);
expect(actualExpirationTime.getTime()).toBeGreaterThanOrEqual(
expectedExpirationTime.getTime() - 1000,
);
expect(actualExpirationTime.getTime()).toBeLessThanOrEqual(
expectedExpirationTime.getTime() + 1000,
);
});
it('should handle getCustomConfig errors gracefully', async () => {
// Mock getCustomConfig to throw an error
getCustomConfig.mockRejectedValue(new Error('Config service unavailable'));
mockReq.body = { isTemporary: true };
const result = await saveMessage(mockReq, mockMessageData);
// Should still save the message but with expiredAt as null
expect(result.messageId).toBe('msg123');
expect(result.expiredAt).toBeNull();
});
it('should use default retention when config is not provided', async () => {
// Mock getCustomConfig to return empty config
getCustomConfig.mockResolvedValue({});
mockReq.body = { isTemporary: true };
const beforeSave = new Date();
const result = await saveMessage(mockReq, mockMessageData);
expect(result.expiredAt).toBeDefined();
// Default retention is 30 days (720 hours)
const expectedExpirationTime = new Date(beforeSave.getTime() + 30 * 24 * 60 * 60 * 1000);
const actualExpirationTime = new Date(result.expiredAt);
expect(actualExpirationTime.getTime()).toBeGreaterThanOrEqual(
expectedExpirationTime.getTime() - 1000,
);
expect(actualExpirationTime.getTime()).toBeLessThanOrEqual(
expectedExpirationTime.getTime() + 1000,
);
});
it('should not update expiredAt on message update', async () => {
// First save a temporary message
getCustomConfig.mockResolvedValue({
interface: {
temporaryChatRetention: 24,
},
});
mockReq.body = { isTemporary: true };
const savedMessage = await saveMessage(mockReq, mockMessageData);
const originalExpiredAt = savedMessage.expiredAt;
// Now update the message without isTemporary flag
mockReq.body = {};
const updatedMessage = await updateMessage(mockReq, {
messageId: 'msg123',
text: 'Updated text',
});
// expiredAt should not be in the returned updated message object
expect(updatedMessage.expiredAt).toBeUndefined();
// Verify in database that expiredAt wasn't changed
const dbMessage = await Message.findOne({ messageId: 'msg123', user: 'user123' });
expect(dbMessage.expiredAt).toEqual(originalExpiredAt);
});
it('should preserve expiredAt when saving existing temporary message', async () => {
// First save a temporary message
getCustomConfig.mockResolvedValue({
interface: {
temporaryChatRetention: 24,
},
});
mockReq.body = { isTemporary: true };
const firstSave = await saveMessage(mockReq, mockMessageData);
const originalExpiredAt = firstSave.expiredAt;
// Wait a bit to ensure time difference
await new Promise((resolve) => setTimeout(resolve, 100));
// Save again with same messageId but different text
const updatedData = { ...mockMessageData, text: 'Updated text' };
const secondSave = await saveMessage(mockReq, updatedData);
// Should update text but create new expiredAt
expect(secondSave.text).toBe('Updated text');
expect(secondSave.expiredAt).toBeDefined();
expect(new Date(secondSave.expiredAt).getTime()).toBeGreaterThan(
new Date(originalExpiredAt).getTime(),
);
});
it('should handle bulk operations with temporary messages', async () => {
// This test verifies bulkSaveMessages doesn't interfere with expiredAt
const messages = [
{
messageId: 'bulk1',
conversationId: uuidv4(),
text: 'Bulk message 1',
user: 'user123',
expiredAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
},
{
messageId: 'bulk2',
conversationId: uuidv4(),
text: 'Bulk message 2',
user: 'user123',
expiredAt: null,
},
];
await bulkSaveMessages(messages);
const savedMessages = await Message.find({
messageId: { $in: ['bulk1', 'bulk2'] },
}).lean();
expect(savedMessages).toHaveLength(2);
const bulk1 = savedMessages.find((m) => m.messageId === 'bulk1');
const bulk2 = savedMessages.find((m) => m.messageId === 'bulk2');
expect(bulk1.expiredAt).toBeDefined();
expect(bulk2.expiredAt).toBeNull();
});
});
});

View File

@@ -1,6 +1,6 @@
{
"name": "@librechat/backend",
"version": "v0.8.0-rc1",
"version": "v0.7.9",
"description": "",
"scripts": {
"start": "echo 'please run this from the root directory'",
@@ -49,10 +49,10 @@
"@langchain/google-vertexai": "^0.2.13",
"@langchain/openai": "^0.5.18",
"@langchain/textsplitters": "^0.1.0",
"@librechat/agents": "^2.4.69",
"@librechat/agents": "^2.4.68",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@modelcontextprotocol/sdk": "^1.17.1",
"@modelcontextprotocol/sdk": "^1.17.0",
"@node-saml/passport-saml": "^5.1.0",
"@waylaidwanderer/fetch-event-source": "^3.0.1",
"axios": "^1.8.2",

View File

@@ -512,39 +512,6 @@ class AgentClient extends BaseClient {
return withoutKeys;
}
/**
* Filters out image URLs from message content
* @param {BaseMessage} message - The message to filter
* @returns {BaseMessage} - A new message with image URLs removed
*/
filterImageUrls(message) {
if (!message.content || typeof message.content === 'string') {
return message;
}
if (Array.isArray(message.content)) {
const filteredContent = message.content.filter(
(part) => part.type !== ContentTypes.IMAGE_URL,
);
if (filteredContent.length === 1 && filteredContent[0].type === ContentTypes.TEXT) {
const MessageClass = message.constructor;
return new MessageClass({
content: filteredContent[0].text,
additional_kwargs: message.additional_kwargs,
});
}
const MessageClass = message.constructor;
return new MessageClass({
content: filteredContent,
additional_kwargs: message.additional_kwargs,
});
}
return message;
}
/**
* @param {BaseMessage[]} messages
* @returns {Promise<void | (TAttachment | null)[]>}
@@ -573,8 +540,7 @@ class AgentClient extends BaseClient {
}
}
const filteredMessages = messagesToProcess.map((msg) => this.filterImageUrls(msg));
const bufferString = getBufferString(filteredMessages);
const bufferString = getBufferString(messagesToProcess);
const bufferMessage = new HumanMessage(`# Current Chat:\n\n${bufferString}`);
return await this.processMemory([bufferMessage]);
} catch (error) {

View File

@@ -727,231 +727,4 @@ describe('AgentClient - titleConvo', () => {
});
});
});
describe('runMemory method', () => {
let client;
let mockReq;
let mockRes;
let mockAgent;
let mockOptions;
let mockProcessMemory;
beforeEach(() => {
jest.clearAllMocks();
mockAgent = {
id: 'agent-123',
endpoint: EModelEndpoint.openAI,
provider: EModelEndpoint.openAI,
model_parameters: {
model: 'gpt-4',
},
};
mockReq = {
app: {
locals: {
memory: {
messageWindowSize: 3,
},
},
},
user: {
id: 'user-123',
personalization: {
memories: true,
},
},
};
mockRes = {};
mockOptions = {
req: mockReq,
res: mockRes,
agent: mockAgent,
};
mockProcessMemory = jest.fn().mockResolvedValue([]);
client = new AgentClient(mockOptions);
client.processMemory = mockProcessMemory;
client.conversationId = 'convo-123';
client.responseMessageId = 'response-123';
});
it('should filter out image URLs from message content', async () => {
const { HumanMessage, AIMessage } = require('@langchain/core/messages');
const messages = [
new HumanMessage({
content: [
{
type: 'text',
text: 'What is in this image?',
},
{
type: 'image_url',
image_url: {
url: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
detail: 'auto',
},
},
],
}),
new AIMessage('I can see a small red pixel in the image.'),
new HumanMessage({
content: [
{
type: 'text',
text: 'What about this one?',
},
{
type: 'image_url',
image_url: {
url: 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/',
detail: 'high',
},
},
],
}),
];
await client.runMemory(messages);
expect(mockProcessMemory).toHaveBeenCalledTimes(1);
const processedMessage = mockProcessMemory.mock.calls[0][0][0];
// Verify the buffer message was created
expect(processedMessage.constructor.name).toBe('HumanMessage');
expect(processedMessage.content).toContain('# Current Chat:');
// Verify that image URLs are not in the buffer string
expect(processedMessage.content).not.toContain('image_url');
expect(processedMessage.content).not.toContain('data:image');
expect(processedMessage.content).not.toContain('base64');
// Verify text content is preserved
expect(processedMessage.content).toContain('What is in this image?');
expect(processedMessage.content).toContain('I can see a small red pixel in the image.');
expect(processedMessage.content).toContain('What about this one?');
});
it('should handle messages with only text content', async () => {
const { HumanMessage, AIMessage } = require('@langchain/core/messages');
const messages = [
new HumanMessage('Hello, how are you?'),
new AIMessage('I am doing well, thank you!'),
new HumanMessage('That is great to hear.'),
];
await client.runMemory(messages);
expect(mockProcessMemory).toHaveBeenCalledTimes(1);
const processedMessage = mockProcessMemory.mock.calls[0][0][0];
expect(processedMessage.content).toContain('Hello, how are you?');
expect(processedMessage.content).toContain('I am doing well, thank you!');
expect(processedMessage.content).toContain('That is great to hear.');
});
it('should handle mixed content types correctly', async () => {
const { HumanMessage } = require('@langchain/core/messages');
const { ContentTypes } = require('librechat-data-provider');
const messages = [
new HumanMessage({
content: [
{
type: 'text',
text: 'Here is some text',
},
{
type: ContentTypes.IMAGE_URL,
image_url: {
url: 'https://example.com/image.png',
},
},
{
type: 'text',
text: ' and more text',
},
],
}),
];
await client.runMemory(messages);
expect(mockProcessMemory).toHaveBeenCalledTimes(1);
const processedMessage = mockProcessMemory.mock.calls[0][0][0];
// Should contain text parts but not image URLs
expect(processedMessage.content).toContain('Here is some text');
expect(processedMessage.content).toContain('and more text');
expect(processedMessage.content).not.toContain('example.com/image.png');
expect(processedMessage.content).not.toContain('IMAGE_URL');
});
it('should preserve original messages without mutation', async () => {
const { HumanMessage } = require('@langchain/core/messages');
const originalContent = [
{
type: 'text',
text: 'Original text',
},
{
type: 'image_url',
image_url: {
url: 'data:image/png;base64,ABC123',
},
},
];
const messages = [
new HumanMessage({
content: [...originalContent],
}),
];
await client.runMemory(messages);
// Verify original message wasn't mutated
expect(messages[0].content).toHaveLength(2);
expect(messages[0].content[1].type).toBe('image_url');
expect(messages[0].content[1].image_url.url).toBe('data:image/png;base64,ABC123');
});
it('should handle message window size correctly', async () => {
const { HumanMessage, AIMessage } = require('@langchain/core/messages');
const messages = [
new HumanMessage('Message 1'),
new AIMessage('Response 1'),
new HumanMessage('Message 2'),
new AIMessage('Response 2'),
new HumanMessage('Message 3'),
new AIMessage('Response 3'),
];
// Window size is set to 3 in mockReq
await client.runMemory(messages);
expect(mockProcessMemory).toHaveBeenCalledTimes(1);
const processedMessage = mockProcessMemory.mock.calls[0][0][0];
// Should only include last 3 messages due to window size
expect(processedMessage.content).toContain('Message 3');
expect(processedMessage.content).toContain('Response 3');
expect(processedMessage.content).not.toContain('Message 1');
expect(processedMessage.content).not.toContain('Response 1');
});
it('should return early if processMemory is not set', async () => {
const { HumanMessage } = require('@langchain/core/messages');
client.processMemory = null;
const result = await client.runMemory([new HumanMessage('Test')]);
expect(result).toBeUndefined();
expect(mockProcessMemory).not.toHaveBeenCalled();
});
});
});

View File

@@ -105,6 +105,8 @@ const createErrorHandler = ({ req, res, getContext, originPath = '/assistants/ch
return res.end();
}
await cache.delete(cacheKey);
// const cancelledRun = await openai.beta.threads.runs.cancel(thread_id, run_id);
// logger.debug(`[${originPath}] Cancelled run:`, cancelledRun);
} catch (error) {
logger.error(`[${originPath}] Error cancelling run`, error);
}
@@ -113,6 +115,7 @@ const createErrorHandler = ({ req, res, getContext, originPath = '/assistants/ch
let run;
try {
// run = await openai.beta.threads.runs.retrieve(thread_id, run_id);
await recordUsage({
...run.usage,
model: run.model,
@@ -125,9 +128,18 @@ const createErrorHandler = ({ req, res, getContext, originPath = '/assistants/ch
let finalEvent;
try {
// const errorContentPart = {
// text: {
// value:
// error?.message ?? 'There was an error processing your request. Please try again later.',
// },
// type: ContentTypes.ERROR,
// };
finalEvent = {
final: true,
conversation: await getConvo(req.user.id, conversationId),
// runMessages,
};
} catch (error) {
logger.error(`[${originPath}] Error finalizing error process`, error);

View File

@@ -152,7 +152,7 @@ const chatV1 = async (req, res) => {
return res.end();
}
await cache.delete(cacheKey);
const cancelledRun = await openai.beta.threads.runs.cancel(run_id, { thread_id });
const cancelledRun = await openai.beta.threads.runs.cancel(thread_id, run_id);
logger.debug('[/assistants/chat/] Cancelled run:', cancelledRun);
} catch (error) {
logger.error('[/assistants/chat/] Error cancelling run', error);
@@ -162,7 +162,7 @@ const chatV1 = async (req, res) => {
let run;
try {
run = await openai.beta.threads.runs.retrieve(run_id, { thread_id });
run = await openai.beta.threads.runs.retrieve(thread_id, run_id);
await recordUsage({
...run.usage,
model: run.model,
@@ -623,7 +623,7 @@ const chatV1 = async (req, res) => {
if (!response.run.usage) {
await sleep(3000);
completedRun = await openai.beta.threads.runs.retrieve(response.run.id, { thread_id });
completedRun = await openai.beta.threads.runs.retrieve(thread_id, response.run.id);
if (completedRun.usage) {
await recordUsage({
...completedRun.usage,

View File

@@ -467,7 +467,7 @@ const chatV2 = async (req, res) => {
if (!response.run.usage) {
await sleep(3000);
completedRun = await openai.beta.threads.runs.retrieve(response.run.id, { thread_id });
completedRun = await openai.beta.threads.runs.retrieve(thread_id, response.run.id);
if (completedRun.usage) {
await recordUsage({
...completedRun.usage,

View File

@@ -108,7 +108,7 @@ const createErrorHandler = ({ req, res, getContext, originPath = '/assistants/ch
return res.end();
}
await cache.delete(cacheKey);
const cancelledRun = await openai.beta.threads.runs.cancel(run_id, { thread_id });
const cancelledRun = await openai.beta.threads.runs.cancel(thread_id, run_id);
logger.debug(`[${originPath}] Cancelled run:`, cancelledRun);
} catch (error) {
logger.error(`[${originPath}] Error cancelling run`, error);
@@ -118,7 +118,7 @@ const createErrorHandler = ({ req, res, getContext, originPath = '/assistants/ch
let run;
try {
run = await openai.beta.threads.runs.retrieve(run_id, { thread_id });
run = await openai.beta.threads.runs.retrieve(thread_id, run_id);
await recordUsage({
...run.usage,
model: run.model,

View File

@@ -173,16 +173,6 @@ const listAssistantsForAzure = async ({ req, res, version, azureConfig = {}, que
};
};
/**
* Initializes the OpenAI client.
* @param {object} params - The parameters object.
* @param {ServerRequest} params.req - The request object.
* @param {ServerResponse} params.res - The response object.
* @param {TEndpointOption} params.endpointOption - The endpoint options.
* @param {boolean} params.initAppClient - Whether to initialize the app client.
* @param {string} params.overrideEndpoint - The endpoint to override.
* @returns {Promise<{ openai: OpenAIClient, openAIApiKey: string; client: import('~/app/clients/OpenAIClient') }>} - The initialized OpenAI client.
*/
async function getOpenAIClient({ req, res, endpointOption, initAppClient, overrideEndpoint }) {
let endpoint = overrideEndpoint ?? req.body.endpoint ?? req.query.endpoint;
const version = await getCurrentVersion(req, endpoint);

View File

@@ -197,7 +197,7 @@ const deleteAssistant = async (req, res) => {
await validateAuthor({ req, openai });
const assistant_id = req.params.id;
const deletionStatus = await openai.beta.assistants.delete(assistant_id);
const deletionStatus = await openai.beta.assistants.del(assistant_id);
if (deletionStatus?.deleted) {
await deleteAssistantActions({ req, assistant_id });
}
@@ -365,7 +365,7 @@ const uploadAssistantAvatar = async (req, res) => {
try {
await fs.unlink(req.file.path);
logger.debug('[/:agent_id/avatar] Temp. image upload file deleted');
} catch {
} catch (error) {
logger.debug('[/:agent_id/avatar] Temp. image upload file already deleted');
}
}

View File

@@ -47,7 +47,7 @@ async function abortRun(req, res) {
try {
await cache.set(cacheKey, 'cancelled', three_minutes);
const cancelledRun = await openai.beta.threads.runs.cancel(run_id, { thread_id });
const cancelledRun = await openai.beta.threads.runs.cancel(thread_id, run_id);
logger.debug('[abortRun] Cancelled run:', cancelledRun);
} catch (error) {
logger.error('[abortRun] Error cancelling run', error);
@@ -60,7 +60,7 @@ async function abortRun(req, res) {
}
try {
const run = await openai.beta.threads.runs.retrieve(run_id, { thread_id });
const run = await openai.beta.threads.runs.retrieve(thread_id, run_id);
await recordUsage({
...run.usage,
model: run.model,

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,10 @@
const express = require('express');
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { CacheKeys, defaultSocialLogins, Constants } = require('librechat-data-provider');
const { getCustomConfig } = require('~/server/services/Config/getCustomConfig');
const { getLdapConfig } = require('~/server/services/Config/ldap');
const { getProjectByName } = require('~/models/Project');
const { getMCPManager } = require('~/config');
const { isEnabled } = require('~/server/utils');
const { getLogStores } = require('~/cache');
const router = express.Router();
@@ -103,16 +102,11 @@ router.get('/', async function (req, res) {
payload.mcpServers = {};
const config = await getCustomConfig();
if (config?.mcpServers != null) {
const mcpManager = getMCPManager();
const oauthServers = mcpManager.getOAuthServers();
for (const serverName in config.mcpServers) {
const serverConfig = config.mcpServers[serverName];
payload.mcpServers[serverName] = {
customUserVars: serverConfig?.customUserVars || {},
chatMenu: serverConfig?.chatMenu,
isOAuth: oauthServers.has(serverName),
startup: serverConfig?.startup,
};
}
}

View File

@@ -111,7 +111,7 @@ router.delete('/', async (req, res) => {
/** @type {{ openai: OpenAI }} */
const { openai } = await assistantClients[endpoint].initializeClient({ req, res });
try {
const response = await openai.beta.threads.delete(thread_id);
const response = await openai.beta.threads.del(thread_id);
logger.debug('Deleted OpenAI thread:', response);
} catch (error) {
logger.error('Error deleting OpenAI thread:', error);

View File

@@ -400,8 +400,7 @@ router.post('/', async (req, res) => {
if (
error.message?.includes('Invalid file format') ||
error.message?.includes('No OCR result') ||
error.message?.includes('exceeds token limit')
error.message?.includes('No OCR result')
) {
message = error.message;
}

View File

@@ -28,18 +28,6 @@ router.post('/', async (req, res) => {
} catch (error) {
// TODO: delete remote file if it exists
logger.error('[/files/images] Error processing file:', error);
let message = 'Error processing file';
// Handle specific error types
if (
error.message?.includes('Invalid file format') ||
error.message?.includes('No OCR result') ||
error.message?.includes('exceeds token limit')
) {
message = error.message;
}
try {
const filepath = path.join(
req.app.locals.paths.imageOutput,
@@ -50,7 +38,7 @@ router.post('/', async (req, res) => {
} catch (error) {
logger.error('[/files/images] Error deleting file:', error);
}
res.status(500).json({ message });
res.status(500).json({ message: 'Error processing file' });
} finally {
try {
await fs.unlink(req.file.path);

View File

@@ -4,7 +4,6 @@ const { MCPOAuthHandler } = require('@librechat/api');
const { CacheKeys, Constants } = require('librechat-data-provider');
const { findToken, updateToken, createToken, deleteTokens } = require('~/models');
const { setCachedTools, getCachedTools, loadCustomConfig } = require('~/server/services/Config');
const { getMCPSetupData, getServerConnectionStatus } = require('~/server/services/MCP');
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
const { getMCPManager, getFlowStateManager } = require('~/config');
const { requireJwtAuth } = require('~/server/middleware');
@@ -93,6 +92,7 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
return res.redirect('/oauth/error?error=missing_state');
}
// Extract flow ID from state
const flowId = state;
logger.debug('[MCP OAuth] Using flow ID from state', { flowId });
@@ -115,17 +115,22 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
hasCodeVerifier: !!flowState.codeVerifier,
});
// Complete the OAuth flow
logger.debug('[MCP OAuth] Completing OAuth flow');
const tokens = await MCPOAuthHandler.completeOAuthFlow(flowId, code, flowManager);
logger.info('[MCP OAuth] OAuth flow completed, tokens received in callback route');
// Try to establish the MCP connection with the new tokens
try {
const mcpManager = getMCPManager(flowState.userId);
logger.debug(`[MCP OAuth] Attempting to reconnect ${serverName} with new OAuth tokens`);
// For user-level OAuth, try to establish the connection
if (flowState.userId !== 'system') {
// We need to get the user object - in this case we'll need to reconstruct it
const user = { id: flowState.userId };
// Try to establish connection with the new tokens
const userConnection = await mcpManager.getUserConnection({
user,
serverName,
@@ -142,8 +147,10 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
`[MCP OAuth] Successfully reconnected ${serverName} for user ${flowState.userId}`,
);
// Fetch and cache tools now that we have a successful connection
const userTools = (await getCachedTools({ userId: flowState.userId })) || {};
// Remove any old tools from this server in the user's cache
const mcpDelimiter = Constants.mcp_delimiter;
for (const key of Object.keys(userTools)) {
if (key.endsWith(`${mcpDelimiter}${serverName}`)) {
@@ -151,6 +158,7 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
}
}
// Add the new tools from this server
const tools = await userConnection.fetchTools();
for (const tool of tools) {
const name = `${tool.name}${Constants.mcp_delimiter}${serverName}`;
@@ -164,6 +172,7 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
};
}
// Save the updated user tool cache
await setCachedTools(userTools, { userId: flowState.userId });
logger.debug(
@@ -173,6 +182,7 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
logger.debug(`[MCP OAuth] System-level OAuth completed for ${serverName}`);
}
} catch (error) {
// Don't fail the OAuth callback if reconnection fails - the tokens are still saved
logger.warn(
`[MCP OAuth] Failed to reconnect ${serverName} after OAuth, but tokens are saved:`,
error,
@@ -208,6 +218,7 @@ router.get('/oauth/tokens/:flowId', requireJwtAuth, async (req, res) => {
return res.status(401).json({ error: 'User not authenticated' });
}
// Allow system flows or user-owned flows
if (!flowId.startsWith(`${user.id}:`) && !flowId.startsWith('system:')) {
return res.status(403).json({ error: 'Access denied' });
}
@@ -275,7 +286,11 @@ router.post('/oauth/cancel/:serverName', requireJwtAuth, async (req, res) => {
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = getFlowStateManager(flowsCache);
// Generate the flow ID for this user/server combination
const flowId = MCPOAuthHandler.generateFlowId(user.id, serverName);
// Check if flow exists
const flowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
if (!flowState) {
@@ -286,7 +301,8 @@ router.post('/oauth/cancel/:serverName', requireJwtAuth, async (req, res) => {
});
}
await flowManager.failFlow(flowId, 'mcp_oauth', 'User cancelled OAuth flow');
// Cancel the flow by marking it as failed
await flowManager.completeFlow(flowId, 'mcp_oauth', null, 'User cancelled OAuth flow');
logger.info(`[MCP OAuth Cancel] Successfully cancelled OAuth flow for ${serverName}`);
@@ -337,7 +353,9 @@ router.post('/:serverName/reinitialize', requireJwtAuth, async (req, res) => {
for (const varName of Object.keys(serverConfig.customUserVars)) {
try {
const value = await getUserPluginAuthValue(user.id, varName, false);
customUserVars[varName] = value;
if (value) {
customUserVars[varName] = value;
}
} catch (err) {
logger.error(`[MCP Reinitialize] Error fetching ${varName} for user ${user.id}:`, err);
}
@@ -360,7 +378,8 @@ router.post('/:serverName/reinitialize', requireJwtAuth, async (req, res) => {
createToken,
deleteTokens,
},
returnOnOAuth: true,
returnOnOAuth: true, // Return immediately when OAuth is initiated
// Add OAuth handlers to capture the OAuth URL when needed
oauthStart: async (authURL) => {
logger.info(`[MCP Reinitialize] OAuth URL received: ${authURL}`);
oauthUrl = authURL;
@@ -375,6 +394,7 @@ router.post('/:serverName/reinitialize', requireJwtAuth, async (req, res) => {
`[MCP Reinitialize] OAuth state - oauthRequired: ${oauthRequired}, oauthUrl: ${oauthUrl ? 'present' : 'null'}`,
);
// Check if this is an OAuth error - if so, the flow state should be set up now
const isOAuthError =
err.message?.includes('OAuth') ||
err.message?.includes('authentication') ||
@@ -387,6 +407,7 @@ router.post('/:serverName/reinitialize', requireJwtAuth, async (req, res) => {
`[MCP Reinitialize] OAuth required for ${serverName} (isOAuthError: ${isOAuthError}, oauthRequired: ${oauthRequired}, isOAuthFlowInitiated: ${isOAuthFlowInitiated})`,
);
oauthRequired = true;
// Don't return error - continue so frontend can handle OAuth
} else {
logger.error(
`[MCP Reinitialize] Error initializing MCP server ${serverName} for user:`,
@@ -396,9 +417,11 @@ router.post('/:serverName/reinitialize', requireJwtAuth, async (req, res) => {
}
}
// Only fetch and cache tools if we successfully connected (no OAuth required)
if (userConnection && !oauthRequired) {
const userTools = (await getCachedTools({ userId: user.id })) || {};
// Remove any old tools from this server in the user's cache
const mcpDelimiter = Constants.mcp_delimiter;
for (const key of Object.keys(userTools)) {
if (key.endsWith(`${mcpDelimiter}${serverName}`)) {
@@ -406,6 +429,7 @@ router.post('/:serverName/reinitialize', requireJwtAuth, async (req, res) => {
}
}
// Add the new tools from this server
const tools = await userConnection.fetchTools();
for (const tool of tools) {
const name = `${tool.name}${Constants.mcp_delimiter}${serverName}`;
@@ -419,6 +443,7 @@ router.post('/:serverName/reinitialize', requireJwtAuth, async (req, res) => {
};
}
// Save the updated user tool cache
await setCachedTools(userTools, { userId: user.id });
}
@@ -426,19 +451,11 @@ router.post('/:serverName/reinitialize', requireJwtAuth, async (req, res) => {
`[MCP Reinitialize] Sending response for ${serverName} - oauthRequired: ${oauthRequired}, oauthUrl: ${oauthUrl ? 'present' : 'null'}`,
);
const getResponseMessage = () => {
if (oauthRequired) {
return `MCP server '${serverName}' ready for OAuth authentication`;
}
if (userConnection) {
return `MCP server '${serverName}' reinitialized successfully`;
}
return `Failed to reinitialize MCP server '${serverName}'`;
};
res.json({
success: (userConnection && !oauthRequired) || (oauthRequired && oauthUrl),
message: getResponseMessage(),
success: true,
message: oauthRequired
? `MCP server '${serverName}' ready for OAuth authentication`
: `MCP server '${serverName}' reinitialized successfully`,
serverName,
oauthRequired,
oauthUrl,
@@ -451,7 +468,7 @@ router.post('/:serverName/reinitialize', requireJwtAuth, async (req, res) => {
/**
* Get connection status for all MCP servers
* This endpoint returns all app level and user-scoped connection statuses from MCPManager without disconnecting idle connections
* This endpoint returns the actual connection status from MCPManager without disconnecting idle connections
*/
router.get('/connection/status', requireJwtAuth, async (req, res) => {
try {
@@ -461,19 +478,84 @@ router.get('/connection/status', requireJwtAuth, async (req, res) => {
return res.status(401).json({ error: 'User not authenticated' });
}
const { mcpConfig, appConnections, userConnections, oauthServers } = await getMCPSetupData(
user.id,
);
const mcpManager = getMCPManager(user.id);
const connectionStatus = {};
const printConfig = false;
const config = await loadCustomConfig(printConfig);
const mcpConfig = config?.mcpServers;
const appConnections = mcpManager.getAllConnections() || new Map();
const userConnections = mcpManager.getUserConnections(user.id) || new Map();
const oauthServers = mcpManager.getOAuthServers() || new Set();
if (!mcpConfig) {
return res.status(404).json({ error: 'MCP config not found' });
}
// Get flow manager to check for active/timed-out OAuth flows
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = getFlowStateManager(flowsCache);
for (const [serverName] of Object.entries(mcpConfig)) {
connectionStatus[serverName] = await getServerConnectionStatus(
user.id,
serverName,
appConnections,
userConnections,
oauthServers,
);
const getConnectionState = (serverName) =>
appConnections.get(serverName)?.connectionState ??
userConnections.get(serverName)?.connectionState ??
'disconnected';
const baseConnectionState = getConnectionState(serverName);
let hasActiveOAuthFlow = false;
let hasFailedOAuthFlow = false;
if (baseConnectionState === 'disconnected' && oauthServers.has(serverName)) {
try {
// Check for user-specific OAuth flows
const flowId = MCPOAuthHandler.generateFlowId(user.id, serverName);
const flowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
if (flowState) {
// Check if flow failed or timed out
const flowAge = Date.now() - flowState.createdAt;
const flowTTL = flowState.ttl || 180000; // Default 3 minutes
if (flowState.status === 'FAILED' || flowAge > flowTTL) {
hasFailedOAuthFlow = true;
logger.debug(`[MCP Connection Status] Found failed OAuth flow for ${serverName}`, {
flowId,
status: flowState.status,
flowAge,
flowTTL,
timedOut: flowAge > flowTTL,
});
} else if (flowState.status === 'PENDING') {
hasActiveOAuthFlow = true;
logger.debug(`[MCP Connection Status] Found active OAuth flow for ${serverName}`, {
flowId,
flowAge,
flowTTL,
});
}
}
} catch (error) {
logger.error(
`[MCP Connection Status] Error checking OAuth flows for ${serverName}:`,
error,
);
}
}
// Determine the final connection state
let finalConnectionState = baseConnectionState;
if (hasFailedOAuthFlow) {
finalConnectionState = 'error'; // Report as error if OAuth failed
} else if (hasActiveOAuthFlow && baseConnectionState === 'disconnected') {
finalConnectionState = 'connecting'; // Still waiting for OAuth
}
connectionStatus[serverName] = {
requiresOAuth: oauthServers.has(serverName),
connectionState: finalConnectionState,
};
}
res.json({
@@ -481,63 +563,11 @@ router.get('/connection/status', requireJwtAuth, async (req, res) => {
connectionStatus,
});
} catch (error) {
if (error.message === 'MCP config not found') {
return res.status(404).json({ error: error.message });
}
logger.error('[MCP Connection Status] Failed to get connection status', error);
res.status(500).json({ error: 'Failed to get connection status' });
}
});
/**
* Get connection status for a single MCP server
* This endpoint returns the connection status for a specific server for a given user
*/
router.get('/connection/status/:serverName', requireJwtAuth, async (req, res) => {
try {
const user = req.user;
const { serverName } = req.params;
if (!user?.id) {
return res.status(401).json({ error: 'User not authenticated' });
}
const { mcpConfig, appConnections, userConnections, oauthServers } = await getMCPSetupData(
user.id,
);
if (!mcpConfig[serverName]) {
return res
.status(404)
.json({ error: `MCP server '${serverName}' not found in configuration` });
}
const serverStatus = await getServerConnectionStatus(
user.id,
serverName,
appConnections,
userConnections,
oauthServers,
);
res.json({
success: true,
serverName,
connectionStatus: serverStatus.connectionState,
requiresOAuth: serverStatus.requiresOAuth,
});
} catch (error) {
if (error.message === 'MCP config not found') {
return res.status(404).json({ error: error.message });
}
logger.error(
`[MCP Per-Server Status] Failed to get connection status for ${req.params.serverName}`,
error,
);
res.status(500).json({ error: 'Failed to get connection status' });
}
});
/**
* Check which authentication values exist for a specific MCP server
* This endpoint returns only boolean flags indicating if values are set, not the actual values
@@ -563,16 +593,19 @@ router.get('/:serverName/auth-values', requireJwtAuth, async (req, res) => {
const pluginKey = `${Constants.mcp_prefix}${serverName}`;
const authValueFlags = {};
// Check existence of saved values for each custom user variable (don't fetch actual values)
if (serverConfig.customUserVars && typeof serverConfig.customUserVars === 'object') {
for (const varName of Object.keys(serverConfig.customUserVars)) {
try {
const value = await getUserPluginAuthValue(user.id, varName, false, pluginKey);
// Only store boolean flag indicating if value exists
authValueFlags[varName] = !!(value && value.length > 0);
} catch (err) {
logger.error(
`[MCP Auth Value Flags] Error checking ${varName} for user ${user.id}:`,
err,
);
// Default to false if we can't check
authValueFlags[varName] = false;
}
}

View File

@@ -281,7 +281,7 @@ function createInProgressHandler(openai, thread_id, messages) {
openai.seenCompletedMessages.add(message_id);
const message = await openai.beta.threads.messages.retrieve(message_id, { thread_id });
const message = await openai.beta.threads.messages.retrieve(thread_id, message_id);
if (!message?.content?.length) {
return;
}
@@ -435,11 +435,9 @@ async function runAssistant({
};
});
const tool_outputs = await processRequiredActions(openai, actions);
const toolRun = await openai.beta.threads.runs.submitToolOutputs(run.id, {
thread_id: run.thread_id,
tool_outputs,
});
const outputs = await processRequiredActions(openai, actions);
const toolRun = await openai.beta.threads.runs.submitToolOutputs(run.thread_id, run.id, outputs);
// Recursive call with accumulated steps and messages
return await runAssistant({

View File

@@ -6,7 +6,7 @@ const {
getUserKeyExpiry,
checkUserKeyExpiry,
} = require('~/server/services/UserService');
const OAIClient = require('~/app/clients/OpenAIClient');
const OpenAIClient = require('~/app/clients/OpenAIClient');
const { isUserProvided } = require('~/server/utils');
const initializeClient = async ({ req, res, endpointOption, version, initAppClient = false }) => {
@@ -79,7 +79,7 @@ const initializeClient = async ({ req, res, endpointOption, version, initAppClie
openai.res = res;
if (endpointOption && initAppClient) {
const client = new OAIClient(apiKey, clientOptions);
const client = new OpenAIClient(apiKey, clientOptions);
return {
client,
openai,

View File

@@ -3,11 +3,11 @@ const { ProxyAgent } = require('undici');
const { constructAzureURL, isUserProvided, resolveHeaders } = require('@librechat/api');
const { ErrorTypes, EModelEndpoint, mapModelToAzureConfig } = require('librechat-data-provider');
const {
checkUserKeyExpiry,
getUserKeyValues,
getUserKeyExpiry,
checkUserKeyExpiry,
} = require('~/server/services/UserService');
const OAIClient = require('~/app/clients/OpenAIClient');
const OpenAIClient = require('~/app/clients/OpenAIClient');
class Files {
constructor(client) {
@@ -184,7 +184,7 @@ const initializeClient = async ({ req, res, version, endpointOption, initAppClie
}
if (endpointOption && initAppClient) {
const client = new OAIClient(apiKey, clientOptions);
const client = new OpenAIClient(apiKey, clientOptions);
return {
client,
openai,

View File

@@ -325,4 +325,4 @@ async function speechToText(req, res) {
await sttService.processTextToSpeech(req, res);
}
module.exports = { speechToText, STTService };
module.exports = { speechToText };

View File

@@ -28,112 +28,12 @@ const { addResourceFileId, deleteResourceFileId } = require('~/server/controller
const { addAgentResourceFile, removeAgentResourceFiles } = require('~/models/Agent');
const { getOpenAIClient } = require('~/server/controllers/assistants/helpers');
const { createFile, updateFileUsage, deleteFiles } = require('~/models/File');
const { generateShortLivedToken } = require('~/server/services/AuthService');
const { loadAuthValues } = require('~/server/services/Tools/credentials');
const { checkCapability } = require('~/server/services/Config');
const { LB_QueueAsyncCall } = require('~/server/utils/queue');
const { getStrategyFunctions } = require('./strategies');
const { determineFileType } = require('~/server/utils');
const { STTService } = require('./Audio/STTService');
const { logger } = require('~/config');
const FormData = require('form-data');
const axios = require('axios');
/**
* Attempts to parse text using RAG API, falls back to native text parsing
* @param {Object} params - The parameters object
* @param {Express.Request} params.req - The Express request object
* @param {Express.Multer.File} params.file - The uploaded file
* @param {string} params.file_id - The file ID
* @returns {Promise<{text: string, bytes: number, source: string}>}
*/
async function parseText({ req, file, file_id }) {
if (!process.env.RAG_API_URL) {
logger.debug('[parseText] RAG_API_URL not defined, falling back to native text parsing');
return parseTextNative(file);
}
try {
const healthResponse = await axios.get(`${process.env.RAG_API_URL}/health`, {
timeout: 5000,
});
if (healthResponse?.statusText !== 'OK' && healthResponse?.status !== 200) {
logger.debug('[parseText] RAG API health check failed, falling back to native parsing');
return parseTextNative(file);
}
} catch (healthError) {
logger.debug(
`[parseText] RAG API health check failed: ${healthError.message}, falling back to native parsing`,
);
return parseTextNative(file);
}
try {
const jwtToken = generateShortLivedToken(req.user.id);
const formData = new FormData();
formData.append('file_id', file_id);
formData.append('file', fs.createReadStream(file.path));
const formHeaders = formData.getHeaders();
// TODO: Actually implement referenced RAG API endpoint /parse-text
const response = await axios.post(`${process.env.RAG_API_URL}/parse-text`, formData, {
headers: {
Authorization: `Bearer ${jwtToken}`,
accept: 'application/json',
...formHeaders,
},
timeout: 30000,
});
const responseData = response.data;
logger.debug('[parseText] Response from RAG API', responseData);
if (!responseData.text) {
throw new Error('RAG API did not return parsed text');
}
return {
text: responseData.text,
bytes: Buffer.byteLength(responseData.text, 'utf8'),
source: 'rag_api',
};
} catch (error) {
logger.warn(
`[parseText] RAG API text parsing failed: ${error.message}, falling back to native parsing`,
);
return parseTextNative(file);
}
}
/**
* Native JavaScript text parsing fallback
* Simple text file reading - complex formats handled by RAG API
* @param {Express.Multer.File} file - The uploaded file
* @returns {{text: string, bytes: number, source: string}}
*/
function parseTextNative(file) {
try {
let text = '';
try {
text = fs.readFileSync(file.path, 'utf8');
} catch (readError) {
throw new Error(`Cannot read file as text: ${readError.message}`);
}
const bytes = Buffer.byteLength(text, 'utf8');
return {
text,
bytes,
source: 'native_js',
};
} catch (error) {
logger.error(`[parseTextNative] Error parsing file: ${error.message}`);
throw new Error(`Failed to parse file: ${error.message}`);
}
}
/**
*
@@ -502,35 +402,6 @@ const processFileUpload = async ({ req, res, metadata }) => {
}
const { file } = req;
const fileConfig = mergeFileConfig(req.app.locals.fileConfig);
const shouldUseSTT = fileConfig.checkType(
file.mimetype,
fileConfig.stt?.supportedMimeTypes || [],
);
if (shouldUseSTT) {
const { text, bytes } = await processAudioFile({ file });
const result = await createFile(
{
user: req.user.id,
file_id,
temp_file_id,
bytes,
filepath: file.path,
filename: file.originalname,
context: FileContext.message_attachment,
type: 'text/plain',
source: FileSources.text,
text,
},
true,
);
return res
.status(200)
.json({ message: 'Audio file processed and converted to text successfully', ...result });
}
const {
id,
bytes,
@@ -625,21 +496,6 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
throw new Error('No tool resource provided for non-image agent file upload');
}
const fileConfig = mergeFileConfig(req.app.locals.fileConfig);
const shouldUseTextParsing = fileConfig.checkType(
file.mimetype,
fileConfig.textParsing?.supportedMimeTypes || [],
);
const shouldUseOCR = fileConfig.checkType(
file.mimetype,
fileConfig.ocr?.supportedMimeTypes || [],
);
const shouldUseSTT = fileConfig.checkType(
file.mimetype,
fileConfig.stt?.supportedMimeTypes || [],
);
let fileInfoMetadata;
const entity_id = messageAttachment === true ? undefined : agent_id;
const basePath = mime.getType(file.originalname)?.startsWith('image') ? 'images' : 'uploads';
@@ -670,107 +526,46 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
throw new Error('OCR capability is not enabled for Agents');
}
const { handleFileUpload: uploadOCR } = getStrategyFunctions(
req.app.locals?.ocr?.strategy ?? FileSources.mistral_ocr,
);
const { file_id, temp_file_id } = metadata;
if (shouldUseOCR) {
const { handleFileUpload: uploadOCR } = getStrategyFunctions(
req.app.locals?.ocr?.strategy ?? FileSources.mistral_ocr,
);
const {
text,
bytes,
// TODO: OCR images support?
images,
filename,
filepath: ocrFileURL,
} = await uploadOCR({ req, file, loadAuthValues });
const {
text,
bytes,
filename,
filepath: ocrFileURL,
} = await uploadOCR({ req, file, loadAuthValues });
const fileInfo = removeNullishValues({
text,
bytes,
file_id,
temp_file_id,
user: req.user.id,
type: 'text/plain',
filepath: ocrFileURL,
source: FileSources.text,
filename: filename ?? file.originalname,
model: messageAttachment ? undefined : req.body.model,
context: messageAttachment ? FileContext.message_attachment : FileContext.agents,
});
const fileInfo = removeNullishValues({
text,
bytes,
if (!messageAttachment && tool_resource) {
await addAgentResourceFile({
req,
file_id,
temp_file_id,
user: req.user.id,
type: 'text/plain',
filepath: ocrFileURL,
source: FileSources.text,
filename: filename ?? file.originalname,
model: messageAttachment ? undefined : req.body.model,
context: messageAttachment ? FileContext.message_attachment : FileContext.agents,
agent_id,
tool_resource,
});
if (!messageAttachment && tool_resource) {
await addAgentResourceFile({
req,
file_id,
agent_id,
tool_resource,
});
}
const result = await createFile(fileInfo, true);
return res
.status(200)
.json({ message: 'Agent file uploaded and processed successfully', ...result });
} else if (shouldUseSTT) {
const { text, bytes } = await processAudioFile({ file });
const fileInfo = removeNullishValues({
text,
bytes,
file_id,
temp_file_id,
user: req.user.id,
type: 'text/plain',
filepath: file.path,
source: FileSources.text,
filename: file.originalname,
model: messageAttachment ? undefined : req.body.model,
context: messageAttachment ? FileContext.message_attachment : FileContext.agents,
});
if (!messageAttachment && tool_resource) {
await addAgentResourceFile({
req,
file_id,
agent_id,
tool_resource,
});
}
const result = await createFile(fileInfo, true);
return res
.status(200)
.json({ message: 'Agent file uploaded and processed successfully', ...result });
} else if (shouldUseTextParsing) {
const { text, bytes } = await parseText({ req, file, file_id });
const fileInfo = removeNullishValues({
text,
bytes,
file_id,
temp_file_id,
user: req.user.id,
type: file.mimetype.startsWith('audio/') ? 'text/plain' : file.mimetype,
filepath: file.path,
source: FileSources.text,
filename: file.originalname,
model: messageAttachment ? undefined : req.body.model,
context: messageAttachment ? FileContext.message_attachment : FileContext.agents,
});
if (!messageAttachment && tool_resource) {
await addAgentResourceFile({
req,
file_id,
agent_id,
tool_resource,
});
}
const result = await createFile(fileInfo, true);
return res
.status(200)
.json({ message: 'Agent file uploaded and processed successfully', ...result });
} else {
throw new Error(`File type ${file.mimetype} is not supported for OCR, STT, or text parsing`);
}
const result = await createFile(fileInfo, true);
return res
.status(200)
.json({ message: 'Agent file uploaded and processed successfully', ...result });
}
const source =
@@ -1159,35 +954,6 @@ function filterFile({ req, image, isAvatar }) {
}
}
/**
* Processes audio files using Speech-to-Text (STT) service.
* @param {Object} params - The parameters object.
* @param {Object} params.file - The audio file object.
* @returns {Promise<Object>} A promise that resolves to an object containing text and bytes.
*/
async function processAudioFile({ file }) {
try {
const sttService = await STTService.getInstance();
const audioBuffer = await fs.promises.readFile(file.path);
const audioFile = {
originalname: file.originalname,
mimetype: file.mimetype,
size: file.size,
};
const [provider, sttSchema] = await sttService.getProviderSchema();
const text = await sttService.sttRequest(provider, sttSchema, { audioBuffer, audioFile });
return {
text,
bytes: Buffer.byteLength(text, 'utf8'),
};
} catch (error) {
logger.error('Error processing audio file with STT:', error);
throw new Error(`Failed to process audio file: ${error.message}`);
}
}
module.exports = {
filterFile,
processFiles,
@@ -1199,5 +965,4 @@ module.exports = {
processDeleteRequest,
processAgentFileUpload,
retrieveAndProcessFile,
processAudioFile,
};

View File

@@ -12,7 +12,7 @@ const {
} = require('@librechat/api');
const { findToken, createToken, updateToken } = require('~/models');
const { getMCPManager, getFlowStateManager } = require('~/config');
const { getCachedTools, loadCustomConfig } = require('./Config');
const { getCachedTools } = require('./Config');
const { getLogStores } = require('~/cache');
/**
@@ -239,135 +239,6 @@ async function createMCPTool({ req, res, toolKey, provider: _provider }) {
return toolInstance;
}
/**
* Get MCP setup data including config, connections, and OAuth servers
* @param {string} userId - The user ID
* @returns {Object} Object containing mcpConfig, appConnections, userConnections, and oauthServers
*/
async function getMCPSetupData(userId) {
const printConfig = false;
const config = await loadCustomConfig(printConfig);
const mcpConfig = config?.mcpServers;
if (!mcpConfig) {
throw new Error('MCP config not found');
}
const mcpManager = getMCPManager(userId);
const appConnections = mcpManager.getAllConnections() || new Map();
const userConnections = mcpManager.getUserConnections(userId) || new Map();
const oauthServers = mcpManager.getOAuthServers() || new Set();
return {
mcpConfig,
appConnections,
userConnections,
oauthServers,
};
}
/**
* Check OAuth flow status for a user and server
* @param {string} userId - The user ID
* @param {string} serverName - The server name
* @returns {Object} Object containing hasActiveFlow and hasFailedFlow flags
*/
async function checkOAuthFlowStatus(userId, serverName) {
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = getFlowStateManager(flowsCache);
const flowId = MCPOAuthHandler.generateFlowId(userId, serverName);
try {
const flowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
if (!flowState) {
return { hasActiveFlow: false, hasFailedFlow: false };
}
const flowAge = Date.now() - flowState.createdAt;
const flowTTL = flowState.ttl || 180000; // Default 3 minutes
if (flowState.status === 'FAILED' || flowAge > flowTTL) {
const wasCancelled = flowState.error && flowState.error.includes('cancelled');
if (wasCancelled) {
logger.debug(`[MCP Connection Status] Found cancelled OAuth flow for ${serverName}`, {
flowId,
status: flowState.status,
error: flowState.error,
});
return { hasActiveFlow: false, hasFailedFlow: false };
} else {
logger.debug(`[MCP Connection Status] Found failed OAuth flow for ${serverName}`, {
flowId,
status: flowState.status,
flowAge,
flowTTL,
timedOut: flowAge > flowTTL,
error: flowState.error,
});
return { hasActiveFlow: false, hasFailedFlow: true };
}
}
if (flowState.status === 'PENDING') {
logger.debug(`[MCP Connection Status] Found active OAuth flow for ${serverName}`, {
flowId,
flowAge,
flowTTL,
});
return { hasActiveFlow: true, hasFailedFlow: false };
}
return { hasActiveFlow: false, hasFailedFlow: false };
} catch (error) {
logger.error(`[MCP Connection Status] Error checking OAuth flows for ${serverName}:`, error);
return { hasActiveFlow: false, hasFailedFlow: false };
}
}
/**
* Get connection status for a specific MCP server
* @param {string} userId - The user ID
* @param {string} serverName - The server name
* @param {Map} appConnections - App-level connections
* @param {Map} userConnections - User-level connections
* @param {Set} oauthServers - Set of OAuth servers
* @returns {Object} Object containing requiresOAuth and connectionState
*/
async function getServerConnectionStatus(
userId,
serverName,
appConnections,
userConnections,
oauthServers,
) {
const getConnectionState = () =>
appConnections.get(serverName)?.connectionState ??
userConnections.get(serverName)?.connectionState ??
'disconnected';
const baseConnectionState = getConnectionState();
let finalConnectionState = baseConnectionState;
if (baseConnectionState === 'disconnected' && oauthServers.has(serverName)) {
const { hasActiveFlow, hasFailedFlow } = await checkOAuthFlowStatus(userId, serverName);
if (hasFailedFlow) {
finalConnectionState = 'error';
} else if (hasActiveFlow) {
finalConnectionState = 'connecting';
}
}
return {
requiresOAuth: oauthServers.has(serverName),
connectionState: finalConnectionState,
};
}
module.exports = {
createMCPTool,
getMCPSetupData,
checkOAuthFlowStatus,
getServerConnectionStatus,
};

View File

@@ -1,510 +0,0 @@
const { logger } = require('@librechat/data-schemas');
const { MCPOAuthHandler } = require('@librechat/api');
const { CacheKeys } = require('librechat-data-provider');
const { getMCPSetupData, checkOAuthFlowStatus, getServerConnectionStatus } = require('./MCP');
// Mock all dependencies
jest.mock('@librechat/data-schemas', () => ({
logger: {
debug: jest.fn(),
error: jest.fn(),
},
}));
jest.mock('@librechat/api', () => ({
MCPOAuthHandler: {
generateFlowId: jest.fn(),
},
}));
jest.mock('librechat-data-provider', () => ({
CacheKeys: {
FLOWS: 'flows',
},
}));
jest.mock('./Config', () => ({
loadCustomConfig: jest.fn(),
}));
jest.mock('~/config', () => ({
getMCPManager: jest.fn(),
getFlowStateManager: jest.fn(),
}));
jest.mock('~/cache', () => ({
getLogStores: jest.fn(),
}));
jest.mock('~/models', () => ({
findToken: jest.fn(),
createToken: jest.fn(),
updateToken: jest.fn(),
}));
describe('tests for the new helper functions used by the MCP connection status endpoints', () => {
let mockLoadCustomConfig;
let mockGetMCPManager;
let mockGetFlowStateManager;
let mockGetLogStores;
beforeEach(() => {
jest.clearAllMocks();
mockLoadCustomConfig = require('./Config').loadCustomConfig;
mockGetMCPManager = require('~/config').getMCPManager;
mockGetFlowStateManager = require('~/config').getFlowStateManager;
mockGetLogStores = require('~/cache').getLogStores;
});
describe('getMCPSetupData', () => {
const mockUserId = 'user-123';
const mockConfig = {
mcpServers: {
server1: { type: 'stdio' },
server2: { type: 'http' },
},
};
beforeEach(() => {
mockGetMCPManager.mockReturnValue({
getAllConnections: jest.fn(() => new Map()),
getUserConnections: jest.fn(() => new Map()),
getOAuthServers: jest.fn(() => new Set()),
});
});
it('should successfully return MCP setup data', async () => {
mockLoadCustomConfig.mockResolvedValue(mockConfig);
const mockAppConnections = new Map([['server1', { status: 'connected' }]]);
const mockUserConnections = new Map([['server2', { status: 'disconnected' }]]);
const mockOAuthServers = new Set(['server2']);
const mockMCPManager = {
getAllConnections: jest.fn(() => mockAppConnections),
getUserConnections: jest.fn(() => mockUserConnections),
getOAuthServers: jest.fn(() => mockOAuthServers),
};
mockGetMCPManager.mockReturnValue(mockMCPManager);
const result = await getMCPSetupData(mockUserId);
expect(mockLoadCustomConfig).toHaveBeenCalledWith(false);
expect(mockGetMCPManager).toHaveBeenCalledWith(mockUserId);
expect(mockMCPManager.getAllConnections).toHaveBeenCalled();
expect(mockMCPManager.getUserConnections).toHaveBeenCalledWith(mockUserId);
expect(mockMCPManager.getOAuthServers).toHaveBeenCalled();
expect(result).toEqual({
mcpConfig: mockConfig.mcpServers,
appConnections: mockAppConnections,
userConnections: mockUserConnections,
oauthServers: mockOAuthServers,
});
});
it('should throw error when MCP config not found', async () => {
mockLoadCustomConfig.mockResolvedValue({});
await expect(getMCPSetupData(mockUserId)).rejects.toThrow('MCP config not found');
});
it('should handle null values from MCP manager gracefully', async () => {
mockLoadCustomConfig.mockResolvedValue(mockConfig);
const mockMCPManager = {
getAllConnections: jest.fn(() => null),
getUserConnections: jest.fn(() => null),
getOAuthServers: jest.fn(() => null),
};
mockGetMCPManager.mockReturnValue(mockMCPManager);
const result = await getMCPSetupData(mockUserId);
expect(result).toEqual({
mcpConfig: mockConfig.mcpServers,
appConnections: new Map(),
userConnections: new Map(),
oauthServers: new Set(),
});
});
});
describe('checkOAuthFlowStatus', () => {
const mockUserId = 'user-123';
const mockServerName = 'test-server';
const mockFlowId = 'flow-123';
beforeEach(() => {
const mockFlowsCache = {};
const mockFlowManager = {
getFlowState: jest.fn(),
};
mockGetLogStores.mockReturnValue(mockFlowsCache);
mockGetFlowStateManager.mockReturnValue(mockFlowManager);
MCPOAuthHandler.generateFlowId.mockReturnValue(mockFlowId);
});
it('should return false flags when no flow state exists', async () => {
const mockFlowManager = { getFlowState: jest.fn(() => null) };
mockGetFlowStateManager.mockReturnValue(mockFlowManager);
const result = await checkOAuthFlowStatus(mockUserId, mockServerName);
expect(mockGetLogStores).toHaveBeenCalledWith(CacheKeys.FLOWS);
expect(MCPOAuthHandler.generateFlowId).toHaveBeenCalledWith(mockUserId, mockServerName);
expect(mockFlowManager.getFlowState).toHaveBeenCalledWith(mockFlowId, 'mcp_oauth');
expect(result).toEqual({ hasActiveFlow: false, hasFailedFlow: false });
});
it('should detect failed flow when status is FAILED', async () => {
const mockFlowState = {
status: 'FAILED',
createdAt: Date.now() - 60000, // 1 minute ago
ttl: 180000,
};
const mockFlowManager = { getFlowState: jest.fn(() => mockFlowState) };
mockGetFlowStateManager.mockReturnValue(mockFlowManager);
const result = await checkOAuthFlowStatus(mockUserId, mockServerName);
expect(result).toEqual({ hasActiveFlow: false, hasFailedFlow: true });
expect(logger.debug).toHaveBeenCalledWith(
expect.stringContaining('Found failed OAuth flow'),
expect.objectContaining({
flowId: mockFlowId,
status: 'FAILED',
}),
);
});
it('should detect failed flow when flow has timed out', async () => {
const mockFlowState = {
status: 'PENDING',
createdAt: Date.now() - 200000, // 200 seconds ago (> 180s TTL)
ttl: 180000,
};
const mockFlowManager = { getFlowState: jest.fn(() => mockFlowState) };
mockGetFlowStateManager.mockReturnValue(mockFlowManager);
const result = await checkOAuthFlowStatus(mockUserId, mockServerName);
expect(result).toEqual({ hasActiveFlow: false, hasFailedFlow: true });
expect(logger.debug).toHaveBeenCalledWith(
expect.stringContaining('Found failed OAuth flow'),
expect.objectContaining({
timedOut: true,
}),
);
});
it('should detect failed flow when TTL not specified and flow exceeds default TTL', async () => {
const mockFlowState = {
status: 'PENDING',
createdAt: Date.now() - 200000, // 200 seconds ago (> 180s default TTL)
// ttl not specified, should use 180000 default
};
const mockFlowManager = { getFlowState: jest.fn(() => mockFlowState) };
mockGetFlowStateManager.mockReturnValue(mockFlowManager);
const result = await checkOAuthFlowStatus(mockUserId, mockServerName);
expect(result).toEqual({ hasActiveFlow: false, hasFailedFlow: true });
});
it('should detect active flow when status is PENDING and within TTL', async () => {
const mockFlowState = {
status: 'PENDING',
createdAt: Date.now() - 60000, // 1 minute ago (< 180s TTL)
ttl: 180000,
};
const mockFlowManager = { getFlowState: jest.fn(() => mockFlowState) };
mockGetFlowStateManager.mockReturnValue(mockFlowManager);
const result = await checkOAuthFlowStatus(mockUserId, mockServerName);
expect(result).toEqual({ hasActiveFlow: true, hasFailedFlow: false });
expect(logger.debug).toHaveBeenCalledWith(
expect.stringContaining('Found active OAuth flow'),
expect.objectContaining({
flowId: mockFlowId,
}),
);
});
it('should return false flags for other statuses', async () => {
const mockFlowState = {
status: 'COMPLETED',
createdAt: Date.now() - 60000,
ttl: 180000,
};
const mockFlowManager = { getFlowState: jest.fn(() => mockFlowState) };
mockGetFlowStateManager.mockReturnValue(mockFlowManager);
const result = await checkOAuthFlowStatus(mockUserId, mockServerName);
expect(result).toEqual({ hasActiveFlow: false, hasFailedFlow: false });
});
it('should handle errors gracefully', async () => {
const mockError = new Error('Flow state error');
const mockFlowManager = {
getFlowState: jest.fn(() => {
throw mockError;
}),
};
mockGetFlowStateManager.mockReturnValue(mockFlowManager);
const result = await checkOAuthFlowStatus(mockUserId, mockServerName);
expect(result).toEqual({ hasActiveFlow: false, hasFailedFlow: false });
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('Error checking OAuth flows'),
mockError,
);
});
});
describe('getServerConnectionStatus', () => {
const mockUserId = 'user-123';
const mockServerName = 'test-server';
it('should return app connection state when available', async () => {
const appConnections = new Map([[mockServerName, { connectionState: 'connected' }]]);
const userConnections = new Map();
const oauthServers = new Set();
const result = await getServerConnectionStatus(
mockUserId,
mockServerName,
appConnections,
userConnections,
oauthServers,
);
expect(result).toEqual({
requiresOAuth: false,
connectionState: 'connected',
});
});
it('should fallback to user connection state when app connection not available', async () => {
const appConnections = new Map();
const userConnections = new Map([[mockServerName, { connectionState: 'connecting' }]]);
const oauthServers = new Set();
const result = await getServerConnectionStatus(
mockUserId,
mockServerName,
appConnections,
userConnections,
oauthServers,
);
expect(result).toEqual({
requiresOAuth: false,
connectionState: 'connecting',
});
});
it('should default to disconnected when no connections exist', async () => {
const appConnections = new Map();
const userConnections = new Map();
const oauthServers = new Set();
const result = await getServerConnectionStatus(
mockUserId,
mockServerName,
appConnections,
userConnections,
oauthServers,
);
expect(result).toEqual({
requiresOAuth: false,
connectionState: 'disconnected',
});
});
it('should prioritize app connection over user connection', async () => {
const appConnections = new Map([[mockServerName, { connectionState: 'connected' }]]);
const userConnections = new Map([[mockServerName, { connectionState: 'disconnected' }]]);
const oauthServers = new Set();
const result = await getServerConnectionStatus(
mockUserId,
mockServerName,
appConnections,
userConnections,
oauthServers,
);
expect(result).toEqual({
requiresOAuth: false,
connectionState: 'connected',
});
});
it('should indicate OAuth requirement when server is in OAuth servers set', async () => {
const appConnections = new Map();
const userConnections = new Map();
const oauthServers = new Set([mockServerName]);
const result = await getServerConnectionStatus(
mockUserId,
mockServerName,
appConnections,
userConnections,
oauthServers,
);
expect(result.requiresOAuth).toBe(true);
});
it('should handle OAuth flow status when disconnected and requires OAuth with failed flow', async () => {
const appConnections = new Map();
const userConnections = new Map();
const oauthServers = new Set([mockServerName]);
// Mock flow state to return failed flow
const mockFlowManager = {
getFlowState: jest.fn(() => ({
status: 'FAILED',
createdAt: Date.now() - 60000,
ttl: 180000,
})),
};
mockGetFlowStateManager.mockReturnValue(mockFlowManager);
mockGetLogStores.mockReturnValue({});
MCPOAuthHandler.generateFlowId.mockReturnValue('test-flow-id');
const result = await getServerConnectionStatus(
mockUserId,
mockServerName,
appConnections,
userConnections,
oauthServers,
);
expect(result).toEqual({
requiresOAuth: true,
connectionState: 'error',
});
});
it('should handle OAuth flow status when disconnected and requires OAuth with active flow', async () => {
const appConnections = new Map();
const userConnections = new Map();
const oauthServers = new Set([mockServerName]);
// Mock flow state to return active flow
const mockFlowManager = {
getFlowState: jest.fn(() => ({
status: 'PENDING',
createdAt: Date.now() - 60000, // 1 minute ago
ttl: 180000, // 3 minutes TTL
})),
};
mockGetFlowStateManager.mockReturnValue(mockFlowManager);
mockGetLogStores.mockReturnValue({});
MCPOAuthHandler.generateFlowId.mockReturnValue('test-flow-id');
const result = await getServerConnectionStatus(
mockUserId,
mockServerName,
appConnections,
userConnections,
oauthServers,
);
expect(result).toEqual({
requiresOAuth: true,
connectionState: 'connecting',
});
});
it('should handle OAuth flow status when disconnected and requires OAuth with no flow', async () => {
const appConnections = new Map();
const userConnections = new Map();
const oauthServers = new Set([mockServerName]);
// Mock flow state to return no flow
const mockFlowManager = {
getFlowState: jest.fn(() => null),
};
mockGetFlowStateManager.mockReturnValue(mockFlowManager);
mockGetLogStores.mockReturnValue({});
MCPOAuthHandler.generateFlowId.mockReturnValue('test-flow-id');
const result = await getServerConnectionStatus(
mockUserId,
mockServerName,
appConnections,
userConnections,
oauthServers,
);
expect(result).toEqual({
requiresOAuth: true,
connectionState: 'disconnected',
});
});
it('should not check OAuth flow status when server is connected', async () => {
const mockFlowManager = {
getFlowState: jest.fn(),
};
mockGetFlowStateManager.mockReturnValue(mockFlowManager);
mockGetLogStores.mockReturnValue({});
const appConnections = new Map([[mockServerName, { connectionState: 'connected' }]]);
const userConnections = new Map();
const oauthServers = new Set([mockServerName]);
const result = await getServerConnectionStatus(
mockUserId,
mockServerName,
appConnections,
userConnections,
oauthServers,
);
expect(result).toEqual({
requiresOAuth: true,
connectionState: 'connected',
});
// Should not call flow manager since server is connected
expect(mockFlowManager.getFlowState).not.toHaveBeenCalled();
});
it('should not check OAuth flow status when server does not require OAuth', async () => {
const mockFlowManager = {
getFlowState: jest.fn(),
};
mockGetFlowStateManager.mockReturnValue(mockFlowManager);
mockGetLogStores.mockReturnValue({});
const appConnections = new Map();
const userConnections = new Map();
const oauthServers = new Set(); // Server not in OAuth servers
const result = await getServerConnectionStatus(
mockUserId,
mockServerName,
appConnections,
userConnections,
oauthServers,
);
expect(result).toEqual({
requiresOAuth: false,
connectionState: 'disconnected',
});
// Should not call flow manager since server doesn't require OAuth
expect(mockFlowManager.getFlowState).not.toHaveBeenCalled();
});
});
});

View File

@@ -91,10 +91,11 @@ class RunManager {
* @param {boolean} [params.final] - The end of the run polling loop, due to `requires_action`, `cancelling`, `cancelled`, `failed`, `completed`, or `expired` statuses.
*/
async fetchRunSteps({ openai, thread_id, run_id, runStatus, final = false }) {
// const { data: steps, first_id, last_id, has_more } = await openai.beta.threads.runs.steps.list(run_id, { thread_id });
// const { data: steps, first_id, last_id, has_more } = await openai.beta.threads.runs.steps.list(thread_id, run_id);
const { data: _steps } = await openai.beta.threads.runs.steps.list(
thread_id,
run_id,
{ thread_id },
{},
{
timeout: 3000,
maxRetries: 5,

View File

@@ -573,9 +573,9 @@ class StreamRunManager {
let toolRun;
try {
toolRun = this.openai.beta.threads.runs.submitToolOutputsStream(
run.thread_id,
run.id,
{
thread_id: run.thread_id,
tool_outputs,
stream: true,
},

View File

@@ -179,7 +179,7 @@ async function waitForRun({
* @return {Promise<RunStep[]>} A promise that resolves to an array of RunStep objects.
*/
async function _retrieveRunSteps({ openai, thread_id, run_id }) {
const runSteps = await openai.beta.threads.runs.steps.list(run_id, { thread_id });
const runSteps = await openai.beta.threads.runs.steps.list(thread_id, run_id);
return runSteps;
}

View File

@@ -192,8 +192,7 @@ async function addThreadMetadata({ openai, thread_id, messageId, messages }) {
const promises = [];
for (const message of messages) {
promises.push(
openai.beta.threads.messages.update(message.id, {
thread_id,
openai.beta.threads.messages.update(thread_id, message.id, {
metadata: {
messageId,
},
@@ -264,8 +263,7 @@ async function syncMessages({
}
modifyPromises.push(
openai.beta.threads.messages.update(apiMessage.id, {
thread_id,
openai.beta.threads.messages.update(thread_id, apiMessage.id, {
metadata: {
messageId: dbMessage.messageId,
},
@@ -415,7 +413,7 @@ async function checkMessageGaps({
}) {
const promises = [];
promises.push(openai.beta.threads.messages.list(thread_id, defaultOrderQuery));
promises.push(openai.beta.threads.runs.steps.list(run_id, { thread_id }));
promises.push(openai.beta.threads.runs.steps.list(thread_id, run_id));
/** @type {[{ data: ThreadMessage[] }, { data: RunStep[] }]} */
const [response, stepsResponse] = await Promise.all(promises);

View File

@@ -104,14 +104,6 @@ class CustomOpenIDStrategy extends OpenIDStrategy {
if (options?.state && !params.has('state')) {
params.set('state', options.state);
}
if (process.env.OPENID_AUDIENCE) {
params.set('audience', process.env.OPENID_AUDIENCE);
logger.debug(
`[openidStrategy] Adding audience to authorization request: ${process.env.OPENID_AUDIENCE}`,
);
}
return params;
}
}
@@ -361,7 +353,7 @@ async function setupOpenId() {
username = userinfo[process.env.OPENID_USERNAME_CLAIM];
} else {
username = convertToUsername(
userinfo.preferred_username || userinfo.username || userinfo.email,
userinfo.username || userinfo.given_name || userinfo.email,
);
}

View File

@@ -52,7 +52,9 @@ jest.mock('openid-client', () => {
}),
fetchUserInfo: jest.fn().mockImplementation((config, accessToken, sub) => {
// Only return additional properties, but don't override any claims
return Promise.resolve({});
return Promise.resolve({
preferred_username: 'preferred_username',
});
}),
customFetch: Symbol('customFetch'),
};
@@ -102,7 +104,6 @@ describe('setupOpenId', () => {
given_name: 'First',
family_name: 'Last',
name: 'My Full',
preferred_username: 'testusername',
username: 'flast',
picture: 'https://example.com/avatar.png',
}),
@@ -155,20 +156,20 @@ describe('setupOpenId', () => {
verifyCallback = require('openid-client/passport').__getVerifyCallback();
});
it('should create a new user with correct username when preferred_username claim exists', async () => {
// Arrange our userinfo already has preferred_username 'testusername'
it('should create a new user with correct username when username claim exists', async () => {
// Arrange our userinfo already has username 'flast'
const userinfo = tokenset.claims();
// Act
const { user } = await validate(tokenset);
// Assert
expect(user.username).toBe(userinfo.preferred_username);
expect(user.username).toBe(userinfo.username);
expect(createUser).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'openid',
openidId: userinfo.sub,
username: userinfo.preferred_username,
username: userinfo.username,
email: userinfo.email,
name: `${userinfo.given_name} ${userinfo.family_name}`,
}),
@@ -178,12 +179,12 @@ describe('setupOpenId', () => {
);
});
it('should use username as username when preferred_username claim is missing', async () => {
// Arrange remove preferred_username from userinfo
it('should use given_name as username when username claim is missing', async () => {
// Arrange remove username from userinfo
const userinfo = { ...tokenset.claims() };
delete userinfo.preferred_username;
// Expect the username to be the "username"
const expectUsername = userinfo.username;
delete userinfo.username;
// Expect the username to be the given name (unchanged case)
const expectUsername = userinfo.given_name;
// Act
const { user } = await validate({ ...tokenset, claims: () => userinfo });
@@ -198,11 +199,11 @@ describe('setupOpenId', () => {
);
});
it('should use email as username when username and preferred_username are missing', async () => {
// Arrange remove username and preferred_username
it('should use email as username when username and given_name are missing', async () => {
// Arrange remove username and given_name
const userinfo = { ...tokenset.claims() };
delete userinfo.username;
delete userinfo.preferred_username;
delete userinfo.given_name;
const expectUsername = userinfo.email;
// Act
@@ -288,7 +289,7 @@ describe('setupOpenId', () => {
expect.objectContaining({
provider: 'openid',
openidId: userinfo.sub,
username: userinfo.preferred_username,
username: userinfo.username,
name: `${userinfo.given_name} ${userinfo.family_name}`,
}),
);

View File

@@ -196,7 +196,6 @@ const amazonModels = {
'amazon.nova-micro-v1:0': 127000, // -1000 from max,
'amazon.nova-lite-v1:0': 295000, // -5000 from max,
'amazon.nova-pro-v1:0': 295000, // -5000 from max,
'amazon.nova-premier-v1:0': 995000, // -5000 from max,
};
const bedrockModels = {

View File

@@ -1,6 +1,6 @@
{
"name": "@librechat/frontend",
"version": "v0.8.0-rc1",
"version": "v0.7.9",
"description": "",
"type": "module",
"scripts": {
@@ -57,8 +57,8 @@
"@react-spring/web": "^9.7.5",
"@tanstack/react-query": "^4.28.0",
"@tanstack/react-table": "^8.11.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"class-variance-authority": "^0.6.0",
"clsx": "^1.2.1",
"copy-to-clipboard": "^3.3.3",
"cross-env": "^7.0.3",
"date-fns": "^3.3.1",
@@ -76,7 +76,7 @@
"librechat-data-provider": "*",
"lodash": "^4.17.21",
"lucide-react": "^0.394.0",
"match-sorter": "^8.1.0",
"match-sorter": "^6.3.4",
"micromark-extension-llm-math": "^3.1.0",
"qrcode.react": "^4.2.0",
"rc-input-number": "^7.4.2",

View File

@@ -7,7 +7,6 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { Toast, ThemeProvider, ToastProvider } from '@librechat/client';
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query';
import { ScreenshotProvider, useApiErrorBoundary } from './hooks';
import { getThemeFromEnv } from './utils/getThemeFromEnv';
import { LiveAnnouncer } from '~/a11y';
import { router } from './routes';
@@ -24,23 +23,11 @@ const App = () => {
}),
});
// Load theme from environment variables if available
const envTheme = getThemeFromEnv();
return (
<QueryClientProvider client={queryClient}>
<RecoilRoot>
<LiveAnnouncer>
<ThemeProvider
// Only pass initialTheme and themeRGB if environment theme exists
// This allows localStorage values to persist when no env theme is set
{...(envTheme && { initialTheme: 'system', themeRGB: envTheme })}
>
{/* The ThemeProvider will automatically:
1. Apply dark/light mode classes
2. Apply custom theme colors if envTheme is provided
3. Otherwise use stored theme preferences from localStorage
4. Fall back to default theme colors if nothing is stored */}
<ThemeProvider>
<RadixToast.Provider>
<ToastProvider>
<DndProvider backend={HTML5Backend}>

View File

@@ -15,142 +15,133 @@ interface ArtifactsSubMenuProps {
handleCustomToggle: () => void;
}
const ArtifactsSubMenu = React.forwardRef<HTMLDivElement, ArtifactsSubMenuProps>(
(
{
isArtifactsPinned,
setIsArtifactsPinned,
artifactsMode,
handleArtifactsToggle,
handleShadcnToggle,
handleCustomToggle,
...props
},
ref,
) => {
const localize = useLocalize();
const ArtifactsSubMenu = ({
isArtifactsPinned,
setIsArtifactsPinned,
artifactsMode,
handleArtifactsToggle,
handleShadcnToggle,
handleCustomToggle,
...props
}: ArtifactsSubMenuProps) => {
const localize = useLocalize();
const menuStore = Ariakit.useMenuStore({
focusLoop: true,
showTimeout: 100,
placement: 'right',
});
const menuStore = Ariakit.useMenuStore({
focusLoop: true,
showTimeout: 100,
placement: 'right',
});
const isEnabled = artifactsMode !== '' && artifactsMode !== undefined;
const isShadcnEnabled = artifactsMode === ArtifactModes.SHADCNUI;
const isCustomEnabled = artifactsMode === ArtifactModes.CUSTOM;
const isEnabled = artifactsMode !== '' && artifactsMode !== undefined;
const isShadcnEnabled = artifactsMode === ArtifactModes.SHADCNUI;
const isCustomEnabled = artifactsMode === ArtifactModes.CUSTOM;
return (
<div ref={ref}>
<Ariakit.MenuProvider store={menuStore}>
<Ariakit.MenuItem
{...props}
hideOnClick={false}
render={
<Ariakit.MenuButton
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
handleArtifactsToggle();
}}
onMouseEnter={() => {
if (isEnabled) {
menuStore.show();
}
}}
className="flex w-full cursor-pointer items-center justify-between rounded-lg p-2 hover:bg-surface-hover"
/>
}
>
<div className="flex items-center gap-2">
<WandSparkles className="icon-md" />
<span>{localize('com_ui_artifacts')}</span>
{isEnabled && <ChevronRight className="ml-auto h-3 w-3" />}
return (
<Ariakit.MenuProvider store={menuStore}>
<Ariakit.MenuItem
{...props}
hideOnClick={false}
render={
<Ariakit.MenuButton
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
handleArtifactsToggle();
}}
onMouseEnter={() => {
if (isEnabled) {
menuStore.show();
}
}}
className="flex w-full cursor-pointer items-center justify-between rounded-lg p-2 hover:bg-surface-hover"
/>
}
>
<div className="flex items-center gap-2">
<WandSparkles className="icon-md" />
<span>{localize('com_ui_artifacts')}</span>
{isEnabled && <ChevronRight className="ml-auto h-3 w-3" />}
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsArtifactsPinned(!isArtifactsPinned);
}}
className={cn(
'rounded p-1 transition-all duration-200',
'hover:bg-surface-tertiary hover:shadow-sm',
!isArtifactsPinned && 'text-text-secondary hover:text-text-primary',
)}
aria-label={isArtifactsPinned ? 'Unpin' : 'Pin'}
>
<div className="h-4 w-4">
<PinIcon unpin={isArtifactsPinned} />
</div>
</button>
</Ariakit.MenuItem>
{isEnabled && (
<Ariakit.Menu
portal={true}
unmountOnHide={true}
className={cn(
'animate-popover-left z-50 ml-3 flex min-w-[250px] flex-col rounded-xl',
'border border-border-light bg-surface-secondary px-1.5 py-1 shadow-lg',
)}
>
<div className="px-2 py-1.5">
<div className="mb-2 text-xs font-medium text-text-secondary">
{localize('com_ui_artifacts_options')}
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsArtifactsPinned(!isArtifactsPinned);
{/* Include shadcn/ui Option */}
<Ariakit.MenuItem
hideOnClick={false}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleShadcnToggle();
}}
disabled={isCustomEnabled}
className={cn(
'mb-1 flex items-center justify-between rounded-lg px-2 py-2',
'cursor-pointer text-text-primary outline-none transition-colors',
'hover:bg-black/[0.075] dark:hover:bg-white/10',
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
isCustomEnabled && 'cursor-not-allowed opacity-50',
)}
>
<div className="flex items-center gap-2">
<Ariakit.MenuItemCheck checked={isShadcnEnabled} />
<span className="text-sm">{localize('com_ui_include_shadcnui' as any)}</span>
</div>
</Ariakit.MenuItem>
{/* Custom Prompt Mode Option */}
<Ariakit.MenuItem
hideOnClick={false}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleCustomToggle();
}}
className={cn(
'rounded p-1 transition-all duration-200',
'hover:bg-surface-tertiary hover:shadow-sm',
!isArtifactsPinned && 'text-text-secondary hover:text-text-primary',
)}
aria-label={isArtifactsPinned ? 'Unpin' : 'Pin'}
>
<div className="h-4 w-4">
<PinIcon unpin={isArtifactsPinned} />
</div>
</button>
</Ariakit.MenuItem>
{isEnabled && (
<Ariakit.Menu
portal={true}
unmountOnHide={true}
className={cn(
'animate-popover-left z-50 ml-3 flex min-w-[250px] flex-col rounded-xl',
'border border-border-light bg-surface-secondary px-1.5 py-1 shadow-lg',
'flex items-center justify-between rounded-lg px-2 py-2',
'cursor-pointer text-text-primary outline-none transition-colors',
'hover:bg-black/[0.075] dark:hover:bg-white/10',
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
)}
>
<div className="px-2 py-1.5">
<div className="mb-2 text-xs font-medium text-text-secondary">
{localize('com_ui_artifacts_options')}
</div>
{/* Include shadcn/ui Option */}
<Ariakit.MenuItem
hideOnClick={false}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleShadcnToggle();
}}
disabled={isCustomEnabled}
className={cn(
'mb-1 flex items-center justify-between rounded-lg px-2 py-2',
'cursor-pointer text-text-primary outline-none transition-colors',
'hover:bg-black/[0.075] dark:hover:bg-white/10',
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
isCustomEnabled && 'cursor-not-allowed opacity-50',
)}
>
<div className="flex items-center gap-2">
<Ariakit.MenuItemCheck checked={isShadcnEnabled} />
<span className="text-sm">{localize('com_ui_include_shadcnui' as any)}</span>
</div>
</Ariakit.MenuItem>
{/* Custom Prompt Mode Option */}
<Ariakit.MenuItem
hideOnClick={false}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleCustomToggle();
}}
className={cn(
'flex items-center justify-between rounded-lg px-2 py-2',
'cursor-pointer text-text-primary outline-none transition-colors',
'hover:bg-black/[0.075] dark:hover:bg-white/10',
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
)}
>
<div className="flex items-center gap-2">
<Ariakit.MenuItemCheck checked={isCustomEnabled} />
<span className="text-sm">{localize('com_ui_custom_prompt_mode' as any)}</span>
</div>
</Ariakit.MenuItem>
<div className="flex items-center gap-2">
<Ariakit.MenuItemCheck checked={isCustomEnabled} />
<span className="text-sm">{localize('com_ui_custom_prompt_mode' as any)}</span>
</div>
</Ariakit.Menu>
)}
</Ariakit.MenuProvider>
</div>
);
},
);
ArtifactsSubMenu.displayName = 'ArtifactsSubMenu';
</Ariakit.MenuItem>
</div>
</Ariakit.Menu>
)}
</Ariakit.MenuProvider>
);
};
export default React.memo(ArtifactsSubMenu);

View File

@@ -1,8 +1,8 @@
import React, { memo, useCallback } from 'react';
import { MultiSelect, MCPIcon } from '@librechat/client';
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
import MCPServerStatusIcon from '~/components/ui/MCP/MCPServerStatusIcon';
import { useMCPServerManager } from '~/hooks/MCP/useMCPServerManager';
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
import MCPConfigDialog from '~/components/ui/MCP/MCPConfigDialog';
function MCPSelect() {
const {
@@ -13,7 +13,6 @@ function MCPSelect() {
batchToggleServers,
getServerStatusIconProps,
getConfigDialogProps,
isInitializing,
localize,
} = useMCPServerManager();
@@ -33,20 +32,14 @@ function MCPSelect() {
const renderItemContent = useCallback(
(serverName: string, defaultContent: React.ReactNode) => {
const statusIconProps = getServerStatusIconProps(serverName);
const isServerInitializing = isInitializing(serverName);
/**
Common wrapper for the main content (check mark + text).
Ensures Check & Text are adjacent and the group takes available space.
*/
// Common wrapper for the main content (check mark + text)
// Ensures Check & Text are adjacent and the group takes available space.
const mainContentWrapper = (
<button
type="button"
className={`flex flex-grow items-center rounded bg-transparent p-0 text-left transition-colors focus:outline-none ${
isServerInitializing ? 'opacity-50' : ''
}`}
className="flex flex-grow items-center rounded bg-transparent p-0 text-left transition-colors focus:outline-none"
tabIndex={0}
disabled={isServerInitializing}
>
{defaultContent}
</button>
@@ -65,13 +58,15 @@ function MCPSelect() {
return mainContentWrapper;
},
[getServerStatusIconProps, isInitializing],
[getServerStatusIconProps],
);
// Don't render if no servers are selected and not pinned
if ((!mcpValues || mcpValues.length === 0) && !isPinned) {
return null;
}
// Don't render if no MCP servers are configured
if (!configuredServers || configuredServers.length === 0) {
return null;
}
@@ -84,6 +79,7 @@ function MCPSelect() {
items={configuredServers}
selectedValues={mcpValues ?? []}
setSelectedValues={batchToggleServers}
defaultSelectedValues={mcpValues ?? []}
renderSelectedValues={renderSelectedValues}
renderItemContent={renderItemContent}
placeholder={placeholderText}

View File

@@ -2,129 +2,124 @@ import React from 'react';
import * as Ariakit from '@ariakit/react';
import { ChevronRight } from 'lucide-react';
import { PinIcon, MCPIcon } from '@librechat/client';
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
import MCPServerStatusIcon from '~/components/ui/MCP/MCPServerStatusIcon';
import { useMCPServerManager } from '~/hooks/MCP/useMCPServerManager';
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
import MCPConfigDialog from '~/components/ui/MCP/MCPConfigDialog';
import { cn } from '~/utils';
interface MCPSubMenuProps {
placeholder?: string;
}
const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
({ placeholder, ...props }, ref) => {
const {
configuredServers,
mcpValues,
isPinned,
setIsPinned,
placeholderText,
toggleServerSelection,
getServerStatusIconProps,
getConfigDialogProps,
isInitializing,
} = useMCPServerManager();
const MCPSubMenu = ({ placeholder, ...props }: MCPSubMenuProps) => {
const {
configuredServers,
mcpValues,
isPinned,
setIsPinned,
placeholderText,
toggleServerSelection,
getServerStatusIconProps,
getConfigDialogProps,
} = useMCPServerManager();
const menuStore = Ariakit.useMenuStore({
focusLoop: true,
showTimeout: 100,
placement: 'right',
});
const menuStore = Ariakit.useMenuStore({
focusLoop: true,
showTimeout: 100,
placement: 'right',
});
// Don't render if no MCP servers are configured
if (!configuredServers || configuredServers.length === 0) {
return null;
}
// Don't render if no MCP servers are configured
if (!configuredServers || configuredServers.length === 0) {
return null;
}
const configDialogProps = getConfigDialogProps();
const configDialogProps = getConfigDialogProps();
return (
<div ref={ref}>
<Ariakit.MenuProvider store={menuStore}>
<Ariakit.MenuItem
{...props}
render={
<Ariakit.MenuButton
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
menuStore.toggle();
}}
className="flex w-full cursor-pointer items-center justify-between rounded-lg p-2 hover:bg-surface-hover"
/>
}
>
<div className="flex items-center gap-2">
<MCPIcon className="icon-md" />
<span>{placeholder || placeholderText}</span>
<ChevronRight className="ml-auto h-3 w-3" />
</div>
<button
type="button"
onClick={(e) => {
return (
<>
<Ariakit.MenuProvider store={menuStore}>
<Ariakit.MenuItem
{...props}
render={
<Ariakit.MenuButton
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
setIsPinned(!isPinned);
menuStore.toggle();
}}
className={cn(
'rounded p-1 transition-all duration-200',
'hover:bg-surface-tertiary hover:shadow-sm',
!isPinned && 'text-text-secondary hover:text-text-primary',
)}
aria-label={isPinned ? 'Unpin' : 'Pin'}
>
<div className="h-4 w-4">
<PinIcon unpin={isPinned} />
</div>
</button>
</Ariakit.MenuItem>
<Ariakit.Menu
portal={true}
unmountOnHide={true}
className="flex w-full cursor-pointer items-center justify-between rounded-lg p-2 hover:bg-surface-hover"
/>
}
>
<div className="flex items-center gap-2">
<MCPIcon className="icon-md" />
<span>{placeholder || placeholderText}</span>
<ChevronRight className="ml-auto h-3 w-3" />
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsPinned(!isPinned);
}}
className={cn(
'animate-popover-left z-50 ml-3 flex min-w-[200px] flex-col rounded-xl',
'border border-border-light bg-surface-secondary p-1 shadow-lg',
'rounded p-1 transition-all duration-200',
'hover:bg-surface-tertiary hover:shadow-sm',
!isPinned && 'text-text-secondary hover:text-text-primary',
)}
aria-label={isPinned ? 'Unpin' : 'Pin'}
>
{configuredServers.map((serverName) => {
const statusIconProps = getServerStatusIconProps(serverName);
const isSelected = mcpValues?.includes(serverName) ?? false;
const isServerInitializing = isInitializing(serverName);
<div className="h-4 w-4">
<PinIcon unpin={isPinned} />
</div>
</button>
</Ariakit.MenuItem>
<Ariakit.Menu
portal={true}
unmountOnHide={true}
className={cn(
'animate-popover-left z-50 ml-3 flex min-w-[200px] flex-col rounded-xl',
'border border-border-light bg-surface-secondary p-1 shadow-lg',
)}
>
{configuredServers.map((serverName) => {
const statusIconProps = getServerStatusIconProps(serverName);
const isSelected = mcpValues?.includes(serverName) ?? false;
const statusIcon = statusIconProps && <MCPServerStatusIcon {...statusIconProps} />;
const statusIcon = statusIconProps && <MCPServerStatusIcon {...statusIconProps} />;
return (
<Ariakit.MenuItem
key={serverName}
onClick={(event) => {
event.preventDefault();
toggleServerSelection(serverName);
}}
disabled={isServerInitializing}
className={cn(
'flex items-center gap-2 rounded-lg px-2 py-1.5 text-text-primary hover:cursor-pointer',
'scroll-m-1 outline-none transition-colors',
'hover:bg-black/[0.075] dark:hover:bg-white/10',
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
'w-full min-w-0 justify-between text-sm',
isServerInitializing &&
'opacity-50 hover:bg-transparent dark:hover:bg-transparent',
)}
return (
<Ariakit.MenuItem
key={serverName}
onClick={(event) => {
event.preventDefault();
toggleServerSelection(serverName);
}}
className={cn(
'flex items-center gap-2 rounded-lg px-2 py-1.5 text-text-primary hover:cursor-pointer',
'scroll-m-1 outline-none transition-colors',
'hover:bg-black/[0.075] dark:hover:bg-white/10',
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
'w-full min-w-0 justify-between text-sm',
)}
>
<button
type="button"
className="flex flex-grow items-center gap-2 rounded bg-transparent p-0 text-left transition-colors focus:outline-none"
tabIndex={0}
>
<div className="flex flex-grow items-center gap-2">
<Ariakit.MenuItemCheck checked={isSelected} />
<span>{serverName}</span>
</div>
{statusIcon && <div className="ml-2 flex items-center">{statusIcon}</div>}
</Ariakit.MenuItem>
);
})}
</Ariakit.Menu>
</Ariakit.MenuProvider>
{configDialogProps && <MCPConfigDialog {...configDialogProps} />}
</div>
);
},
);
MCPSubMenu.displayName = 'MCPSubMenu';
<Ariakit.MenuItemCheck checked={isSelected} />
<span>{serverName}</span>
</button>
{statusIcon && <div className="ml-2 flex items-center">{statusIcon}</div>}
</Ariakit.MenuItem>
);
})}
</Ariakit.Menu>
</Ariakit.MenuProvider>
{configDialogProps && <MCPConfigDialog {...configDialogProps} />}
</>
);
};
export default React.memo(MCPSubMenu);

View File

@@ -1,99 +0,0 @@
import React from 'react';
import { RefreshCw } from 'lucide-react';
import { Button, Spinner } from '@librechat/client';
import { useMCPServerManager } from '~/hooks/MCP/useMCPServerManager';
import { useLocalize } from '~/hooks';
interface ServerInitializationSectionProps {
sidePanel?: boolean;
serverName: string;
requiresOAuth: boolean;
hasCustomUserVars?: boolean;
}
export default function ServerInitializationSection({
sidePanel = false,
serverName,
requiresOAuth,
hasCustomUserVars = false,
}: ServerInitializationSectionProps) {
const localize = useLocalize();
const {
initializeServer,
connectionStatus,
cancelOAuthFlow,
isInitializing,
isCancellable,
getOAuthUrl,
} = useMCPServerManager();
const serverStatus = connectionStatus[serverName];
const isConnected = serverStatus?.connectionState === 'connected';
const canCancel = isCancellable(serverName);
const isServerInitializing = isInitializing(serverName);
const serverOAuthUrl = getOAuthUrl(serverName);
const shouldShowReinit = isConnected && (requiresOAuth || hasCustomUserVars);
const shouldShowInit = !isConnected && !serverOAuthUrl;
if (!shouldShowReinit && !shouldShowInit && !serverOAuthUrl) {
return null;
}
if (serverOAuthUrl) {
return (
<>
<div className="flex items-center gap-2">
<Button
onClick={() => cancelOAuthFlow(serverName)}
disabled={!canCancel}
variant="outline"
title={!canCancel ? 'disabled' : undefined}
>
{localize('com_ui_cancel')}
</Button>
<Button
variant="submit"
onClick={() => window.open(serverOAuthUrl, '_blank', 'noopener,noreferrer')}
className="flex-1"
>
{localize('com_ui_continue_oauth')}
</Button>
</div>
</>
);
}
// Unified button rendering
const isReinit = shouldShowReinit;
const outerClass = isReinit ? 'flex justify-start' : 'flex justify-end';
const buttonVariant = isReinit ? undefined : 'default';
const buttonText = isServerInitializing
? localize('com_ui_loading')
: isReinit
? localize('com_ui_reinitialize')
: requiresOAuth
? localize('com_ui_authenticate')
: localize('com_ui_mcp_initialize');
const icon = isServerInitializing ? (
<Spinner className="h-4 w-4" />
) : (
<RefreshCw className="h-4 w-4" />
);
return (
<div className={outerClass}>
<Button
variant={buttonVariant}
onClick={() => initializeServer(serverName, false)}
disabled={isServerInitializing}
size={sidePanel ? 'sm' : 'default'}
className="w-full"
>
{icon}
{buttonText}
</Button>
</div>
);
}

View File

@@ -72,7 +72,7 @@ export default function ExportModal({
const { exportConversation } = useExportConversation({
conversation,
filename: filenamify(filename),
filename,
type,
includeOptions,
exportBranches,
@@ -95,7 +95,7 @@ export default function ExportModal({
<Input
id="filename"
value={filename}
onChange={(e) => setFileName(e.target.value || '')}
onChange={(e) => setFileName(filenamify(e.target.value || ''))}
placeholder={localize('com_nav_export_filename_placeholder')}
/>
</div>

View File

@@ -105,8 +105,6 @@ export const LangSelector = ({
{ value: 'nl-NL', label: localize('com_nav_lang_dutch') },
{ value: 'id-ID', label: localize('com_nav_lang_indonesia') },
{ value: 'fi-FI', label: localize('com_nav_lang_finnish') },
{ value: 'bo', label: localize('com_nav_lang_tibetan') },
{ value: 'uk-UA', label: localize('com_nav_lang_ukrainian') },
];
return (

View File

@@ -1,10 +1,8 @@
import React, { useCallback } from 'react';
import { Trash2 } from 'lucide-react';
import { useDeletePrompt } from '~/data-provider';
import { Button, OGDialog, OGDialogTrigger, Label, OGDialogTemplate } from '@librechat/client';
import { useLocalize } from '~/hooks';
const DeleteConfirmDialog = ({
const DeleteVersion = ({
name,
disabled,
selectHandler,
@@ -60,42 +58,4 @@ const DeleteConfirmDialog = ({
);
};
interface DeletePromptProps {
promptId?: string;
groupId: string;
promptName: string;
disabled: boolean;
}
const DeletePrompt = React.memo(
({ promptId, groupId, promptName, disabled }: DeletePromptProps) => {
const deletePromptMutation = useDeletePrompt();
const handleDelete = useCallback(() => {
if (!promptId) {
console.warn('No prompt ID provided for deletion');
return;
}
deletePromptMutation.mutate({
_id: promptId,
groupId,
});
}, [promptId, groupId, deletePromptMutation]);
if (!promptId) {
return null;
}
return (
<DeleteConfirmDialog
name={promptName}
disabled={disabled || !promptId}
selectHandler={handleDelete}
/>
);
},
);
DeletePrompt.displayName = 'DeletePrompt';
export default DeletePrompt;
export default DeleteVersion;

View File

@@ -1,13 +1,10 @@
import React, { useMemo, useState } from 'react';
import * as Ariakit from '@ariakit/react';
import React, { useMemo } from 'react';
import { Dropdown } from '@librechat/client';
import { useTranslation } from 'react-i18next';
import { DropdownPopup } from '@librechat/client';
import { LocalStorageKeys } from 'librechat-data-provider';
import { useFormContext, Controller } from 'react-hook-form';
import type { MenuItemProps } from '@librechat/client';
import { LocalStorageKeys } from 'librechat-data-provider';
import type { ReactNode } from 'react';
import { useCategories } from '~/hooks';
import { cn } from '~/utils';
interface CategorySelectorProps {
currentCategory?: string;
@@ -23,11 +20,10 @@ const CategorySelector: React.FC<CategorySelectorProps> = ({
const { t } = useTranslation();
const formContext = useFormContext();
const { categories, emptyCategory } = useCategories();
const [isOpen, setIsOpen] = useState(false);
const control = formContext?.control;
const watch = formContext?.watch;
const setValue = formContext?.setValue;
const control = formContext.control;
const watch = formContext.watch;
const setValue = formContext.setValue;
const watchedCategory = watch ? watch('category') : currentCategory;
@@ -50,71 +46,53 @@ const CategorySelector: React.FC<CategorySelectorProps> = ({
return categoryOption;
}, [categoryOption, t]);
const menuItems: MenuItemProps[] = useMemo(() => {
if (!categories) return [];
return categories.map((category) => ({
id: category.value,
label: category.label,
icon: 'icon' in category ? category.icon : undefined,
onClick: () => {
const value = category.value || '';
if (formContext && setValue) {
setValue('category', value, { shouldDirty: false });
}
localStorage.setItem(LocalStorageKeys.LAST_PROMPT_CATEGORY, value);
onValueChange?.(value);
setIsOpen(false);
},
}));
}, [categories, formContext, setValue, onValueChange]);
const trigger = (
<Ariakit.MenuButton
className={cn(
'focus:ring-offset-ring-offset relative inline-flex items-center justify-between rounded-xl border border-input bg-background px-3 py-2 text-sm text-text-primary transition-all duration-200 ease-in-out hover:bg-accent hover:text-accent-foreground focus:ring-ring-primary',
'w-fit gap-2',
className,
)}
onClick={() => setIsOpen(!isOpen)}
aria-label="Prompt's category selector"
aria-labelledby="category-selector-label"
>
<div className="flex items-center space-x-2">
{'icon' in displayCategory && displayCategory.icon != null && (
<span>{displayCategory.icon as ReactNode}</span>
)}
<span>{displayCategory.value ? displayCategory.label : t('com_ui_category')}</span>
</div>
<Ariakit.MenuButtonArrow />
</Ariakit.MenuButton>
);
return formContext ? (
<Controller
name="category"
control={control}
render={() => (
<DropdownPopup
trigger={trigger}
items={menuItems}
isOpen={isOpen}
setIsOpen={setIsOpen}
menuId="category-selector-menu"
className="mt-2"
portal={true}
<Dropdown
value={displayCategory.value ?? ''}
label={displayCategory.value ? undefined : t('com_ui_category')}
onChange={(value: string) => {
setValue('category', value, { shouldDirty: false });
localStorage.setItem(LocalStorageKeys.LAST_PROMPT_CATEGORY, value);
onValueChange?.(value);
}}
aria-labelledby="category-selector-label"
ariaLabel="Prompt's category selector"
className={className}
options={categories || []}
renderValue={() => (
<div className="flex items-center space-x-2">
{'icon' in displayCategory && displayCategory.icon != null && (
<span>{displayCategory.icon as ReactNode}</span>
)}
<span>{displayCategory.label}</span>
</div>
)}
/>
)}
/>
) : (
<DropdownPopup
trigger={trigger}
items={menuItems}
isOpen={isOpen}
setIsOpen={setIsOpen}
menuId="category-selector-menu"
className="mt-2"
portal={true}
<Dropdown
value={currentCategory ?? ''}
onChange={(value: string) => {
localStorage.setItem(LocalStorageKeys.LAST_PROMPT_CATEGORY, value);
onValueChange?.(value);
}}
aria-labelledby="category-selector-label"
ariaLabel="Prompt's category selector"
className={className}
options={categories || []}
renderValue={() => (
<div className="flex items-center space-x-2">
{'icon' in displayCategory && displayCategory.icon != null && (
<span>{displayCategory.icon as ReactNode}</span>
)}
<span>{displayCategory.label}</span>
</div>
)}
/>
);
};

View File

@@ -1,5 +1,4 @@
import { useEffect, useState, useMemo, useCallback, useRef } from 'react';
import React from 'react';
import debounce from 'lodash/debounce';
import { useRecoilValue } from 'recoil';
import { Menu, Rocket } from 'lucide-react';
@@ -7,13 +6,14 @@ import { useForm, FormProvider } from 'react-hook-form';
import { useParams, useOutletContext } from 'react-router-dom';
import { Button, Skeleton, useToastContext } from '@librechat/client';
import { SystemRoles, PermissionTypes, Permissions } from 'librechat-data-provider';
import type { TCreatePrompt, TPrompt, TPromptGroup } from 'librechat-data-provider';
import type { TCreatePrompt } from 'librechat-data-provider';
import {
useGetPrompts,
useCreatePrompt,
useGetPrompts,
useGetPromptGroup,
useUpdatePromptGroup,
useMakePromptProduction,
useDeletePrompt,
} from '~/data-provider';
import { useAuthContext, usePromptGroupsNav, useHasAccess, useLocalize } from '~/hooks';
import CategorySelector from './Groups/CategorySelector';
@@ -22,7 +22,7 @@ import PromptVariables from './PromptVariables';
import { cn, findPromptGroup } from '~/utils';
import PromptVersions from './PromptVersions';
import { PromptsEditorMode } from '~/common';
import DeleteVersion from './DeleteVersion';
import DeleteConfirm from './DeleteVersion';
import PromptDetails from './PromptDetails';
import PromptEditor from './PromptEditor';
import SkeletonForm from './SkeletonForm';
@@ -32,136 +32,16 @@ import PromptName from './PromptName';
import Command from './Command';
import store from '~/store';
interface RightPanelProps {
group: TPromptGroup;
prompts: TPrompt[];
selectedPrompt: any;
selectionIndex: number;
selectedPromptId?: string;
isLoadingPrompts: boolean;
setSelectionIndex: React.Dispatch<React.SetStateAction<number>>;
}
const RightPanel = React.memo(
({
group,
prompts,
selectedPrompt,
selectedPromptId,
isLoadingPrompts,
selectionIndex,
setSelectionIndex,
}: RightPanelProps) => {
const localize = useLocalize();
const { showToast } = useToastContext();
const editorMode = useRecoilValue(store.promptsEditorMode);
const hasShareAccess = useHasAccess({
permissionType: PermissionTypes.PROMPTS,
permission: Permissions.SHARED_GLOBAL,
});
const updateGroupMutation = useUpdatePromptGroup({
onError: () => {
showToast({
status: 'error',
message: localize('com_ui_prompt_update_error'),
});
},
});
const makeProductionMutation = useMakePromptProduction();
const groupId = group?._id || '';
const groupName = group?.name || '';
const groupCategory = group?.category || '';
const isLoadingGroup = !group;
return (
<div
className="h-full w-full overflow-y-auto bg-surface-primary px-4"
style={{ maxHeight: 'calc(100vh - 100px)' }}
>
<div className="mb-2 flex flex-col lg:flex-row lg:items-center lg:justify-center lg:gap-x-2 xl:flex-row xl:space-y-0">
<CategorySelector
currentCategory={groupCategory}
onValueChange={(value) =>
updateGroupMutation.mutate({
id: groupId,
payload: { name: groupName, category: value },
})
}
/>
<div className="mt-2 flex flex-row items-center justify-center gap-x-2 lg:mt-0">
{hasShareAccess && <SharePrompt group={group} disabled={isLoadingGroup} />}
{editorMode === PromptsEditorMode.ADVANCED && (
<Button
variant="submit"
size="sm"
aria-label="Make prompt production"
className="h-10 w-10 border border-transparent p-0.5 transition-all"
onClick={() => {
if (!selectedPrompt) {
console.warn('No prompt is selected');
return;
}
const { _id: promptVersionId = '', prompt } = selectedPrompt;
makeProductionMutation.mutate({
id: promptVersionId,
groupId,
productionPrompt: { prompt },
});
}}
disabled={
isLoadingGroup ||
!selectedPrompt ||
selectedPrompt._id === group?.productionId ||
makeProductionMutation.isLoading
}
>
<Rocket className="size-5 cursor-pointer text-white" />
</Button>
)}
<DeleteVersion
promptId={selectedPromptId}
groupId={groupId}
promptName={groupName}
disabled={isLoadingGroup}
/>
</div>
</div>
{editorMode === PromptsEditorMode.ADVANCED &&
(isLoadingPrompts
? Array.from({ length: 6 }).map((_, index: number) => (
<div key={index} className="my-2">
<Skeleton className="h-[72px] w-full" />
</div>
))
: prompts.length > 0 && (
<PromptVersions
group={group}
prompts={prompts}
selectionIndex={selectionIndex}
setSelectionIndex={setSelectionIndex}
/>
))}
</div>
);
},
);
RightPanel.displayName = 'RightPanel';
const PromptForm = () => {
const params = useParams();
const localize = useLocalize();
const { user } = useAuthContext();
const { showToast } = useToastContext();
const alwaysMakeProd = useRecoilValue(store.alwaysMakeProd);
const { showToast } = useToastContext();
const promptId = params.promptId || '';
const editorMode = useRecoilValue(store.promptsEditorMode);
const [selectionIndex, setSelectionIndex] = useState<number>(0);
const editorMode = useRecoilValue(store.promptsEditorMode);
const prevIsEditingRef = useRef(false);
const [isEditing, setIsEditing] = useState(false);
const [initialLoad, setInitialLoad] = useState(true);
@@ -192,9 +72,11 @@ const PromptForm = () => {
[prompts, selectionIndex],
);
const selectedPromptId = useMemo(() => selectedPrompt?._id, [selectedPrompt?._id]);
const { groupsQuery } = useOutletContext<ReturnType<typeof usePromptGroupsNav>>();
const hasShareAccess = useHasAccess({
permissionType: PermissionTypes.PROMPTS,
permission: Permissions.SHARED_GLOBAL,
});
const updateGroupMutation = useUpdatePromptGroup({
onError: () => {
@@ -206,6 +88,7 @@ const PromptForm = () => {
});
const makeProductionMutation = useMakePromptProduction();
const deletePromptMutation = useDeletePrompt();
const createPromptMutation = useCreatePrompt({
onMutate: (variables) => {
@@ -294,40 +177,24 @@ const PromptForm = () => {
return () => window.removeEventListener('resize', handleResize);
}, []);
const debouncedUpdateOneliner = useMemo(
() =>
debounce((groupId: string, oneliner: string, mutate: any) => {
mutate({ id: groupId, payload: { oneliner } });
}, 950),
[],
);
const debouncedUpdateCommand = useMemo(
() =>
debounce((groupId: string, command: string, mutate: any) => {
mutate({ id: groupId, payload: { command } });
}, 950),
[],
);
const handleUpdateOneliner = useCallback(
(oneliner: string) => {
const debouncedUpdateOneliner = useCallback(
debounce((oneliner: string) => {
if (!group || !group._id) {
return console.warn('Group not found');
}
debouncedUpdateOneliner(group._id, oneliner, updateGroupMutation.mutate);
},
[group, updateGroupMutation.mutate, debouncedUpdateOneliner],
updateGroupMutation.mutate({ id: group._id, payload: { oneliner } });
}, 950),
[updateGroupMutation, group],
);
const handleUpdateCommand = useCallback(
(command: string) => {
const debouncedUpdateCommand = useCallback(
debounce((command: string) => {
if (!group || !group._id) {
return console.warn('Group not found');
}
debouncedUpdateCommand(group._id, command, updateGroupMutation.mutate);
},
[group, updateGroupMutation.mutate, debouncedUpdateCommand],
updateGroupMutation.mutate({ id: group._id, payload: { command } });
}, 950),
[updateGroupMutation, group],
);
if (initialLoad) {
@@ -350,7 +217,89 @@ const PromptForm = () => {
return null;
}
const groupId = group._id;
const groupName = group.name;
const groupCategory = group.category;
const RightPanel = () => (
<div
className="h-full w-full overflow-y-auto bg-surface-primary px-4"
style={{ maxHeight: 'calc(100vh - 100px)' }}
>
<div className="mb-2 flex flex-col lg:flex-row lg:items-center lg:justify-center lg:gap-x-2 xl:flex-row xl:space-y-0">
<CategorySelector
currentCategory={groupCategory}
onValueChange={(value) =>
updateGroupMutation.mutate({
id: groupId,
payload: { name: groupName, category: value },
})
}
/>
<div className="mt-2 flex flex-row items-center justify-center gap-x-2 lg:mt-0">
{hasShareAccess && <SharePrompt group={group} disabled={isLoadingGroup} />}
{editorMode === PromptsEditorMode.ADVANCED && (
<Button
variant="submit"
size="sm"
aria-label="Make prompt production"
className="h-10 w-10 border border-transparent p-0.5 transition-all"
onClick={() => {
if (!selectedPrompt) {
console.warn('No prompt is selected');
return;
}
const { _id: promptVersionId = '', prompt } = selectedPrompt;
makeProductionMutation.mutate({
id: promptVersionId,
groupId,
productionPrompt: { prompt },
});
}}
disabled={
isLoadingGroup ||
!selectedPrompt ||
selectedPrompt._id === group.productionId ||
makeProductionMutation.isLoading
}
>
<Rocket className="size-5 cursor-pointer text-white" />
</Button>
)}
<DeleteConfirm
name={groupName}
disabled={isLoadingGroup}
selectHandler={() => {
if (!selectedPrompt || !selectedPrompt._id) {
console.warn('No prompt is selected or prompt _id is missing');
return;
}
deletePromptMutation.mutate({
_id: selectedPrompt._id,
groupId,
});
}}
/>
</div>
</div>
{editorMode === PromptsEditorMode.ADVANCED &&
(isLoadingPrompts
? Array.from({ length: 6 }).map((_, index: number) => (
<div key={index} className="my-2">
<Skeleton className="h-[72px] w-full" />
</div>
))
: prompts.length > 0 && (
<PromptVersions
group={group}
prompts={prompts}
selectionIndex={selectionIndex}
setSelectionIndex={setSelectionIndex}
/>
))}
</div>
);
return (
<FormProvider {...methods}>
@@ -390,17 +339,7 @@ const PromptForm = () => {
<Menu className="size-5" />
</Button>
<div className="hidden lg:block">
{editorMode === PromptsEditorMode.SIMPLE && (
<RightPanel
group={group}
prompts={prompts}
selectedPrompt={selectedPrompt}
selectionIndex={selectionIndex}
selectedPromptId={selectedPromptId}
isLoadingPrompts={isLoadingPrompts}
setSelectionIndex={setSelectionIndex}
/>
)}
{editorMode === PromptsEditorMode.SIMPLE && <RightPanel />}
</div>
</>
)}
@@ -413,11 +352,11 @@ const PromptForm = () => {
<PromptVariables promptText={promptText} />
<Description
initialValue={group.oneliner ?? ''}
onValueChange={handleUpdateOneliner}
onValueChange={debouncedUpdateOneliner}
/>
<Command
initialValue={group.command ?? ''}
onValueChange={handleUpdateCommand}
onValueChange={debouncedUpdateCommand}
/>
</div>
)}
@@ -425,15 +364,7 @@ const PromptForm = () => {
{editorMode === PromptsEditorMode.ADVANCED && (
<div className="hidden w-1/4 border-l border-border-light lg:block">
<RightPanel
group={group}
prompts={prompts}
selectionIndex={selectionIndex}
selectedPrompt={selectedPrompt}
selectedPromptId={selectedPromptId}
isLoadingPrompts={isLoadingPrompts}
setSelectionIndex={setSelectionIndex}
/>
<RightPanel />
</div>
)}
</div>
@@ -464,15 +395,7 @@ const PromptForm = () => {
>
<div className="h-full">
<div className="h-full overflow-auto">
<RightPanel
group={group}
prompts={prompts}
selectionIndex={selectionIndex}
selectedPrompt={selectedPrompt}
selectedPromptId={selectedPromptId}
isLoadingPrompts={isLoadingPrompts}
setSelectionIndex={setSelectionIndex}
/>
<RightPanel />
</div>
</div>
</div>

View File

@@ -1,4 +1,3 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { ArrowUpDown } from 'lucide-react';
import { Button } from '@librechat/client';
import type { ColumnDef } from '@tanstack/react-table';

View File

@@ -5,15 +5,14 @@ import { Button, useToastContext } from '@librechat/client';
import { Constants, QueryKeys } from 'librechat-data-provider';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import type { TUpdateUserPlugins } from 'librechat-data-provider';
import ServerInitializationSection from '~/components/MCP/ServerInitializationSection';
import ServerInitializationSection from '~/components/ui/MCP/ServerInitializationSection';
import CustomUserVarsSection from '~/components/ui/MCP/CustomUserVarsSection';
import { useMCPConnectionStatusQuery } from '~/data-provider/Tools/queries';
import CustomUserVarsSection from '~/components/MCP/CustomUserVarsSection';
import BadgeRowProvider from '~/Providers/BadgeRowContext';
import { useGetStartupConfig } from '~/data-provider';
import MCPPanelSkeleton from './MCPPanelSkeleton';
import { useLocalize } from '~/hooks';
function MCPPanelContent() {
export default function MCPPanel() {
const localize = useLocalize();
const { showToast } = useToastContext();
const queryClient = useQueryClient();
@@ -127,45 +126,50 @@ function MCPPanelContent() {
const serverStatus = connectionStatus[selectedServerNameForEditing];
return (
<div className="h-auto max-w-full space-y-4 overflow-x-hidden py-2">
<Button variant="outline" onClick={handleGoBackToList} size="sm">
<div className="h-auto max-w-full overflow-x-hidden p-3">
<Button
variant="outline"
onClick={handleGoBackToList}
className="mb-3 flex items-center px-3 py-2 text-sm"
>
<ChevronLeft className="mr-1 h-4 w-4" />
{localize('com_ui_back')}
</Button>
<h3 className="mb-3 text-lg font-medium">
{localize('com_sidepanel_mcp_variables_for', { '0': serverBeingEdited.serverName })}
</h3>
{/* Server Initialization Section */}
<div className="mb-4">
<CustomUserVarsSection
<ServerInitializationSection
serverName={selectedServerNameForEditing}
fields={serverBeingEdited.config.customUserVars}
onSave={(authData) => {
if (selectedServerNameForEditing) {
handleConfigSave(selectedServerNameForEditing, authData);
}
}}
onRevoke={() => {
if (selectedServerNameForEditing) {
handleConfigRevoke(selectedServerNameForEditing);
}
}}
isSubmitting={updateUserPluginsMutation.isLoading}
requiresOAuth={serverStatus?.requiresOAuth || false}
/>
</div>
<ServerInitializationSection
sidePanel={true}
{/* Custom User Variables Section */}
<CustomUserVarsSection
serverName={selectedServerNameForEditing}
requiresOAuth={serverStatus?.requiresOAuth || false}
hasCustomUserVars={
serverBeingEdited.config.customUserVars &&
Object.keys(serverBeingEdited.config.customUserVars).length > 0
}
fields={serverBeingEdited.config.customUserVars}
onSave={(authData) => {
if (selectedServerNameForEditing) {
handleConfigSave(selectedServerNameForEditing, authData);
}
}}
onRevoke={() => {
if (selectedServerNameForEditing) {
handleConfigRevoke(selectedServerNameForEditing);
}
}}
isSubmitting={updateUserPluginsMutation.isLoading}
/>
</div>
);
} else {
// Server List View
return (
<div className="h-auto max-w-full overflow-x-hidden py-2">
<div className="h-auto max-w-full overflow-x-hidden p-3">
<div className="space-y-2">
{mcpServerDefinitions.map((server) => {
const serverStatus = connectionStatus[server.serverName];
@@ -182,7 +186,7 @@ function MCPPanelContent() {
<span>{server.serverName}</span>
{serverStatus && (
<span
className={`rounded-xl px-2 py-0.5 text-xs ${
className={`rounded px-2 py-0.5 text-xs ${
isConnected
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'
@@ -201,11 +205,3 @@ function MCPPanelContent() {
);
}
}
export default function MCPPanel() {
return (
<BadgeRowProvider>
<MCPPanelContent />
</BadgeRowProvider>
);
}

View File

@@ -4,18 +4,18 @@ import { Plus } from 'lucide-react';
import { matchSorter } from 'match-sorter';
import { SystemRoles, PermissionTypes, Permissions } from 'librechat-data-provider';
import {
Spinner,
EditIcon,
TrashIcon,
Table,
Input,
Label,
Button,
Switch,
Spinner,
TableRow,
OGDialog,
EditIcon,
TableHead,
TableBody,
TrashIcon,
TableCell,
TableHeader,
TooltipAnchor,
@@ -25,10 +25,10 @@ import {
} from '@librechat/client';
import type { TUserMemory } from 'librechat-data-provider';
import {
useUpdateMemoryPreferencesMutation,
useDeleteMemoryMutation,
useMemoriesQuery,
useGetUserQuery,
useMemoriesQuery,
useDeleteMemoryMutation,
useUpdateMemoryPreferencesMutation,
} from '~/data-provider';
import { useLocalize, useAuthContext, useHasAccess } from '~/hooks';
import MemoryCreateDialog from './MemoryCreateDialog';
@@ -36,114 +36,18 @@ import MemoryEditDialog from './MemoryEditDialog';
import AdminSettings from './AdminSettings';
import { cn } from '~/utils';
const EditMemoryButton = ({ memory }: { memory: TUserMemory }) => {
const localize = useLocalize();
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLDivElement>(null);
return (
<MemoryEditDialog
open={open}
memory={memory}
onOpenChange={setOpen}
triggerRef={triggerRef as React.MutableRefObject<HTMLButtonElement | null>}
>
<OGDialogTrigger asChild>
<TooltipAnchor
description={localize('com_ui_edit_memory')}
render={
<Button
variant="ghost"
aria-label={localize('com_ui_bookmarks_edit')}
onClick={() => setOpen(!open)}
className="h-8 w-8 p-0"
>
<EditIcon />
</Button>
}
/>
</OGDialogTrigger>
</MemoryEditDialog>
);
};
const DeleteMemoryButton = ({ memory }: { memory: TUserMemory }) => {
const localize = useLocalize();
const { showToast } = useToastContext();
const [open, setOpen] = useState(false);
const { mutate: deleteMemory } = useDeleteMemoryMutation();
const [deletingKey, setDeletingKey] = useState<string | null>(null);
const confirmDelete = async () => {
setDeletingKey(memory.key);
deleteMemory(memory.key, {
onSuccess: () => {
showToast({
message: localize('com_ui_deleted'),
status: 'success',
});
setOpen(false);
},
onError: () =>
showToast({
message: localize('com_ui_error'),
status: 'error',
}),
onSettled: () => setDeletingKey(null),
});
};
return (
<OGDialog open={open} onOpenChange={setOpen}>
<OGDialogTrigger asChild>
<TooltipAnchor
description={localize('com_ui_delete_memory')}
render={
<Button
variant="ghost"
aria-label={localize('com_ui_delete')}
onClick={() => setOpen(!open)}
className="h-8 w-8 p-0"
>
{deletingKey === memory.key ? (
<Spinner className="size-4 animate-spin" />
) : (
<TrashIcon className="size-4" />
)}
</Button>
}
/>
</OGDialogTrigger>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_memory')}
className="w-11/12 max-w-lg"
main={
<Label className="text-left text-sm font-medium">
{localize('com_ui_delete_confirm')} &quot;{memory.key}&quot;?
</Label>
}
selection={{
selectHandler: confirmDelete,
selectClasses:
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white',
selectText: localize('com_ui_delete'),
}}
/>
</OGDialog>
);
};
const pageSize = 10;
export default function MemoryViewer() {
const localize = useLocalize();
const { user } = useAuthContext();
const { data: userData } = useGetUserQuery();
const { data: memData, isLoading } = useMemoriesQuery();
const { mutate: deleteMemory } = useDeleteMemoryMutation();
const { showToast } = useToastContext();
const [pageIndex, setPageIndex] = useState(0);
const [searchQuery, setSearchQuery] = useState('');
const pageSize = 10;
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [deletingKey, setDeletingKey] = useState<string | null>(null);
const [referenceSavedMemories, setReferenceSavedMemories] = useState(true);
const updateMemoryPreferencesMutation = useUpdateMemoryPreferencesMutation({
@@ -215,6 +119,108 @@ export default function MemoryViewer() {
return 'stroke-green-500';
};
const EditMemoryButton = ({ memory }: { memory: TUserMemory }) => {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLDivElement>(null);
// Only show edit button if user has UPDATE permission
if (!hasUpdateAccess) {
return null;
}
return (
<MemoryEditDialog
open={open}
memory={memory}
onOpenChange={setOpen}
triggerRef={triggerRef as React.MutableRefObject<HTMLButtonElement | null>}
>
<OGDialogTrigger asChild>
<TooltipAnchor
description={localize('com_ui_edit_memory')}
render={
<Button
variant="ghost"
aria-label={localize('com_ui_bookmarks_edit')}
onClick={() => setOpen(!open)}
className="h-8 w-8 p-0"
>
<EditIcon />
</Button>
}
/>
</OGDialogTrigger>
</MemoryEditDialog>
);
};
const DeleteMemoryButton = ({ memory }: { memory: TUserMemory }) => {
const [open, setOpen] = useState(false);
if (!hasUpdateAccess) {
return null;
}
const confirmDelete = async () => {
setDeletingKey(memory.key);
deleteMemory(memory.key, {
onSuccess: () => {
showToast({
message: localize('com_ui_deleted'),
status: 'success',
});
setOpen(false);
},
onError: () =>
showToast({
message: localize('com_ui_error'),
status: 'error',
}),
onSettled: () => setDeletingKey(null),
});
};
return (
<OGDialog open={open} onOpenChange={setOpen}>
<OGDialogTrigger asChild>
<TooltipAnchor
description={localize('com_ui_delete_memory')}
render={
<Button
variant="ghost"
aria-label={localize('com_ui_delete')}
onClick={() => setOpen(!open)}
className="h-8 w-8 p-0"
>
{deletingKey === memory.key ? (
<Spinner className="size-4 animate-spin" />
) : (
<TrashIcon className="size-4" />
)}
</Button>
}
/>
</OGDialogTrigger>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_memory')}
className="w-11/12 max-w-lg"
main={
<Label className="text-left text-sm font-medium">
{localize('com_ui_delete_confirm')} &quot;{memory.key}&quot;?
</Label>
}
selection={{
selectHandler: confirmDelete,
selectClasses:
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white',
selectText: localize('com_ui_delete'),
}}
/>
</OGDialog>
);
};
if (isLoading) {
return (
<div className="flex h-full w-full items-center justify-center p-4">

View File

@@ -21,7 +21,7 @@ interface SourceItemProps {
expanded?: boolean;
}
function SourceItem({ source, isNews: _isNews, expanded = false }: SourceItemProps) {
function SourceItem({ source, isNews, expanded = false }: SourceItemProps) {
const localize = useLocalize();
const domain = getCleanDomain(source.link);

View File

@@ -1,6 +1,6 @@
import React, { useMemo } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { Input, Label, Button, TooltipAnchor, CircleHelpIcon } from '@librechat/client';
import { Input, Label, Button } from '@librechat/client';
import { useMCPAuthValuesQuery } from '~/data-provider/Tools/queries';
import { useLocalize } from '~/hooks';
@@ -31,24 +31,16 @@ function AuthField({ name, config, hasValue, control, errors }: AuthFieldProps)
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<TooltipAnchor
description={config.description || ''}
render={
<div className="flex items-center gap-2">
<Label htmlFor={name} className="text-sm font-medium">
{config.title}
</Label>
<CircleHelpIcon className="h-5 w-5 text-text-tertiary" />
</div>
}
/>
<Label htmlFor={name} className="text-sm font-medium">
{config.title}
</Label>
{hasValue ? (
<div className="flex min-w-fit items-center gap-2 whitespace-nowrap rounded-full border border-border-light px-2 py-0.5 text-xs font-medium text-text-secondary">
<div className="flex min-w-fit items-center gap-2 whitespace-nowrap rounded-full border border-border-medium px-2 py-0.5 text-xs font-medium text-text-secondary">
<div className="h-1.5 w-1.5 rounded-full bg-green-500" />
<span>{localize('com_ui_set')}</span>
</div>
) : (
<div className="flex min-w-fit items-center gap-2 whitespace-nowrap rounded-full border border-border-light px-2 py-0.5 text-xs font-medium text-text-secondary">
<div className="flex min-w-fit items-center gap-2 whitespace-nowrap rounded-full border border-border-medium px-2 py-0.5 text-xs font-medium text-text-secondary">
<div className="h-1.5 w-1.5 rounded-full border border-border-medium" />
<span>{localize('com_ui_unset')}</span>
</div>
@@ -68,10 +60,16 @@ function AuthField({ name, config, hasValue, control, errors }: AuthFieldProps)
? localize('com_ui_mcp_update_var', { 0: config.title })
: localize('com_ui_mcp_enter_var', { 0: config.title })
}
className="w-full shadow-sm sm:text-sm"
className="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white sm:text-sm"
/>
)}
/>
{config.description && (
<p
className="text-xs text-text-secondary [&_a]:text-blue-500 [&_a]:hover:text-blue-600 dark:[&_a]:text-blue-400 dark:[&_a]:hover:text-blue-300"
dangerouslySetInnerHTML={{ __html: config.description }}
/>
)}
{errors[name] && <p className="text-xs text-red-500">{errors[name]?.message}</p>}
</div>
);
@@ -112,15 +110,17 @@ export default function CustomUserVarsSection({
const handleRevokeClick = () => {
onRevoke();
// Reset form after revoke
reset();
};
// Don't render if no fields to configure
if (!fields || Object.keys(fields).length === 0) {
return null;
}
return (
<div className="flex-1 space-y-4">
<div className="space-y-4">
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
{Object.entries(fields).map(([key, config]) => {
const hasValue = authValuesData?.authValueFlags?.[key] || false;
@@ -138,11 +138,21 @@ export default function CustomUserVarsSection({
})}
</form>
<div className="flex justify-end gap-2">
<Button onClick={handleRevokeClick} variant="destructive" disabled={isSubmitting}>
<div className="flex justify-end gap-2 pt-2">
<Button
onClick={handleRevokeClick}
className="bg-red-600 text-white hover:bg-red-700 dark:hover:bg-red-800"
disabled={isSubmitting}
size="sm"
>
{localize('com_ui_revoke')}
</Button>
<Button onClick={handleSubmit(onFormSubmit)} variant="submit" disabled={isSubmitting}>
<Button
onClick={handleSubmit(onFormSubmit)}
className="bg-green-500 text-white hover:bg-green-600"
disabled={isSubmitting}
size="sm"
>
{isSubmitting ? localize('com_ui_saving') : localize('com_ui_save')}
</Button>
</div>

View File

@@ -1,13 +1,13 @@
import React from 'react';
import { KeyRound, PlugZap, AlertTriangle } from 'lucide-react';
import { Loader2, KeyRound, PlugZap, AlertTriangle } from 'lucide-react';
import { MCPServerStatus } from 'librechat-data-provider/dist/types/types/queries';
import {
Spinner,
OGDialog,
OGDialogTitle,
OGDialogHeader,
OGDialogContent,
OGDialogHeader,
OGDialogTitle,
OGDialogDescription,
} from '@librechat/client';
import type { MCPServerStatus } from 'librechat-data-provider';
import ServerInitializationSection from './ServerInitializationSection';
import CustomUserVarsSection from './CustomUserVarsSection';
import { useLocalize } from '~/hooks';
@@ -45,6 +45,9 @@ export default function MCPConfigDialog({
const dialogTitle = hasFields
? localize('com_ui_configure_mcp_variables_for', { 0: serverName })
: `${serverName} MCP Server`;
const dialogDescription = hasFields
? localize('com_ui_mcp_dialog_desc')
: `Manage connection and settings for the ${serverName} MCP server.`;
// Helper function to render status badge based on connection state
const renderStatusBadge = () => {
@@ -57,7 +60,7 @@ export default function MCPConfigDialog({
if (connectionState === 'connecting') {
return (
<div className="flex items-center gap-2 rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-600 dark:bg-blue-950 dark:text-blue-400">
<Spinner className="h-3 w-3" />
<Loader2 className="h-3 w-3 animate-spin" />
<span>{localize('com_ui_connecting')}</span>
</div>
);
@@ -104,30 +107,31 @@ export default function MCPConfigDialog({
return (
<OGDialog open={isOpen} onOpenChange={onOpenChange}>
<OGDialogContent className="flex max-h-screen w-11/12 max-w-lg flex-col space-y-2">
<OGDialogContent className="flex max-h-[90vh] w-full max-w-md flex-col">
<OGDialogHeader>
<div className="flex items-center gap-3">
<OGDialogTitle className="text-xl">
{dialogTitle.charAt(0).toUpperCase() + dialogTitle.slice(1)}
</OGDialogTitle>
<OGDialogTitle>{dialogTitle}</OGDialogTitle>
{renderStatusBadge()}
</div>
<OGDialogDescription>{dialogDescription}</OGDialogDescription>
</OGDialogHeader>
{/* Custom User Variables Section */}
<CustomUserVarsSection
serverName={serverName}
fields={fieldsSchema}
onSave={onSave}
onRevoke={onRevoke || (() => {})}
isSubmitting={isSubmitting}
/>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{/* Custom User Variables Section */}
<CustomUserVarsSection
serverName={serverName}
fields={fieldsSchema}
onSave={onSave}
onRevoke={onRevoke || (() => {})}
isSubmitting={isSubmitting}
/>
</div>
{/* Server Initialization Section */}
<ServerInitializationSection
serverName={serverName}
requiresOAuth={serverStatus?.requiresOAuth || false}
hasCustomUserVars={fieldsSchema && Object.keys(fieldsSchema).length > 0}
/>
</OGDialogContent>
</OGDialog>

View File

@@ -1,6 +1,5 @@
import React from 'react';
import { Spinner } from '@librechat/client';
import { SettingsIcon, AlertTriangle, KeyRound, PlugZap, X } from 'lucide-react';
import { SettingsIcon, AlertTriangle, Loader2, KeyRound, PlugZap, X } from 'lucide-react';
import type { MCPServerStatus, TPlugin } from 'librechat-data-provider';
import { useLocalize } from '~/hooks';
@@ -97,12 +96,12 @@ function InitializingStatusIcon({ serverName, onCancel, canCancel }: Initializin
<button
type="button"
onClick={onCancel}
className="group flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-red-100 dark:hover:bg-red-900/20"
className="flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-red-100 dark:hover:bg-red-900/20"
aria-label={localize('com_ui_cancel')}
title={localize('com_ui_cancel')}
>
<div className="relative h-4 w-4">
<Spinner className="h-4 w-4 group-hover:opacity-0" />
<div className="group relative h-4 w-4">
<Loader2 className="h-4 w-4 animate-spin text-blue-500 group-hover:opacity-0" />
<X className="absolute inset-0 h-4 w-4 text-red-500 opacity-0 group-hover:opacity-100" />
</div>
</button>
@@ -111,8 +110,8 @@ function InitializingStatusIcon({ serverName, onCancel, canCancel }: Initializin
return (
<div className="flex h-6 w-6 items-center justify-center rounded p-1">
<Spinner
className="h-4 w-4"
<Loader2
className="h-4 w-4 animate-spin text-blue-500"
aria-label={localize('com_nav_mcp_status_connecting', { 0: serverName })}
/>
</div>
@@ -122,8 +121,8 @@ function InitializingStatusIcon({ serverName, onCancel, canCancel }: Initializin
function ConnectingStatusIcon({ serverName }: StatusIconProps) {
return (
<div className="flex h-6 w-6 items-center justify-center rounded p-1">
<Spinner
className="h-4 w-4"
<Loader2
className="h-4 w-4 animate-spin text-blue-500"
aria-label={localize('com_nav_mcp_status_connecting', { 0: serverName })}
/>
</div>

View File

@@ -0,0 +1,131 @@
import React, { useState, useCallback } from 'react';
import { Button } from '@librechat/client';
import { RefreshCw, Link } from 'lucide-react';
import { useMCPServerInitialization } from '~/hooks/MCP/useMCPServerInitialization';
import { useLocalize } from '~/hooks';
interface ServerInitializationSectionProps {
serverName: string;
requiresOAuth: boolean;
}
export default function ServerInitializationSection({
serverName,
requiresOAuth,
}: ServerInitializationSectionProps) {
const localize = useLocalize();
const [oauthUrl, setOauthUrl] = useState<string | null>(null);
// Use the shared initialization hook
const { initializeServer, isLoading, connectionStatus, cancelOAuthFlow, isCancellable } =
useMCPServerInitialization({
onOAuthStarted: (name, url) => {
// Store the OAuth URL locally for display
setOauthUrl(url);
},
onSuccess: () => {
// Clear OAuth URL on success
setOauthUrl(null);
},
});
const serverStatus = connectionStatus[serverName];
const isConnected = serverStatus?.connectionState === 'connected';
const canCancel = isCancellable(serverName);
const handleInitializeClick = useCallback(() => {
setOauthUrl(null);
initializeServer(serverName);
}, [initializeServer, serverName]);
const handleCancelClick = useCallback(() => {
setOauthUrl(null);
cancelOAuthFlow(serverName);
}, [cancelOAuthFlow, serverName]);
// Show subtle reinitialize option if connected
if (isConnected) {
return (
<div className="flex justify-start">
<button
onClick={handleInitializeClick}
disabled={isLoading}
className="flex items-center gap-1 text-xs text-gray-400 hover:text-gray-600 disabled:opacity-50 dark:text-gray-500 dark:hover:text-gray-400"
>
<RefreshCw className={`h-3 w-3 ${isLoading ? 'animate-spin' : ''}`} />
{isLoading ? localize('com_ui_loading') : localize('com_ui_reinitialize')}
</button>
</div>
);
}
return (
<div className="rounded-lg border border-[#991b1b] bg-[#2C1315] p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-red-700 dark:text-red-300">
{requiresOAuth
? localize('com_ui_mcp_not_authenticated', { 0: serverName })
: localize('com_ui_mcp_not_initialized', { 0: serverName })}
</span>
</div>
{/* Only show authenticate button when OAuth URL is not present */}
{!oauthUrl && (
<Button
onClick={handleInitializeClick}
disabled={isLoading}
className="flex items-center gap-2 bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 dark:hover:bg-blue-800"
>
{isLoading ? (
<>
<RefreshCw className="h-4 w-4 animate-spin" />
{localize('com_ui_loading')}
</>
) : (
<>
<RefreshCw className="h-4 w-4" />
{requiresOAuth
? localize('com_ui_authenticate')
: localize('com_ui_mcp_initialize')}
</>
)}
</Button>
)}
</div>
{/* OAuth URL display */}
{oauthUrl && (
<div className="mt-4 rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-700 dark:bg-blue-900/20">
<div className="mb-2 flex items-center gap-2">
<div className="flex h-4 w-4 items-center justify-center rounded-full bg-blue-500">
<Link className="h-2.5 w-2.5 text-white" />
</div>
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">
{localize('com_ui_auth_url')}
</span>
</div>
<div className="flex items-center gap-2">
<Button
onClick={() => window.open(oauthUrl, '_blank', 'noopener,noreferrer')}
className="flex-1 bg-blue-600 text-white hover:bg-blue-700 dark:hover:bg-blue-800"
>
{localize('com_ui_continue_oauth')}
</Button>
<Button
onClick={handleCancelClick}
disabled={!canCancel}
className="bg-gray-200 text-gray-700 hover:bg-gray-300 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
title={!canCancel ? 'disabled' : undefined}
>
{localize('com_ui_cancel')}
</Button>
</div>
<p className="mt-2 text-xs text-blue-600 dark:text-blue-400">
{localize('com_ui_oauth_flow_desc')}
</p>
</div>
)}
</div>
);
}

View File

@@ -55,10 +55,9 @@ export const useUpdateConversationMutation = (
return useMutation(
(payload: t.TUpdateConversationRequest) => dataService.updateConversation(payload),
{
onSuccess: (updatedConvo, payload) => {
const targetId = payload.conversationId || id;
queryClient.setQueryData([QueryKeys.conversation, targetId], updatedConvo);
updateConvoInAllQueries(queryClient, targetId, () => updatedConvo);
onSuccess: (updatedConvo) => {
queryClient.setQueryData([QueryKeys.conversation, id], updatedConvo);
updateConvoInAllQueries(queryClient, id, () => updatedConvo);
},
},
);

View File

@@ -135,14 +135,10 @@ const useFileHandling = (params?: UseFileHandling) => {
const file_id = body.get('file_id');
clearUploadTimer(file_id as string);
deleteFileById(file_id as string);
let errorMessage = 'com_error_files_upload';
if (error?.code === 'ERR_CANCELED') {
errorMessage = 'com_error_files_upload_canceled';
} else if (error?.response?.data?.message) {
errorMessage = error.response.data.message;
}
const errorMessage =
error?.code === 'ERR_CANCELED'
? 'com_error_files_upload_canceled'
: (error?.response?.data?.message ?? 'com_error_files_upload');
setError(errorMessage);
},
},

View File

@@ -107,7 +107,7 @@ const useSpeechToTextExternal = (
});
setPermission(true);
audioStream.current = streamData ?? null;
} catch {
} catch (err) {
setPermission(false);
}
};
@@ -268,7 +268,6 @@ const useSpeechToTextExternal = (
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isListening]);
return {

View File

@@ -1 +1 @@
export { useMCPServerManager } from './useMCPServerManager';
export { useMCPServerInitialization } from './useMCPServerInitialization';

View File

@@ -0,0 +1,317 @@
import { useCallback, useState, useEffect, useMemo } from 'react';
import { useToastContext } from '@librechat/client';
import { QueryKeys } from 'librechat-data-provider';
import { useQueryClient } from '@tanstack/react-query';
import {
useReinitializeMCPServerMutation,
useCancelMCPOAuthMutation,
} from 'librechat-data-provider/react-query';
import { useMCPConnectionStatusQuery } from '~/data-provider/Tools/queries';
import { useLocalize } from '~/hooks';
import { logger } from '~/utils';
interface UseMCPServerInitializationOptions {
onSuccess?: (serverName: string) => void;
onOAuthStarted?: (serverName: string, oauthUrl: string) => void;
onError?: (serverName: string, error: any) => void;
}
export function useMCPServerInitialization(options?: UseMCPServerInitializationOptions) {
const localize = useLocalize();
const { showToast } = useToastContext();
const queryClient = useQueryClient();
// OAuth state management
const [oauthPollingServers, setOauthPollingServers] = useState<Map<string, string>>(new Map());
const [oauthStartTimes, setOauthStartTimes] = useState<Map<string, number>>(new Map());
const [initializingServers, setInitializingServers] = useState<Set<string>>(new Set());
const [cancellableServers, setCancellableServers] = useState<Set<string>>(new Set());
// Get connection status
const { data: connectionStatusData } = useMCPConnectionStatusQuery();
const connectionStatus = useMemo(
() => connectionStatusData?.connectionStatus || {},
[connectionStatusData],
);
// Main initialization mutation
const reinitializeMutation = useReinitializeMCPServerMutation();
// Track which server is currently being processed
const [currentProcessingServer, setCurrentProcessingServer] = useState<string | null>(null);
// Cancel OAuth mutation
const cancelOAuthMutation = useCancelMCPOAuthMutation();
// Helper function to clean up OAuth state
const cleanupOAuthState = useCallback((serverName: string) => {
setOauthPollingServers((prev) => {
const newMap = new Map(prev);
newMap.delete(serverName);
return newMap;
});
setOauthStartTimes((prev) => {
const newMap = new Map(prev);
newMap.delete(serverName);
return newMap;
});
setInitializingServers((prev) => {
const newSet = new Set(prev);
newSet.delete(serverName);
return newSet;
});
setCancellableServers((prev) => {
const newSet = new Set(prev);
newSet.delete(serverName);
return newSet;
});
}, []);
// Cancel OAuth flow
const cancelOAuthFlow = useCallback(
(serverName: string) => {
logger.info(`[MCP OAuth] User cancelling OAuth flow for ${serverName}`);
cancelOAuthMutation.mutate(serverName, {
onSuccess: () => {
cleanupOAuthState(serverName);
showToast({
message: localize('com_ui_mcp_oauth_cancelled', { 0: serverName }),
status: 'info',
});
},
onError: (error) => {
logger.error(`[MCP OAuth] Failed to cancel OAuth flow for ${serverName}:`, error);
// Clean up state anyway
cleanupOAuthState(serverName);
},
});
},
[cancelOAuthMutation, cleanupOAuthState, showToast, localize],
);
// Helper function to handle successful connection
const handleSuccessfulConnection = useCallback(
async (serverName: string, message: string) => {
showToast({ message, status: 'success' });
// Force immediate refetch to update UI
await Promise.all([
queryClient.refetchQueries([QueryKeys.mcpConnectionStatus]),
queryClient.refetchQueries([QueryKeys.tools]),
]);
// Clean up OAuth state
cleanupOAuthState(serverName);
// Call optional success callback
options?.onSuccess?.(serverName);
},
[showToast, queryClient, options, cleanupOAuthState],
);
// Helper function to handle OAuth timeout/failure
const handleOAuthFailure = useCallback(
(serverName: string, isTimeout: boolean) => {
logger.warn(
`[MCP OAuth] OAuth ${isTimeout ? 'timed out' : 'failed'} for ${serverName}, stopping poll`,
);
// Clean up OAuth state
cleanupOAuthState(serverName);
// Show error toast
showToast({
message: isTimeout
? localize('com_ui_mcp_oauth_timeout', { 0: serverName })
: localize('com_ui_mcp_init_failed'),
status: 'error',
});
},
[showToast, localize, cleanupOAuthState],
);
// Poll for OAuth completion
useEffect(() => {
if (oauthPollingServers.size === 0) {
return;
}
const pollInterval = setInterval(() => {
// Check each polling server
oauthPollingServers.forEach((oauthUrl, serverName) => {
const serverStatus = connectionStatus[serverName];
// Check for client-side timeout (3 minutes)
const startTime = oauthStartTimes.get(serverName);
const hasTimedOut = startTime && Date.now() - startTime > 180000; // 3 minutes
if (serverStatus?.connectionState === 'connected') {
// OAuth completed successfully
handleSuccessfulConnection(
serverName,
localize('com_ui_mcp_authenticated_success', { 0: serverName }),
);
} else if (serverStatus?.connectionState === 'error' || hasTimedOut) {
// OAuth failed or timed out
handleOAuthFailure(serverName, !!hasTimedOut);
}
setCancellableServers((prev) => new Set(prev).add(serverName));
});
queryClient.refetchQueries([QueryKeys.mcpConnectionStatus]);
}, 3500);
return () => {
clearInterval(pollInterval);
};
}, [
oauthPollingServers,
oauthStartTimes,
connectionStatus,
queryClient,
handleSuccessfulConnection,
handleOAuthFailure,
localize,
]);
// Initialize server function
const initializeServer = useCallback(
(serverName: string) => {
// Prevent spam - check if already initializing
if (initializingServers.has(serverName)) {
return;
}
if (connectionStatus[serverName]?.requiresOAuth) {
setCancellableServers((prev) => new Set(prev).add(serverName));
}
// Add to initializing set
setInitializingServers((prev) => new Set(prev).add(serverName));
// If there's already a server being processed, that one will be cancelled
if (currentProcessingServer && currentProcessingServer !== serverName) {
// Clean up the cancelled server's state immediately
showToast({
message: localize('com_ui_mcp_init_cancelled', { 0: currentProcessingServer }),
status: 'warning',
});
cleanupOAuthState(currentProcessingServer);
}
// Track the current server being processed
setCurrentProcessingServer(serverName);
reinitializeMutation.mutate(serverName, {
onSuccess: (response: any) => {
// Clear current processing server
setCurrentProcessingServer(null);
if (response.success) {
if (response.oauthRequired && response.oauthUrl) {
// OAuth required - store URL and start polling
setOauthPollingServers((prev) => new Map(prev).set(serverName, response.oauthUrl));
// Track when OAuth started for timeout detection
setOauthStartTimes((prev) => new Map(prev).set(serverName, Date.now()));
// Call optional OAuth callback or open URL directly
if (options?.onOAuthStarted) {
options.onOAuthStarted(serverName, response.oauthUrl);
} else {
window.open(response.oauthUrl, '_blank', 'noopener,noreferrer');
}
showToast({
message: localize('com_ui_connecting'),
status: 'info',
});
} else if (response.oauthRequired) {
// OAuth required but no URL - shouldn't happen
showToast({
message: localize('com_ui_mcp_oauth_no_url'),
status: 'warning',
});
// Remove from initializing since it failed
setInitializingServers((prev) => {
const newSet = new Set(prev);
newSet.delete(serverName);
return newSet;
});
} else {
// Successful connection without OAuth
handleSuccessfulConnection(
serverName,
response.message || localize('com_ui_mcp_initialized_success', { 0: serverName }),
);
}
} else {
// Remove from initializing if not successful
setInitializingServers((prev) => {
const newSet = new Set(prev);
newSet.delete(serverName);
return newSet;
});
}
},
onError: (error: any) => {
console.error(`Error initializing MCP server ${serverName}:`, error);
setCurrentProcessingServer(null);
const isCancelled =
error?.name === 'CanceledError' ||
error?.code === 'ERR_CANCELED' ||
error?.message?.includes('cancel') ||
error?.message?.includes('abort');
if (isCancelled) {
showToast({
message: localize('com_ui_mcp_init_cancelled', { 0: serverName }),
status: 'warning',
});
} else {
showToast({
message: localize('com_ui_mcp_init_failed'),
status: 'error',
});
}
// Clean up OAuth state using helper function
cleanupOAuthState(serverName);
// Call optional error callback
options?.onError?.(serverName, error);
},
});
},
[
initializingServers,
connectionStatus,
currentProcessingServer,
reinitializeMutation,
showToast,
localize,
cleanupOAuthState,
options,
handleSuccessfulConnection,
],
);
return {
initializeServer,
isInitializing: (serverName: string) => initializingServers.has(serverName),
isCancellable: (serverName: string) => cancellableServers.has(serverName),
initializingServers,
oauthPollingServers,
oauthStartTimes,
connectionStatus,
isLoading: reinitializeMutation.isLoading,
cancelOAuthFlow,
};
}

View File

@@ -1,52 +1,34 @@
import { useCallback, useState, useMemo, useRef, useEffect } from 'react';
import { useToastContext } from '@librechat/client';
import { useQueryClient } from '@tanstack/react-query';
import { Constants, QueryKeys } from 'librechat-data-provider';
import {
useCancelMCPOAuthMutation,
useUpdateUserPluginsMutation,
useReinitializeMCPServerMutation,
} from 'librechat-data-provider/react-query';
import { useCallback, useState, useMemo, useRef } from 'react';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import { useMCPServerInitialization } from '~/hooks/MCP/useMCPServerInitialization';
import type { ConfigFieldDetail } from '~/components/ui/MCP/MCPConfigDialog';
import type { TUpdateUserPlugins, TPlugin } from 'librechat-data-provider';
import type { ConfigFieldDetail } from '~/components/MCP/MCPConfigDialog';
import { useMCPConnectionStatusQuery } from '~/data-provider/Tools/queries';
import { useBadgeRowContext } from '~/Providers';
import { useLocalize } from '~/hooks';
interface ServerState {
isInitializing: boolean;
oauthUrl: string | null;
oauthStartTime: number | null;
isCancellable: boolean;
pollInterval: NodeJS.Timeout | null;
}
export function useMCPServerManager() {
const localize = useLocalize();
const { showToast } = useToastContext();
const { mcpSelect, startupConfig } = useBadgeRowContext();
const { mcpValues, setMCPValues, mcpToolDetails, isPinned, setIsPinned } = mcpSelect;
const queryClient = useQueryClient();
const [isConfigModalOpen, setIsConfigModalOpen] = useState(false);
const [selectedToolForConfig, setSelectedToolForConfig] = useState<TPlugin | null>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
const mcpValuesRef = useRef(mcpValues);
// fixes the issue where OAuth flows would deselect all the servers except the one that is being authenticated on success
useEffect(() => {
mcpValuesRef.current = mcpValues;
}, [mcpValues]);
const configuredServers = useMemo(() => {
if (!startupConfig?.mcpServers) return [];
if (!startupConfig?.mcpServers) {
return [];
}
return Object.entries(startupConfig.mcpServers)
.filter(([, config]) => config.chatMenu !== false)
.map(([serverName]) => serverName);
}, [startupConfig?.mcpServers]);
const reinitializeMutation = useReinitializeMCPServerMutation();
const cancelOAuthMutation = useCancelMCPOAuthMutation();
const [isConfigModalOpen, setIsConfigModalOpen] = useState(false);
const [selectedToolForConfig, setSelectedToolForConfig] = useState<TPlugin | null>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
const queryClient = useQueryClient();
const updateUserPluginsMutation = useUpdateUserPluginsMutation({
onSuccess: async () => {
@@ -67,310 +49,52 @@ export function useMCPServerManager() {
},
});
const [serverStates, setServerStates] = useState<Record<string, ServerState>>(() => {
const initialStates: Record<string, ServerState> = {};
configuredServers.forEach((serverName) => {
initialStates[serverName] = {
isInitializing: false,
oauthUrl: null,
oauthStartTime: null,
isCancellable: false,
pollInterval: null,
};
});
return initialStates;
});
const { data: connectionStatusData } = useMCPConnectionStatusQuery();
const connectionStatus = useMemo(
() => connectionStatusData?.connectionStatus || {},
[connectionStatusData?.connectionStatus],
);
useEffect(() => {
if (!mcpValues?.length) return;
const connectedSelected = mcpValues.filter(
(serverName) => connectionStatus[serverName]?.connectionState === 'connected',
);
if (connectedSelected.length !== mcpValues.length) {
setMCPValues(connectedSelected);
}
}, [connectionStatus, mcpValues, setMCPValues]);
const updateServerState = useCallback((serverName: string, updates: Partial<ServerState>) => {
setServerStates((prev) => {
const newStates = { ...prev };
const currentState = newStates[serverName] || {
isInitializing: false,
oauthUrl: null,
oauthStartTime: null,
isCancellable: false,
pollInterval: null,
};
newStates[serverName] = { ...currentState, ...updates };
return newStates;
});
}, []);
const cleanupServerState = useCallback(
(serverName: string) => {
const state = serverStates[serverName];
if (state?.pollInterval) {
clearInterval(state.pollInterval);
}
updateServerState(serverName, {
isInitializing: false,
oauthUrl: null,
oauthStartTime: null,
isCancellable: false,
pollInterval: null,
});
},
[serverStates, updateServerState],
);
const startServerPolling = useCallback(
(serverName: string) => {
const pollInterval = setInterval(async () => {
try {
await queryClient.refetchQueries([QueryKeys.mcpConnectionStatus]);
const freshConnectionData = queryClient.getQueryData([
QueryKeys.mcpConnectionStatus,
]) as any;
const freshConnectionStatus = freshConnectionData?.connectionStatus || {};
const state = serverStates[serverName];
const serverStatus = freshConnectionStatus[serverName];
if (serverStatus?.connectionState === 'connected') {
clearInterval(pollInterval);
showToast({
message: localize('com_ui_mcp_authenticated_success', { 0: serverName }),
status: 'success',
});
const currentValues = mcpValuesRef.current ?? [];
if (!currentValues.includes(serverName)) {
setMCPValues([...currentValues, serverName]);
}
// This delay is to ensure UI has updated with new connection status before cleanup
// Otherwise servers will show as disconnected for a second after OAuth flow completes
setTimeout(() => {
cleanupServerState(serverName);
}, 1000);
return;
}
if (state?.oauthStartTime && Date.now() - state.oauthStartTime > 180000) {
showToast({
message: localize('com_ui_mcp_oauth_timeout', { 0: serverName }),
status: 'error',
});
clearInterval(pollInterval);
cleanupServerState(serverName);
return;
}
if (serverStatus?.connectionState === 'error') {
showToast({
message: localize('com_ui_mcp_init_failed'),
status: 'error',
});
clearInterval(pollInterval);
cleanupServerState(serverName);
return;
}
} catch (error) {
console.error(`[MCP Manager] Error polling server ${serverName}:`, error);
clearInterval(pollInterval);
cleanupServerState(serverName);
return;
}
}, 3500);
updateServerState(serverName, { pollInterval });
},
[
queryClient,
serverStates,
showToast,
localize,
setMCPValues,
cleanupServerState,
updateServerState,
],
);
const initializeServer = useCallback(
async (serverName: string, autoOpenOAuth: boolean = true) => {
updateServerState(serverName, { isInitializing: true });
try {
const response = await reinitializeMutation.mutateAsync(serverName);
if (response.success) {
if (response.oauthRequired && response.oauthUrl) {
updateServerState(serverName, {
oauthUrl: response.oauthUrl,
oauthStartTime: Date.now(),
isCancellable: true,
isInitializing: true,
});
if (autoOpenOAuth) {
window.open(response.oauthUrl, '_blank', 'noopener,noreferrer');
}
startServerPolling(serverName);
} else {
await queryClient.refetchQueries([QueryKeys.mcpConnectionStatus]);
showToast({
message: localize('com_ui_mcp_initialized_success', { 0: serverName }),
status: 'success',
});
const currentValues = mcpValues ?? [];
if (!currentValues.includes(serverName)) {
setMCPValues([...currentValues, serverName]);
}
cleanupServerState(serverName);
}
} else {
showToast({
message: localize('com_ui_mcp_init_failed', { 0: serverName }),
status: 'error',
});
cleanupServerState(serverName);
}
} catch (error) {
console.error(`[MCP Manager] Failed to initialize ${serverName}:`, error);
showToast({
message: localize('com_ui_mcp_init_failed', { 0: serverName }),
status: 'error',
});
cleanupServerState(serverName);
}
},
[
updateServerState,
reinitializeMutation,
startServerPolling,
queryClient,
showToast,
localize,
mcpValues,
cleanupServerState,
setMCPValues,
],
);
const cancelOAuthFlow = useCallback(
(serverName: string) => {
cancelOAuthMutation.mutate(serverName, {
onSuccess: () => {
cleanupServerState(serverName);
queryClient.invalidateQueries([QueryKeys.mcpConnectionStatus]);
showToast({
message: localize('com_ui_mcp_oauth_cancelled', { 0: serverName }),
status: 'warning',
});
},
onError: (error) => {
console.error(`[MCP Manager] Failed to cancel OAuth for ${serverName}:`, error);
showToast({
message: localize('com_ui_mcp_init_failed', { 0: serverName }),
status: 'error',
});
},
});
},
[queryClient, cleanupServerState, showToast, localize, cancelOAuthMutation],
);
const isInitializing = useCallback(
(serverName: string) => {
return serverStates[serverName]?.isInitializing || false;
},
[serverStates],
);
const isCancellable = useCallback(
(serverName: string) => {
return serverStates[serverName]?.isCancellable || false;
},
[serverStates],
);
const getOAuthUrl = useCallback(
(serverName: string) => {
return serverStates[serverName]?.oauthUrl || null;
},
[serverStates],
);
const placeholderText = useMemo(
() => startupConfig?.interface?.mcpServers?.placeholder || localize('com_ui_mcp_servers'),
[startupConfig?.interface?.mcpServers?.placeholder, localize],
);
const batchToggleServers = useCallback(
(serverNames: string[]) => {
const connectedServers: string[] = [];
const disconnectedServers: string[] = [];
serverNames.forEach((serverName) => {
if (isInitializing(serverName)) {
return;
}
const serverStatus = connectionStatus[serverName];
if (serverStatus?.connectionState === 'connected') {
connectedServers.push(serverName);
} else {
disconnectedServers.push(serverName);
}
});
setMCPValues(connectedServers);
disconnectedServers.forEach((serverName) => {
initializeServer(serverName);
});
},
[connectionStatus, setMCPValues, initializeServer, isInitializing],
);
const toggleServerSelection = useCallback(
(serverName: string) => {
if (isInitializing(serverName)) {
return;
}
const currentValues = mcpValues ?? [];
const isCurrentlySelected = currentValues.includes(serverName);
if (isCurrentlySelected) {
const filteredValues = currentValues.filter((name) => name !== serverName);
setMCPValues(filteredValues);
} else {
const serverStatus = connectionStatus[serverName];
if (serverStatus?.connectionState === 'connected') {
const { initializeServer, isInitializing, connectionStatus, cancelOAuthFlow, isCancellable } =
useMCPServerInitialization({
onSuccess: (serverName) => {
const currentValues = mcpValues ?? [];
if (!currentValues.includes(serverName)) {
setMCPValues([...currentValues, serverName]);
} else {
initializeServer(serverName);
}
}
},
[mcpValues, setMCPValues, connectionStatus, initializeServer, isInitializing],
);
},
onError: (serverName) => {
const tool = mcpToolDetails?.find((t) => t.name === serverName);
const serverConfig = startupConfig?.mcpServers?.[serverName];
const serverStatus = connectionStatus[serverName];
const hasAuthConfig =
(tool?.authConfig && tool.authConfig.length > 0) ||
(serverConfig?.customUserVars && Object.keys(serverConfig.customUserVars).length > 0);
const wouldShowButton =
!serverStatus ||
serverStatus.connectionState === 'disconnected' ||
serverStatus.connectionState === 'error' ||
(serverStatus.connectionState === 'connected' && hasAuthConfig);
if (!wouldShowButton) {
return;
}
const configTool = tool || {
name: serverName,
pluginKey: `${Constants.mcp_prefix}${serverName}`,
authConfig: serverConfig?.customUserVars
? Object.entries(serverConfig.customUserVars).map(([key, config]) => ({
authField: key,
label: config.title,
description: config.description,
}))
: [],
authenticated: false,
};
previousFocusRef.current = document.activeElement as HTMLElement;
setSelectedToolForConfig(configTool);
setIsConfigModalOpen(true);
},
});
const handleConfigSave = useCallback(
(targetName: string, authData: Record<string, string>) => {
@@ -432,6 +156,48 @@ export function useMCPServerManager() {
}
}, []);
const toggleServerSelection = useCallback(
(serverName: string) => {
const currentValues = mcpValues ?? [];
const serverStatus = connectionStatus[serverName];
if (currentValues.includes(serverName)) {
const filteredValues = currentValues.filter((name) => name !== serverName);
setMCPValues(filteredValues);
} else {
if (serverStatus?.connectionState === 'connected') {
setMCPValues([...currentValues, serverName]);
} else {
initializeServer(serverName);
}
}
},
[connectionStatus, mcpValues, setMCPValues, initializeServer],
);
const batchToggleServers = useCallback(
(serverNames: string[]) => {
const connectedServers: string[] = [];
const disconnectedServers: string[] = [];
serverNames.forEach((serverName) => {
const serverStatus = connectionStatus[serverName];
if (serverStatus?.connectionState === 'connected') {
connectedServers.push(serverName);
} else {
disconnectedServers.push(serverName);
}
});
setMCPValues(connectedServers);
disconnectedServers.forEach((serverName) => {
initializeServer(serverName);
});
},
[connectionStatus, setMCPValues, initializeServer],
);
const getServerStatusIconProps = useCallback(
(serverName: string) => {
const tool = mcpToolDetails?.find((t) => t.name === serverName);
@@ -490,6 +256,11 @@ export function useMCPServerManager() {
],
);
const placeholderText = useMemo(
() => startupConfig?.interface?.mcpServers?.placeholder || localize('com_ui_mcp_servers'),
[startupConfig?.interface?.mcpServers?.placeholder, localize],
);
const getConfigDialogProps = useCallback(() => {
if (!selectedToolForConfig) return null;
@@ -532,31 +303,27 @@ export function useMCPServerManager() {
]);
return {
// Data
configuredServers,
connectionStatus,
initializeServer,
cancelOAuthFlow,
isInitializing,
isCancellable,
getOAuthUrl,
mcpValues,
setMCPValues,
mcpToolDetails,
isPinned,
setIsPinned,
startupConfig,
connectionStatus,
placeholderText,
batchToggleServers,
toggleServerSelection,
localize,
isConfigModalOpen,
handleDialogOpenChange,
selectedToolForConfig,
setSelectedToolForConfig,
handleSave,
handleRevoke,
// Handlers
toggleServerSelection,
batchToggleServers,
getServerStatusIconProps,
// Dialog state
selectedToolForConfig,
isConfigModalOpen,
getConfigDialogProps,
// Utilities
localize,
};
}

View File

@@ -155,10 +155,7 @@ export default function useSideNavLinks({
if (
startupConfig?.mcpServers &&
Object.values(startupConfig.mcpServers).some(
(server: any) =>
(server.customUserVars && Object.keys(server.customUserVars).length > 0) ||
server.isOAuth ||
server.startup === false,
(server) => server.customUserVars && Object.keys(server.customUserVars).length > 0,
)
) {
links.push({

View File

@@ -81,21 +81,13 @@ export function useMCPSelect({ conversationId }: UseMCPSelectOptions) {
[setEphemeralAgent],
);
const [mcpValues, setMCPValuesRaw] = useLocalStorage<string[]>(
const [mcpValues, setMCPValues] = useLocalStorage<string[]>(
`${LocalStorageKeys.LAST_MCP_}${key}`,
mcpState,
setSelectedValues,
storageCondition,
);
const setMCPValuesRawRef = useRef(setMCPValuesRaw);
setMCPValuesRawRef.current = setMCPValuesRaw;
// Create a stable memoized setter to avoid re-creating it on every render and causing an infinite render loop
const setMCPValues = useCallback((value: string[]) => {
setMCPValuesRawRef.current(value);
}, []);
const [isPinned, setIsPinned] = useLocalStorage<boolean>(
`${LocalStorageKeys.PIN_MCP_}${key}`,
true,

View File

@@ -8,7 +8,6 @@ export * from './Nav';
export * from './Files';
export * from './Generic';
export * from './Input';
export * from './MCP';
export * from './Messages';
export * from './Plugins';
export * from './Prompts';

View File

@@ -1,135 +0,0 @@
# LibreChat Localization Guide
This guide explains how to add new languages to LibreChat's localization system.
## Adding a New Language
To add a new language to LibreChat, follow these steps:
### 1. Add the Language to Locize Project
- Navigate to the [LibreChat locize project](https://www.locize.app/cat/62uyy7c9),
- Click the "ADD LANGUAGE" button, typically found within the "..." menu of the "Start to translate" card on the project overview page.
### 2. Update the Language Selector Component
Edit `client/src/components/Nav/SettingsTabs/General/General.tsx` and add your new language option to the `languageOptions` array:
```typescript
{ value: 'language-code', label: localize('com_nav_lang_language_name') },
```
Example:
```typescript
{ value: 'bo', label: localize('com_nav_lang_tibetan') },
{ value: 'uk-UA', label: localize('com_nav_lang_ukrainian') },
```
**Note:** Use the appropriate language code format:
- Use simple codes (e.g., `bo`) for languages without regional variants
- Use region-specific codes (e.g., `uk-UA`) when needed
### 3. Add Localization Keys
In `client/src/locales/en/translation.json`, add the corresponding localization key for your language label:
```json
"com_nav_lang_language_name": "Native Language Name",
```
Example:
```json
"com_nav_lang_tibetan": "བོད་སྐད་",
"com_nav_lang_ukrainian": "Українська",
```
**Best Practice:** Use the native language name as the value.
### 4. Create the Translation File
Create a new directory and translation file:
```bash
mkdir -p client/src/locales/[language-code]
```
Create `client/src/locales/[language-code]/translation.json` with an empty JSON object:
```json
{
}
```
Example:
- `client/src/locales/bo/translation.json`
- `client/src/locales/uk/translation.json`
### 5. Configure i18n
Update `client/src/locales/i18n.ts`:
1. Import the new translation file:
```typescript
import translationLanguageCode from './language-code/translation.json';
```
2. Add it to the `resources` object:
```typescript
export const resources = {
// ... existing languages
'language-code': { translation: translationLanguageCode },
} as const;
```
Example:
```typescript
import translationBo from './bo/translation.json';
import translationUk from './uk/translation.json';
export const resources = {
// ... existing languages
bo: { translation: translationBo },
uk: { translation: translationUk },
} as const;
```
### 6. Handle Fallback Languages (Optional)
If your language should fall back to a specific language when translations are missing, update the `fallbackLng` configuration in `i18n.ts`:
```typescript
fallbackLng: {
'language-variant': ['fallback-language', 'en'],
// ... existing fallbacks
},
```
## Translation Process
After adding a new language:
1. The empty translation file will be populated through LibreChat's automated translation platform
2. Only the English (`en`) translation file should be manually updated
3. Other language translations are managed externally
## Language Code Standards
- Use ISO 639-1 codes for most languages (e.g., `en`, `fr`, `de`)
- Use ISO 639-1 with region codes when needed (e.g., `pt-BR`, `zh-Hans`)
- Tibetan uses `bo` (Bodic)
- Ukrainian uses `uk` or `uk-UA` with region
## Testing
After adding a new language:
1. Restart the development server
2. Navigate to Settings > General
3. Verify your language appears in the dropdown
4. Select it to ensure it changes the UI language code
## Notes
- Keep language options alphabetically sorted in the dropdown for better UX
- Always use native script for language names in the dropdown
- The system will use English as fallback for any missing translations

View File

@@ -722,6 +722,7 @@
"com_ui_upload_success": "تم تحميل الملف بنجاح",
"com_ui_upload_type": "اختر نوع التحميل",
"com_ui_use_micrphone": "استخدام الميكروفون",
"com_ui_use_prompt": "استخدم الأمر",
"com_ui_variables": "متغيرات",
"com_ui_variables_info": "استخدم أقواس مزدوجة في نصك لإنشاء متغيرات، مثل `{{متغير كمثال}}`، لملئها لاحقاً عند استخدام النص البرمجي.",
"com_ui_version_var": "الإصدار {{0}}",
@@ -729,4 +730,4 @@
"com_ui_yes": "نعم",
"com_ui_zoom": "تكبير",
"com_user_message": "أنت"
}
}

View File

@@ -1 +0,0 @@
{}

View File

@@ -61,7 +61,6 @@
"com_assistants_non_retrieval_model": "La cerca de fitxers no està habilitada en aquest model. Selecciona un altre model.",
"com_assistants_retrieval": "Recuperació",
"com_assistants_running_action": "Executant acció",
"com_assistants_running_var": "Executant {{0}}",
"com_assistants_search_name": "Cerca assistents per nom",
"com_assistants_update_actions_error": "S'ha produït un error en crear o actualitzar l'acció.",
"com_assistants_update_actions_success": "Acció creada o actualitzada amb èxit",
@@ -123,7 +122,6 @@
"com_auth_reset_password_if_email_exists": "Si existeix un compte amb aquest correu, s'ha enviat un correu amb instruccions per restablir la contrasenya. Comprova la carpeta de correu brossa.",
"com_auth_reset_password_link_sent": "Correu enviat",
"com_auth_reset_password_success": "Contrasenya restablerta amb èxit",
"com_auth_saml_login": "Continuar amb SAML",
"com_auth_sign_in": "Inicia sessió",
"com_auth_sign_up": "Registra't",
"com_auth_submit_registration": "Envia el registre",
@@ -135,8 +133,6 @@
"com_auth_username_min_length": "El nom d'usuari ha de tenir almenys 2 caràcters",
"com_auth_verify_your_identity": "Verifica la teva identitat",
"com_auth_welcome_back": "Benvingut/da de nou",
"com_citation_more_details": "Més detalls sobre {{label}}",
"com_citation_source": "Font",
"com_click_to_download": "(fes clic aquí per descarregar)",
"com_download_expired": "(descàrrega caducada)",
"com_download_expires": "(fes clic aquí per descarregar - caduca en {{0}})",
@@ -303,18 +299,6 @@
"com_nav_auto_transcribe_audio": "Transcriu àudio automàticament",
"com_nav_automatic_playback": "Reprodueix automàticament el darrer missatge",
"com_nav_balance": "Balanç",
"com_nav_balance_day": "dia",
"com_nav_balance_days": "dies",
"com_nav_balance_hour": "hora",
"com_nav_balance_hours": "hores",
"com_nav_balance_minute": "minut",
"com_nav_balance_minutes": "minuts",
"com_nav_balance_month": "mes",
"com_nav_balance_months": "mesos",
"com_nav_balance_second": "segon",
"com_nav_balance_seconds": "segons",
"com_nav_balance_week": "setmana",
"com_nav_balance_weeks": "setmanes",
"com_nav_browser": "Navegador",
"com_nav_center_chat_input": "Centra la entrada del xat a la pantalla de benvinguda",
"com_nav_change_picture": "Canvia la imatge",
@@ -576,10 +560,8 @@
"com_ui_confirm_action": "Confirma l'acció",
"com_ui_confirm_admin_use_change": "Canviar aquesta opció bloquejarà l'accés als administradors, inclòs tu mateix. Segur que vols continuar?",
"com_ui_confirm_change": "Confirma el canvi",
"com_ui_connecting": "Connectant",
"com_ui_context": "Context",
"com_ui_continue": "Continua",
"com_ui_continue_oauth": "Continuar amb OAuth",
"com_ui_controls": "Controls",
"com_ui_convo_delete_error": "No s'ha pogut eliminar la conversa",
"com_ui_copied": "Copiat!",
@@ -643,7 +625,6 @@
"com_ui_duplication_processing": "Duplicant conversa...",
"com_ui_duplication_success": "Conversa duplicada amb èxit",
"com_ui_edit": "Edita",
"com_ui_edit_memory": "Editar memòria",
"com_ui_empty_category": "-",
"com_ui_endpoint": "Extrem",
"com_ui_endpoint_menu": "Menú d'extrem LLM",
@@ -720,8 +701,6 @@
"com_ui_manage": "Gestiona",
"com_ui_max_tags": "El màxim permès és {{0}}, s'utilitzen els últims valors.",
"com_ui_mcp_servers": "Servidors MCP",
"com_ui_memories": "Memòries",
"com_ui_memory": "Memòria",
"com_ui_mention": "Menciona un endpoint, assistent o predefinit per canviar-hi ràpidament",
"com_ui_min_tags": "No es poden eliminar més valors, el mínim requerit és {{0}}.",
"com_ui_misc": "Miscel·lània",
@@ -750,7 +729,6 @@
"com_ui_off": "Desactivat",
"com_ui_on": "Activat",
"com_ui_openai": "OpenAI",
"com_ui_optional": "(opcional)",
"com_ui_page": "Pàgina",
"com_ui_prev": "Anterior",
"com_ui_preview": "Previsualitza",
@@ -800,7 +778,6 @@
"com_ui_schema": "Esquema",
"com_ui_scope": "Abast",
"com_ui_search": "Cerca",
"com_ui_seconds": "segons",
"com_ui_secret_key": "Clau secreta",
"com_ui_select": "Selecciona",
"com_ui_select_file": "Selecciona un fitxer",
@@ -875,6 +852,7 @@
"com_ui_use_2fa_code": "Utilitza codi 2FA",
"com_ui_use_backup_code": "Utilitza codi de recuperació",
"com_ui_use_micrphone": "Utilitza el micròfon",
"com_ui_use_prompt": "Utilitza prompt",
"com_ui_used": "Utilitzat",
"com_ui_variables": "Variables",
"com_ui_variables_info": "Utilitza claus dobles per crear variables, per ex. `{{exemple variable}}`, per omplir-les quan utilitzis el prompt.",
@@ -882,11 +860,10 @@
"com_ui_version_var": "Versió {{0}}",
"com_ui_versions": "Versions",
"com_ui_view_source": "Mostra el xat original",
"com_ui_web_search_processing": "Processant resultats",
"com_ui_weekend_morning": "Bon cap de setmana",
"com_ui_write": "Escriptura",
"com_ui_x_selected": "{{0}} seleccionats",
"com_ui_yes": "Sí",
"com_ui_zoom": "Zoom",
"com_user_message": "Tu"
}
}

View File

@@ -721,6 +721,7 @@
"com_ui_use_2fa_code": "Použít kód 2FA",
"com_ui_use_backup_code": "Použít záložní kód",
"com_ui_use_micrphone": "Použít mikrofon",
"com_ui_use_prompt": "Použít výzvu",
"com_ui_used": "Použito",
"com_ui_variables": "Proměnné",
"com_ui_variables_info": "Použijte dvojité složené závorky k vytvoření proměnných, např. `{{příklad proměnné}}`, které lze vyplnit při použití výzvy.",
@@ -732,4 +733,4 @@
"com_ui_yes": "Ano",
"com_ui_zoom": "Přiblížit",
"com_user_message": "Vy"
}
}

View File

@@ -908,6 +908,7 @@
"com_ui_use_2fa_code": "Brug 2FA-kode i stedet",
"com_ui_use_backup_code": "Brug backup-koden i stedet",
"com_ui_use_micrphone": "Brug mikrofon",
"com_ui_use_prompt": "Brug prompt",
"com_ui_used": "Brugt",
"com_ui_variables": "Variabler",
"com_ui_variables_info": "Brug dobbelte parenteser i din tekst til at oprette variabler, f.eks.{{example variable}}`, som senere skal udfyldes ved brug af prompten.",
@@ -940,4 +941,4 @@
"com_ui_yes": "Ja",
"com_ui_zoom": "Zoom",
"com_user_message": "Du"
}
}

View File

@@ -503,6 +503,7 @@
"com_sidepanel_hide_panel": "Seitenleiste ausblenden",
"com_sidepanel_manage_files": "Dateien verwalten",
"com_sidepanel_mcp_no_servers_with_vars": "Keine MCP-Server mit konfigurierbaren Variablen.",
"com_sidepanel_mcp_variables_for": "MCP Variablen für {{0}}",
"com_sidepanel_parameters": "KI-Einstellungen",
"com_sources_image_alt": "Suchergebnis Bild\n",
"com_sources_more_sources": "+{{count}} Quellen\n",
@@ -839,6 +840,7 @@
"com_ui_low": "Niedrig",
"com_ui_manage": "Verwalten",
"com_ui_max_tags": "Die maximale Anzahl ist {{0}}, es werden die neuesten Werte verwendet.",
"com_ui_mcp_dialog_desc": "Bitte geben Sie unten die erforderlichen Informationen ein.",
"com_ui_mcp_enter_var": "Geben Sie einen Wert für {{0}} ein",
"com_ui_mcp_server_not_found": "Server nicht gefunden",
"com_ui_mcp_servers": "MCP Server",
@@ -1046,6 +1048,7 @@
"com_ui_use_backup_code": "Stattdessen Backup-Code verwenden",
"com_ui_use_memory": "Erinnerung nutzen",
"com_ui_use_micrphone": "Mikrofon verwenden",
"com_ui_use_prompt": "Prompt verwenden",
"com_ui_used": "Verwendet",
"com_ui_value": "Wert",
"com_ui_variables": "Variablen",
@@ -1083,4 +1086,4 @@
"com_ui_yes": "Ja",
"com_ui_zoom": "Zoom",
"com_user_message": "Du"
}
}

View File

@@ -435,10 +435,8 @@
"com_nav_lang_spanish": "Español",
"com_nav_lang_swedish": "Svenska",
"com_nav_lang_thai": "ไทย",
"com_nav_lang_tibetan": "བོད་སྐད་",
"com_nav_lang_traditional_chinese": "繁體中文",
"com_nav_lang_turkish": "Türkçe",
"com_nav_lang_ukrainian": "Українська",
"com_nav_lang_uyghur": "Uyƣur tili",
"com_nav_lang_vietnamese": "Tiếng Việt",
"com_nav_language": "Language",
@@ -508,6 +506,7 @@
"com_sidepanel_hide_panel": "Hide Panel",
"com_sidepanel_manage_files": "Manage Files",
"com_sidepanel_mcp_no_servers_with_vars": "No MCP servers with configurable variables.",
"com_sidepanel_mcp_variables_for": "MCP Variables for {{0}}",
"com_sidepanel_parameters": "Parameters",
"com_sources_image_alt": "Search result image",
"com_sources_more_sources": "+{{count}} sources",
@@ -774,9 +773,6 @@
"com_ui_field_required": "This field is required",
"com_ui_file_size": "File Size",
"com_ui_files": "Files",
"com_ui_file_token_limit": "File Token Limit",
"com_ui_file_token_limit_desc": "Set maximum token limit for file processing to control costs and resource usage",
"com_ui_filter_prompts": "Filter Prompts",
"com_ui_filter_prompts_name": "Filter prompts by name",
"com_ui_final_touch": "Final touch",
@@ -853,16 +849,21 @@
"com_ui_manage": "Manage",
"com_ui_max_tags": "Maximum number allowed is {{0}}, using latest values.",
"com_ui_mcp_authenticated_success": "MCP server '{{0}}' authenticated successfully",
"com_ui_mcp_dialog_desc": "Please enter the necessary information below.",
"com_ui_mcp_enter_var": "Enter value for {{0}}",
"com_ui_mcp_init_failed": "Failed to initialize MCP server",
"com_ui_mcp_initialize": "Initialize",
"com_ui_mcp_initialized_success": "MCP server '{{0}}' initialized successfully",
"com_ui_mcp_not_authenticated": "{{0}} not authenticated (OAuth Required)",
"com_ui_mcp_not_initialized": "{{0}} not initialized",
"com_ui_mcp_oauth_cancelled": "OAuth login cancelled for {{0}}",
"com_ui_mcp_oauth_no_url": "OAuth authentication required but no URL provided",
"com_ui_mcp_oauth_timeout": "OAuth login timed out for {{0}}",
"com_ui_mcp_server_not_found": "Server not found.",
"com_ui_mcp_servers": "MCP Servers",
"com_ui_mcp_update_var": "Update {{0}}",
"com_ui_mcp_url": "MCP Server URL",
"com_ui_mcp_init_cancelled": "MCP server '{{0}}' initialization was cancelled due to simultaneous request",
"com_ui_medium": "Medium",
"com_ui_memories": "Memories",
"com_ui_memories_allow_create": "Allow creating Memories",
@@ -916,6 +917,7 @@
"com_ui_oauth_error_missing_code": "Authorization code is missing. Please try again.",
"com_ui_oauth_error_missing_state": "State parameter is missing. Please try again.",
"com_ui_oauth_error_title": "Authentication Failed",
"com_ui_oauth_flow_desc": "Complete the OAuth flow in the new window, then return here.",
"com_ui_oauth_success_description": "Your authentication was successful. This window will close in",
"com_ui_oauth_success_title": "Authentication Successful",
"com_ui_of": "of",
@@ -1070,6 +1072,7 @@
"com_ui_use_backup_code": "Use Backup Code Instead",
"com_ui_use_memory": "Use memory",
"com_ui_use_micrphone": "Use microphone",
"com_ui_use_prompt": "Use prompt",
"com_ui_used": "Used",
"com_ui_value": "Value",
"com_ui_variables": "Variables",
@@ -1107,4 +1110,4 @@
"com_ui_yes": "Yes",
"com_ui_zoom": "Zoom",
"com_user_message": "You"
}
}

View File

@@ -743,6 +743,7 @@
"com_ui_upload_success": "Archivo subido con éxito",
"com_ui_upload_type": "Seleccionar tipo de carga",
"com_ui_use_micrphone": "Usar micrófono",
"com_ui_use_prompt": "Usar prompt",
"com_ui_variables": "Variables",
"com_ui_variables_info": "Utilice llaves dobles en su texto para crear variables, por ejemplo `{{variable de ejemplo}}`, para completarlas posteriormente al usar el prompt.",
"com_ui_verify": "Verificar",
@@ -754,4 +755,4 @@
"com_ui_yes": "Sí",
"com_ui_zoom": "Zoom",
"com_user_message": "Usted"
}
}

View File

@@ -930,6 +930,7 @@
"com_ui_use_2fa_code": "Kasuta hoopis 2FA koodi",
"com_ui_use_backup_code": "Kasuta hoopis varukoodi",
"com_ui_use_micrphone": "Kasuta mikrofoni",
"com_ui_use_prompt": "Kasuta sisendit",
"com_ui_used": "Kasutatud",
"com_ui_variables": "Muutujad",
"com_ui_variables_info": "Kasuta oma tekstis topelt sulgusid, et luua muutujaid, nt `{{näidismuutuja}}`, et hiljem sisendi kasutamisel täita.",
@@ -962,4 +963,4 @@
"com_ui_yes": "Jah",
"com_ui_zoom": "Suumi",
"com_user_message": "Sina"
}
}

View File

@@ -832,6 +832,7 @@
"com_ui_use_2fa_code": "به جای آن از کد 2FA استفاده کنید",
"com_ui_use_backup_code": "به جای آن از کد پشتیبان استفاده کنید",
"com_ui_use_micrphone": "از میکروفون استفاده کنید",
"com_ui_use_prompt": "از prompt استفاده کنید",
"com_ui_used": "استفاده می شود",
"com_ui_variables": "متغیرها",
"com_ui_variables_info": "از پرانتزهای دوتایی در متن خود برای ایجاد متغیرها استفاده کنید، به عنوان مثال. `{{example variable}}`، تا بعداً هنگام استفاده از درخواست پر شود.",
@@ -844,4 +845,4 @@
"com_ui_yes": "بله",
"com_ui_zoom": "بزرگنمایی ضربه بزنید؛",
"com_user_message": "شما"
}
}

View File

@@ -596,10 +596,11 @@
"com_ui_upload_invalid_var": "Virheellinen ladattava tiedosto. Tiedoston täytyy olla enintään {{0}} MB kokoinen kuvatiedosto",
"com_ui_upload_success": "Tiedoston lataus onnistui",
"com_ui_use_micrphone": "Käytä mikrofonia",
"com_ui_use_prompt": "Käytä syötettä",
"com_ui_variables": "Muuttujat",
"com_ui_variables_info": "Käytä kaksoisaaltosulkeita tekstissäsi muuttujien luomiseen, esim. {{esimerkkimuuttuja}}. Muuttujia voi täyttää myöhemmin syötettä käyttäessä.",
"com_ui_version_var": "Versio {{0}}",
"com_ui_versions": "Versiot",
"com_ui_yes": "Kyllä",
"com_user_message": "Sinä"
}
}

View File

@@ -502,6 +502,7 @@
"com_sidepanel_hide_panel": "Masquer le panneau",
"com_sidepanel_manage_files": "Gérer les fichiers",
"com_sidepanel_mcp_no_servers_with_vars": "Aucun serveur MCP dont les variables sont configurables.",
"com_sidepanel_mcp_variables_for": "Variables MCP de {{0}}",
"com_sidepanel_parameters": "Paramètres",
"com_sources_image_alt": "Image de résultat de recherche",
"com_sources_more_sources": "+{{count}} sources",
@@ -836,6 +837,7 @@
"com_ui_low": "Faible",
"com_ui_manage": "Gérer",
"com_ui_max_tags": "Le nombre maximum autorisé est {{0}}, en utilisant les dernières valeurs.",
"com_ui_mcp_dialog_desc": "Veuillez saisir les informations importantes ci-dessous.",
"com_ui_mcp_enter_var": "Saisissez la valeur de {{0}}",
"com_ui_mcp_server_not_found": "Le serveur n'a pas été trouvé.",
"com_ui_mcp_servers": "Serveurs MCP",
@@ -1019,18 +1021,18 @@
"com_ui_update": "Mettre à jour",
"com_ui_update_mcp_error": "Une erreur est survenue lors de la création ou l'actualisation du MCP.",
"com_ui_update_mcp_success": "Création ou actualisation du MCP réussie",
"com_ui_upload": "Télécharger",
"com_ui_upload_code_files": "Télécharger pour l'Interpréteur de Code",
"com_ui_upload": "Téléverser",
"com_ui_upload_code_files": "Téléverser pour l'Interpréteur de Code",
"com_ui_upload_delay": "Le téléversement de \"{{0}}\" prend plus de temps que prévu. Veuillez patienter pendant que le fichier termine son indexation pour la récupération.",
"com_ui_upload_error": "Une erreur s'est produite lors du téléversement de votre fichier",
"com_ui_upload_file_context": "Télécharger le contexte du fichier",
"com_ui_upload_file_search": "Télécharger pour la recherche dans un fichier",
"com_ui_upload_files": "Télécharger des fichiers",
"com_ui_upload_image": "Télécharger une image",
"com_ui_upload_file_context": "Téléverser le contexte du fichier",
"com_ui_upload_file_search": "Téléverser pour la recherche de fichiers",
"com_ui_upload_files": "Téléverser des fichiers",
"com_ui_upload_image": "Téléverser une image",
"com_ui_upload_image_input": "Téléverser une image",
"com_ui_upload_invalid": "Fichier non valide pour le téléchargement. L'image ne doit pas dépasser la limite",
"com_ui_upload_invalid_var": "Fichier non valide pour le téléchargement. L'image ne doit pas dépasser {{0}} Mo",
"com_ui_upload_ocr_text": "Téléchager en tant que texte",
"com_ui_upload_ocr_text": "Téléverser en tant que texte",
"com_ui_upload_success": "Fichier téléversé avec succès",
"com_ui_upload_type": "Sélectionner le type de téléversement",
"com_ui_usage": "Utilisation",
@@ -1038,6 +1040,7 @@
"com_ui_use_backup_code": "Utiliser un code de sauvegarde à la place",
"com_ui_use_memory": "Utiliser le Souvenir",
"com_ui_use_micrphone": "Utiliser le microphone",
"com_ui_use_prompt": "Utiliser le prompt",
"com_ui_used": "Déjà utilisé",
"com_ui_value": "Valeur",
"com_ui_variables": "Variables",
@@ -1074,4 +1077,4 @@
"com_ui_yes": "Oui",
"com_ui_zoom": "Zoom",
"com_user_message": "Vous"
}
}

View File

@@ -495,6 +495,7 @@
"com_sidepanel_hide_panel": "הסתר פאנל",
"com_sidepanel_manage_files": "נהל קבצים",
"com_sidepanel_mcp_no_servers_with_vars": "אין שרתי MCP עם משתנים הניתנים להגדרה.",
"com_sidepanel_mcp_variables_for": "משתני MCP עבור {{0}}",
"com_sidepanel_parameters": "פרמטרים",
"com_sources_image_alt": "תמונת תוצאות החיפוש",
"com_sources_more_sources": "+{{count}}} מקורות",
@@ -831,6 +832,7 @@
"com_ui_low": "נמוך",
"com_ui_manage": "נהל",
"com_ui_max_tags": "המספר המקסימלי המותר על פי הערכים העדכניים הוא {{0}}.",
"com_ui_mcp_dialog_desc": "אנא הזן למטה את המידע הדרוש",
"com_ui_mcp_enter_var": "הזן ערך עבור {{0}}",
"com_ui_mcp_server_not_found": "נשרת לא נמצא",
"com_ui_mcp_servers": "שרתי MCP",
@@ -1037,6 +1039,7 @@
"com_ui_use_backup_code": "השתמש בקוד גיבוי במקום",
"com_ui_use_memory": "השתמש בזיכרון",
"com_ui_use_micrphone": "שימוש במיקורפון",
"com_ui_use_prompt": "השתמש בהנחיה (פרומפט)",
"com_ui_used": "נוצל",
"com_ui_value": "ערך",
"com_ui_variables": "משתנים",
@@ -1066,4 +1069,4 @@
"com_ui_yes": "כן",
"com_ui_zoom": "זום",
"com_user_message": "אתה"
}
}

View File

@@ -832,6 +832,7 @@
"com_ui_use_2fa_code": "2FA kód használata helyette",
"com_ui_use_backup_code": "Biztonsági mentési kód használata helyette",
"com_ui_use_micrphone": "Mikrofon használata",
"com_ui_use_prompt": "Prompt használata",
"com_ui_used": "Használt",
"com_ui_variables": "Változók",
"com_ui_variables_info": "Használjon dupla kapcsos zárójeleket a szövegben változók létrehozásához, pl. `{{példa változó}}`, amelyeket később a prompt használatakor kitölthet.",
@@ -844,4 +845,4 @@
"com_ui_yes": "Igen",
"com_ui_zoom": "Zoom",
"com_user_message": "Ön"
}
}

View File

@@ -198,4 +198,4 @@
"com_ui_write": "Գրում է",
"com_ui_yes": "Այո",
"com_user_message": "Դու"
}
}

View File

@@ -35,8 +35,6 @@ import translationHy from './hy/translation.json';
import translationFi from './fi/translation.json';
import translationZh_Hans from './zh-Hans/translation.json';
import translationZh_Hant from './zh-Hant/translation.json';
import translationBo from './bo/translation.json';
import translationUk from './uk/translation.json';
export const defaultNS = 'translation';
@@ -73,8 +71,6 @@ export const resources = {
hu: { translation: translationHu },
hy: { translation: translationHy },
fi: { translation: translationFi },
bo: { translation: translationBo },
uk: { translation: translationUk },
} as const;
i18n

View File

@@ -287,5 +287,6 @@
"com_ui_upload": "Unggah",
"com_ui_upload_error": "Ada kesalahan saat mengunggah file Anda",
"com_ui_upload_success": "Berhasil mengunggah file",
"com_ui_use_prompt": "Gunakan petunjuk",
"com_user_message": "Kamu"
}
}

View File

@@ -815,6 +815,7 @@
"com_ui_use_2fa_code": "Usa invece il codice 2FA",
"com_ui_use_backup_code": "Usa invece il codice di backup",
"com_ui_use_micrphone": "Usa microfono",
"com_ui_use_prompt": "Usa prompt",
"com_ui_used": "Usato",
"com_ui_variables": "Variabili",
"com_ui_variables_info": "Usa le doppie parentesi graffe nel testo per creare variabili, ad esempio `{{variabile esempio}}`, da compilare successivamente quando utilizzi il prompt.",
@@ -827,4 +828,4 @@
"com_ui_yes": "Sì",
"com_ui_zoom": "Zoom",
"com_user_message": "Mostra nome utente nei messaggi"
}
}

View File

@@ -501,6 +501,7 @@
"com_sidepanel_hide_panel": "パネルを隠す",
"com_sidepanel_manage_files": "ファイルを管理",
"com_sidepanel_mcp_no_servers_with_vars": "設定可能な変数を持つMCPサーバーはありません。",
"com_sidepanel_mcp_variables_for": "{{0}}のMCP変数",
"com_sidepanel_parameters": "パラメータ",
"com_sources_image_alt": "検索結果画像",
"com_sources_more_sources": "+{{count}} ソース",
@@ -838,6 +839,7 @@
"com_ui_low": "低い",
"com_ui_manage": "管理",
"com_ui_max_tags": "最新の値を使用した場合、許可される最大数は {{0}} です。",
"com_ui_mcp_dialog_desc": "以下に必要事項を入力してください。",
"com_ui_mcp_enter_var": "{{0}}の値を入力する。",
"com_ui_mcp_server_not_found": "サーバーが見つかりません。",
"com_ui_mcp_servers": "MCP サーバー",
@@ -1045,6 +1047,7 @@
"com_ui_use_backup_code": "代わりにバックアップコードを使用する",
"com_ui_use_memory": "メモリを使用する",
"com_ui_use_micrphone": "マイクを使用する",
"com_ui_use_prompt": "プロンプトの利用",
"com_ui_used": "使用済み",
"com_ui_value": "値",
"com_ui_variables": "変数",
@@ -1082,4 +1085,4 @@
"com_ui_yes": "はい",
"com_ui_zoom": "ズーム",
"com_user_message": "あなた"
}
}

View File

@@ -66,4 +66,4 @@
"com_nav_lang_turkish": "თურქული",
"com_nav_lang_vietnamese": "ვიეტნამური",
"com_nav_language": "ენა"
}
}

View File

@@ -24,7 +24,6 @@
"com_agents_missing_provider_model": "에이전트를 생성하기 전에 제공업체와 모델을 선택해 주세요",
"com_agents_name_placeholder": "선택 사항: 에이전트의 이름",
"com_agents_no_access": "이 에이전트를 수정할 권한이 없습니다",
"com_agents_no_agent_id_error": "에이전트 ID를 찾을 수 없습니다. 먼저 에이전트가 생성되었는지 확인하세요.",
"com_agents_not_available": "에이전트를 사용할 수 없음",
"com_agents_search_info": "활성화하면 에이전트가 최신 정보를 검색할 수 있도록 허용합니다. 유효한 API 키가 필요합니다.",
"com_agents_search_name": "이름으로 에이전트 검색",
@@ -129,7 +128,6 @@
"com_auth_reset_password_if_email_exists": "해당 이메일 주소로 등록된 계정이 있다면, 비밀번호 재설정 안내 메일을 발송했습니다. 스팸 폴더도 확인해 주세요.",
"com_auth_reset_password_link_sent": "이메일 전송",
"com_auth_reset_password_success": "비밀번호 재설정 성공",
"com_auth_saml_login": "SAML로 계속하기",
"com_auth_sign_in": "로그인",
"com_auth_sign_up": "가입하기",
"com_auth_submit_registration": "등록하기",
@@ -158,7 +156,6 @@
"com_endpoint_anthropic_thinking_budget": "Claude의 내부 추론에 사용할 수 있는 최대 토큰 수를 결정합니다. 큰 예산은 복잡한 문제에 대해 더 철저한 분석을 가능하게 하여 응답 품질을 개선할 수 있지만, 32K 이상 범위에서는 Claude가 할당된 전체 예산을 모두 사용하지 않을 수도 있습니다. 이 설정은 \"최대 출력 토큰\"보다 낮아야 합니다.",
"com_endpoint_anthropic_topk": "Top-k는 모델이 출력에 사용할 토큰을 선택하는 방식을 변경합니다. top-k가 1인 경우 모델의 어휘 중 가장 확률이 높은 토큰이 선택됩니다(greedy decoding). top-k가 3인 경우 다음 토큰은 가장 확률이 높은 3개의 토큰 중에서 선택됩니다(temperature 사용).",
"com_endpoint_anthropic_topp": "Top-p는 모델이 출력에 사용할 토큰을 선택하는 방식을 변경합니다. 토큰은 가장 높은 확률부터 가장 낮은 확률까지 선택됩니다. 선택된 토큰의 확률의 합이 top-p 값과 같아질 때까지 선택됩니다.",
"com_endpoint_anthropic_use_web_search": "Anthropic의 내장 검색 기능을 사용하여 웹 검색 기능을 활성화합니다. 모델이 최신 정보를 검색하여 더 정확하고 현재의 응답을 제공할 수 있게 합니다.",
"com_endpoint_assistant": "어시스턴트",
"com_endpoint_assistant_model": "에이전트 모델",
"com_endpoint_assistant_placeholder": "오른쪽 사이드 패널에서 에이전트를 선택하세요",
@@ -196,8 +193,6 @@
"com_endpoint_deprecated": "단축됨",
"com_endpoint_deprecated_info": "이 엔드포인트는 단축되었으며 향후 버전에서 제거될 수 있습니다. 대신 에이전트 엔드포인트를 사용하세요.",
"com_endpoint_deprecated_info_a11y": "이 플러그인 엔드포인트는 단축되었으며 향후 버전에서 제거될 수 있습니다. 대신 에이전트 엔드포인트를 사용하세요.",
"com_endpoint_disable_streaming": "스트리밍 응답을 비활성화하고 완전한 응답을 한 번에 받습니다. o3와 같이 스트리밍을 위해 조직 확인이 필요한 모델에 유용합니다",
"com_endpoint_disable_streaming_label": "스트리밍 비활성화",
"com_endpoint_examples": " 프리셋",
"com_endpoint_export": "내보내기",
"com_endpoint_export_share": "내보내기/공유",
@@ -206,11 +201,8 @@
"com_endpoint_google_custom_name_placeholder": "Google에 대한 사용자 정의 이름 설정",
"com_endpoint_google_maxoutputtokens": "응답에서 생성할 수 있는 최대 토큰 수입니다. 짧은 응답에는 낮은 값을, 긴 응답에는 높은 값을 지정하세요.",
"com_endpoint_google_temp": "높은 값 = 더 무작위, 낮은 값 = 더 집중적이고 결정적입니다. 이 값을 변경하거나 Top P 중 하나만 변경하는 것을 권장합니다.",
"com_endpoint_google_thinking": "추론을 활성화하거나 비활성화합니다. 이 설정은 특정 모델(2.5 시리즈)에서만 지원됩니다. 이전 모델의 경우 이 설정이 영향을 미치지 않을 수 있습니다.",
"com_endpoint_google_thinking_budget": "모델이 사용하는 추론 토큰 수를 안내합니다. 실제 양은 프롬프트에 따라 이 값을 초과하거나 미달될 수 있습니다.\n\n이 설정은 특정 모델(2.5 시리즈)에서만 지원됩니다. Gemini 2.5 Pro는 128-32,768 토큰을 지원합니다. Gemini 2.5 Flash는 0-24,576 토큰을 지원합니다. Gemini 2.5 Flash Lite는 512-24,576 토큰을 지원합니다.\n\n비워두거나 \"-1\"로 설정하면 모델이 언제 얼마나 생각할지 자동으로 결정합니다. 기본적으로 Gemini 2.5 Flash Lite는 생각하지 않습니다.",
"com_endpoint_google_topk": "Top-k는 모델이 출력에 사용할 토큰을 선택하는 방식을 변경합니다. top-k가 1인 경우 모델의 어휘 중 가장 확률이 높은 토큰이 선택됩니다(greedy decoding). top-k가 3인 경우 다음 토큰은 가장 확률이 높은 3개의 토큰 중에서 선택됩니다(temperature 사용).",
"com_endpoint_google_topp": "Top-p는 모델이 출력에 사용할 토큰을 선택하는 방식을 변경합니다. 토큰은 가장 높은 확률부터 가장 낮은 확률까지 선택됩니다. 선택된 토큰의 확률의 합이 top-p 값과 같아질 때까지 선택됩니다.",
"com_endpoint_google_use_search_grounding": "Google의 검색 그라운딩 기능을 사용하여 실시간 웹 검색 결과로 응답을 향상시킵니다. 모델이 현재 정보에 접근하여 더 정확하고 최신의 답변을 제공할 수 있게 합니다.",
"com_endpoint_instructions_assistants": "에이전트 지침 재정의",
"com_endpoint_instructions_assistants_placeholder": "어시스턴트의 지침을 재정의합니다. 이를 통해 실행마다 동작을 수정할 수 있습니다.",
"com_endpoint_max_output_tokens": "최대 출력 토큰 수",
@@ -228,14 +220,11 @@
"com_endpoint_openai_pres": "텍스트에서 토큰이 나타나는지 여부에 따라 새로운 토큰에 패널티를 부여합니다. 이전에 나온 텍스트에 나타나는 토큰에 대한 패널티를 증가시켜 새로운 주제에 대해 이야기할 가능성을 높입니다.",
"com_endpoint_openai_prompt_prefix_placeholder": "시스템 메시지에 포함할 사용자 정의 지시사항을 설정하세요. 기본값: 없음",
"com_endpoint_openai_reasoning_effort": "o1 및 o3 모델 전용: 추론 모델의 추론 노력(reasoning effort)을 제한합니다. 추론 노력을 줄이면 응답 속도가 빨라지고, 응답에서 사용되는 추론 관련 토큰 수가 줄어들 수 있습니다.",
"com_endpoint_openai_reasoning_summary": "Responses API 전용: 모델이 수행한 추론의 요약입니다. 디버깅과 모델의 추론 과정을 이해하는 데 유용할 수 있습니다. none, auto, concise 또는 detailed로 설정하세요.",
"com_endpoint_openai_resend": "이전에 첨부한 모든 이미지를 다시 전송합니다. 참고: 이렇게 하면 토큰 비용이 크게 증가할 수 있으며, 많은 이미지를 첨부하면 오류가 발생할 수 있습니다.",
"com_endpoint_openai_resend_files": "이전에 첨부한 모든 파일을 다시 보내세요. 참고: 이렇게 하면 토큰 비용이 증가하고 많은 첨부 파일로 인해 오류가 발생할 수 있습니다.",
"com_endpoint_openai_stop": "API가 추가 토큰 생성을 중지할 최대 4개의 시퀀스입니다.",
"com_endpoint_openai_temp": "높은 값 = 더 무작위, 낮은 값 = 더 집중적이고 결정적입니다. 이 값을 변경하거나 Top P 중 하나만 변경하는 것을 권장합니다.",
"com_endpoint_openai_topp": "온도를 사용한 샘플링 대신, top_p 확률 질량을 고려하는 nucleus 샘플링입니다. 따라서 0.1은 상위 10% 확률 질량을 구성하는 토큰만 고려합니다. 이 값을 변경하거나 온도를 변경하는 것을 권장하지만, 둘 다 변경하지는 마세요.",
"com_endpoint_openai_use_responses_api": "OpenAI의 확장 기능이 포함된 Chat Completions 대신 Responses API를 사용합니다. o1-pro, o3-pro에 필수이며 추론 요약을 활성화하는 데 필요합니다.",
"com_endpoint_openai_use_web_search": "OpenAI의 내장 검색 기능을 사용하여 웹 검색 기능을 활성화합니다. 모델이 최신 정보를 검색하여 더 정확하고 현재의 응답을 제공할 수 있게 합니다.",
"com_endpoint_output": "출력",
"com_endpoint_plug_image_detail": "이미지 상세 정보",
"com_endpoint_plug_resend_files": "파일 재전송",
@@ -266,7 +255,6 @@
"com_endpoint_prompt_prefix_assistants_placeholder": "추가 지시사항 또는 컨텍스트를 Assistant의 기본 지시사항에 추가합니다. 비어 있으면 무시됩니다.",
"com_endpoint_prompt_prefix_placeholder": "사용자 정의 지시사항 또는 컨텍스트를 설정하세요. 비어 있으면 무시됩니다.",
"com_endpoint_reasoning_effort": "추론 노력",
"com_endpoint_reasoning_summary": "추론 요약",
"com_endpoint_save_as_preset": "프리셋으로 저장",
"com_endpoint_search": "이름으로 엔드포인트 검색",
"com_endpoint_search_endpoint_models": "{{0}} 모델 검색중...",
@@ -282,8 +270,6 @@
"com_endpoint_top_k": "Top K",
"com_endpoint_top_p": "Top P",
"com_endpoint_use_active_assistant": "활성 에이전트 사용",
"com_endpoint_use_responses_api": "Responses API 사용",
"com_endpoint_use_search_grounding": "Google 검색으로 그라운딩",
"com_error_expired_user_key": "{{0}}에 대한 키가 {{1}}에 만료되었습니다. 새 키를 제공하고 다시 시도해주세요.",
"com_error_files_dupe": "중복된 파일이 감지되었습니다",
"com_error_files_empty": "빈 파일은 허용되지 않습니다",
@@ -292,8 +278,6 @@
"com_error_files_upload": "파일 업로드 중 오류가 발생했습니다",
"com_error_files_upload_canceled": "파일 업로드가 취소되었습니다. 참고: 업로드 처리가 아직 진행 중일 수 있으며 수동으로 삭제해야 할 수 있습니다.",
"com_error_files_validation": "파일 유효성 검사 중 오류가 발생했습니다",
"com_error_google_tool_conflict": "내장 Google 도구는 외부 도구와 함께 사용할 수 없습니다. 내장 도구 또는 외부 도구 중 하나를 비활성화하세요.",
"com_error_heic_conversion": "HEIC 이미지를 JPEG로 변환하는 데 실패했습니다. 수동으로 이미지를 변환하거나 다른 형식을 사용해 보세요.",
"com_error_input_length": "최신 메시지의 토큰 수가 너무 많아 토큰 제한을 초과했거나, 토큰 제한 관련 파라미터가 잘못 설정되어 있어 컨텍스트 창에 부정적인 영향을 미치고 있습니다. 자세한 정보: {{0}}. 메시지를 줄이거나, 대화 파라미터에서 최대 컨텍스트 크기를 조정하거나, 대화를 포크(fork)하여 계속 진행해 주세요.",
"com_error_invalid_agent_provider": "\"{{0}}\" 제공자는 에이전트와 함께 사용할 수 없습니다. 에이전트 설정으로 이동하여 현재 사용 가능한 제공자를 선택하세요.",
"com_error_invalid_user_key": "제공된 키가 유효하지 않습니다. 키를 제공하고 다시 시도해주세요.",
@@ -306,7 +290,6 @@
"com_files_table": "내용이 비어 있었습니다.",
"com_generated_files": "생성된 파일:",
"com_hide_examples": "예시 숨기기",
"com_info_heic_converting": "HEIC 이미지를 JPEG로 변환 중...",
"com_nav_2fa": "이단계 인증 (2FA)",
"com_nav_account_settings": "계정 설정",
"com_nav_always_make_prod": "항상 새 버전을 프로덕션으로 설정",
@@ -324,26 +307,6 @@
"com_nav_auto_transcribe_audio": "오디오 자동 변환",
"com_nav_automatic_playback": "최신 메시지 자동 재생",
"com_nav_balance": "잔고",
"com_nav_balance_auto_refill_disabled": "자동 충전이 비활성화되었습니다.",
"com_nav_balance_auto_refill_error": "자동 충전 설정을 불러오는 중 오류가 발생했습니다.",
"com_nav_balance_auto_refill_settings": "자동 충전 설정",
"com_nav_balance_day": "일",
"com_nav_balance_days": "일",
"com_nav_balance_every": "매",
"com_nav_balance_hour": "시간",
"com_nav_balance_hours": "시간",
"com_nav_balance_interval": "간격:",
"com_nav_balance_last_refill": "마지막 충전:",
"com_nav_balance_minute": "분",
"com_nav_balance_minutes": "분",
"com_nav_balance_month": "월",
"com_nav_balance_next_refill": "다음 충전:",
"com_nav_balance_next_refill_info": "다음 충전은 두 조건이 모두 충족될 때만 자동으로 발생합니다: 마지막 충전 이후 지정된 시간 간격이 지났고, 프롬프트를 보내면 잔액이 0 아래로 떨어질 때입니다.",
"com_nav_balance_refill_amount": "충전 금액:",
"com_nav_balance_second": "초",
"com_nav_balance_seconds": "초",
"com_nav_balance_week": "주",
"com_nav_balance_weeks": "주",
"com_nav_browser": "브라우저",
"com_nav_center_chat_input": "환영 화면에서 채팅 입력 중앙 정렬",
"com_nav_change_picture": "프로필 사진 변경",
@@ -404,7 +367,6 @@
"com_nav_info_show_thinking": "이 기능을 활성화하면, 채팅에서 추론 드롭다운이 기본적으로 열려 있어 AI의 사고 과정을 실시간으로 볼 수 있습니다. 비활성화하면 더 깔끔하고 간결한 인터페이스를 위해 드롭다운이 기본적으로 닫힙니다.",
"com_nav_info_user_name_display": "활성화하면 보내는 각 메시지 위에 사용자 이름이 표시됩니다. 비활성화하면 내 메시지 위에 \"나\"라고만 표시됩니다.",
"com_nav_lang_arabic": "العربية",
"com_nav_lang_armenian": "아르메니아어",
"com_nav_lang_auto": "자동 감지",
"com_nav_lang_brazilian_portuguese": "Português Brasileiro",
"com_nav_lang_catalan": "카탈로니아어",
@@ -424,7 +386,6 @@
"com_nav_lang_italian": "Italiano",
"com_nav_lang_japanese": "日本語",
"com_nav_lang_korean": "한국어",
"com_nav_lang_latvian": "라트비아어",
"com_nav_lang_persian": "페르시아어",
"com_nav_lang_polish": "Polski",
"com_nav_lang_portuguese": "Português",
@@ -434,17 +395,12 @@
"com_nav_lang_thai": "ไทย",
"com_nav_lang_traditional_chinese": "繁體中文",
"com_nav_lang_turkish": "Türkçe",
"com_nav_lang_uyghur": "위구르어",
"com_nav_lang_vietnamese": "Tiếng Việt",
"com_nav_language": "언어",
"com_nav_latex_parsing": "메시지에서 LaTeX 구문 분석(성능에 영향을 줄 수 있음)",
"com_nav_log_out": "로그아웃",
"com_nav_long_audio_warning": "긴 텍스트일수록 처리 시간이 더 오래 걸립니다.",
"com_nav_maximize_chat_space": "채팅창 최대화",
"com_nav_mcp_configure_server": "{{0}} 설정",
"com_nav_mcp_status_connecting": "{{0}} - 연결 중",
"com_nav_mcp_vars_update_error": "MCP 사용자 정의 변수 업데이트 오류: {{0}}",
"com_nav_mcp_vars_updated": "MCP 사용자 정의 변수가 성공적으로 업데이트되었습니다.",
"com_nav_modular_chat": "대화 중간에 엔드포인트 전환 허용",
"com_nav_my_files": "내 파일",
"com_nav_not_supported": "지원되지 않음",
@@ -468,8 +424,6 @@
"com_nav_setting_chat": "채팅",
"com_nav_setting_data": "데이터 제어",
"com_nav_setting_general": "일반",
"com_nav_setting_mcp": "MCP 설정",
"com_nav_setting_personalization": "개인화",
"com_nav_setting_speech": "음성",
"com_nav_settings": "설정",
"com_nav_shared_links": "공유 링크",
@@ -502,7 +456,6 @@
"com_sidepanel_conversation_tags": "북마크",
"com_sidepanel_hide_panel": "패널 숨기기",
"com_sidepanel_manage_files": "파일 관리",
"com_sidepanel_mcp_no_servers_with_vars": "설정 가능한 변수가 있는 MCP 서버가 없습니다.",
"com_sidepanel_parameters": "매개변수",
"com_sources_image_alt": "검색 결과 이미지",
"com_sources_more_sources": "+{{count}}개 소스",
@@ -522,7 +475,6 @@
"com_ui_2fa_verified": "이단계 인증이 성공적으로 인증되었습니다",
"com_ui_accept": "동의합니다",
"com_ui_action_button": "액션 버튼",
"com_ui_active": "활성",
"com_ui_add": "추가",
"com_ui_add_mcp": "MCP 추가",
"com_ui_add_mcp_server": "MCP 서버 추가",
@@ -575,7 +527,6 @@
"com_ui_archive_error": "대화 아카이브 실패",
"com_ui_artifact_click": "클릭하여 열기",
"com_ui_artifacts": "아티팩트",
"com_ui_artifacts_options": "아티팩트 옵션",
"com_ui_artifacts_toggle": "아티팩트 UI 표시/숨기기",
"com_ui_artifacts_toggle_agent": "아티팩트 활성화",
"com_ui_ascending": "오름차순",
@@ -593,14 +544,11 @@
"com_ui_attachment": "첨부 파일",
"com_ui_auth_type": "인증 유형",
"com_ui_auth_url": "인증 URL",
"com_ui_authenticate": "인증",
"com_ui_authentication": "인증",
"com_ui_authentication_type": "인증 방식",
"com_ui_auto": "자동",
"com_ui_available_tools": "사용 가능 툴",
"com_ui_avatar": "프로필 사진",
"com_ui_azure": "Azure",
"com_ui_back": "뒤로",
"com_ui_back_to_chat": "채팅으로 돌아가기",
"com_ui_back_to_prompts": "프롬프트로 돌아가기",
"com_ui_backup_codes": "백업 코드",
@@ -640,21 +588,16 @@
"com_ui_client_secret": "클라이언트 비밀",
"com_ui_close": "닫기",
"com_ui_close_menu": "메뉴 닫기",
"com_ui_close_window": "창 닫기",
"com_ui_code": "코드",
"com_ui_collapse_chat": "채팅 접기",
"com_ui_command_placeholder": "선택 사항: 프롬프트에 대한 명령어를 입력하세요. 입력하지 않으면 이름이 사용됩니다.",
"com_ui_command_usage_placeholder": "명령어나 이름으로 프롬프트 선택",
"com_ui_complete_setup": "설정 완료",
"com_ui_concise": "간결",
"com_ui_configure_mcp_variables_for": "{{0}}의 변수 설정",
"com_ui_confirm_action": "작업 확인",
"com_ui_confirm_admin_use_change": "이 설정을 변경하면 관리자 포함 모든 사용자의 접근이 차단됩니다. 계속하시겠습니까?",
"com_ui_confirm_change": "변경 확인",
"com_ui_connecting": "연결 중",
"com_ui_context": "맥락",
"com_ui_continue": "계속",
"com_ui_continue_oauth": "OAuth로 계속하기",
"com_ui_controls": "컨트롤",
"com_ui_convo_delete_error": "대화 삭제 실패",
"com_ui_copied": "복사됨",
@@ -667,7 +610,6 @@
"com_ui_create_memory": "메모리 생성",
"com_ui_create_prompt": "프롬프트 만들기",
"com_ui_creating_image": "이미지 생성 중입니다. 잠시 기다려 주세요.",
"com_ui_current": "현재",
"com_ui_currently_production": "현재 프로덕션 중",
"com_ui_custom": "사용자 지정",
"com_ui_custom_header_name": "사용자 지정 헤더 이름",
@@ -705,19 +647,15 @@
"com_ui_delete_mcp_error": "MCP 서버 삭제 실패",
"com_ui_delete_mcp_success": "MCP 서버 삭제 완료",
"com_ui_delete_memory": "메모리 삭제",
"com_ui_delete_not_allowed": "삭제 작업이 허용되지 않습니다",
"com_ui_delete_prompt": "프롬프트를 삭제하시겠습니까?",
"com_ui_delete_shared_link": "공유 링크를 삭제하시겠습니까?",
"com_ui_delete_success": "성공적으로 삭제됨",
"com_ui_delete_tool": "도구 삭제",
"com_ui_delete_tool_confirm": "이 도구를 삭제하시겠습니까?",
"com_ui_deleted": "삭제 완료",
"com_ui_deleting_file": "파일 삭제 중...",
"com_ui_descending": "내림차순",
"com_ui_description": "설명",
"com_ui_description_placeholder": "선택 사항: 프롬프트에 표시할 설명을 입력하세요",
"com_ui_deselect_all": "모두 선택 해제",
"com_ui_detailed": "상세",
"com_ui_disabling": "비활성화 중...",
"com_ui_download": "다운로드",
"com_ui_download_artifact": "아티팩트 다운로드",
@@ -759,7 +697,6 @@
"com_ui_feedback_tag_attention_to_detail": "디테일 함",
"com_ui_feedback_tag_bad_style": "표현이나 말투가 어색함",
"com_ui_feedback_tag_clear_well_written": "글이 분명하고 매끄럽게 작성됨",
"com_ui_feedback_tag_creative_solution": "창의적인 해결책",
"com_ui_feedback_tag_inaccurate": "정확하지 않거나 잘못된 응답",
"com_ui_feedback_tag_missing_image": "이미지가 포함될 줄 알았음",
"com_ui_feedback_tag_not_helpful": "유용한 정보가 부족함",
@@ -779,7 +716,6 @@
"com_ui_fork_change_default": "기본 포크 옵션",
"com_ui_fork_default": "기본 포크 옵션 사용",
"com_ui_fork_error": "대화 분기 중 오류가 발생했습니다",
"com_ui_fork_error_rate_limit": "포크 요청이 너무 많습니다. 나중에 다시 시도하세요",
"com_ui_fork_from_message": "포크 옵션 선택",
"com_ui_fork_info_1": "이 설정을 사용하면 원하는 동작으로 메시지를 분기할 수 있습니다.",
"com_ui_fork_info_2": "\"포킹(Forking)\"은 현재 대화에서 특정 메시지를 시작/종료 지점으로 하여 새로운 대화를 생성하고, 선택한 옵션에 따라 복사본을 만드는 것을 의미합니다.",
@@ -812,9 +748,7 @@
"com_ui_good_morning": "좋은 아침입니다",
"com_ui_happy_birthday": "내 첫 생일이야!",
"com_ui_hide_image_details": "이미지 세부정보 숨기기",
"com_ui_hide_password": "비밀번호 숨기기",
"com_ui_hide_qr": "QR 코드 숨기기",
"com_ui_high": "높음",
"com_ui_host": "호스트",
"com_ui_icon": "아이콘",
"com_ui_idea": "아이디어",
@@ -841,21 +775,10 @@
"com_ui_loading": "로딩 중...",
"com_ui_locked": "잠김",
"com_ui_logo": "{{0}} 로고",
"com_ui_low": "낮음",
"com_ui_manage": "관리",
"com_ui_max_tags": "최대 {{0}}개까지만 허용됩니다. 최신 값을 사용 중입니다.",
"com_ui_mcp_authenticated_success": "MCP 서버 '{{0}}'가 성공적으로 인증되었습니다",
"com_ui_mcp_enter_var": "{{0}}의 값 입력",
"com_ui_mcp_init_failed": "MCP 서버 초기화 실패",
"com_ui_mcp_initialize": "초기화",
"com_ui_mcp_initialized_success": "MCP 서버 '{{0}}'가 성공적으로 초기화되었습니다",
"com_ui_mcp_oauth_cancelled": "{{0}}의 OAuth 로그인이 취소되었습니다",
"com_ui_mcp_oauth_timeout": "{{0}}의 OAuth 로그인 시간이 초과되었습니다",
"com_ui_mcp_server_not_found": "서버를 찾을 수 없습니다.",
"com_ui_mcp_servers": "MCP 서버",
"com_ui_mcp_update_var": "{{0}} 업데이트",
"com_ui_mcp_url": "MCP 서버 URL",
"com_ui_medium": "중간",
"com_ui_memories": "메모리",
"com_ui_memories_allow_create": "메모리 생성 허용",
"com_ui_memories_allow_opt_out": "사용자가 메모리 기능을 비활성화할 수 있도록 허용",
@@ -864,17 +787,12 @@
"com_ui_memories_allow_use": "메모리 사용 허용",
"com_ui_memories_filter": "메모리 필터링...",
"com_ui_memory": "메모리",
"com_ui_memory_already_exceeded": "메모리 저장소가 이미 가득 참 - {{tokens}} 토큰 초과. 새로운 메모리를 추가하기 전에 기존 메모리를 삭제하세요.",
"com_ui_memory_created": "메모리 생성 완료",
"com_ui_memory_deleted": "메모리 삭제 완료",
"com_ui_memory_deleted_items": "삭제된 메모리",
"com_ui_memory_error": "메모리 오류",
"com_ui_memory_key_exists": "이 키를 가진 메모리가 이미 존재합니다. 다른 키를 사용해주세요.",
"com_ui_memory_key_validation": "메모리 키는 소문자와 밑줄만 포함해야 합니다.",
"com_ui_memory_storage_full": "메모리 저장소가 가득 참",
"com_ui_memory_updated": "저장된 메모리 업데이트 완료",
"com_ui_memory_updated_items": "저장된 메모리",
"com_ui_memory_would_exceed": "저장할 수 없음 - {{tokens}} 토큰 제한 초과. 공간을 확보하기 위해 기존 메모리를 삭제하세요.",
"com_ui_mention": "엔드포인트, 어시스턴트 또는 프리셋을 언급하여 빠르게 전환하세요",
"com_ui_min_tags": "최소 {{0}}개는 필수로 입력해야 합니다. 더 이상 값을 제거할 수 없습니다.",
"com_ui_misc": "기타",
@@ -901,17 +819,8 @@
"com_ui_not_used": "미사용",
"com_ui_nothing_found": "찾을 수 없습니다",
"com_ui_oauth": "OAuth",
"com_ui_oauth_connected_to": "연결됨:",
"com_ui_oauth_error_callback_failed": "인증 콜백이 실패했습니다. 다시 시도하세요.",
"com_ui_oauth_error_generic": "인증이 실패했습니다. 다시 시도하세요.",
"com_ui_oauth_error_missing_code": "인증 코드가 누락되었습니다. 다시 시도하세요.",
"com_ui_oauth_error_missing_state": "상태 파라미터가 누락되었습니다. 다시 시도하세요.",
"com_ui_oauth_error_title": "인증 실패",
"com_ui_oauth_success_description": "인증에 성공했습니다. 이 창은 닫힙니다.",
"com_ui_oauth_success_title": "인증 성공",
"com_ui_of": "/",
"com_ui_off": "꺼짐",
"com_ui_offline": "오프라인",
"com_ui_on": "켜기",
"com_ui_openai": "OpenAI",
"com_ui_optional": "(선택사항)",
@@ -934,7 +843,6 @@
"com_ui_prompts_allow_share_global": "모든 사용자와 프롬프트 공유 허용",
"com_ui_prompts_allow_use": "프롬프트 사용 허용",
"com_ui_provider": "제공자",
"com_ui_quality": "품질",
"com_ui_read_aloud": "소리내어 읽기",
"com_ui_redirecting_to_provider": "{{0}}로 이동하는 중입니다. 잠시 기다리세요...",
"com_ui_reference_saved_memories": "저장된 메모리 참고",
@@ -944,14 +852,12 @@
"com_ui_regenerate_backup": "백업 코드 재생성",
"com_ui_regenerating": "재생성 중...",
"com_ui_region": "지역",
"com_ui_reinitialize": "다시 초기화",
"com_ui_rename": "이름 바꾸기",
"com_ui_rename_conversation": "대화 이름 변경",
"com_ui_rename_failed": "대화 이름 변경 실패",
"com_ui_rename_prompt": "프롬프트 이름 변경",
"com_ui_requires_auth": "인증이 필요합니다",
"com_ui_reset_var": "{{0}} 초기화",
"com_ui_reset_zoom": "초기화",
"com_ui_result": "결과",
"com_ui_revoke": "취소",
"com_ui_revoke_info": "사용자가 제공한 자격 증명을 모두 취소합니다.",
@@ -967,11 +873,9 @@
"com_ui_save_badge_changes": "배지 변경 사항 저장하시겠습니까?",
"com_ui_save_submit": "저장 및 제출",
"com_ui_saved": "저장되었습니다!",
"com_ui_saving": "저장 중...",
"com_ui_schema": "스키마",
"com_ui_scope": "범위",
"com_ui_search": "검색",
"com_ui_seconds": "초",
"com_ui_secret_key": "비밀 키",
"com_ui_select": "선택",
"com_ui_select_all": "모두 선택",
@@ -984,7 +888,6 @@
"com_ui_select_search_plugin": "이름으로 플러그인 검색",
"com_ui_select_search_provider": "이름으로 공급자 검색",
"com_ui_select_search_region": "이름으로 지역 검색",
"com_ui_set": "설정",
"com_ui_share": "공유하기",
"com_ui_share_create_message": "이름과 공유 후에 추가하는 메시지는 비공개로 유지됩니다.",
"com_ui_share_delete_error": "공유 링크를 삭제하는 중에 오류가 발생했습니다.",
@@ -1002,7 +905,6 @@
"com_ui_show": "보기",
"com_ui_show_all": "전체 보기",
"com_ui_show_image_details": "이미지 세부사항 보기",
"com_ui_show_password": "비밀번호 표시",
"com_ui_show_qr": "QR 코드 보기",
"com_ui_sign_in_to_domain": "{{0}}에 로그인",
"com_ui_simple": "간단",
@@ -1028,16 +930,12 @@
"com_ui_token_exchange_method": "토큰 교환 방식",
"com_ui_token_url": "토큰 URL",
"com_ui_tokens": "토큰",
"com_ui_tool_collection_prefix": "제공하는 도구 모음",
"com_ui_tool_info": "도구 정보",
"com_ui_tool_more_info": "이 도구에 대한 추가 정보",
"com_ui_tools": "도구",
"com_ui_travel": "여행",
"com_ui_trust_app": "신뢰할 수 있는 어플리케이션",
"com_ui_unarchive": "아카이브 해제",
"com_ui_unarchive_error": "대화 아카이브 해제 실패",
"com_ui_unknown": "알 수 없음",
"com_ui_unset": "설정 해제",
"com_ui_untitled": "제목 없음",
"com_ui_update": "업데이트",
"com_ui_update_mcp_error": " MCP 생성 혹은 업데이트 중 오류가 발생했습니다.",
@@ -1061,6 +959,7 @@
"com_ui_use_backup_code": "백업 코드 사용",
"com_ui_use_memory": "메모리 사용",
"com_ui_use_micrphone": "마이크 사용",
"com_ui_use_prompt": "프롬프트 사용",
"com_ui_used": "사용됨",
"com_ui_value": "값",
"com_ui_variables": "변수",
@@ -1076,7 +975,6 @@
"com_ui_web_search_jina_key": "Jina API 키 입력",
"com_ui_web_search_processing": "결과 처리 중",
"com_ui_web_search_provider": "검색 제공자",
"com_ui_web_search_provider_searxng": "SearXNG",
"com_ui_web_search_provider_serper": "Serper API",
"com_ui_web_search_provider_serper_key": "Serper API 키 발급받기",
"com_ui_web_search_reading": "결과 읽기 중",
@@ -1088,8 +986,6 @@
"com_ui_web_search_scraper": "스크래퍼",
"com_ui_web_search_scraper_firecrawl": "Firecrawl API",
"com_ui_web_search_scraper_firecrawl_key": "Firecrawl API 키 발급받기",
"com_ui_web_search_searxng_api_key": "SearXNG API 키 입력 (선택사항)",
"com_ui_web_search_searxng_instance_url": "SearXNG 인스턴스 URL",
"com_ui_web_searching": "웹 검색 진행 중",
"com_ui_web_searching_again": "웹 검색 다시 진행",
"com_ui_weekend_morning": "행복한 주말 되세요",
@@ -1098,4 +994,4 @@
"com_ui_yes": "네",
"com_ui_zoom": "확대/축소",
"com_user_message": "당신"
}
}

View File

@@ -435,10 +435,8 @@
"com_nav_lang_spanish": "Spāņu",
"com_nav_lang_swedish": "Zviedru",
"com_nav_lang_thai": "ไทย",
"com_nav_lang_tibetan": "Tibetiešu",
"com_nav_lang_traditional_chinese": "繁體中文",
"com_nav_lang_turkish": "Türkçe",
"com_nav_lang_ukrainian": "Ukraiņu",
"com_nav_lang_uyghur": "Uyƣur tili",
"com_nav_lang_vietnamese": "Tiếng Việt",
"com_nav_language": "Valoda",
@@ -508,6 +506,7 @@
"com_sidepanel_hide_panel": "Slēpt paneli",
"com_sidepanel_manage_files": "Pārvaldīt failus",
"com_sidepanel_mcp_no_servers_with_vars": "Nav MCP serveru ar konfigurējamiem mainīgajiem.",
"com_sidepanel_mcp_variables_for": "MCP parametri {{0}}",
"com_sidepanel_parameters": "Parametri",
"com_sources_image_alt": "Meklēšanas rezultāta attēls",
"com_sources_more_sources": "+{{count}} avoti",
@@ -839,7 +838,7 @@
"com_ui_instructions": "Instrukcijas",
"com_ui_key": "Atslēga",
"com_ui_late_night": "Priecīgu vēlu nakti",
"com_ui_latest_footer": "Mākslīgais intelekts ikvienam.",
"com_ui_latest_footer": "Katrs mākslīgais intelekts ikvienam.",
"com_ui_latest_production_version": "Jaunākā produkcijas versija",
"com_ui_latest_version": "Jaunākā versija",
"com_ui_librechat_code_api_key": "Iegūstiet savu LibreChat koda interpretatora API atslēgu",
@@ -852,11 +851,15 @@
"com_ui_manage": "Pārvaldīt",
"com_ui_max_tags": "Maksimālais atļautais skaits ir {{0}}, izmantojot jaunākās vērtības.",
"com_ui_mcp_authenticated_success": "MCP serveris '{{0}}' veiksmīgi autentificēts",
"com_ui_mcp_dialog_desc": "Lūdzu, ievadiet nepieciešamo informāciju zemāk.",
"com_ui_mcp_enter_var": "Ievadiet vērtību {{0}}",
"com_ui_mcp_init_failed": "Neizdevās inicializēt MCP serveri",
"com_ui_mcp_initialize": "Inicializēt",
"com_ui_mcp_initialized_success": "MCP serveris '{{0}}' veiksmīgi inicializēts",
"com_ui_mcp_not_authenticated": "{{0}} nav autentificēts (nepieciešams OAuth).",
"com_ui_mcp_not_initialized": "{{0}} nav inicializēts",
"com_ui_mcp_oauth_cancelled": "OAuth pieteikšanās atcelta {{0}}",
"com_ui_mcp_oauth_no_url": "Nepieciešama OAuth autentifikācija, bet URL nav padots",
"com_ui_mcp_oauth_timeout": "OAuth pieteikšanās beidzās priekš {{0}}",
"com_ui_mcp_server_not_found": "Serveris nav atrasts.",
"com_ui_mcp_servers": "MCP serveri",
@@ -915,6 +918,7 @@
"com_ui_oauth_error_missing_code": "Trūkst autorizācijas koda. Lūdzu, mēģiniet vēlreiz.",
"com_ui_oauth_error_missing_state": "Trūkst stāvokļa parametrs. Lūdzu, mēģiniet vēlreiz.",
"com_ui_oauth_error_title": "Autentifikācija neizdevās",
"com_ui_oauth_flow_desc": "Pabeidziet OAuth plūsmu jaunajā logā un pēc tam atgriezieties šeit.",
"com_ui_oauth_success_description": "Jūsu autentifikācija bija veiksmīga. Šis logs aizvērsies pēc",
"com_ui_oauth_success_title": "Autentifikācija veiksmīga",
"com_ui_of": "no",
@@ -1069,6 +1073,7 @@
"com_ui_use_backup_code": "Izmantojiet rezerves kodu",
"com_ui_use_memory": "Izmantot atmiņu",
"com_ui_use_micrphone": "Izmantot mikrofonu",
"com_ui_use_prompt": "Izmantojiet uzvedni",
"com_ui_used": "Lietots",
"com_ui_value": "Vērtība",
"com_ui_variables": "Mainīgie",
@@ -1106,4 +1111,4 @@
"com_ui_yes": "Jā",
"com_ui_zoom": "Tālummaiņa",
"com_user_message": "Tu"
}
}

View File

@@ -351,5 +351,6 @@
"com_ui_terms_and_conditions": "Gebruiksvoorwaarden",
"com_ui_unarchive": "Uit archiveren",
"com_ui_unarchive_error": "Kan conversatie niet uit archiveren",
"com_ui_upload_success": "Bestand succesvol geüpload"
}
"com_ui_upload_success": "Bestand succesvol geüpload",
"com_ui_use_prompt": "Gebruik prompt"
}

View File

@@ -708,6 +708,7 @@
"com_ui_upload_success": "Pomyślnie przesłano plik",
"com_ui_upload_type": "Wybierz typ przesyłania",
"com_ui_use_micrphone": "Użyj mikrofonu",
"com_ui_use_prompt": "Użyj podpowiedzi",
"com_ui_variables": "Zmienne",
"com_ui_variables_info": "Użyj podwójnych nawiasów klamrowych w tekście, aby utworzyć zmienne, np. `{{przykładowa zmienna}}`, które później można wypełnić podczas używania promptu.",
"com_ui_version_var": "Wersja {{0}}",
@@ -717,4 +718,4 @@
"com_ui_yes": "Tak",
"com_ui_zoom": "Powiększ",
"com_user_message": "Ty"
}
}

View File

@@ -28,7 +28,6 @@
"com_agents_no_access": "Não tens permissões para editar este agente.",
"com_agents_no_agent_id_error": "Nenhum ID de agente encontrado. Certifique-se de que o agente seja criado primeiro.",
"com_agents_not_available": "Agente não disponível.",
"com_agents_search_info": "Quando ativado, permite seu agente buscar informações atualizadas na web. Requer uma chave de API válida.",
"com_agents_search_name": "Pesquisar agentes por nome",
"com_agents_update_error": "Houve um erro ao atualizar seu agente.",
"com_assistants_action_attempt": "Assistente quer falar com {{0}}",
@@ -131,7 +130,6 @@
"com_auth_reset_password_if_email_exists": "Se uma conta com esse e-mail existir, um e-mail com instruções para redefinir a senha foi enviado. Certifique-se de verificar sua pasta de spam.",
"com_auth_reset_password_link_sent": "E-mail enviado",
"com_auth_reset_password_success": "Senha redefinida com sucesso",
"com_auth_saml_login": "Continue com SAML",
"com_auth_sign_in": "Entrar",
"com_auth_sign_up": "Inscrever-se",
"com_auth_submit_registration": "Enviar registro",
@@ -160,7 +158,6 @@
"com_endpoint_anthropic_thinking_budget": "Determina o número máximo de tokens que o Claude pode utilizar para o seu processo de raciocínio interno. Orçamentos maiores podem melhorar a qualidade da resposta, permitindo uma análise mais completa para problemas complexos, embora o Claude possa não usar todo o orçamento alocado, especialmente em intervalos acima de 32K. Essa configuração deve ser menor que \"Máximo de tokens de saída\".",
"com_endpoint_anthropic_topk": "Top-k altera como o modelo seleciona tokens para saída. Um top-k de 1 significa que o token selecionado é o mais provável entre todos os tokens no vocabulário do modelo (também chamado de decodificação gananciosa), enquanto um top-k de 3 significa que o próximo token é selecionado entre os 3 tokens mais prováveis (usando temperatura).",
"com_endpoint_anthropic_topp": "Top-p altera como o modelo seleciona tokens para saída. Os tokens são selecionados dos mais prováveis (veja o parâmetro topK) até os menos prováveis até que a soma de suas probabilidades atinja o valor top-p.",
"com_endpoint_anthropic_use_web_search": "Habilita a funcionalidade de pesquisa na web usando o recurso integrado da Anthropic. Isso permite que o modelo pesquise informações atualizadas na web e forneça respostas mais precisas e atuais.",
"com_endpoint_assistant": "Assistente",
"com_endpoint_assistant_model": "Modelo de Assistente",
"com_endpoint_assistant_placeholder": "Por favor, selecione um Assistente no Painel Lateral Direito",
@@ -206,7 +203,6 @@
"com_endpoint_google_custom_name_placeholder": "Defina um nome personalizado para o Google",
"com_endpoint_google_maxoutputtokens": "Número máximo de tokens que podem ser gerados na resposta. Especifique um valor mais baixo para respostas mais curtas e um valor mais alto para respostas mais longas. Nota: os modelos podem parar antes de atingir esse máximo.",
"com_endpoint_google_temp": "Valores mais altos = mais aleatório, enquanto valores mais baixos = mais focado e determinístico. Recomendamos alterar isso ou Top P, mas não ambos.",
"com_endpoint_google_thinking": "Habilita ou desabilita o pensamento. Essa opção é suportada apenas por certos modelos (série 2.5). Para modelos antigos, esta opção pode não ter efeito.",
"com_endpoint_google_topk": "Top-k altera como o modelo seleciona tokens para saída. Um top-k de 1 significa que o token selecionado é o mais provável entre todos os tokens no vocabulário do modelo (também chamado de decodificação gananciosa), enquanto um top-k de 3 significa que o próximo token é selecionado entre os 3 tokens mais prováveis (usando temperatura).",
"com_endpoint_google_topp": "Top-p altera como o modelo seleciona tokens para saída. Os tokens são selecionados dos mais prováveis (veja o parâmetro topK) até os menos prováveis até que a soma de suas probabilidades atinja o valor top-p.",
"com_endpoint_instructions_assistants": "Substituir Instruções",
@@ -231,7 +227,6 @@
"com_endpoint_openai_stop": "Até 4 sequências onde a API parará de gerar mais tokens.",
"com_endpoint_openai_temp": "Valores mais altos = mais aleatório, enquanto valores mais baixos = mais focado e determinístico. Recomendamos alterar isso ou Top P, mas não ambos.",
"com_endpoint_openai_topp": "Uma alternativa à amostragem com temperatura, chamada amostragem de núcleo, onde o modelo considera os resultados dos tokens com massa de probabilidade top_p. Então, 0.1 significa que apenas os tokens que compreendem os 10% principais da massa de probabilidade são considerados. Recomendamos alterar isso ou a temperatura, mas não ambos.",
"com_endpoint_openai_use_responses_api": "Usa a API de Respostas ao invés de Conclusões de Chat, que inclui funcionalidades extendidas da OpenAI. Requerida para o1-pro, o3-pro, e para habilitar resumos de raciocínio.",
"com_endpoint_output": "Saída",
"com_endpoint_plug_image_detail": "Detalhe da Imagem",
"com_endpoint_plug_resend_files": "Reenviar Arquivos",
@@ -264,7 +259,6 @@
"com_endpoint_reasoning_effort": "Esforço de raciocínio",
"com_endpoint_save_as_preset": "Salvar Como Preset",
"com_endpoint_search": "Procurar endpoint por nome",
"com_endpoint_search_models": "Buscar modelos...",
"com_endpoint_set_custom_name": "Defina um nome personalizado, caso você possa encontrar este preset",
"com_endpoint_skip_hover": "Habilitar pular a etapa de conclusão, que revisa a resposta final e os passos gerados",
"com_endpoint_stop": "Sequências de Parada",
@@ -311,16 +305,6 @@
"com_nav_auto_transcribe_audio": "Transcrever áudio automaticamente",
"com_nav_automatic_playback": "Reprodução Automática da Última Mensagem",
"com_nav_balance": "Crédito",
"com_nav_balance_day": "dia",
"com_nav_balance_days": "dias",
"com_nav_balance_hour": "hora",
"com_nav_balance_hours": "horas",
"com_nav_balance_minute": "minuto",
"com_nav_balance_minutes": "minutos",
"com_nav_balance_month": "mês",
"com_nav_balance_months": "meses",
"com_nav_balance_second": "segundo",
"com_nav_balance_seconds": "segundos",
"com_nav_browser": "Navegador",
"com_nav_change_picture": "Mudar foto",
"com_nav_chat_commands": "Comandos do chat",
@@ -378,11 +362,9 @@
"com_nav_info_show_thinking": "Quando ativado, o chat apresentará os menus pendentes de raciocínio abertos por predefinição, permitindo-lhe ver o raciocínio da IA em tempo real. Quando desativado, os menus suspensos de raciocínio permanecerão fechados por predefinição para uma interface mais limpa e simplificada",
"com_nav_info_user_name_display": "Quando habilitado, o nome de usuário do remetente será mostrado acima de cada mensagem que você enviar. Quando desabilitado, você verá apenas \"Você\" acima de suas mensagens.",
"com_nav_lang_arabic": "العربية",
"com_nav_lang_armenian": "Armênio",
"com_nav_lang_auto": "Detecção automática",
"com_nav_lang_brazilian_portuguese": "Português Brasileiro",
"com_nav_lang_chinese": "中文",
"com_nav_lang_danish": "Dinamarquês",
"com_nav_lang_dutch": "Nederlands",
"com_nav_lang_english": "English",
"com_nav_lang_estonian": "Eesti keel",
@@ -463,7 +445,6 @@
"com_sidepanel_hide_panel": "Ocultar Painel",
"com_sidepanel_manage_files": "Gerenciar Arquivos",
"com_sidepanel_parameters": "Parâmetros",
"com_sources_tab_images": "Imagens",
"com_ui_2fa_account_security": "A autenticação de dois fatores acrescenta uma camada extra de segurança à sua conta",
"com_ui_2fa_disable": "Desabilitar 2FA",
"com_ui_2fa_disable_error": "Ocorreu um erro ao desativar a autenticação de dois fatores",
@@ -475,17 +456,13 @@
"com_ui_2fa_setup": "Configurar 2FA",
"com_ui_2fa_verified": "Autenticação de dois fatores verificada com sucesso",
"com_ui_accept": "Eu aceito",
"com_ui_active": "Ativo",
"com_ui_add": "Adicionar",
"com_ui_add_mcp": "Adicionar MCP",
"com_ui_add_mcp_server": "Adicionar Servidor MCP",
"com_ui_add_model_preset": "Adicionar um modelo ou predefinição para uma resposta adicional",
"com_ui_add_multi_conversation": "Adicionar multi-conversação",
"com_ui_admin": "Admin",
"com_ui_admin_access_warning": "Desabilitar o acesso de Admin a esse recurso pode causar problemas inesperados na IU que exigem atualização. Se salvo, a única maneira de reverter é por meio da configuração de interface na configuração librechat.yaml que afeta todas as funções.",
"com_ui_admin_settings": "Configurações de Admin",
"com_ui_advanced": "Avançado",
"com_ui_advanced_settings": "Opções Avançadas",
"com_ui_agent": "Agente",
"com_ui_agent_delete_error": "Houve um erro ao excluir o agente",
"com_ui_agent_deleted": "Agente excluído com sucesso",
@@ -493,12 +470,6 @@
"com_ui_agent_duplicated": "Agente duplicado com sucesso",
"com_ui_agent_editing_allowed": "Outros usuários já podem editar este agente",
"com_ui_agent_shared_to_all": "algo precisa ir aqui. esta vazio",
"com_ui_agent_version": "Versão",
"com_ui_agent_version_active": "Versão Ativa",
"com_ui_agent_version_empty": "Não há versões disponíveis",
"com_ui_agent_version_history": "Histórico de Versões",
"com_ui_agent_version_no_date": "Data não disponível",
"com_ui_agent_version_unknown_date": "Data desconhecida",
"com_ui_agents": "Agentes",
"com_ui_agents_allow_create": "Permitir a criação de agentes",
"com_ui_agents_allow_share_global": "Permitir compartilhamento de agentes para todos os usuários",
@@ -524,15 +495,12 @@
"com_ui_attach_error_openai": "Não é possível anexar arquivos de Assistente a outros endpoints",
"com_ui_attach_error_size": "Limite de tamanho de arquivo excedido para o endpoint:",
"com_ui_attach_error_type": "Tipo de arquivo não suportado para o endpoint:",
"com_ui_attach_remove": "Remover arquivo",
"com_ui_attach_warn_endpoint": "Arquivos não compatíveis podem ser ignorados sem uma ferramenta compatível",
"com_ui_attachment": "Anexo",
"com_ui_auth_type": "Tipo de autenticação",
"com_ui_auth_url": "URL de autorização",
"com_ui_authenticate": "Autenticar",
"com_ui_authentication": "Autenticação",
"com_ui_authentication_type": "Tipo de Autenticação",
"com_ui_available_tools": "Ferramentas Disponíveis",
"com_ui_avatar": "Avatar",
"com_ui_azure": "Azure",
"com_ui_back_to_chat": "Voltar ao Chat",
@@ -564,8 +532,6 @@
"com_ui_bulk_delete_error": "Falha ao excluir links compartilhados",
"com_ui_callback_url": "URL de retorno de chamada",
"com_ui_cancel": "Cancelar",
"com_ui_cancelled": "Cancelado",
"com_ui_category": "Categoria",
"com_ui_chat": "Chat",
"com_ui_chat_history": "Histórico de Chat",
"com_ui_clear": "Limpar",
@@ -574,7 +540,6 @@
"com_ui_client_secret": "Segredo do cliente",
"com_ui_close": "Fechar",
"com_ui_close_menu": "Fechar Menu",
"com_ui_close_window": "Fechar Janela",
"com_ui_code": "Código",
"com_ui_collapse_chat": "Recolher bate-papo",
"com_ui_command_placeholder": "Opcional: Insira um comando para o prompt ou o nome será usado.",
@@ -583,12 +548,9 @@
"com_ui_confirm_action": "Confirmar Ação",
"com_ui_confirm_admin_use_change": "Alterar esta configuração bloqueará o acesso para administradores, incluindo você. Tem certeza de que deseja prosseguir?",
"com_ui_confirm_change": "Confirmar alteração",
"com_ui_connecting": "Conectando",
"com_ui_context": "Contexto",
"com_ui_continue": "Continuar",
"com_ui_continue_oauth": "Continuar com OAuth",
"com_ui_controls": "Controles",
"com_ui_convo_delete_error": "Falha ao excluir conversa",
"com_ui_copied": "Copiado!",
"com_ui_copied_to_clipboard": "Copiado para a área de transferência",
"com_ui_copy_code": "Copiar código",
@@ -596,9 +558,7 @@
"com_ui_copy_to_clipboard": "Copiar para a área de transferência",
"com_ui_create": "Criar",
"com_ui_create_link": "Criar link",
"com_ui_create_memory": "Criar Memória",
"com_ui_create_prompt": "Criar Prompt",
"com_ui_creating_image": "Criando a imagem. Pode levar algum tempo",
"com_ui_currently_production": "Atualmente em produção",
"com_ui_custom": "Personalizado",
"com_ui_custom_header_name": "Nome do cabeçalho personalizado",
@@ -631,22 +591,13 @@
"com_ui_delete_confirm": "Isso excluirá",
"com_ui_delete_confirm_prompt_version_var": "Isso excluirá a versão selecionada para \"{{0}}\". Se não houver outras versões, o prompt será excluído.",
"com_ui_delete_conversation": "Excluir chat?",
"com_ui_delete_mcp": "Remover MCP",
"com_ui_delete_mcp_confirm": "Você tem certeza que quer remover este servidor MCP?",
"com_ui_delete_mcp_error": "Falha ao remover servidor MCP",
"com_ui_delete_mcp_success": "Servidor MCP removido com sucesso",
"com_ui_delete_memory": "Remover Memória",
"com_ui_delete_prompt": "Excluir Prompt?",
"com_ui_delete_shared_link": "Excluir link compartilhado?",
"com_ui_delete_success": "Removido com sucesso",
"com_ui_delete_tool": "Excluir Ferramenta",
"com_ui_delete_tool_confirm": "Tem certeza de que deseja excluir esta ferramenta?",
"com_ui_deleted": "Removido",
"com_ui_deleting_file": "Removendo arquivo...",
"com_ui_descending": "Desc",
"com_ui_description": "Descrição",
"com_ui_description_placeholder": "Opcional: Insira uma descrição para exibir para o prompt",
"com_ui_detailed": "Detalhado",
"com_ui_disabling": "Desativando...",
"com_ui_download": "Download",
"com_ui_download_artifact": "Download artefato",
@@ -661,35 +612,18 @@
"com_ui_duplication_processing": "Duplicando conversa...",
"com_ui_duplication_success": "Conversa duplicada com sucesso",
"com_ui_edit": "Editar",
"com_ui_edit_mcp_server": "Editar Servidor MCP",
"com_ui_edit_memory": "Editar Memória",
"com_ui_empty_category": "-",
"com_ui_endpoint": "Endpoint",
"com_ui_endpoint_menu": "Menu endpoint LLM",
"com_ui_enter": "Entrar",
"com_ui_enter_api_key": "Insira a chave da API",
"com_ui_enter_key": "Inserir chave",
"com_ui_enter_openapi_schema": "Insira seu esquema OpenAPI aqui",
"com_ui_enter_value": "Inserir valor",
"com_ui_error": "Erro",
"com_ui_error_connection": "Erro ao conectar ao servidor, tente atualizar a página.",
"com_ui_error_save_admin_settings": "Houve um erro ao salvar suas configurações de admin.",
"com_ui_error_updating_preferences": "Erro ao atualizar preferências",
"com_ui_examples": "Exemplos",
"com_ui_expand_chat": "Expandir Chat",
"com_ui_export_convo_modal": "Exportar Modal de Conversação",
"com_ui_feedback_more": "Mais...",
"com_ui_feedback_more_information": "Fornecer feedback adicional",
"com_ui_feedback_negative": "Precisa de melhorias",
"com_ui_feedback_placeholder": "Por favor, forneça qualquer feedback adicional aqui",
"com_ui_feedback_positive": "Amei isso",
"com_ui_feedback_tag_accurate_reliable": "Preciso e confiável",
"com_ui_feedback_tag_creative_solution": "Solução Criativa",
"com_ui_feedback_tag_not_helpful": "Faltou informação útil",
"com_ui_feedback_tag_unjustified_refusal": "Recusado com razão",
"com_ui_field_required": "Este campo é obrigatório",
"com_ui_file_size": "Tamanho do Arquivo",
"com_ui_files": "Arquivos",
"com_ui_filter_prompts": "Filtrar prompts",
"com_ui_filter_prompts_name": "Filtrar prompts por nome",
"com_ui_finance": "Financiar",
@@ -718,23 +652,13 @@
"com_ui_generate_backup": "Gerar códigos de backup",
"com_ui_generate_qrcode": "Gerar QR Code",
"com_ui_generating": "Gerando...",
"com_ui_generation_settings": "Configurações de Geração",
"com_ui_global_group": "algo precisa ir aqui. estava vazio",
"com_ui_go_back": "Volte",
"com_ui_go_to_conversation": "Ir para a conversa",
"com_ui_good_afternoon": "Boa tarde",
"com_ui_good_evening": "Boa noite",
"com_ui_good_morning": "Bom dia",
"com_ui_happy_birthday": "É meu 1º aniversário!",
"com_ui_hide_image_details": "Esconder Detalhes de Imagem",
"com_ui_hide_password": "Esconder senha",
"com_ui_hide_qr": "Ocultar QR Code",
"com_ui_host": "Host",
"com_ui_icon": "Ícone",
"com_ui_idea": "Ideias",
"com_ui_image_created": "Imagem criada",
"com_ui_image_details": "Detalhes da Imagem",
"com_ui_image_edited": "Imagem editada",
"com_ui_image_gen": "Geração de Imagem",
"com_ui_import": "Importar",
"com_ui_import_conversation_error": "Houve um erro ao importar suas conversas",
@@ -744,7 +668,6 @@
"com_ui_include_shadcnui": "Incluir instruções de componentes shadcn/ui",
"com_ui_input": "Entrada",
"com_ui_instructions": "Instruções",
"com_ui_key": "Chave",
"com_ui_latest_footer": "Toda IA para Todos.",
"com_ui_latest_production_version": "Última versão de produção",
"com_ui_latest_version": "Ultima versão",
@@ -756,28 +679,6 @@
"com_ui_logo": "{{0}} Logo",
"com_ui_manage": "Gerenciar",
"com_ui_max_tags": "O número máximo permitido é {{0}}, usando os valores mais recentes.",
"com_ui_mcp_authenticated_success": "Servidor MCP '{{0}}' autenticado com sucesso",
"com_ui_mcp_enter_var": "Insira um valor para {{0}}",
"com_ui_mcp_initialize": "Inicializar",
"com_ui_mcp_initialized_success": "Servidor MCP '{{0}}' inicializou com sucesso",
"com_ui_mcp_server_not_found": "Servidor não encontrado.",
"com_ui_mcp_servers": "Servidores MCP",
"com_ui_mcp_update_var": "Atualizar {{0}}",
"com_ui_mcp_url": "URL do Servidor MCP",
"com_ui_medium": "Médio",
"com_ui_memories": "Memórias",
"com_ui_memories_allow_create": "Permitir criação de Memórias",
"com_ui_memories_allow_update": "Permite a atualização de Memórias",
"com_ui_memories_allow_use": "Permite a utilização de Memórias",
"com_ui_memories_filter": "Filtrar memórias...",
"com_ui_memory": "Memória",
"com_ui_memory_created": "Memória criada com sucesso",
"com_ui_memory_deleted": "Memória removida",
"com_ui_memory_deleted_items": "Memórias removidas",
"com_ui_memory_storage_full": "Armazenamento de Memória Cheio",
"com_ui_memory_updated": "Memória salva atualizada",
"com_ui_memory_updated_items": "Memórias Atualizadas",
"com_ui_memory_would_exceed": "Impossível salvar - excederia o limite por {{tokens} tokens. Remova memórias existentes para liberar espaço.",
"com_ui_mention": "Mencione um endpoint, assistente ou predefinição para alternar rapidamente para ele",
"com_ui_min_tags": "Não é possível remover mais valores, um mínimo de {{0}} é necessário.",
"com_ui_misc": "Diversos",
@@ -916,6 +817,7 @@
"com_ui_use_2fa_code": "Use o código 2FA em vez disso",
"com_ui_use_backup_code": "Use o código de backup",
"com_ui_use_micrphone": "Usar microfone",
"com_ui_use_prompt": "Usar prompt",
"com_ui_used": "Usado",
"com_ui_variables": "Variáveis",
"com_ui_variables_info": "Use chaves duplas no seu texto para criar variáveis, por exemplo, `{{exemplo de variável}}`, para preencher posteriormente ao usar o prompt.",
@@ -923,31 +825,8 @@
"com_ui_version_var": "Versão {{0}}",
"com_ui_versions": "Versões",
"com_ui_view_source": "Ver chat de origem",
"com_ui_web_search": "Busca na web",
"com_ui_web_search_cohere_key": "Insira a chave de API Cohere",
"com_ui_web_search_firecrawl_url": "URL da API Firecrawl (opcional)",
"com_ui_web_search_jina_key": "Insira a chave de API Jina",
"com_ui_web_search_processing": "Resultados de processamento",
"com_ui_web_search_provider": "Provedor de Buscas",
"com_ui_web_search_provider_searxng": "SearXNG",
"com_ui_web_search_provider_serper": "Serper API",
"com_ui_web_search_provider_serper_key": "Obtenha sua chave de API Serper",
"com_ui_web_search_reading": "Resultados da leitura",
"com_ui_web_search_reranker": "Reranker",
"com_ui_web_search_reranker_cohere": "Cohere",
"com_ui_web_search_reranker_cohere_key": "Obtenha sua chave de API Cohere",
"com_ui_web_search_reranker_jina": "Jina AI",
"com_ui_web_search_reranker_jina_key": "Obtenha sua chave de API Jina",
"com_ui_web_search_scraper": "Scraper",
"com_ui_web_search_scraper_firecrawl": "Firecrawl API",
"com_ui_web_search_scraper_firecrawl_key": "Obtenha sua chave de API Firecrawl",
"com_ui_web_search_searxng_api_key": "Insira sua Chave de API SearXNG (opcional)",
"com_ui_web_searching": "Procurando na web",
"com_ui_web_searching_again": "Procurando na web novamente",
"com_ui_weekend_morning": "Boa semana",
"com_ui_write": "Escrevendo",
"com_ui_x_selected": "{{0}} selecionado",
"com_ui_yes": "Sim",
"com_ui_zoom": "Zoom",
"com_user_message": "Você"
}
}

Some files were not shown because too many files have changed in this diff Show More