Compare commits

..

20 Commits

Author SHA1 Message Date
Dustin Healy
cbf3ce7559 style: fix coloring for light mode and align more with existing design patterns 2025-07-28 22:59:37 -07:00
Dustin Healy
666e5bc7d4 style: swap CustomUserVarsSection and ServerInitializationSection positions 2025-07-28 22:53:34 -07:00
Dustin Healy
f68c61d148 fix: change MCP config components to better handle servers without customUserVars
- removes the subtle reinitialize button from config components of servers without customUserVars or OAuth
- adds a placeholder message for components where servers have no customUserVars configured
2025-07-28 22:47:33 -07:00
Dustin Healy
37701d3e9b fix: correct improper handling of failure state in reinitialize endpoint 2025-07-28 22:34:52 -07:00
Dustin Healy
084de9a912 feat: add startup flag check to conditional rendering logic 2025-07-28 15:01:08 -07:00
Dustin Healy
49f87016a8 feat: add OAuth servers to conditional rendering logic for MCPPanel in SideNav 2025-07-28 14:09:40 -07:00
github-actions[bot]
ef9d9b1276 🌍 i18n: Update translation.json with latest translations (#8676)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-28 15:15:06 -04:00
Danny Avila
a4ca4b7d9d 🔀 fix: Rerender Edge Cases After Migration to Shared Package (#8713)
* fix: render issues in PromptForm by decoupling nested dependencies as a result of @librechat/client components

* fix: MemoryViewer flicker by moving EditMemoryButton and DeleteMemoryButton outside of rendering

* fix: CategorySelector to use DropdownPopup for improved mobile compatibility

* chore: imports
2025-07-28 15:14:37 -04:00
Danny Avila
8e6eef04ab 🔧 fix: Update Proxy Config for OpenAI Image Tools (#8712)
- Replaced HttpsProxyAgent with ProxyAgent from undici for improved proxy handling in DALLE3.js and OpenAIImageTools.js.
- Updated fetchOptions to use dispatcher for proxy configuration.
- Added new test suite for DALLE3 to verify proxy configuration behavior based on environment variables.
2025-07-28 15:12:29 -04:00
Danny Avila
ec3cbca6e3 feat: Enhance Redis Config and Error Handling (#8709)
*  feat: Enhance Redis Config and Error Handling

- Added new Redis configuration options: `REDIS_RETRY_MAX_DELAY`, `REDIS_RETRY_MAX_ATTEMPTS`, `REDIS_CONNECT_TIMEOUT`, and `REDIS_ENABLE_OFFLINE_QUEUE` to improve connection resilience.
- Implemented error handling for Redis cache creation and session store initialization in `cacheFactory.js`.
- Enhanced logging for Redis client events and errors in `redisClients.js`.
- Updated `README.md` to document new Redis configuration options.

* chore: Add JSDoc comments to Redis configuration options in cacheConfig.js for improved clarity and documentation

* ci: update cacheFactory tests

* refactor: remove fallback

* fix: Improve error handling in Redis cache creation, re-throw errors when expected
2025-07-28 14:21:39 -04:00
Dustin Healy
4639dc3255 🐜 fix: Forward Ref to MCPSubMenu and ArtifactsSubMenu (#8696)
ToolsDropdown uses a menu library that passes refs to submenu items. Function components can't receive refs by default though, so we get  "Function components cannot be given refs" warnings in the console. React.forwardRef() allows them to properly handle ref forwarding by wrapping the component and attaching the ref to the outer div element.
2025-07-28 12:26:11 -04:00
Dustin Healy
0ef3fefaec 🏹 feat: Concurrent MCP Initialization Support (#8677)
*  feat: Enhance MCP Connection Status Management

- Introduced new functions to retrieve and manage connection status for multiple MCP servers, including OAuth flow checks and server-specific status retrieval.
- Refactored the MCP connection status endpoints to support both all servers and individual server queries.
- Replaced the old server initialization hook with a new `useMCPServerManager` hook for improved state management and handling of multiple OAuth flows.
- Updated the MCPPanel component to utilize the new context provider for better state handling and UI updates.
- Fixed a number of UI bugs when initializing servers

* 🗣️ i18n: Remove unused strings from translation.json

* refactor: move helper functions out of the route module into mcp service file

* ci: add tests for newly added functions in mcp service file

* fix: memoize setMCPValues to avoid render loop
2025-07-28 12:25:34 -04:00
ryanh-ai
37aba18a96 🪟 feat: Context Window for amazon.nova-premier (#8689) 2025-07-28 12:24:08 -04:00
Marco Beretta
2ce6ac74f4 📻 feat: radio component (#8692)
* 📦 feat: Add Radio component

* 📦 feat: Integrate localization for 'No options available' message in Radio component

* 📦 feat: Bump version to 0.2.0 in package.json

* 📦 feat: Update client package version to 0.2.0 in package-lock.json
2025-07-28 12:18:59 -04:00
Danny Avila
9fddb0ff6a 🐋 ci: Include packages/client source files in Dockerfile.multi Client Build 2025-07-27 15:11:42 -04:00
Danny Avila
32f7dbd11f 🐳 ci: Build client package for Dockerfile.multi 2025-07-27 13:29:43 -04:00
Danny Avila
79197454f8 📦 feat: Move Shared Components to @librechat/client (#8685)
* feat: init @librechat/client

* feat: Add common types and interfaces for accessibility, agents, artifacts, assistants, and tools

* feat: Add jotai as a peer dependency

* fix build client package

* 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

* fix: cleanup

* fix: package; refactor: tsconfig

* feat: add back `recoil`

* fix: move dependencies to peerDependencies in client package

* feat: add @librechat/client as a dependency in package.json and package-lock.json

* 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.

* 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.

* chore: rename package/client build script command

* 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.

* 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.

* feat: export Toast component in client package

- Added export for the Toast component in index.ts to enhance modularity and accessibility of components.

* feat: /client transition to @librechat/client

* chore: fixed formatting issues

* fix: update peer dependencies in @librechat/client to prevent bundling them

* fix: correct useSprings implementation in SplitText component

* fix: circular dependencies in DataTable

* fix: add remaining peer dependencies and match actual versions previously used in `client/package.json`

* fix: correct frontend:ci script to include client package build

* chore: enhance unused package detection for @librechat/client and improve dependency extraction

* fix: add missing peer dependency for @radix-ui/react-collapsible

* chore: include "packages/client" in unused i18next keys detection

* test: update AgentFooter tests to use document.querySelector for spinner checks
test: mock window.matchMedia in setupTests.js for consistent test environment

* feat: add react-hook-form dependency and update FormInput component to use its types

* chore: linting

* refactor: remove unused defaultSelectedValues prop from MCPSelect and MultiSelect components

* chore: linting

* feat: update GitHub Actions workflow to publish @librechat/client

* chore: update GitHub Actions workflow to install and build data-provider and client dependencies

* chore: add missing @testing-library/react dependency to client package

* chore: update tsconfig.json to exclude additional test files

* chore: fix build issues, resolve latest LC changes

* chore: move MCP components outside of `~/components/ui`

* feat: implement dynamic theme system with environment variable support and Tailwind CSS integration

* chore: remove unnecessary logging of sttExternal and ttsExternal in Speech component

* chore: squashed cleanup commits

chore: move @tanstack/react-virtual to dependencies and remove recoil from package.json

chore: move dependencies to peerDependencies in package.json

feat: update package.json and rollup.config.js to include jotai and enhance bundling configuration

feat: update package.json and rollup.config.js to include jotai and enhance bundling configuration

refactor: reorganize exports in index.ts for improved clarity

refactor: remove unused types and interfaces from common files

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.

refactor: clean up client workflow and update package dependencies

refactor: update package dependencies and improve PostCSS and Rollup configurations

chore: bump version to 0.1.2 in package.json

chore: bump client version to 0.1.2 in package-lock.json

chore: bump client version to 0.1.3 and update dependencies

chore: bump client version to 0.1.4 and update @react-spring dependencies

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.

refactor: code structure; chore: translations cleanup

chore: remove unused imports and clean up code in NewChat component

refactor: enhance Menu component to support custom render functions for menu items

style: update itemClassName in ToolsDropdown for improved UI consistency

fix: merge conflicts

chore: update @radix-ui/react-accordion to version 1.2.11

* refactor: remove unnecessary TypeScript type assertions in AnimatedTabs, Label, Separator, and Slider components

* feat: enhance theme system with localStorage persistence and new theme atoms

* chore: bump version of @librechat/client to 0.1.7

* chore: fix ci/cd warnings/errors related to linting and unused localization keys

* chore: update dependencies for class-variance-authority, clsx, and match-sorter

* chore: bump @librechat/client to v0.1.8

* feat: add utility colors for theme customization and remove unused tailwindConfig

* v0.1.9

---------

Co-authored-by: Marco Beretta <81851188+berry-13@users.noreply.github.com>
2025-07-27 12:19:01 -04:00
Danny Avila
97e1cdd224 🧗 refactor: Replace traverse package with Minimal Traversal for Logging (#8687)
* 📦 chore: Remove `keyv` from peerDependencies in package.json and package-lock.json for data-schemas

* refactor: replace traverse import with custom object-traverse utility for better control and error handling during logging

* chore(data-schemas): bump version to 0.0.15 and remove unused dependencies

* refactor: optimize message construction in debugTraverse

* chore: update Node.js version to 20.x in data-schemas workflow
2025-07-27 11:47:37 -04:00
Dustin Healy
d6a65f5a08 🐛 fix: Temporary Chats Still Visible in Sidebar (#8688)
* 🐛 fix: Fix import error causing temporary chats to still display in sidebar

* refactor: Update import path for `getCustomConfig` in Conversation and Message models

* chore: eslint warnings

* ci: add tests for Conversation and Message models

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-07-27 11:42:35 -04:00
Danny Avila
f4facb7d35 🪵 refactor: Dynamic getLogDirectory utility for Loggers (#8686) 2025-07-26 20:11:20 -04:00
88 changed files with 5525 additions and 2419 deletions

View File

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

View File

@@ -16,6 +16,7 @@ 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/
@@ -45,11 +46,19 @@ 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,7 +46,10 @@ class DALLE3 extends Tool {
}
if (process.env.PROXY) {
config.httpAgent = new HttpsProxyAgent(process.env.PROXY);
const proxyAgent = new ProxyAgent(process.env.PROXY);
config.fetchOptions = {
dispatcher: proxyAgent,
};
}
/** @type {OpenAI} */
@@ -163,7 +166,8 @@ Error Message: ${error.message}`);
if (this.isAgent) {
let fetchOptions = {};
if (process.env.PROXY) {
fetchOptions.agent = new HttpsProxyAgent(process.env.PROXY);
const proxyAgent = new ProxyAgent(process.env.PROXY);
fetchOptions.dispatcher = proxyAgent;
}
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,7 +189,10 @@ function createOpenAIImageTools(fields = {}) {
}
const clientConfig = { ...closureConfig };
if (process.env.PROXY) {
clientConfig.httpAgent = new HttpsProxyAgent(process.env.PROXY);
const proxyAgent = new ProxyAgent(process.env.PROXY);
clientConfig.fetchOptions = {
dispatcher: proxyAgent,
};
}
/** @type {OpenAI} */
@@ -335,7 +338,10 @@ Error Message: ${error.message}`);
const clientConfig = { ...closureConfig };
if (process.env.PROXY) {
clientConfig.httpAgent = new HttpsProxyAgent(process.env.PROXY);
const proxyAgent = new ProxyAgent(process.env.PROXY);
clientConfig.fetchOptions = {
dispatcher: proxyAgent,
};
}
const formData = new FormData();
@@ -447,6 +453,19 @@ 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

@@ -0,0 +1,94 @@
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,6 +44,14 @@ 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,12 +1,13 @@
const KeyvRedis = require('@keyv/redis').default;
const { Keyv } = require('keyv');
const { cacheConfig } = require('./cacheConfig');
const { keyvRedisClient, ioredisClient, GLOBAL_PREFIX_SEPARATOR } = require('./redisClients');
const { RedisStore } = require('rate-limit-redis');
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.
@@ -20,11 +21,21 @@ const standardCache = (namespace, ttl = undefined, fallbackStore = undefined) =>
cacheConfig.USE_REDIS &&
!cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES?.includes(namespace)
) {
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;
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;
}
}
if (fallbackStore) return new Keyv({ store: fallbackStore, namespace, ttl });
return new Keyv({ namespace, ttl });
@@ -50,7 +61,13 @@ 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 });
return new ConnectRedis({ client: ioredisClient, ttl, prefix: namespace });
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;
};
/**
@@ -62,8 +79,30 @@ const limiterCache = (prefix) => {
if (!prefix) throw new Error('prefix is required');
if (!cacheConfig.USE_REDIS) return undefined;
prefix = prefix.endsWith(':') ? prefix : `${prefix}:`;
return new RedisStore({ sendCommand, 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;
});
};
const sendCommand = (...args) => ioredisClient?.call(...args);
module.exports = { standardCache, sessionCache, violationCache, limiterCache };

View File

@@ -6,13 +6,17 @@ const mockKeyvRedis = {
keyPrefixSeparator: '',
};
const mockKeyv = jest.fn().mockReturnValue({ mock: 'keyv' });
const mockKeyv = jest.fn().mockReturnValue({
mock: 'keyv',
on: jest.fn(),
});
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 = {};
@@ -53,6 +57,14 @@ 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');
@@ -142,6 +154,28 @@ 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', () => {
@@ -233,6 +267,86 @@ 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', () => {
@@ -274,8 +388,10 @@ describe('cacheFactory', () => {
});
});
it('should pass sendCommand function that calls ioredisClient.call', () => {
it('should pass sendCommand function that calls ioredisClient.call', async () => {
cacheConfig.USE_REDIS = true;
mockIoredisClient.call.mockResolvedValue('test-value');
limiterCache('rate-limit');
const sendCommandCall = mockRedisStore.mock.calls[0][0];
@@ -283,9 +399,29 @@ describe('cacheFactory', () => {
// Test that sendCommand properly delegates to ioredisClient.call
const args = ['GET', 'test-key'];
sendCommand(...args);
const result = await 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,23 +13,82 @@ 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 });
: 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,
});
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 = () => {
@@ -42,7 +101,9 @@ if (cacheConfig.USE_REDIS) {
if (cacheConfig.REDIS_PING_INTERVAL > 0) {
pingInterval = setInterval(() => {
if (ioredisClient && ioredisClient.status === 'ready') {
ioredisClient.ping();
ioredisClient.ping().catch((err) => {
logger.error('ioredis ping failed:', err);
});
}
}, cacheConfig.REDIS_PING_INTERVAL * 1000);
ioredisClient.on('close', clearPingInterval);
@@ -56,8 +117,32 @@ 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 } };
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,
};
keyvRedisClient =
urls.length === 1
@@ -73,6 +158,27 @@ 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 = () => {
@@ -85,7 +191,9 @@ if (cacheConfig.USE_REDIS) {
if (cacheConfig.REDIS_PING_INTERVAL > 0) {
pingInterval = setInterval(() => {
if (keyvRedisClient && keyvRedisClient.isReady) {
keyvRedisClient.ping();
keyvRedisClient.ping().catch((err) => {
logger.error('@keyv/redis ping failed:', err);
});
}
}, 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

@@ -0,0 +1,572 @@
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,17 +1,21 @@
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>}
*/
@@ -117,21 +121,21 @@ describe('Message Operations', () => {
const conversationId = uuidv4();
// Create multiple messages in the same conversation
const message1 = await saveMessage(mockReq, {
await saveMessage(mockReq, {
messageId: 'msg1',
conversationId,
text: 'First message',
user: 'user123',
});
const message2 = await saveMessage(mockReq, {
await saveMessage(mockReq, {
messageId: 'msg2',
conversationId,
text: 'Second message',
user: 'user123',
});
const message3 = await saveMessage(mockReq, {
await saveMessage(mockReq, {
messageId: 'msg3',
conversationId,
text: 'Third message',
@@ -314,4 +318,265 @@ 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

@@ -3,6 +3,7 @@ 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 { getMCPManager } = require('~/config');
const { getProjectByName } = require('~/models/Project');
const { isEnabled } = require('~/server/utils');
const { getLogStores } = require('~/cache');
@@ -102,11 +103,16 @@ 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

@@ -4,6 +4,7 @@ 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');
@@ -451,11 +452,19 @@ 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: true,
message: oauthRequired
? `MCP server '${serverName}' ready for OAuth authentication`
: `MCP server '${serverName}' reinitialized successfully`,
success: userConnection && !oauthRequired,
message: getResponseMessage(),
serverName,
oauthRequired,
oauthUrl,
@@ -468,7 +477,7 @@ router.post('/:serverName/reinitialize', requireJwtAuth, async (req, res) => {
/**
* Get connection status for all MCP servers
* This endpoint returns the actual connection status from MCPManager without disconnecting idle connections
* This endpoint returns all app level and user-scoped connection statuses from MCPManager without disconnecting idle connections
*/
router.get('/connection/status', requireJwtAuth, async (req, res) => {
try {
@@ -478,84 +487,19 @@ router.get('/connection/status', requireJwtAuth, async (req, res) => {
return res.status(401).json({ error: 'User not authenticated' });
}
const mcpManager = getMCPManager(user.id);
const { mcpConfig, appConnections, userConnections, oauthServers } = await getMCPSetupData(
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)) {
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,
};
connectionStatus[serverName] = await getServerConnectionStatus(
user.id,
serverName,
appConnections,
userConnections,
oauthServers,
);
}
res.json({
@@ -563,11 +507,67 @@ 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' });
}
if (!serverName) {
return res.status(400).json({ error: 'Server name is required' });
}
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

View File

@@ -12,7 +12,7 @@ const {
} = require('@librechat/api');
const { findToken, createToken, updateToken } = require('~/models');
const { getMCPManager, getFlowStateManager } = require('~/config');
const { getCachedTools } = require('./Config');
const { getCachedTools, loadCustomConfig } = require('./Config');
const { getLogStores } = require('~/cache');
/**
@@ -239,6 +239,123 @@ 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) {
logger.debug(`[MCP Connection Status] Found failed OAuth flow for ${serverName}`, {
flowId,
status: flowState.status,
flowAge,
flowTTL,
timedOut: flowAge > flowTTL,
});
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

@@ -0,0 +1,510 @@
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

@@ -196,6 +196,7 @@ 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

@@ -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.6.0",
"clsx": "^1.2.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.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": "^6.3.4",
"match-sorter": "^8.1.0",
"micromark-extension-llm-math": "^3.1.0",
"qrcode.react": "^4.2.0",
"rc-input-number": "^7.4.2",

View File

@@ -7,6 +7,7 @@ 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';
@@ -23,11 +24,23 @@ const App = () => {
}),
});
// Load theme from environment variables if available
const envTheme = getThemeFromEnv();
return (
<QueryClientProvider client={queryClient}>
<RecoilRoot>
<LiveAnnouncer>
<ThemeProvider>
<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 */}
<RadixToast.Provider>
<ToastProvider>
<DndProvider backend={HTML5Backend}>

View File

@@ -15,133 +15,142 @@ interface ArtifactsSubMenuProps {
handleCustomToggle: () => void;
}
const ArtifactsSubMenu = ({
isArtifactsPinned,
setIsArtifactsPinned,
artifactsMode,
handleArtifactsToggle,
handleShadcnToggle,
handleCustomToggle,
...props
}: ArtifactsSubMenuProps) => {
const localize = useLocalize();
const ArtifactsSubMenu = React.forwardRef<HTMLDivElement, ArtifactsSubMenuProps>(
(
{
isArtifactsPinned,
setIsArtifactsPinned,
artifactsMode,
handleArtifactsToggle,
handleShadcnToggle,
handleCustomToggle,
...props
},
ref,
) => {
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 (
<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')}
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" />}
</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();
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsArtifactsPinned(!isArtifactsPinned);
}}
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',
'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="flex items-center gap-2">
<Ariakit.MenuItemCheck checked={isCustomEnabled} />
<span className="text-sm">{localize('com_ui_custom_prompt_mode' as any)}</span>
<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>
</Ariakit.MenuItem>
</div>
</Ariakit.Menu>
)}
</Ariakit.MenuProvider>
);
};
</Ariakit.Menu>
)}
</Ariakit.MenuProvider>
</div>
);
},
);
ArtifactsSubMenu.displayName = 'ArtifactsSubMenu';
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/ui/MCP/MCPServerStatusIcon';
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
import { useMCPServerManager } from '~/hooks/MCP/useMCPServerManager';
import MCPConfigDialog from '~/components/ui/MCP/MCPConfigDialog';
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
function MCPSelect() {
const {
@@ -79,7 +79,6 @@ function MCPSelect() {
items={configuredServers}
selectedValues={mcpValues ?? []}
setSelectedValues={batchToggleServers}
defaultSelectedValues={mcpValues ?? []}
renderSelectedValues={renderSelectedValues}
renderItemContent={renderItemContent}
placeholder={placeholderText}

View File

@@ -2,124 +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/ui/MCP/MCPServerStatusIcon';
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
import { useMCPServerManager } from '~/hooks/MCP/useMCPServerManager';
import MCPConfigDialog from '~/components/ui/MCP/MCPConfigDialog';
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
import { cn } from '~/utils';
interface MCPSubMenuProps {
placeholder?: string;
}
const MCPSubMenu = ({ placeholder, ...props }: MCPSubMenuProps) => {
const {
configuredServers,
mcpValues,
isPinned,
setIsPinned,
placeholderText,
toggleServerSelection,
getServerStatusIconProps,
getConfigDialogProps,
} = useMCPServerManager();
const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
({ placeholder, ...props }, ref) => {
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 (
<>
<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) => {
e.stopPropagation();
setIsPinned(!isPinned);
}}
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={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} />;
return (
<Ariakit.MenuItem
key={serverName}
onClick={(event) => {
event.preventDefault();
toggleServerSelection(serverName);
return (
<div ref={ref}>
<Ariakit.MenuProvider store={menuStore}>
<Ariakit.MenuItem
{...props}
render={
<Ariakit.MenuButton
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
menuStore.toggle();
}}
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}
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(
'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={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} />;
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',
)}
>
<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} />}
</>
);
};
<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';
export default React.memo(MCPSubMenu);

View File

@@ -114,9 +114,13 @@ export default function CustomUserVarsSection({
reset();
};
// Don't render if no fields to configure
// Show message if no fields to configure
if (!fields || Object.keys(fields).length === 0) {
return null;
return (
<div className="p-4 text-center text-sm text-gray-500">
{localize('com_sidepanel_mcp_no_custom_vars', { '0': serverName })}
</div>
);
}
return (

View File

@@ -1,13 +1,13 @@
import React from 'react';
import { Loader2, KeyRound, PlugZap, AlertTriangle } from 'lucide-react';
import { MCPServerStatus } from 'librechat-data-provider/dist/types/types/queries';
import {
OGDialog,
OGDialogContent,
OGDialogHeader,
OGDialogTitle,
OGDialogHeader,
OGDialogContent,
OGDialogDescription,
} from '@librechat/client';
import type { MCPServerStatus } from 'librechat-data-provider';
import ServerInitializationSection from './ServerInitializationSection';
import CustomUserVarsSection from './CustomUserVarsSection';
import { useLocalize } from '~/hooks';
@@ -132,6 +132,7 @@ export default function MCPConfigDialog({
<ServerInitializationSection
serverName={serverName}
requiresOAuth={serverStatus?.requiresOAuth || false}
hasCustomUserVars={fieldsSchema && Object.keys(fieldsSchema).length > 0}
/>
</OGDialogContent>
</OGDialog>

View File

@@ -1,83 +1,85 @@
import React, { useState, useCallback } from 'react';
import React, { useCallback } from 'react';
import { Button } from '@librechat/client';
import { RefreshCw, Link } from 'lucide-react';
import { useMCPServerInitialization } from '~/hooks/MCP/useMCPServerInitialization';
import { useMCPServerManager } from '~/hooks/MCP/useMCPServerManager';
import { useLocalize } from '~/hooks';
interface ServerInitializationSectionProps {
serverName: string;
requiresOAuth: boolean;
hasCustomUserVars?: boolean;
}
export default function ServerInitializationSection({
serverName,
requiresOAuth,
hasCustomUserVars = false,
}: 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);
},
});
// Use the centralized server manager instead of the old initialization hook so we can handle multiple oauth flows at once
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 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) {
// Show subtle reinitialize option if connected AND server has OAuth or custom user vars
if (isConnected && (requiresOAuth || hasCustomUserVars)) {
return (
<div className="flex justify-start">
<button
onClick={handleInitializeClick}
disabled={isLoading}
disabled={isServerInitializing}
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')}
<RefreshCw className={`h-3 w-3 ${isServerInitializing ? 'animate-spin' : ''}`} />
{isServerInitializing ? localize('com_ui_loading') : localize('com_ui_reinitialize')}
</button>
</div>
);
}
// Don't show anything for connected servers that don't need OAuth or custom vars
if (isConnected) {
return null;
}
return (
<div className="rounded-lg border border-[#991b1b] bg-[#2C1315] p-4">
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-700 dark:bg-amber-900/20">
<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">
<span className="text-sm font-medium text-amber-800 dark:text-amber-200">
{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 && (
{!serverOAuthUrl && (
<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"
disabled={isServerInitializing}
className="btn btn-primary focus:shadow-outline flex w-full items-center justify-center px-4 py-2 font-semibold text-white hover:bg-green-600 focus:border-green-500"
>
{isLoading ? (
{isServerInitializing ? (
<>
<RefreshCw className="h-4 w-4 animate-spin" />
{localize('com_ui_loading')}
@@ -95,7 +97,7 @@ export default function ServerInitializationSection({
</div>
{/* OAuth URL display */}
{oauthUrl && (
{serverOAuthUrl && (
<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">
@@ -107,8 +109,8 @@ export default function ServerInitializationSection({
</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"
onClick={() => window.open(serverOAuthUrl, '_blank', 'noopener,noreferrer')}
className="flex-1 bg-green-600 text-white hover:bg-green-700 dark:hover:bg-green-800"
>
{localize('com_ui_continue_oauth')}
</Button>

View File

@@ -1,8 +1,10 @@
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 DeleteVersion = ({
const DeleteConfirmDialog = ({
name,
disabled,
selectHandler,
@@ -58,4 +60,42 @@ const DeleteVersion = ({
);
};
export default DeleteVersion;
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;

View File

@@ -1,10 +1,13 @@
import React, { useMemo } from 'react';
import { Dropdown } from '@librechat/client';
import React, { useMemo, useState } from 'react';
import * as Ariakit from '@ariakit/react';
import { useTranslation } from 'react-i18next';
import { useFormContext, Controller } from 'react-hook-form';
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 type { ReactNode } from 'react';
import { useCategories } from '~/hooks';
import { cn } from '~/utils';
interface CategorySelectorProps {
currentCategory?: string;
@@ -20,10 +23,11 @@ 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;
@@ -46,53 +50,71 @@ 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={() => (
<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>
)}
<DropdownPopup
trigger={trigger}
items={menuItems}
isOpen={isOpen}
setIsOpen={setIsOpen}
menuId="category-selector-menu"
className="mt-2"
portal={true}
/>
);
};

View File

@@ -1,4 +1,5 @@
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';
@@ -6,14 +7,13 @@ 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 } from 'librechat-data-provider';
import type { TCreatePrompt, TPrompt, TPromptGroup } from 'librechat-data-provider';
import {
useCreatePrompt,
useGetPrompts,
useCreatePrompt,
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 DeleteConfirm from './DeleteVersion';
import DeleteVersion from './DeleteVersion';
import PromptDetails from './PromptDetails';
import PromptEditor from './PromptEditor';
import SkeletonForm from './SkeletonForm';
@@ -32,16 +32,136 @@ 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 alwaysMakeProd = useRecoilValue(store.alwaysMakeProd);
const { showToast } = useToastContext();
const alwaysMakeProd = useRecoilValue(store.alwaysMakeProd);
const promptId = params.promptId || '';
const [selectionIndex, setSelectionIndex] = useState<number>(0);
const editorMode = useRecoilValue(store.promptsEditorMode);
const [selectionIndex, setSelectionIndex] = useState<number>(0);
const prevIsEditingRef = useRef(false);
const [isEditing, setIsEditing] = useState(false);
const [initialLoad, setInitialLoad] = useState(true);
@@ -72,11 +192,9 @@ 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: () => {
@@ -88,7 +206,6 @@ const PromptForm = () => {
});
const makeProductionMutation = useMakePromptProduction();
const deletePromptMutation = useDeletePrompt();
const createPromptMutation = useCreatePrompt({
onMutate: (variables) => {
@@ -177,24 +294,40 @@ const PromptForm = () => {
return () => window.removeEventListener('resize', handleResize);
}, []);
const debouncedUpdateOneliner = useCallback(
debounce((oneliner: string) => {
if (!group || !group._id) {
return console.warn('Group not found');
}
updateGroupMutation.mutate({ id: group._id, payload: { oneliner } });
}, 950),
[updateGroupMutation, group],
const debouncedUpdateOneliner = useMemo(
() =>
debounce((groupId: string, oneliner: string, mutate: any) => {
mutate({ id: groupId, payload: { oneliner } });
}, 950),
[],
);
const debouncedUpdateCommand = useCallback(
debounce((command: string) => {
const debouncedUpdateCommand = useMemo(
() =>
debounce((groupId: string, command: string, mutate: any) => {
mutate({ id: groupId, payload: { command } });
}, 950),
[],
);
const handleUpdateOneliner = useCallback(
(oneliner: string) => {
if (!group || !group._id) {
return console.warn('Group not found');
}
updateGroupMutation.mutate({ id: group._id, payload: { command } });
}, 950),
[updateGroupMutation, group],
debouncedUpdateOneliner(group._id, oneliner, updateGroupMutation.mutate);
},
[group, updateGroupMutation.mutate, debouncedUpdateOneliner],
);
const handleUpdateCommand = useCallback(
(command: string) => {
if (!group || !group._id) {
return console.warn('Group not found');
}
debouncedUpdateCommand(group._id, command, updateGroupMutation.mutate);
},
[group, updateGroupMutation.mutate, debouncedUpdateCommand],
);
if (initialLoad) {
@@ -217,89 +350,7 @@ 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}>
@@ -339,7 +390,17 @@ const PromptForm = () => {
<Menu className="size-5" />
</Button>
<div className="hidden lg:block">
{editorMode === PromptsEditorMode.SIMPLE && <RightPanel />}
{editorMode === PromptsEditorMode.SIMPLE && (
<RightPanel
group={group}
prompts={prompts}
selectedPrompt={selectedPrompt}
selectionIndex={selectionIndex}
selectedPromptId={selectedPromptId}
isLoadingPrompts={isLoadingPrompts}
setSelectionIndex={setSelectionIndex}
/>
)}
</div>
</>
)}
@@ -352,11 +413,11 @@ const PromptForm = () => {
<PromptVariables promptText={promptText} />
<Description
initialValue={group.oneliner ?? ''}
onValueChange={debouncedUpdateOneliner}
onValueChange={handleUpdateOneliner}
/>
<Command
initialValue={group.command ?? ''}
onValueChange={debouncedUpdateCommand}
onValueChange={handleUpdateCommand}
/>
</div>
)}
@@ -364,7 +425,15 @@ const PromptForm = () => {
{editorMode === PromptsEditorMode.ADVANCED && (
<div className="hidden w-1/4 border-l border-border-light lg:block">
<RightPanel />
<RightPanel
group={group}
prompts={prompts}
selectionIndex={selectionIndex}
selectedPrompt={selectedPrompt}
selectedPromptId={selectedPromptId}
isLoadingPrompts={isLoadingPrompts}
setSelectionIndex={setSelectionIndex}
/>
</div>
)}
</div>
@@ -395,7 +464,15 @@ const PromptForm = () => {
>
<div className="h-full">
<div className="h-full overflow-auto">
<RightPanel />
<RightPanel
group={group}
prompts={prompts}
selectionIndex={selectionIndex}
selectedPrompt={selectedPrompt}
selectedPromptId={selectedPromptId}
isLoadingPrompts={isLoadingPrompts}
setSelectionIndex={setSelectionIndex}
/>
</div>
</div>
</div>

View File

@@ -1,3 +1,4 @@
/* 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,14 +5,15 @@ 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/ui/MCP/ServerInitializationSection';
import CustomUserVarsSection from '~/components/ui/MCP/CustomUserVarsSection';
import ServerInitializationSection from '~/components/MCP/ServerInitializationSection';
import CustomUserVarsSection from '~/components/MCP/CustomUserVarsSection';
import { useMCPConnectionStatusQuery } from '~/data-provider/Tools/queries';
import BadgeRowProvider from '~/Providers/BadgeRowContext';
import { useGetStartupConfig } from '~/data-provider';
import MCPPanelSkeleton from './MCPPanelSkeleton';
import { useLocalize } from '~/hooks';
export default function MCPPanel() {
function MCPPanelContent() {
const localize = useLocalize();
const { showToast } = useToastContext();
const queryClient = useQueryClient();
@@ -140,29 +141,31 @@ export default function MCPPanel() {
{localize('com_sidepanel_mcp_variables_for', { '0': serverBeingEdited.serverName })}
</h3>
{/* Server Initialization Section */}
<div className="mb-4">
<ServerInitializationSection
<CustomUserVarsSection
serverName={selectedServerNameForEditing}
requiresOAuth={serverStatus?.requiresOAuth || false}
fields={serverBeingEdited.config.customUserVars}
onSave={(authData) => {
if (selectedServerNameForEditing) {
handleConfigSave(selectedServerNameForEditing, authData);
}
}}
onRevoke={() => {
if (selectedServerNameForEditing) {
handleConfigRevoke(selectedServerNameForEditing);
}
}}
isSubmitting={updateUserPluginsMutation.isLoading}
/>
</div>
{/* Custom User Variables Section */}
<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}
hasCustomUserVars={
serverBeingEdited.config.customUserVars &&
Object.keys(serverBeingEdited.config.customUserVars).length > 0
}
/>
</div>
);
@@ -205,3 +208,11 @@ export default function MCPPanel() {
);
}
}
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 {
useGetUserQuery,
useMemoriesQuery,
useDeleteMemoryMutation,
useUpdateMemoryPreferencesMutation,
useDeleteMemoryMutation,
useMemoriesQuery,
useGetUserQuery,
} from '~/data-provider';
import { useLocalize, useAuthContext, useHasAccess } from '~/hooks';
import MemoryCreateDialog from './MemoryCreateDialog';
@@ -36,18 +36,114 @@ 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({
@@ -119,108 +215,6 @@ 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, expanded = false }: SourceItemProps) {
function SourceItem({ source, isNews: _isNews, expanded = false }: SourceItemProps) {
const localize = useLocalize();
const domain = getCleanDomain(source.link);

View File

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

View File

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

View File

@@ -1,317 +0,0 @@
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,34 +1,52 @@
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 { 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 {
useUpdateUserPluginsMutation,
useReinitializeMCPServerMutation,
useCancelMCPOAuthMutation,
} from 'librechat-data-provider/react-query';
import { useMCPConnectionStatusQuery } from '~/data-provider/Tools/queries';
import type { TUpdateUserPlugins, TPlugin } from 'librechat-data-provider';
import { useBadgeRowContext } from '~/Providers';
import type { ConfigFieldDetail } from '~/components/MCP/MCPConfigDialog';
import { useLocalize } from '~/hooks';
import { useBadgeRowContext } from '~/Providers';
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 [isConfigModalOpen, setIsConfigModalOpen] = useState(false);
const [selectedToolForConfig, setSelectedToolForConfig] = useState<TPlugin | null>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
const queryClient = useQueryClient();
const reinitializeMutation = useReinitializeMCPServerMutation();
const cancelOAuthMutation = useCancelMCPOAuthMutation();
const updateUserPluginsMutation = useUpdateUserPluginsMutation({
onSuccess: async () => {
@@ -49,52 +67,284 @@ export function useMCPServerManager() {
},
});
const { initializeServer, isInitializing, connectionStatus, cancelOAuthFlow, isCancellable } =
useMCPServerInitialization({
onSuccess: (serverName) => {
const currentValues = mcpValues ?? [];
if (!currentValues.includes(serverName)) {
setMCPValues([...currentValues, serverName]);
}
},
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 [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',
});
cleanupServerState(serverName);
return;
}
if (serverStatus?.connectionState === 'error') {
showToast({
message: localize('com_ui_mcp_init_failed'),
status: 'error',
});
cleanupServerState(serverName);
}
} catch (error) {
console.error(`[MCP Manager] Error polling server ${serverName}:`, error);
}
}, 3500);
updateServerState(serverName, { pollInterval });
},
[
queryClient,
serverStates,
showToast,
localize,
setMCPValues,
cleanupServerState,
updateServerState,
],
);
const initializeServer = useCallback(
async (serverName: string) => {
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,
});
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) => {
queryClient.invalidateQueries([QueryKeys.mcpConnectionStatus]);
cleanupServerState(serverName);
cancelOAuthMutation.mutate(serverName);
showToast({
message: localize('com_ui_mcp_oauth_cancelled', { 0: serverName }),
status: 'warning',
});
},
[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) => {
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 toggleServerSelection = useCallback(
(serverName: string) => {
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') {
setMCPValues([...currentValues, serverName]);
} else {
initializeServer(serverName);
}
}
},
[mcpValues, setMCPValues, connectionStatus, initializeServer],
);
const handleConfigSave = useCallback(
(targetName: string, authData: Record<string, string>) => {
@@ -156,48 +406,6 @@ 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);
@@ -256,11 +464,6 @@ 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;
@@ -303,27 +506,31 @@ export function useMCPServerManager() {
]);
return {
// Data
configuredServers,
connectionStatus,
initializeServer,
cancelOAuthFlow,
isInitializing,
isCancellable,
getOAuthUrl,
mcpValues,
setMCPValues,
mcpToolDetails,
isPinned,
setIsPinned,
startupConfig,
connectionStatus,
placeholderText,
// Handlers
toggleServerSelection,
batchToggleServers,
getServerStatusIconProps,
// Dialog state
selectedToolForConfig,
isConfigModalOpen,
getConfigDialogProps,
// Utilities
toggleServerSelection,
localize,
isConfigModalOpen,
handleDialogOpenChange,
selectedToolForConfig,
setSelectedToolForConfig,
handleSave,
handleRevoke,
getServerStatusIconProps,
getConfigDialogProps,
};
}

View File

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

View File

@@ -81,13 +81,21 @@ export function useMCPSelect({ conversationId }: UseMCPSelectOptions) {
[setEphemeralAgent],
);
const [mcpValues, setMCPValues] = useLocalStorage<string[]>(
const [mcpValues, setMCPValuesRaw] = 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,6 +8,7 @@ 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

@@ -506,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_no_custom_vars": "No custom user variables set for {{0}}",
"com_sidepanel_mcp_variables_for": "MCP Variables for {{0}}",
"com_sidepanel_parameters": "Parameters",
"com_sources_image_alt": "Search result image",
@@ -857,13 +858,11 @@
"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",
@@ -1072,7 +1071,6 @@
"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",
@@ -1110,4 +1108,4 @@
"com_ui_yes": "Yes",
"com_ui_zoom": "Zoom",
"com_user_message": "You"
}
}

View File

@@ -23,7 +23,7 @@ export default function Search() {
isError,
fetchNextPage,
isFetchingNextPage,
hasNextPage,
hasNextPage: _hasNextPage,
} = useMessagesInfiniteQuery(
{
search: searchQuery || undefined,

View File

@@ -51,6 +51,7 @@
--amber-800: #92400e;
--amber-900: #78350f;
--amber-950: #451a03;
--brand-purple: #ab68ff;
--gizmo-gray-500: #999;
--gizmo-gray-600: #666;
--gizmo-gray-950: #0f0f0f;
@@ -61,6 +62,7 @@
--font-size-xl: 1.25rem;
}
html {
--brand-purple: #ab68ff;
--presentation: var(--white);
--text-primary: var(--gray-800);
--text-secondary: var(--gray-600);
@@ -121,6 +123,7 @@ html {
--switch-unchecked: 0 0% 58%;
}
.dark {
--brand-purple: #ab68ff;
--presentation: var(--gray-800);
--text-primary: var(--gray-100);
--text-secondary: var(--gray-300);

View File

@@ -0,0 +1,58 @@
/**
* Loads theme configuration from environment variables
* @returns {import('@librechat/client').IThemeRGB | undefined}
*/
export function getThemeFromEnv() {
// Check if any theme environment variables are set
const hasThemeEnvVars = Object.keys(process.env).some((key) =>
key.startsWith('REACT_APP_THEME_'),
);
if (!hasThemeEnvVars) {
return undefined; // Use default themes
}
// Build theme object from environment variables
const theme = {};
// Helper to get env value with prefix
const getEnv = (key) => process.env[`REACT_APP_THEME_${key}`];
// Text colors
if (getEnv('TEXT_PRIMARY')) theme['rgb-text-primary'] = getEnv('TEXT_PRIMARY');
if (getEnv('TEXT_SECONDARY')) theme['rgb-text-secondary'] = getEnv('TEXT_SECONDARY');
if (getEnv('TEXT_TERTIARY')) theme['rgb-text-tertiary'] = getEnv('TEXT_TERTIARY');
if (getEnv('TEXT_WARNING')) theme['rgb-text-warning'] = getEnv('TEXT_WARNING');
// Surface colors
if (getEnv('SURFACE_PRIMARY')) theme['rgb-surface-primary'] = getEnv('SURFACE_PRIMARY');
if (getEnv('SURFACE_SECONDARY')) theme['rgb-surface-secondary'] = getEnv('SURFACE_SECONDARY');
if (getEnv('SURFACE_TERTIARY')) theme['rgb-surface-tertiary'] = getEnv('SURFACE_TERTIARY');
if (getEnv('SURFACE_SUBMIT')) theme['rgb-surface-submit'] = getEnv('SURFACE_SUBMIT');
if (getEnv('SURFACE_SUBMIT_HOVER'))
theme['rgb-surface-submit-hover'] = getEnv('SURFACE_SUBMIT_HOVER');
if (getEnv('SURFACE_DESTRUCTIVE'))
theme['rgb-surface-destructive'] = getEnv('SURFACE_DESTRUCTIVE');
if (getEnv('SURFACE_DESTRUCTIVE_HOVER'))
theme['rgb-surface-destructive-hover'] = getEnv('SURFACE_DESTRUCTIVE_HOVER');
if (getEnv('SURFACE_DIALOG')) theme['rgb-surface-dialog'] = getEnv('SURFACE_DIALOG');
if (getEnv('SURFACE_CHAT')) theme['rgb-surface-chat'] = getEnv('SURFACE_CHAT');
// Border colors
if (getEnv('BORDER_LIGHT')) theme['rgb-border-light'] = getEnv('BORDER_LIGHT');
if (getEnv('BORDER_MEDIUM')) theme['rgb-border-medium'] = getEnv('BORDER_MEDIUM');
if (getEnv('BORDER_HEAVY')) theme['rgb-border-heavy'] = getEnv('BORDER_HEAVY');
if (getEnv('BORDER_XHEAVY')) theme['rgb-border-xheavy'] = getEnv('BORDER_XHEAVY');
// Brand colors
if (getEnv('BRAND_PURPLE')) theme['rgb-brand-purple'] = getEnv('BRAND_PURPLE');
// Header colors
if (getEnv('HEADER_PRIMARY')) theme['rgb-header-primary'] = getEnv('HEADER_PRIMARY');
if (getEnv('HEADER_HOVER')) theme['rgb-header-hover'] = getEnv('HEADER_HOVER');
// Presentation
if (getEnv('PRESENTATION')) theme['rgb-presentation'] = getEnv('PRESENTATION');
return Object.keys(theme).length > 0 ? theme : undefined;
}

View File

@@ -24,6 +24,7 @@ export { default as cleanupPreset } from './cleanupPreset';
export { default as buildDefaultConvo } from './buildDefaultConvo';
export { default as getDefaultEndpoint } from './getDefaultEndpoint';
export { default as createChatSearchParams } from './createChatSearchParams';
export { getThemeFromEnv } from './getThemeFromEnv';
export const languages = [
'java',

View File

@@ -2,7 +2,11 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{js,jsx,ts,tsx}'],
content: [
'./src/**/*.{js,jsx,ts,tsx}',
// Include component library files
'../packages/client/src/**/*.{js,jsx,ts,tsx}',
],
// darkMode: 'class',
darkMode: ['class'],
theme: {
@@ -61,8 +65,8 @@ module.exports = {
800: '#06373e',
900: '#031f29',
},
'brand-purple': '#ab68ff',
'presentation': 'var(--presentation)',
'brand-purple': 'var(--brand-purple)',
presentation: 'var(--presentation)',
'text-primary': 'var(--text-primary)',
'text-secondary': 'var(--text-secondary)',
'text-secondary-alt': 'var(--text-secondary-alt)',
@@ -135,7 +139,7 @@ module.exports = {
},
plugins: [
require('tailwindcss-animate'),
require('tailwindcss-radix')(),
require('tailwindcss-radix'),
// require('@tailwindcss/typography'),
],
};

1419
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@librechat/client",
"version": "0.1.6",
"version": "0.2.0",
"description": "React components for LibreChat",
"main": "dist/index.js",
"module": "dist/index.es.js",
@@ -51,17 +51,14 @@
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.0.3",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.2.7"
},
"dependencies": {
"@react-spring/web": "^10.0.1",
"class-variance-authority": "^0.6.0",
"clsx": "^1.2.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.23.6",
"i18next-browser-languagedetector": "^8.2.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.525.0",
"match-sorter": "^6.3.4",
"match-sorter": "^8.1.0",
"rc-input-number": "^7.4.2",
"react-hook-form": "^7.56.4",
"react-resizable-panels": "^3.0.2",
@@ -74,17 +71,13 @@
"@rollup/plugin-node-resolve": "^15.0.0",
"@rollup/plugin-replace": "^5.0.5",
"@rollup/plugin-terser": "^0.4.4",
"@tailwindcss/typography": "^0.5.10",
"@tanstack/react-query": "^4.28.0",
"@testing-library/react": "^14.0.0",
"@types/react": "^18.2.11",
"@types/react-dom": "^18.2.4",
"autoprefixer": "^10.4.20",
"concat-with-sourcemaps": "^1.1.0",
"i18next": "^24.2.3",
"jotai": "^2.12.5",
"postcss": "^8.4.31",
"postcss-import": "^15.1.0",
"postcss-preset-env": "^8.5.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-i18next": "^15.4.0",
@@ -93,8 +86,6 @@
"rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-postcss": "^4.0.2",
"rollup-plugin-typescript2": "^0.35.0",
"tailwindcss": "^3.4.1",
"tailwindcss-animate": "^1.0.5",
"tailwindcss-radix": "^2.8.0",
"typescript": "^5.0.0"
}

View File

@@ -1,8 +0,0 @@
module.exports = {
plugins: [
require('postcss-import'),
require('postcss-preset-env'),
require('tailwindcss'),
require('autoprefixer'),
],
};

View File

@@ -133,7 +133,7 @@ export function AnimatedTabs({
data-state={tabIds[index] === firstTabId ? 'active' : 'inactive'}
>
{/* TypeScript workaround for React i18next children type compatibility */}
{tab.label as any}
{tab.label}
</Tab>
))}
</Ariakit.TabList>
@@ -152,7 +152,7 @@ export function AnimatedTabs({
className={tabPanelClassName}
>
{/* TypeScript workaround for React i18next children type compatibility */}
{tab.content as any}
{tab.content}
</TabPanel>
))}
</div>

View File

@@ -1,4 +1,4 @@
import * as React from 'react';
import React from 'react';
import * as Ariakit from '@ariakit/react';
import type * as t from '~/common';
import { cn } from '~/utils';
@@ -42,14 +42,14 @@ const DropdownPopup: React.FC<DropdownProps> = ({
if (mountByState) {
return (
<Ariakit.MenuProvider store={menu}>
{trigger as React.JSX.Element}
{trigger}
{isOpen && <Menu {...props} />}
</Ariakit.MenuProvider>
);
}
return (
<Ariakit.MenuProvider store={menu}>
{trigger as React.JSX.Element}
{trigger}
<Menu {...props} />
</Ariakit.MenuProvider>
);
@@ -94,13 +94,11 @@ const Menu: React.FC<MenuProps> = ({
key={`${keyPrefix ?? ''}${index}-${item.id ?? ''}`}
id={item.id}
className={cn(
'group flex w-full cursor-pointer outline-none transition-colors duration-200',
item.render
? itemClassName
: 'items-center gap-2 rounded-lg px-3 py-3.5 text-sm text-text-primary hover:bg-surface-hover focus:bg-surface-hover md:px-2.5 md:py-2',
!item.render && itemClassName,
'group flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-3.5 text-sm text-text-primary outline-none transition-colors duration-200 hover:bg-surface-hover focus:bg-surface-hover md:px-2.5 md:py-2',
itemClassName,
)}
disabled={item.disabled}
render={item.render}
ref={item.ref}
hideOnClick={item.hideOnClick}
onClick={(event) => {
@@ -114,30 +112,16 @@ const Menu: React.FC<MenuProps> = ({
menu?.hide();
}}
>
{item.render ? (
typeof item.render === 'function' ? (
item.render({
className: cn(
'flex w-full items-center rounded-lg px-3 py-3.5 text-sm text-text-primary hover:bg-surface-hover md:px-2.5 md:py-2',
),
})
) : (
item.render
)
) : (
<>
{item.icon != null && (
<span className={cn('mr-2 size-4', iconClassName)} aria-hidden="true">
{item.icon}
</span>
)}
{item.label}
{item.kbd != null && (
<kbd className="ml-auto hidden font-sans text-xs text-black/50 group-hover:inline group-focus:inline dark:text-white/50">
{item.kbd}
</kbd>
)}
</>
{item.icon != null && (
<span className={cn('mr-2 size-4', iconClassName)} aria-hidden="true">
{item.icon}
</span>
)}
{item.label}
{item.kbd != null && (
<kbd className="ml-auto hidden font-sans text-xs text-black/50 group-hover:inline group-focus:inline dark:text-white/50">
{item.kbd}
</kbd>
)}
</Ariakit.MenuItem>
);

View File

@@ -10,11 +10,13 @@ const Label = React.forwardRef<
>(({ className = '', ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
{...(props as any)}
{...({ className: cn(
'block w-full break-all text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-gray-200',
className,
) } as any)}
{...props}
{...{
className: cn(
'block w-full break-all text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-gray-200',
className,
),
}}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;

View File

@@ -0,0 +1,99 @@
import React, { useState, useRef, useLayoutEffect, useCallback, memo } from 'react';
import { useLocalize } from '~/hooks';
interface Option {
value: string;
label: string;
}
interface RadioProps {
options: Option[];
value?: string;
onChange?: (value: string) => void;
disabled?: boolean;
}
const Radio = memo(function Radio({ options, value, onChange, disabled = false }: RadioProps) {
const localize = useLocalize();
const [currentValue, setCurrentValue] = useState<string>(value ?? '');
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [backgroundStyle, setBackgroundStyle] = useState<React.CSSProperties>({});
const handleChange = (newValue: string) => {
setCurrentValue(newValue);
onChange?.(newValue);
};
const updateBackgroundStyle = useCallback(() => {
const selectedIndex = options.findIndex((opt) => opt.value === currentValue);
if (selectedIndex >= 0 && buttonRefs.current[selectedIndex]) {
const selectedButton = buttonRefs.current[selectedIndex];
const container = selectedButton?.parentElement;
if (selectedButton && container) {
const containerRect = container.getBoundingClientRect();
const buttonRect = selectedButton.getBoundingClientRect();
const offsetLeft = buttonRect.left - containerRect.left - 4;
setBackgroundStyle({
width: `${buttonRect.width}px`,
transform: `translateX(${offsetLeft}px)`,
});
}
}
}, [currentValue, options]);
useLayoutEffect(() => {
updateBackgroundStyle();
}, [updateBackgroundStyle]);
useLayoutEffect(() => {
if (value !== undefined) {
setCurrentValue(value);
}
}, [value]);
if (options.length === 0) {
return (
<div
className="relative inline-flex items-center rounded-lg bg-muted p-1 opacity-50"
role="radiogroup"
>
<span className="px-4 py-2 text-xs text-muted-foreground">
{localize('com_ui_no_options')}
</span>
</div>
);
}
const selectedIndex = options.findIndex((opt) => opt.value === currentValue);
return (
<div className="relative inline-flex items-center rounded-lg bg-muted p-1" role="radiogroup">
{selectedIndex >= 0 && (
<div
className="pointer-events-none absolute inset-y-1 rounded-md border border-border/50 bg-background shadow-sm transition-all duration-300 ease-out"
style={backgroundStyle}
/>
)}
{options.map((option, index) => (
<button
key={option.value}
ref={(el) => {
buttonRefs.current[index] = el;
}}
type="button"
role="radio"
aria-checked={currentValue === option.value}
onClick={() => handleChange(option.value)}
disabled={disabled}
className={`relative z-10 flex h-[34px] items-center justify-center rounded-md px-4 text-sm font-medium transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
currentValue === option.value ? 'text-foreground' : 'text-foreground'
} ${disabled ? 'cursor-not-allowed opacity-50' : ''}`}
>
<span className="whitespace-nowrap">{option.label}</span>
</button>
))}
</div>
);
});
export default Radio;

View File

@@ -11,8 +11,8 @@ const Separator = React.forwardRef<
>(({ className = '', orientation = 'horizontal', decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
{...(props as any)}
{...({
{...props}
{...{
decorative,
orientation,
className: cn(
@@ -20,7 +20,7 @@ const Separator = React.forwardRef<
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className,
),
} as any)}
}}
/>
));
Separator.displayName = SeparatorPrimitive.Root.displayName;

View File

@@ -4,26 +4,33 @@ import { cn } from '~/utils';
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> & {
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> & {
className?: string;
onDoubleClick?: () => void;
}
>(({ className, onDoubleClick, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
{...(props as any)}
{...({
{...props}
{...{
className: cn(
'relative flex w-full cursor-pointer touch-none select-none items-center',
className,
),
onDoubleClick,
} as any)}
}}
>
<SliderPrimitive.Track {...({ className: "relative h-2 w-full grow overflow-hidden rounded-full bg-secondary" } as any)}>
<SliderPrimitive.Range {...({ className: "absolute h-full bg-primary" } as any)} />
<SliderPrimitive.Track
{...{ className: 'relative h-2 w-full grow overflow-hidden rounded-full bg-secondary' }}
>
<SliderPrimitive.Range {...{ className: 'absolute h-full bg-primary' }} />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb {...({ className: "block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" } as any)} />
<SliderPrimitive.Thumb
{...{
className:
'block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
}}
/>
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;

View File

@@ -1,6 +1,6 @@
import { useContext, useCallback, useEffect, useState } from 'react';
import { Sun, Moon, Monitor } from 'lucide-react';
import { ThemeContext } from '~/hooks';
import { ThemeContext } from '../theme';
declare global {
interface Window {

View File

@@ -4,7 +4,6 @@ export * from './AlertDialog';
export * from './Breadcrumb';
export * from './Button';
export * from './Checkbox';
export * from './Collapsible';
export * from './DataTableColumnHeader';
export * from './Dialog';
export * from './DropdownMenu';
@@ -31,6 +30,7 @@ export * from './Progress';
export * from './InputOTP';
export * from './MultiSearch';
export * from './Resizable';
export { default as Radio } from './Radio';
export { default as Badge } from './Badge';
export { default as Combobox } from './Combobox';
export { default as Dropdown } from './Dropdown';

View File

@@ -0,0 +1,88 @@
//ThemeContext.js
// source: https://plainenglish.io/blog/light-and-dark-mode-in-react-web-application-with-tailwind-css-89674496b942
import { useSetAtom } from 'jotai';
import React, { createContext, useState, useEffect } from 'react';
import { getInitialTheme, applyFontSize } from '~/utils';
import { fontSizeAtom } from '~/store';
type ProviderValue = {
theme: string;
setTheme: React.Dispatch<React.SetStateAction<string>>;
};
const defaultContextValue: ProviderValue = {
theme: getInitialTheme(),
setTheme: () => {
return;
},
};
export const isDark = (theme: string): boolean => {
if (theme === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
return theme === 'dark';
};
export const ThemeContext = createContext<ProviderValue>(defaultContextValue);
export const ThemeProvider = ({
initialTheme,
children,
}: {
initialTheme?: string;
children: React.ReactNode;
}) => {
const [theme, setTheme] = useState(getInitialTheme);
const setFontSize = useSetAtom(fontSizeAtom);
const rawSetTheme = (rawTheme: string) => {
const root = window.document.documentElement;
const darkMode = isDark(rawTheme);
root.classList.remove(darkMode ? 'light' : 'dark');
root.classList.add(darkMode ? 'dark' : 'light');
localStorage.setItem('color-theme', rawTheme);
};
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const changeThemeOnSystemChange = () => {
rawSetTheme(mediaQuery.matches ? 'dark' : 'light');
};
mediaQuery.addEventListener('change', changeThemeOnSystemChange);
return () => {
mediaQuery.removeEventListener('change', changeThemeOnSystemChange);
};
}, []);
useEffect(() => {
const fontSize = localStorage.getItem('fontSize');
if (fontSize == null) {
setFontSize('text-base');
applyFontSize('text-base');
localStorage.setItem('fontSize', JSON.stringify('text-base'));
return;
}
try {
applyFontSize(JSON.parse(fontSize));
} catch (error) {
console.log(error);
}
// Reason: This effect should only run once, and `setFontSize` is a stable function
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (initialTheme) {
rawSetTheme(initialTheme);
}
useEffect(() => {
rawSetTheme(theme);
}, [theme]);
return <ThemeContext.Provider value={{ theme, setTheme }}>{children}</ThemeContext.Provider>;
};

View File

@@ -1,54 +0,0 @@
import React, { createContext, useState, useEffect } from 'react';
import { useSetAtom } from 'jotai';
import { getInitialTheme, applyFontSize } from '~/utils';
import { fontSizeAtom } from '~/store';
type ProviderValue = {
theme: string;
setTheme: React.Dispatch<React.SetStateAction<string>>;
};
export const ThemeContext = createContext<ProviderValue>({
theme: 'light',
setTheme: () => {},
});
export const ThemeProvider = ({
initialTheme,
children,
}: {
initialTheme?: string;
children: React.ReactNode;
}) => {
const [theme, setTheme] = useState<string>(() => initialTheme ?? getInitialTheme());
const setFontSize = useSetAtom(fontSizeAtom);
useEffect(() => {
const root = document.documentElement;
const darkMode =
theme === 'system'
? window.matchMedia('(prefers-color-scheme: dark)').matches
: theme === 'dark';
root.classList.toggle('dark', darkMode);
root.classList.toggle('light', !darkMode);
localStorage.setItem('color-theme', theme);
}, [theme]);
useEffect(() => {
if (theme !== 'system') return;
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent) => setTheme(e.matches ? 'dark' : 'light');
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, [theme]);
useEffect(() => {
const saved = localStorage.getItem('fontSize') || 'text-base';
applyFontSize(saved);
setFontSize(saved);
}, [setFontSize]);
return <ThemeContext.Provider value={{ theme, setTheme }}>{children}</ThemeContext.Provider>;
};

View File

@@ -1,4 +1,4 @@
export * from './ThemeContext';
// Theme exports are now handled by the theme module in the main index.ts
export type { TranslationKeys } from './useLocalize';

View File

@@ -1,172 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Custom Variables */
:root {
--white: #fff;
--black: #000;
--gray-20: #ececf1;
--gray-50: #f7f7f8;
--gray-100: #ececec;
--gray-200: #e3e3e3;
--gray-300: #cdcdcd;
--gray-400: #999696;
--gray-500: #595959;
--gray-600: #424242;
--gray-700: #2f2f2f;
--gray-800: #212121;
--gray-850: #171717;
--gray-900: #0d0d0d;
--green-50: #ecfdf5;
--green-100: #d1fae5;
--green-200: #a7f3d0;
--green-300: #6ee7b7;
--green-400: #34d399;
--green-500: #10b981;
--green-600: #059669;
--green-700: #047857;
--green-800: #065f46;
--green-900: #064e3b;
--green-950: #022c22;
--red-50: #fef2f2;
--red-100: #fee2e2;
--red-200: #fecaca;
--red-300: #fca5a5;
--red-400: #f87171;
--red-500: #ef4444;
--red-600: #dc2626;
--red-700: #b91c1c;
--red-800: #991b1b;
--red-900: #7f1d1d;
--red-950: #450a0a;
--amber-50: #fffbeb;
--amber-100: #fef3c7;
--amber-200: #fde68a;
--amber-300: #fcd34d;
--amber-400: #fbbf24;
--amber-500: #f59e0b;
--amber-600: #d97706;
--amber-700: #b45309;
--amber-800: #92400e;
--amber-900: #78350f;
--amber-950: #451a03;
}
html {
--presentation: var(--white);
--text-primary: var(--gray-800);
--text-secondary: var(--gray-600);
--text-secondary-alt: var(--gray-500);
--text-tertiary: var(--gray-500);
--text-warning: var(--amber-500);
--ring-primary: var(--gray-500);
--header-primary: var(--white);
--header-hover: var(--gray-50);
--header-button-hover: var(--gray-50);
--surface-active: var(--gray-100);
--surface-active-alt: var(--gray-200);
--surface-hover: var(--gray-200);
--surface-hover-alt: var(--gray-300);
--surface-primary: var(--white);
--surface-primary-alt: var(--gray-50);
--surface-primary-contrast: var(--gray-100);
--surface-secondary: var(--gray-50);
--surface-secondary-alt: var(--gray-200);
--surface-tertiary: var(--gray-100);
--surface-tertiary-alt: var(--white);
--surface-dialog: var(--white);
--surface-submit: var(--green-700);
--surface-submit-hover: var(--green-800);
--surface-destructive: var(--red-700);
--surface-destructive-hover: var(--red-800);
--surface-chat: var(--white);
--border-light: var(--gray-200);
--border-medium-alt: var(--gray-300);
--border-medium: var(--gray-300);
--border-heavy: var(--gray-400);
--border-xheavy: var(--gray-500);
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--radius: 0.5rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--switch-unchecked: 0 0% 58%;
}
.dark {
--presentation: var(--gray-800);
--text-primary: var(--gray-100);
--text-secondary: var(--gray-300);
--text-secondary-alt: var(--gray-400);
--text-tertiary: var(--gray-500);
--text-warning: var(--amber-500);
--header-primary: var(--gray-700);
--header-hover: var(--gray-600);
--header-button-hover: var(--gray-700);
--surface-active: var(--gray-500);
--surface-active-alt: var(--gray-700);
--surface-hover: var(--gray-600);
--surface-hover-alt: var(--gray-600);
--surface-primary: var(--gray-900);
--surface-primary-alt: var(--gray-850);
--surface-primary-contrast: var(--gray-850);
--surface-secondary: var(--gray-800);
--surface-secondary-alt: var(--gray-800);
--surface-tertiary: var(--gray-700);
--surface-tertiary-alt: var(--gray-700);
--surface-dialog: var(--gray-850);
--surface-submit: var(--green-700);
--surface-submit-hover: var(--green-800);
--surface-destructive: var(--red-800);
--surface-destructive-hover: var(--red-900);
--surface-chat: var(--gray-700);
--border-light: var(--gray-700);
--border-medium-alt: var(--gray-600);
--border-medium: var(--gray-600);
--border-heavy: var(--gray-500);
--border-xheavy: var(--gray-400);
--background: 0 0% 7%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 40.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--switch-unchecked: 0 0% 40%;
}

View File

@@ -1,6 +1,3 @@
// Styles
import './index.css';
// Components
export * from './components';
@@ -22,3 +19,6 @@ export * from './utils';
// Providers
export * from './Providers';
// Theme
export * from './theme';

View File

@@ -1,3 +1,4 @@
{
"com_ui_cancel": "Cancel"
"com_ui_cancel": "Cancel",
"com_ui_no_options": "No options available"
}

View File

@@ -0,0 +1,473 @@
# Dynamic Theme System for @librechat/client
This theme system allows you to dynamically change colors in your React application using CSS variables and Tailwind CSS. It combines dark/light mode switching with dynamic color theming capabilities.
## Table of Contents
- [Overview](#overview)
- [How It Works](#how-it-works)
- [Basic Usage](#basic-usage)
- [Available Theme Colors](#available-theme-colors)
- [Creating Custom Themes](#creating-custom-themes)
- [Environment Variable Themes](#environment-variable-themes)
- [Dark/Light Mode](#darklight-mode)
- [Migration Guide](#migration-guide)
- [Implementation Details](#implementation-details)
- [Troubleshooting](#troubleshooting)
## Overview
The theme system provides:
1. **Dark/Light Mode Switching** - Automatic theme switching based on user preference
2. **Dynamic Color Theming** - Change colors at runtime without recompiling CSS
3. **CSS Variable Based** - Uses CSS custom properties for performance
4. **Tailwind Integration** - Works seamlessly with Tailwind CSS utilities
5. **TypeScript Support** - Full type safety for theme definitions
## How It Works
The theme system operates in three layers:
1. **CSS Variables Layer**: Default colors defined in your app's CSS
2. **ThemeProvider Layer**: React context that manages theme state and applies CSS variables
3. **Tailwind Layer**: Maps CSS variables to Tailwind utility classes
### Default Behavior (No Custom Theme)
- CSS variables cascade from your app's `style.css` definitions
- Light mode uses variables under `html` selector
- Dark mode uses variables under `.dark` selector
- No JavaScript intervention in color values
### Custom Theme Behavior
- Only applies when `themeRGB` prop is provided
- Overrides CSS variables with `rgb()` formatted values
- Maintains compatibility with existing CSS
## Basic Usage
### 1. Install the Component Library
```bash
npm install @librechat/client
```
### 2. Wrap Your App with ThemeProvider
```tsx
import { ThemeProvider } from '@librechat/client';
function App() {
return (
<ThemeProvider initialTheme="system">
<YourApp />
</ThemeProvider>
);
}
```
### 3. Set Up Your Base CSS
Ensure your app has CSS variables defined as fallbacks:
```css
/* style.css */
:root {
--white: #fff;
--gray-800: #212121;
--gray-100: #ececec;
/* ... other color definitions */
}
html {
--text-primary: var(--gray-800);
--surface-primary: var(--white);
/* ... other theme variables */
}
.dark {
--text-primary: var(--gray-100);
--surface-primary: var(--gray-900);
/* ... other dark theme variables */
}
```
### 4. Configure Tailwind
Update your `tailwind.config.js`:
```js
module.exports = {
content: [
'./src/**/*.{js,jsx,ts,tsx}',
// Include component library files
'./node_modules/@librechat/client/dist/**/*.js',
],
darkMode: ['class'],
theme: {
extend: {
colors: {
// Map CSS variables to Tailwind colors
'text-primary': 'var(--text-primary)',
'surface-primary': 'var(--surface-primary)',
'brand-purple': 'var(--brand-purple)',
// ... other colors
},
},
},
};
```
### 5. Use Theme Colors in Components
```tsx
function MyComponent() {
return (
<div className="bg-surface-primary text-text-primary border border-border-light">
<h1 className="text-text-secondary">Hello World</h1>
<button className="bg-surface-submit hover:bg-surface-submit-hover text-white">
Submit
</button>
</div>
);
}
```
## Available Theme Colors
### Text Colors
- `text-text-primary` - Primary text color
- `text-text-secondary` - Secondary text color
- `text-text-secondary-alt` - Alternative secondary text
- `text-text-tertiary` - Tertiary text color
- `text-text-warning` - Warning text color
### Surface Colors
- `bg-surface-primary` - Primary background
- `bg-surface-secondary` - Secondary background
- `bg-surface-tertiary` - Tertiary background
- `bg-surface-submit` - Submit button background
- `bg-surface-destructive` - Destructive action background
- `bg-surface-dialog` - Dialog/modal background
- `bg-surface-chat` - Chat interface background
### Border Colors
- `border-border-light` - Light border
- `border-border-medium` - Medium border
- `border-border-heavy` - Heavy border
- `border-border-xheavy` - Extra heavy border
### Other Colors
- `bg-brand-purple` - Brand purple color
- `bg-presentation` - Presentation background
- `ring-ring-primary` - Focus ring color
## Creating Custom Themes
### 1. Define Your Theme
```tsx
import { IThemeRGB } from '@librechat/client';
export const customTheme: IThemeRGB = {
'rgb-text-primary': '0 0 0', // Black
'rgb-text-secondary': '100 100 100', // Gray
'rgb-surface-primary': '255 255 255', // White
'rgb-surface-submit': '0 128 0', // Green
'rgb-brand-purple': '138 43 226', // Blue Violet
// ... define other colors
};
```
### 2. Use Your Custom Theme
```tsx
import { ThemeProvider } from '@librechat/client';
import { customTheme } from './themes/custom';
function App() {
return (
<ThemeProvider themeRGB={customTheme} themeName="custom">
<YourApp />
</ThemeProvider>
);
}
```
## Environment Variable Themes
Load theme colors from environment variables:
### 1. Create Environment Variables
```env
# .env.local
REACT_APP_THEME_BRAND_PURPLE=171 104 255
REACT_APP_THEME_TEXT_PRIMARY=33 33 33
REACT_APP_THEME_TEXT_SECONDARY=66 66 66
REACT_APP_THEME_SURFACE_PRIMARY=255 255 255
REACT_APP_THEME_SURFACE_SUBMIT=4 120 87
```
### 2. Create a Theme Loader
```tsx
function getThemeFromEnv(): IThemeRGB | undefined {
// Check if any theme environment variables are set
const hasThemeEnvVars = Object.keys(process.env).some(key =>
key.startsWith('REACT_APP_THEME_')
);
if (!hasThemeEnvVars) {
return undefined; // Use default themes
}
return {
'rgb-text-primary': process.env.REACT_APP_THEME_TEXT_PRIMARY || '33 33 33',
'rgb-brand-purple': process.env.REACT_APP_THEME_BRAND_PURPLE || '171 104 255',
// ... other colors
};
}
```
### 3. Apply Environment Theme
```tsx
<ThemeProvider
initialTheme="system"
themeRGB={getThemeFromEnv()}
>
<App />
</ThemeProvider>
```
## Dark/Light Mode
The ThemeProvider handles dark/light mode automatically:
### Using the Theme Hook
```tsx
import { useTheme } from '@librechat/client';
function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
Current theme: {theme}
</button>
);
}
```
### Theme Options
- `'light'` - Force light mode
- `'dark'` - Force dark mode
- `'system'` - Follow system preference
## Migration Guide
If you're migrating from an older theme system:
### 1. Update Imports
**Before:**
```tsx
import { ThemeContext, ThemeProvider } from '~/hooks/ThemeContext';
```
**After:**
```tsx
import { ThemeContext, ThemeProvider } from '@librechat/client';
```
### 2. Update ThemeProvider Usage
The new ThemeProvider is backward compatible but adds new capabilities:
```tsx
<ThemeProvider
initialTheme="system" // Same as before
themeRGB={customTheme} // New: optional custom colors
>
<App />
</ThemeProvider>
```
### 3. Existing Components
Components using ThemeContext continue to work without changes:
```tsx
// This still works!
const { theme, setTheme } = useContext(ThemeContext);
```
## Implementation Details
### File Structure
```
packages/client/src/theme/
├── context/
│ └── ThemeProvider.tsx # Main theme provider
├── types/
│ └── index.ts # TypeScript interfaces
├── themes/
│ ├── default.ts # Light theme colors
│ ├── dark.ts # Dark theme colors
│ └── index.ts # Theme exports
├── utils/
│ ├── applyTheme.ts # Apply CSS variables
│ ├── tailwindConfig.ts # Tailwind helpers
│ └── createTailwindColors.js
├── README.md # This documentation
└── index.ts # Main exports
```
### CSS Variable Format
The theme system uses RGB values in CSS variables:
- CSS Variable: `--text-primary: rgb(33 33 33)`
- Theme Definition: `'rgb-text-primary': '33 33 33'`
- Tailwind Usage: `text-text-primary`
### RGB Format Requirements
All color values must be in space-separated RGB format:
- ✅ Correct: `'255 255 255'`
- ❌ Incorrect: `'#ffffff'` or `'rgb(255, 255, 255)'`
This format allows Tailwind to apply opacity modifiers like `bg-surface-primary/50`.
## Troubleshooting
### Common Issues
#### 1. Colors Not Applying
- **Issue**: Custom theme colors aren't showing
- **Solution**: Ensure you're passing the `themeRGB` prop to ThemeProvider
- **Check**: CSS variables in DevTools should show `rgb(R G B)` format
#### 2. Circular Reference Errors
- **Issue**: `--brand-purple: var(--brand-purple)` creates infinite loop
- **Solution**: Use direct color values: `--brand-purple: #ab68ff`
#### 3. Dark Mode Not Working
- **Issue**: Dark mode doesn't switch
- **Solution**: Ensure `darkMode: ['class']` is in your Tailwind config
- **Check**: The `<html>` element should have `class="dark"` in dark mode
#### 4. TypeScript Errors
- **Issue**: Type errors when defining themes
- **Solution**: Import and use the `IThemeRGB` interface:
```tsx
import { IThemeRGB } from '@librechat/client';
```
### Debugging Tips
1. **Check CSS Variables**: Use browser DevTools to inspect computed CSS variables
2. **Verify Theme Application**: Look for inline styles on the root element
3. **Console Errors**: Check for validation errors in the console
4. **Test Isolation**: Try a minimal theme to isolate issues
## Examples
### Dynamic Theme Switching
```tsx
import { ThemeProvider, defaultTheme, darkTheme } from '@librechat/client';
import { useState } from 'react';
function App() {
const [isDark, setIsDark] = useState(false);
return (
<ThemeProvider
initialTheme={isDark ? 'dark' : 'light'}
themeRGB={isDark ? darkTheme : defaultTheme}
themeName={isDark ? 'dark' : 'default'}
>
<button onClick={() => setIsDark(!isDark)}>
Toggle Theme
</button>
<YourApp />
</ThemeProvider>
);
}
```
### Multi-Theme Selector
```tsx
const themes = {
default: undefined, // Use CSS defaults
ocean: {
'rgb-brand-purple': '0 119 190',
'rgb-surface-primary': '240 248 255',
// ... ocean theme colors
},
forest: {
'rgb-brand-purple': '34 139 34',
'rgb-surface-primary': '245 255 250',
// ... forest theme colors
},
};
function App() {
const [selectedTheme, setSelectedTheme] = useState('default');
return (
<ThemeProvider
themeRGB={themes[selectedTheme]}
themeName={selectedTheme}
>
<select onChange={(e) => setSelectedTheme(e.target.value)}>
{Object.keys(themes).map(name => (
<option key={name} value={name}>{name}</option>
))}
</select>
<YourApp />
</ThemeProvider>
);
}
```
### Using with the Main Application
When using the ThemeProvider in your main application with localStorage persistence:
```tsx
import { ThemeProvider } from '@librechat/client';
import { getThemeFromEnv } from './utils';
function App() {
const envTheme = getThemeFromEnv();
return (
<ThemeProvider
// Only pass props if you want to override stored values
// If you always pass props, they will override localStorage
initialTheme={envTheme ? "system" : undefined}
themeRGB={envTheme || undefined}
>
{/* Your app content */}
</ThemeProvider>
);
}
```
**Important**: Props passed to ThemeProvider will override stored values on initial mount. Only pass props when you explicitly want to override the user's saved preferences.
## Contributing
When adding new theme colors:
1. Add the type definition in `types/index.ts`
2. Add the color to default and dark themes
3. Update the applyTheme mapping
4. Add to Tailwind configuration
5. Document in this README
## License
This theme system is part of the @librechat/client package.

View File

@@ -0,0 +1,36 @@
import { atomWithStorage } from 'jotai/utils';
import { IThemeRGB } from '../types';
/**
* Atom for storing the theme mode (light/dark/system) in localStorage
* Key: 'color-theme'
*/
export const themeModeAtom = atomWithStorage<string>('color-theme', 'system', undefined, {
getOnInit: true,
});
/**
* Atom for storing custom theme colors in localStorage
* Key: 'theme-colors'
*/
export const themeColorsAtom = atomWithStorage<IThemeRGB | undefined>(
'theme-colors',
undefined,
undefined,
{
getOnInit: true,
},
);
/**
* Atom for storing the theme name in localStorage
* Key: 'theme-name'
*/
export const themeNameAtom = atomWithStorage<string | undefined>(
'theme-name',
undefined,
undefined,
{
getOnInit: true,
},
);

View File

@@ -0,0 +1,165 @@
import React, { createContext, useContext, useEffect, useMemo, useCallback, useRef } from 'react';
import { useAtom } from 'jotai';
import { IThemeRGB } from '../types';
import applyTheme from '../utils/applyTheme';
import { themeModeAtom, themeColorsAtom, themeNameAtom } from '../atoms/themeAtoms';
type ThemeContextType = {
theme: string; // 'light' | 'dark' | 'system'
setTheme: (theme: string) => void;
themeRGB?: IThemeRGB;
setThemeRGB: (colors?: IThemeRGB) => void;
themeName?: string;
setThemeName: (name?: string) => void;
resetTheme: () => void;
};
// Export ThemeContext so it can be imported from hooks
export const ThemeContext = createContext<ThemeContextType>({
theme: 'system',
setTheme: () => undefined,
setThemeRGB: () => undefined,
setThemeName: () => undefined,
resetTheme: () => undefined,
});
export interface ThemeProviderProps {
children: React.ReactNode;
themeRGB?: IThemeRGB;
themeName?: string;
initialTheme?: string;
}
/**
* Check if theme is dark
*/
export const isDark = (theme: string): boolean => {
if (theme === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
return theme === 'dark';
};
/**
* ThemeProvider component that handles both dark/light mode switching
* and dynamic color themes via CSS variables with localStorage persistence
*/
export function ThemeProvider({
children,
themeRGB: propThemeRGB,
themeName: propThemeName,
initialTheme,
}: ThemeProviderProps) {
// Use jotai atoms for persistent state
const [theme, setTheme] = useAtom(themeModeAtom);
const [storedThemeRGB, setStoredThemeRGB] = useAtom(themeColorsAtom);
const [storedThemeName, setStoredThemeName] = useAtom(themeNameAtom);
// Track if props have been initialized
const propsInitialized = useRef(false);
// Initialize from props only once on mount
useEffect(() => {
if (!propsInitialized.current) {
propsInitialized.current = true;
// Set initial theme if provided
if (initialTheme) {
setTheme(initialTheme);
}
// Set initial theme colors if provided
if (propThemeRGB) {
setStoredThemeRGB(propThemeRGB);
}
// Set initial theme name if provided
if (propThemeName) {
setStoredThemeName(propThemeName);
}
}
}, [initialTheme, propThemeRGB, propThemeName, setTheme, setStoredThemeRGB, setStoredThemeName]);
// Apply class-based dark mode
const applyThemeMode = useCallback((rawTheme: string) => {
const root = window.document.documentElement;
const darkMode = isDark(rawTheme);
root.classList.remove(darkMode ? 'light' : 'dark');
root.classList.add(darkMode ? 'dark' : 'light');
}, []);
// Handle system theme changes
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const changeThemeOnSystemChange = () => {
if (theme === 'system') {
applyThemeMode('system');
}
};
mediaQuery.addEventListener('change', changeThemeOnSystemChange);
return () => {
mediaQuery.removeEventListener('change', changeThemeOnSystemChange);
};
}, [theme, applyThemeMode]);
// Apply dark/light mode class
useEffect(() => {
applyThemeMode(theme);
}, [theme, applyThemeMode]);
// Apply dynamic color theme
useEffect(() => {
if (storedThemeRGB) {
applyTheme(storedThemeRGB);
}
}, [storedThemeRGB]);
// Reset theme function
const resetTheme = useCallback(() => {
setTheme('system');
setStoredThemeRGB(undefined);
setStoredThemeName(undefined);
// Remove any custom CSS variables
const root = document.documentElement;
const customProps = Array.from(root.style).filter((prop) => prop.startsWith('--'));
customProps.forEach((prop) => root.style.removeProperty(prop));
}, [setTheme, setStoredThemeRGB, setStoredThemeName]);
const value = useMemo(
() => ({
theme,
setTheme,
themeRGB: storedThemeRGB,
setThemeRGB: setStoredThemeRGB,
themeName: storedThemeName,
setThemeName: setStoredThemeName,
resetTheme,
}),
[
theme,
setTheme,
storedThemeRGB,
setStoredThemeRGB,
storedThemeName,
setStoredThemeName,
resetTheme,
],
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
/**
* Hook to access the current theme context
*/
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
export default ThemeProvider;

View File

@@ -0,0 +1,14 @@
// Export types
export * from './types';
// Export ThemeProvider, ThemeContext, useTheme hook, and isDark
export { ThemeProvider, ThemeContext, useTheme, isDark } from './context/ThemeProvider';
// Export utility functions
export { default as applyTheme } from './utils/applyTheme';
// Export theme atoms for persistence
export { themeModeAtom, themeColorsAtom, themeNameAtom } from './atoms/themeAtoms';
// Export predefined themes
export * from './themes';

View File

@@ -0,0 +1,72 @@
import { IThemeRGB } from '../types';
/**
* Dark theme
* RGB values extracted from the existing dark mode CSS variables
*/
export const darkTheme: IThemeRGB = {
// Text colors
'rgb-text-primary': '236 236 236', // #ececec (gray-100)
'rgb-text-secondary': '205 205 205', // #cdcdcd (gray-300)
'rgb-text-secondary-alt': '153 150 150', // #999696 (gray-400)
'rgb-text-tertiary': '89 89 89', // #595959 (gray-500)
'rgb-text-warning': '245 158 11', // #f59e0b (amber-500)
// Ring colors (not defined in dark mode, using default)
'rgb-ring-primary': '89 89 89', // #595959 (gray-500)
// Header colors
'rgb-header-primary': '47 47 47', // #2f2f2f (gray-700)
'rgb-header-hover': '66 66 66', // #424242 (gray-600)
'rgb-header-button-hover': '47 47 47', // #2f2f2f (gray-700)
// Surface colors
'rgb-surface-active': '89 89 89', // #595959 (gray-500)
'rgb-surface-active-alt': '47 47 47', // #2f2f2f (gray-700)
'rgb-surface-hover': '66 66 66', // #424242 (gray-600)
'rgb-surface-hover-alt': '66 66 66', // #424242 (gray-600)
'rgb-surface-primary': '13 13 13', // #0d0d0d (gray-900)
'rgb-surface-primary-alt': '23 23 23', // #171717 (gray-850)
'rgb-surface-primary-contrast': '23 23 23', // #171717 (gray-850)
'rgb-surface-secondary': '33 33 33', // #212121 (gray-800)
'rgb-surface-secondary-alt': '33 33 33', // #212121 (gray-800)
'rgb-surface-tertiary': '47 47 47', // #2f2f2f (gray-700)
'rgb-surface-tertiary-alt': '47 47 47', // #2f2f2f (gray-700)
'rgb-surface-dialog': '23 23 23', // #171717 (gray-850)
'rgb-surface-submit': '4 120 87', // #047857 (green-700)
'rgb-surface-submit-hover': '6 95 70', // #065f46 (green-800)
'rgb-surface-destructive': '153 27 27', // #991b1b (red-800)
'rgb-surface-destructive-hover': '127 29 29', // #7f1d1d (red-900)
'rgb-surface-chat': '47 47 47', // #2f2f2f (gray-700)
// Border colors
'rgb-border-light': '47 47 47', // #2f2f2f (gray-700)
'rgb-border-medium': '66 66 66', // #424242 (gray-600)
'rgb-border-medium-alt': '66 66 66', // #424242 (gray-600)
'rgb-border-heavy': '89 89 89', // #595959 (gray-500)
'rgb-border-xheavy': '153 150 150', // #999696 (gray-400)
// Brand colors
'rgb-brand-purple': '171 104 255', // #ab68ff
// Presentation
'rgb-presentation': '33 33 33', // #212121 (gray-800)
// Utility colors (mapped to existing colors for backwards compatibility)
'rgb-background': '33 33 33', // Same as surface-primary
'rgb-foreground': '255 255 255', // Same as text-primary
'rgb-primary': '66 66 66', // Same as surface-active
'rgb-primary-foreground': '255 255 255', // Same as surface-primary-contrast
'rgb-secondary': '42 42 42', // Same as surface-secondary
'rgb-secondary-foreground': '193 193 193', // Same as text-secondary
'rgb-muted': '56 56 56', // Same as surface-tertiary
'rgb-muted-foreground': '140 140 140', // Same as text-tertiary
'rgb-accent': '82 82 82', // Same as surface-active-alt
'rgb-accent-foreground': '255 255 255', // Same as text-primary
'rgb-destructive-foreground': '255 255 255', // Same as text-primary
'rgb-border': '82 82 82', // Same as border-medium
'rgb-input': '66 66 66', // Same as border-light
'rgb-ring': '255 255 255', // Same as ring-primary
'rgb-card': '42 42 42', // Same as surface-secondary
'rgb-card-foreground': '255 255 255', // Same as text-primary
};

View File

@@ -0,0 +1,72 @@
import { IThemeRGB } from '../types';
/**
* Default light theme
* RGB values extracted from the existing CSS variables
*/
export const defaultTheme: IThemeRGB = {
// Text colors
'rgb-text-primary': '33 33 33', // #212121 (gray-800)
'rgb-text-secondary': '66 66 66', // #424242 (gray-600)
'rgb-text-secondary-alt': '89 89 89', // #595959 (gray-500)
'rgb-text-tertiary': '89 89 89', // #595959 (gray-500)
'rgb-text-warning': '245 158 11', // #f59e0b (amber-500)
// Ring colors
'rgb-ring-primary': '89 89 89', // #595959 (gray-500)
// Header colors
'rgb-header-primary': '255 255 255', // #fff (white)
'rgb-header-hover': '247 247 248', // #f7f7f8 (gray-50)
'rgb-header-button-hover': '247 247 248', // #f7f7f8 (gray-50)
// Surface colors
'rgb-surface-active': '236 236 236', // #ececec (gray-100)
'rgb-surface-active-alt': '227 227 227', // #e3e3e3 (gray-200)
'rgb-surface-hover': '227 227 227', // #e3e3e3 (gray-200)
'rgb-surface-hover-alt': '205 205 205', // #cdcdcd (gray-300)
'rgb-surface-primary': '255 255 255', // #fff (white)
'rgb-surface-primary-alt': '247 247 248', // #f7f7f8 (gray-50)
'rgb-surface-primary-contrast': '236 236 236', // #ececec (gray-100)
'rgb-surface-secondary': '247 247 248', // #f7f7f8 (gray-50)
'rgb-surface-secondary-alt': '227 227 227', // #e3e3e3 (gray-200)
'rgb-surface-tertiary': '236 236 236', // #ececec (gray-100)
'rgb-surface-tertiary-alt': '255 255 255', // #fff (white)
'rgb-surface-dialog': '255 255 255', // #fff (white)
'rgb-surface-submit': '4 120 87', // #047857 (green-700)
'rgb-surface-submit-hover': '6 95 70', // #065f46 (green-800)
'rgb-surface-destructive': '185 28 28', // #b91c1c (red-700)
'rgb-surface-destructive-hover': '153 27 27', // #991b1b (red-800)
'rgb-surface-chat': '255 255 255', // #fff (white)
// Border colors
'rgb-border-light': '227 227 227', // #e3e3e3 (gray-200)
'rgb-border-medium': '205 205 205', // #cdcdcd (gray-300)
'rgb-border-medium-alt': '205 205 205', // #cdcdcd (gray-300)
'rgb-border-heavy': '153 150 150', // #999696 (gray-400)
'rgb-border-xheavy': '89 89 89', // #595959 (gray-500)
// Brand colors
'rgb-brand-purple': '171 104 255', // #ab68ff
// Presentation
'rgb-presentation': '255 255 255', // #fff (white)
// Utility colors (mapped to existing colors for backwards compatibility)
'rgb-background': '255 255 255', // Same as surface-primary
'rgb-foreground': '17 17 17', // Same as text-primary
'rgb-primary': '235 235 235', // Same as surface-active
'rgb-primary-foreground': '0 0 0', // Same as surface-primary-contrast
'rgb-secondary': '247 247 248', // Same as surface-secondary
'rgb-secondary-foreground': '66 66 66', // Same as text-secondary
'rgb-muted': '250 250 250', // Same as surface-tertiary
'rgb-muted-foreground': '120 120 120', // Same as text-tertiary
'rgb-accent': '245 245 245', // Same as surface-active-alt
'rgb-accent-foreground': '17 17 17', // Same as text-primary
'rgb-destructive-foreground': '17 17 17', // Same as text-primary
'rgb-border': '215 215 215', // Same as border-medium
'rgb-input': '230 230 230', // Same as border-light
'rgb-ring': '180 180 180', // Same as ring-primary
'rgb-card': '247 247 248', // Same as surface-secondary
'rgb-card-foreground': '17 17 17', // Same as text-primary
};

View File

@@ -0,0 +1,2 @@
export { defaultTheme } from './default';
export { darkTheme } from './dark';

View File

@@ -0,0 +1,189 @@
/**
* Defines the color channels. Passed to the context from each app.
* RGB values should be in format "255 255 255" (space-separated)
*/
export interface IThemeRGB {
// Text colors
'rgb-text-primary'?: string;
'rgb-text-secondary'?: string;
'rgb-text-secondary-alt'?: string;
'rgb-text-tertiary'?: string;
'rgb-text-warning'?: string;
// Ring colors
'rgb-ring-primary'?: string;
// Header colors
'rgb-header-primary'?: string;
'rgb-header-hover'?: string;
'rgb-header-button-hover'?: string;
// Surface colors
'rgb-surface-active'?: string;
'rgb-surface-active-alt'?: string;
'rgb-surface-hover'?: string;
'rgb-surface-hover-alt'?: string;
'rgb-surface-primary'?: string;
'rgb-surface-primary-alt'?: string;
'rgb-surface-primary-contrast'?: string;
'rgb-surface-secondary'?: string;
'rgb-surface-secondary-alt'?: string;
'rgb-surface-tertiary'?: string;
'rgb-surface-tertiary-alt'?: string;
'rgb-surface-dialog'?: string;
'rgb-surface-submit'?: string;
'rgb-surface-submit-hover'?: string;
'rgb-surface-destructive'?: string;
'rgb-surface-destructive-hover'?: string;
'rgb-surface-chat'?: string;
// Border colors
'rgb-border-light'?: string;
'rgb-border-medium'?: string;
'rgb-border-medium-alt'?: string;
'rgb-border-heavy'?: string;
'rgb-border-xheavy'?: string;
// Brand colors
'rgb-brand-purple'?: string;
// Presentation
'rgb-presentation'?: string;
// Utility colors
'rgb-background'?: string;
'rgb-foreground'?: string;
'rgb-primary'?: string;
'rgb-primary-foreground'?: string;
'rgb-secondary'?: string;
'rgb-secondary-foreground'?: string;
'rgb-muted'?: string;
'rgb-muted-foreground'?: string;
'rgb-accent'?: string;
'rgb-accent-foreground'?: string;
'rgb-destructive-foreground'?: string;
'rgb-border'?: string;
'rgb-input'?: string;
'rgb-ring'?: string;
'rgb-card'?: string;
'rgb-card-foreground'?: string;
}
/**
* Name of the CSS variables used in tailwind.config
*/
export interface IThemeVariables {
'--text-primary': string;
'--text-secondary': string;
'--text-secondary-alt': string;
'--text-tertiary': string;
'--text-warning': string;
'--ring-primary': string;
'--header-primary': string;
'--header-hover': string;
'--header-button-hover': string;
'--surface-active': string;
'--surface-active-alt': string;
'--surface-hover': string;
'--surface-hover-alt': string;
'--surface-primary': string;
'--surface-primary-alt': string;
'--surface-primary-contrast': string;
'--surface-secondary': string;
'--surface-secondary-alt': string;
'--surface-tertiary': string;
'--surface-tertiary-alt': string;
'--surface-dialog': string;
'--surface-submit': string;
'--surface-submit-hover': string;
'--surface-destructive': string;
'--surface-destructive-hover': string;
'--surface-chat': string;
'--border-light': string;
'--border-medium': string;
'--border-medium-alt': string;
'--border-heavy': string;
'--border-xheavy': string;
'--brand-purple': string;
'--presentation': string;
// Utility variables
'--background': string;
'--foreground': string;
'--primary': string;
'--primary-foreground': string;
'--secondary': string;
'--secondary-foreground': string;
'--muted': string;
'--muted-foreground': string;
'--accent': string;
'--accent-foreground': string;
'--destructive-foreground': string;
'--border': string;
'--input': string;
'--ring': string;
'--card': string;
'--card-foreground': string;
}
/**
* Name of the defined colors in the Tailwind theme
*/
export interface IThemeColors {
'text-primary'?: string;
'text-secondary'?: string;
'text-secondary-alt'?: string;
'text-tertiary'?: string;
'text-warning'?: string;
'ring-primary'?: string;
'header-primary'?: string;
'header-hover'?: string;
'header-button-hover'?: string;
'surface-active'?: string;
'surface-active-alt'?: string;
'surface-hover'?: string;
'surface-hover-alt'?: string;
'surface-primary'?: string;
'surface-primary-alt'?: string;
'surface-primary-contrast'?: string;
'surface-secondary'?: string;
'surface-secondary-alt'?: string;
'surface-tertiary'?: string;
'surface-tertiary-alt'?: string;
'surface-dialog'?: string;
'surface-submit'?: string;
'surface-submit-hover'?: string;
'surface-destructive'?: string;
'surface-destructive-hover'?: string;
'surface-chat'?: string;
'border-light'?: string;
'border-medium'?: string;
'border-medium-alt'?: string;
'border-heavy'?: string;
'border-xheavy'?: string;
'brand-purple'?: string;
presentation?: string;
// Utility colors
background?: string;
foreground?: string;
primary?: string;
'primary-foreground'?: string;
secondary?: string;
'secondary-foreground'?: string;
muted?: string;
'muted-foreground'?: string;
accent?: string;
'accent-foreground'?: string;
'destructive-foreground'?: string;
border?: string;
input?: string;
ring?: string;
card?: string;
'card-foreground'?: string;
}
export interface Theme {
name: string;
colors: IThemeRGB;
}

View File

@@ -0,0 +1,115 @@
import { IThemeRGB, IThemeVariables } from '../types';
/**
* Validates RGB string format (e.g., "255 255 255")
*/
function validateRGB(rgb: string): boolean {
if (!rgb) return true;
const rgbRegex = /^(\d{1,3})\s+(\d{1,3})\s+(\d{1,3})$/;
const match = rgb.match(rgbRegex);
if (!match) return false;
// Check that each value is between 0-255
const [, r, g, b] = match;
return [r, g, b].every((val) => {
const num = parseInt(val, 10);
return num >= 0 && num <= 255;
});
}
/**
* Maps theme RGB values to CSS variables
*/
function mapTheme(rgb: IThemeRGB): Partial<IThemeVariables> {
const variables: Partial<IThemeVariables> = {};
// Map each RGB value to its corresponding CSS variable
const mappings: Record<keyof IThemeRGB, keyof IThemeVariables> = {
'rgb-text-primary': '--text-primary',
'rgb-text-secondary': '--text-secondary',
'rgb-text-secondary-alt': '--text-secondary-alt',
'rgb-text-tertiary': '--text-tertiary',
'rgb-text-warning': '--text-warning',
'rgb-ring-primary': '--ring-primary',
'rgb-header-primary': '--header-primary',
'rgb-header-hover': '--header-hover',
'rgb-header-button-hover': '--header-button-hover',
'rgb-surface-active': '--surface-active',
'rgb-surface-active-alt': '--surface-active-alt',
'rgb-surface-hover': '--surface-hover',
'rgb-surface-hover-alt': '--surface-hover-alt',
'rgb-surface-primary': '--surface-primary',
'rgb-surface-primary-alt': '--surface-primary-alt',
'rgb-surface-primary-contrast': '--surface-primary-contrast',
'rgb-surface-secondary': '--surface-secondary',
'rgb-surface-secondary-alt': '--surface-secondary-alt',
'rgb-surface-tertiary': '--surface-tertiary',
'rgb-surface-tertiary-alt': '--surface-tertiary-alt',
'rgb-surface-dialog': '--surface-dialog',
'rgb-surface-submit': '--surface-submit',
'rgb-surface-submit-hover': '--surface-submit-hover',
'rgb-surface-destructive': '--surface-destructive',
'rgb-surface-destructive-hover': '--surface-destructive-hover',
'rgb-surface-chat': '--surface-chat',
'rgb-border-light': '--border-light',
'rgb-border-medium': '--border-medium',
'rgb-border-medium-alt': '--border-medium-alt',
'rgb-border-heavy': '--border-heavy',
'rgb-border-xheavy': '--border-xheavy',
'rgb-brand-purple': '--brand-purple',
'rgb-presentation': '--presentation',
// Utility colors
'rgb-background': '--background',
'rgb-foreground': '--foreground',
'rgb-primary': '--primary',
'rgb-primary-foreground': '--primary-foreground',
'rgb-secondary': '--secondary',
'rgb-secondary-foreground': '--secondary-foreground',
'rgb-muted': '--muted',
'rgb-muted-foreground': '--muted-foreground',
'rgb-accent': '--accent',
'rgb-accent-foreground': '--accent-foreground',
'rgb-destructive-foreground': '--destructive-foreground',
'rgb-border': '--border',
'rgb-input': '--input',
'rgb-ring': '--ring',
'rgb-card': '--card',
'rgb-card-foreground': '--card-foreground',
};
Object.entries(mappings).forEach(([rgbKey, cssVar]) => {
const value = rgb[rgbKey as keyof IThemeRGB];
if (value) {
variables[cssVar] = value;
}
});
return variables;
}
/**
* Applies theme to the document root
* Sets CSS variables as rgb() values for compatibility with existing CSS
*/
export default function applyTheme(themeRGB?: IThemeRGB) {
if (!themeRGB) return;
const themeObject = mapTheme(themeRGB);
const root = document.documentElement;
Object.entries(themeObject).forEach(([cssVar, value]) => {
if (!value) return;
const validation = validateRGB(value);
if (!validation) {
console.error(`Invalid RGB value for ${cssVar}: ${value}`);
return;
}
// Set the CSS variable as rgb() value for compatibility
// This ensures existing CSS that expects color values (not space-separated RGB) continues to work
root.style.setProperty(cssVar, `rgb(${value})`);
});
}

View File

@@ -0,0 +1,86 @@
/**
* Helper function to create a color value that uses CSS variables with alpha support
* This is a CommonJS version for use in tailwind.config.js
*/
function withOpacity(variableName) {
return ({ opacityValue }) => {
if (opacityValue !== undefined) {
// The CSS variable already contains rgb() so we need to extract the values
return `rgba(var(${variableName}), ${opacityValue})`.replace('rgb(', '').replace(')', '');
}
return `var(${variableName})`;
};
}
/**
* Creates Tailwind color configuration that uses CSS variables
* This allows dynamic theme switching by changing CSS variable values
*/
function createTailwindColors() {
return {
'text-primary': withOpacity('--text-primary'),
'text-secondary': withOpacity('--text-secondary'),
'text-secondary-alt': withOpacity('--text-secondary-alt'),
'text-tertiary': withOpacity('--text-tertiary'),
'text-warning': withOpacity('--text-warning'),
'ring-primary': withOpacity('--ring-primary'),
'header-primary': withOpacity('--header-primary'),
'header-hover': withOpacity('--header-hover'),
'header-button-hover': withOpacity('--header-button-hover'),
'surface-active': withOpacity('--surface-active'),
'surface-active-alt': withOpacity('--surface-active-alt'),
'surface-hover': withOpacity('--surface-hover'),
'surface-hover-alt': withOpacity('--surface-hover-alt'),
'surface-primary': withOpacity('--surface-primary'),
'surface-primary-alt': withOpacity('--surface-primary-alt'),
'surface-primary-contrast': withOpacity('--surface-primary-contrast'),
'surface-secondary': withOpacity('--surface-secondary'),
'surface-secondary-alt': withOpacity('--surface-secondary-alt'),
'surface-tertiary': withOpacity('--surface-tertiary'),
'surface-tertiary-alt': withOpacity('--surface-tertiary-alt'),
'surface-dialog': withOpacity('--surface-dialog'),
'surface-submit': withOpacity('--surface-submit'),
'surface-submit-hover': withOpacity('--surface-submit-hover'),
'surface-destructive': withOpacity('--surface-destructive'),
'surface-destructive-hover': withOpacity('--surface-destructive-hover'),
'surface-chat': withOpacity('--surface-chat'),
'border-light': withOpacity('--border-light'),
'border-medium': withOpacity('--border-medium'),
'border-medium-alt': withOpacity('--border-medium-alt'),
'border-heavy': withOpacity('--border-heavy'),
'border-xheavy': withOpacity('--border-xheavy'),
'brand-purple': withOpacity('--brand-purple'),
presentation: withOpacity('--presentation'),
background: withOpacity('--background'),
foreground: withOpacity('--foreground'),
primary: {
DEFAULT: withOpacity('--primary'),
foreground: withOpacity('--primary-foreground'),
},
secondary: {
DEFAULT: withOpacity('--secondary'),
foreground: withOpacity('--secondary-foreground'),
},
muted: {
DEFAULT: withOpacity('--muted'),
foreground: withOpacity('--muted-foreground'),
},
accent: {
DEFAULT: withOpacity('--accent'),
foreground: withOpacity('--accent-foreground'),
},
destructive: {
DEFAULT: withOpacity('--surface-destructive'),
foreground: withOpacity('--destructive-foreground'),
},
border: withOpacity('--border'),
input: withOpacity('--input'),
ring: withOpacity('--ring'),
card: {
DEFAULT: withOpacity('--card'),
foreground: withOpacity('--card-foreground'),
},
};
}
module.exports = { createTailwindColors };

View File

@@ -1,130 +1,13 @@
const { createTailwindColors } = require('./src/theme/utils/createTailwindColors.js');
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{js,jsx,ts,tsx}'],
darkMode: ['class'],
theme: {
extend: {
width: {
authPageWidth: '370px',
},
keyframes: {
'accordion-down': {
from: { height: 0 },
to: { height: 'var(--radix-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: 0 },
},
},
animation: {
'fade-in': 'fadeIn 0.5s ease-out forwards',
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
colors: {
gray: {
20: '#ececf1',
50: '#f7f7f8',
100: '#ececec',
200: '#e3e3e3',
300: '#cdcdcd',
400: '#999696',
500: '#595959',
600: '#424242',
700: '#2f2f2f',
800: '#212121',
850: '#171717',
900: '#0d0d0d',
},
green: {
50: '#f1f9f7',
100: '#def2ed',
200: '#a6e5d6',
300: '#6dc8b9',
400: '#41a79d',
500: '#10a37f',
550: '#349072',
600: '#126e6b',
700: '#0a4f53',
800: '#06373e',
900: '#031f29',
},
'brand-purple': '#ab68ff',
presentation: 'var(--presentation)',
'text-primary': 'var(--text-primary)',
'text-secondary': 'var(--text-secondary)',
'text-secondary-alt': 'var(--text-secondary-alt)',
'text-tertiary': 'var(--text-tertiary)',
'text-warning': 'var(--text-warning)',
'ring-primary': 'var(--ring-primary)',
'header-primary': 'var(--header-primary)',
'header-hover': 'var(--header-hover)',
'header-button-hover': 'var(--header-button-hover)',
'surface-active': 'var(--surface-active)',
'surface-active-alt': 'var(--surface-active-alt)',
'surface-hover': 'var(--surface-hover)',
'surface-hover-alt': 'var(--surface-hover-alt)',
'surface-primary': 'var(--surface-primary)',
'surface-primary-alt': 'var(--surface-primary-alt)',
'surface-primary-contrast': 'var(--surface-primary-contrast)',
'surface-secondary': 'var(--surface-secondary)',
'surface-secondary-alt': 'var(--surface-secondary-alt)',
'surface-tertiary': 'var(--surface-tertiary)',
'surface-tertiary-alt': 'var(--surface-tertiary-alt)',
'surface-dialog': 'var(--surface-dialog)',
'surface-submit': 'var(--surface-submit)',
'surface-submit-hover': 'var(--surface-submit-hover)',
'surface-destructive': 'var(--surface-destructive)',
'surface-destructive-hover': 'var(--surface-destructive-hover)',
'surface-chat': 'var(--surface-chat)',
'border-light': 'var(--border-light)',
'border-medium': 'var(--border-medium)',
'border-medium-alt': 'var(--border-medium-alt)',
'border-heavy': 'var(--border-heavy)',
'border-xheavy': 'var(--border-xheavy)',
/* These are test styles */
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
['switch-unchecked']: 'hsl(var(--switch-unchecked))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
colors: createTailwindColors(),
},
},
plugins: [
require('tailwindcss-animate'),
require('tailwindcss-radix')(),
// require('@tailwindcss/typography'),
],
plugins: [],
};

View File

@@ -134,6 +134,8 @@ export const plugins = () => '/api/plugins';
export const mcpReinitialize = (serverName: string) => `/api/mcp/${serverName}/reinitialize`;
export const mcpConnectionStatus = () => '/api/mcp/connection/status';
export const mcpServerConnectionStatus = (serverName: string) =>
`/api/mcp/connection/status/${serverName}`;
export const mcpAuthValues = (serverName: string) => {
return `/api/mcp/${serverName}/auth-values`;
};

View File

@@ -613,6 +613,8 @@ export type TStartupConfig = {
}
>;
chatMenu?: boolean;
isOAuth?: boolean;
startup?: boolean;
}
>;
mcpPlaceholder?: string;

View File

@@ -149,6 +149,12 @@ export const getMCPConnectionStatus = (): Promise<q.MCPConnectionStatusResponse>
return request.get(endpoints.mcpConnectionStatus());
};
export const getMCPServerConnectionStatus = (
serverName: string,
): Promise<q.MCPServerConnectionStatusResponse> => {
return request.get(endpoints.mcpServerConnectionStatus(serverName));
};
export const getMCPAuthValues = (serverName: string): Promise<q.MCPAuthValuesResponse> => {
return request.get(endpoints.mcpAuthValues(serverName));
};

View File

@@ -6,6 +6,7 @@ import type {
} from '@tanstack/react-query';
import { Constants, initialModelsConfig } from '../config';
import { defaultOrderQuery } from '../types/assistants';
import { MCPServerConnectionStatusResponse } from '../types/queries';
import * as dataService from '../data-service';
import * as m from '../types/mutations';
import { QueryKeys } from '../keys';
@@ -380,3 +381,21 @@ export const useUpdateFeedbackMutation = (
},
);
};
export const useMCPServerConnectionStatusQuery = (
serverName: string,
config?: UseQueryOptions<MCPServerConnectionStatusResponse>,
): QueryObserverResult<MCPServerConnectionStatusResponse> => {
return useQuery<MCPServerConnectionStatusResponse>(
[QueryKeys.mcpConnectionStatus, serverName],
() => dataService.getMCPServerConnectionStatus(serverName),
{
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
staleTime: 10000, // 10 seconds
enabled: !!serverName,
...config,
},
);
};

View File

@@ -0,0 +1 @@
export * from './queries';

View File

@@ -135,6 +135,13 @@ export interface MCPConnectionStatusResponse {
connectionStatus: Record<string, MCPServerStatus>;
}
export interface MCPServerConnectionStatusResponse {
success: boolean;
serverName: string;
connectionStatus: string;
requiresOAuth: boolean;
}
export interface MCPAuthValuesResponse {
success: boolean;
serverName: string;

View File

@@ -1,6 +1,6 @@
{
"name": "@librechat/data-schemas",
"version": "0.0.12",
"version": "0.0.15",
"description": "Mongoose schemas and models for LibreChat",
"type": "module",
"main": "dist/index.cjs",
@@ -48,7 +48,6 @@
"@types/express": "^5.0.0",
"@types/jest": "^29.5.2",
"@types/node": "^20.3.0",
"@types/traverse": "^0.6.37",
"jest": "^29.5.0",
"jest-junit": "^16.0.0",
"mongodb-memory-server": "^10.1.4",
@@ -62,14 +61,12 @@
},
"peerDependencies": {
"jsonwebtoken": "^9.0.2",
"keyv": "^5.3.2",
"klona": "^2.0.6",
"librechat-data-provider": "*",
"lodash": "^4.17.21",
"meilisearch": "^0.38.0",
"mongoose": "^8.12.1",
"nanoid": "^3.3.7",
"traverse": "^0.6.11",
"winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0"
},

View File

@@ -1,8 +1,8 @@
import path from 'path';
import winston from 'winston';
import 'winston-daily-rotate-file';
import { getLogDirectory } from './utils';
const logDir = path.join(__dirname, '..', '..', '..', 'api', 'logs');
const logDir = getLogDirectory();
const { NODE_ENV, DEBUG_LOGGING = 'false' } = process.env;

View File

@@ -1,6 +1,7 @@
import { klona } from 'klona';
import winston from 'winston';
import traverse from 'traverse';
import traverse from '../utils/object-traverse';
import type { TraverseContext } from '../utils/object-traverse';
const SPLAT_SYMBOL = Symbol.for('splat');
const MESSAGE_SYMBOL = Symbol.for('message');
@@ -123,15 +124,17 @@ const debugTraverse = winston.format.printf(
return `${timestamp} ${level}: ${JSON.stringify(message)}`;
}
let msg = `${timestamp} ${level}: ${truncateLongStrings(message.trim(), 150)}`;
const msgParts: string[] = [
`${timestamp} ${level}: ${truncateLongStrings(message.trim(), 150)}`,
];
try {
if (level !== 'debug') {
return msg;
return msgParts[0];
}
if (!metadata) {
return msg;
return msgParts[0];
}
// Type-safe access to SPLAT_SYMBOL using bracket notation
@@ -140,59 +143,66 @@ const debugTraverse = winston.format.printf(
const debugValue = Array.isArray(splatArray) ? splatArray[0] : undefined;
if (!debugValue) {
return msg;
return msgParts[0];
}
if (debugValue && Array.isArray(debugValue)) {
msg += `\n${JSON.stringify(debugValue.map(condenseArray))}`;
return msg;
msgParts.push(`\n${JSON.stringify(debugValue.map(condenseArray))}`);
return msgParts.join('');
}
if (typeof debugValue !== 'object') {
return (msg += ` ${debugValue}`);
msgParts.push(` ${debugValue}`);
return msgParts.join('');
}
msg += '\n{';
msgParts.push('\n{');
const copy = klona(metadata);
try {
const traversal = traverse(copy);
traversal.forEach(function (this: TraverseContext, value: unknown) {
if (typeof this?.key === 'symbol') {
return;
}
traverse(copy).forEach(function (this: traverse.TraverseContext, value: unknown) {
if (typeof this?.key === 'symbol') {
return;
}
let _parentKey = '';
const parent = this.parent;
let _parentKey = '';
const parent = this.parent;
if (typeof parent?.key !== 'symbol' && parent?.key !== undefined) {
_parentKey = String(parent.key);
}
if (typeof parent?.key !== 'symbol' && parent?.key) {
_parentKey = parent.key;
}
const parentKey = `${parent && parent.notRoot ? _parentKey + '.' : ''}`;
const tabs = `${parent && parent.notRoot ? ' ' : ' '}`;
const currentKey = this?.key ?? 'unknown';
const parentKey = `${parent && parent.notRoot ? _parentKey + '.' : ''}`;
const tabs = `${parent && parent.notRoot ? ' ' : ' '}`;
const currentKey = this?.key ?? 'unknown';
if (this.isLeaf && typeof value === 'string') {
const truncatedText = truncateLongStrings(value);
msgParts.push(`\n${tabs}${parentKey}${currentKey}: ${JSON.stringify(truncatedText)},`);
} else if (this.notLeaf && Array.isArray(value) && value.length > 0) {
const currentMessage = `\n${tabs}// ${value.length} ${String(currentKey).replace(/s$/, '')}(s)`;
this.update(currentMessage);
msgParts.push(currentMessage);
const stringifiedArray = value.map(condenseArray);
msgParts.push(`\n${tabs}${parentKey}${currentKey}: [${stringifiedArray}],`);
} else if (this.isLeaf && typeof value === 'function') {
msgParts.push(`\n${tabs}${parentKey}${currentKey}: function,`);
} else if (this.isLeaf) {
msgParts.push(`\n${tabs}${parentKey}${currentKey}: ${value},`);
}
});
} catch (e: unknown) {
const errorMessage = e instanceof Error ? e.message : 'Unknown error';
msgParts.push(`\n[LOGGER TRAVERSAL ERROR] ${errorMessage}`);
}
if (this.isLeaf && typeof value === 'string') {
const truncatedText = truncateLongStrings(value);
msg += `\n${tabs}${parentKey}${currentKey}: ${JSON.stringify(truncatedText)},`;
} else if (this.notLeaf && Array.isArray(value) && value.length > 0) {
const currentMessage = `\n${tabs}// ${value.length} ${currentKey.replace(/s$/, '')}(s)`;
this.update(currentMessage, true);
msg += currentMessage;
const stringifiedArray = value.map(condenseArray);
msg += `\n${tabs}${parentKey}${currentKey}: [${stringifiedArray}],`;
} else if (this.isLeaf && typeof value === 'function') {
msg += `\n${tabs}${parentKey}${currentKey}: function,`;
} else if (this.isLeaf) {
msg += `\n${tabs}${parentKey}${currentKey}: ${value},`;
}
});
msg += '\n}';
return msg;
msgParts.push('\n}');
return msgParts.join('');
} catch (e: unknown) {
const errorMessage = e instanceof Error ? e.message : 'Unknown error';
return (msg += `\n[LOGGER PARSING ERROR] ${errorMessage}`);
msgParts.push(`\n[LOGGER PARSING ERROR] ${errorMessage}`);
return msgParts.join('');
}
},
);

View File

@@ -0,0 +1,37 @@
import path from 'path';
/**
* Determine the log directory in a cross-compatible way.
* Priority:
* 1. LIBRECHAT_LOG_DIR environment variable
* 2. If running within LibreChat monorepo (when cwd ends with /api), use api/logs
* 3. If api/logs exists relative to cwd, use that (for running from project root)
* 4. Otherwise, use logs directory relative to process.cwd()
*
* This avoids using __dirname which is not available in ESM modules
*/
export const getLogDirectory = (): string => {
if (process.env.LIBRECHAT_LOG_DIR) {
return process.env.LIBRECHAT_LOG_DIR;
}
const cwd = process.cwd();
// Check if we're running from within the api directory
if (cwd.endsWith('/api') || cwd.endsWith('\\api')) {
return path.join(cwd, 'logs');
}
// Check if api/logs exists relative to current directory (running from project root)
// We'll just use the path and let the file system create it if needed
const apiLogsPath = path.join(cwd, 'api', 'logs');
// For LibreChat project structure, use api/logs
// For external consumers, they should set LIBRECHAT_LOG_DIR
if (cwd.includes('LibreChat')) {
return apiLogsPath;
}
// Default to logs directory relative to current working directory
return path.join(cwd, 'logs');
};

View File

@@ -1,9 +1,9 @@
import path from 'path';
import winston from 'winston';
import 'winston-daily-rotate-file';
import { redactFormat, redactMessage, debugTraverse, jsonTruncateFormat } from './parsers';
import { getLogDirectory } from './utils';
const logDir = path.join(__dirname, '..', '..', '..', 'api', 'logs');
const logDir = getLogDirectory();
const { NODE_ENV, DEBUG_LOGGING, CONSOLE_JSON, DEBUG_CONSOLE } = process.env;

View File

@@ -0,0 +1,178 @@
/**
* ESM-native object traversal utility
* Simplified implementation focused on the forEach use case
*/
export interface TraverseContext {
node: unknown;
path: (string | number)[];
parent: TraverseContext | undefined;
key: string | number | undefined;
isLeaf: boolean;
notLeaf: boolean;
isRoot: boolean;
notRoot: boolean;
level: number;
circular: TraverseContext | null;
update: (value: unknown, stopHere?: boolean) => void;
remove: () => void;
}
type ForEachCallback = (this: TraverseContext, value: unknown) => void;
// Type guards for proper typing
type TraversableObject = Record<string | number, unknown> | unknown[];
function isObject(value: unknown): value is TraversableObject {
if (value === null || typeof value !== 'object') {
return false;
}
// Treat these built-in types as leaf nodes, not objects to traverse
if (value instanceof Date) return false;
if (value instanceof RegExp) return false;
if (value instanceof Error) return false;
if (value instanceof URL) return false;
// Check for Buffer (Node.js)
if (typeof Buffer !== 'undefined' && Buffer.isBuffer(value)) return false;
// Check for TypedArrays and ArrayBuffer
if (ArrayBuffer.isView(value)) return false;
if (value instanceof ArrayBuffer) return false;
if (value instanceof SharedArrayBuffer) return false;
// Check for other built-in types that shouldn't be traversed
if (value instanceof Promise) return false;
if (value instanceof WeakMap) return false;
if (value instanceof WeakSet) return false;
if (value instanceof Map) return false;
if (value instanceof Set) return false;
// Check if it's a primitive wrapper object
const stringTag = Object.prototype.toString.call(value);
if (
stringTag === '[object Boolean]' ||
stringTag === '[object Number]' ||
stringTag === '[object String]'
) {
return false;
}
return true;
}
// Helper to safely set a property on an object or array
function setProperty(obj: TraversableObject, key: string | number, value: unknown): void {
if (Array.isArray(obj) && typeof key === 'number') {
obj[key] = value;
} else if (!Array.isArray(obj) && typeof key === 'string') {
obj[key] = value;
} else if (!Array.isArray(obj) && typeof key === 'number') {
// Handle numeric keys on objects
obj[key] = value;
}
}
// Helper to safely delete a property from an object
function deleteProperty(obj: TraversableObject, key: string | number): void {
if (Array.isArray(obj) && typeof key === 'number') {
// For arrays, we should use splice, but this is handled in remove()
// This function is only called for non-array deletion
return;
}
if (!Array.isArray(obj)) {
delete obj[key];
}
}
function forEach(obj: unknown, callback: ForEachCallback): void {
const visited = new WeakSet<object>();
function walk(node: unknown, path: (string | number)[] = [], parent?: TraverseContext): void {
// Check for circular references
let circular: TraverseContext | null = null;
if (isObject(node)) {
if (visited.has(node)) {
// Find the circular reference in the parent chain
let p = parent;
while (p) {
if (p.node === node) {
circular = p;
break;
}
p = p.parent;
}
return; // Skip circular references
}
visited.add(node);
}
const key = path.length > 0 ? path[path.length - 1] : undefined;
const isRoot = path.length === 0;
const level = path.length;
// Determine if this is a leaf node
const isLeaf =
!isObject(node) ||
(Array.isArray(node) && node.length === 0) ||
Object.keys(node).length === 0;
// Create context
const context: TraverseContext = {
node,
path: [...path],
parent,
key,
isLeaf,
notLeaf: !isLeaf,
isRoot,
notRoot: !isRoot,
level,
circular,
update(value: unknown) {
if (!isRoot && parent && key !== undefined && isObject(parent.node)) {
setProperty(parent.node, key, value);
}
this.node = value;
},
remove() {
if (!isRoot && parent && key !== undefined && isObject(parent.node)) {
if (Array.isArray(parent.node) && typeof key === 'number') {
parent.node.splice(key, 1);
} else {
deleteProperty(parent.node, key);
}
}
},
};
// Call the callback with the context
callback.call(context, node);
// Traverse children if not circular and is an object
if (!circular && isObject(node) && !isLeaf) {
if (Array.isArray(node)) {
for (let i = 0; i < node.length; i++) {
walk(node[i], [...path, i], context);
}
} else {
for (const [childKey, childValue] of Object.entries(node)) {
walk(childValue, [...path, childKey], context);
}
}
}
}
walk(obj);
}
// Main traverse function that returns an object with forEach method
export default function traverse(obj: unknown) {
return {
forEach(callback: ForEachCallback): void {
forEach(obj, callback);
},
};
}

View File

@@ -174,6 +174,15 @@ REDIS_KEY_PREFIX=librechat
# Connection limits
REDIS_MAX_LISTENERS=40
# Ping interval to keep connection alive (seconds, 0 to disable)
REDIS_PING_INTERVAL=0
# Reconnection configuration
REDIS_RETRY_MAX_DELAY=3000 # Max delay between reconnection attempts (ms)
REDIS_RETRY_MAX_ATTEMPTS=10 # Max reconnection attempts (0 = infinite)
REDIS_CONNECT_TIMEOUT=10000 # Connection timeout (ms)
REDIS_ENABLE_OFFLINE_QUEUE=true # Queue commands when disconnected
```
## TLS/SSL Redis Setup