Compare commits
9 Commits
chart-1.9.
...
fix/avatar
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9ef26ddd5 | ||
|
|
c63f2a634c | ||
|
|
39d83b705b | ||
|
|
e5a5931818 | ||
|
|
41380d9cb9 | ||
|
|
9b4c4cafb6 | ||
|
|
c0f1cfcaba | ||
|
|
ea45d0b9c6 | ||
|
|
8f4705f683 |
10
.env.example
10
.env.example
@@ -702,6 +702,16 @@ HELP_AND_FAQ_URL=https://librechat.ai
|
||||
# Comma-separated list of CacheKeys (e.g., ROLES,MESSAGES)
|
||||
# FORCED_IN_MEMORY_CACHE_NAMESPACES=ROLES,MESSAGES
|
||||
|
||||
# Leader Election Configuration (for multi-instance deployments with Redis)
|
||||
# Duration in seconds that the leader lease is valid before it expires (default: 25)
|
||||
# LEADER_LEASE_DURATION=25
|
||||
# Interval in seconds at which the leader renews its lease (default: 10)
|
||||
# LEADER_RENEW_INTERVAL=10
|
||||
# Maximum number of retry attempts when renewing the lease fails (default: 3)
|
||||
# LEADER_RENEW_ATTEMPTS=3
|
||||
# Delay in seconds between retry attempts when renewing the lease (default: 0.5)
|
||||
# LEADER_RENEW_RETRY_DELAY=0.5
|
||||
|
||||
#==================================================#
|
||||
# Others #
|
||||
#==================================================#
|
||||
|
||||
13
.github/workflows/cache-integration-tests.yml
vendored
13
.github/workflows/cache-integration-tests.yml
vendored
@@ -8,12 +8,13 @@ on:
|
||||
- release/*
|
||||
paths:
|
||||
- 'packages/api/src/cache/**'
|
||||
- 'packages/api/src/cluster/**'
|
||||
- 'redis-config/**'
|
||||
- '.github/workflows/cache-integration-tests.yml'
|
||||
|
||||
jobs:
|
||||
cache_integration_tests:
|
||||
name: Run Cache Integration Tests
|
||||
name: Integration Tests that use actual Redis Cache
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -66,7 +67,15 @@ jobs:
|
||||
USE_REDIS: true
|
||||
REDIS_URI: redis://127.0.0.1:6379
|
||||
REDIS_CLUSTER_URI: redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003
|
||||
run: npm run test:cache:integration
|
||||
run: npm run test:cache-integration:core
|
||||
|
||||
- name: Run cluster integration tests
|
||||
working-directory: packages/api
|
||||
env:
|
||||
NODE_ENV: test
|
||||
USE_REDIS: true
|
||||
REDIS_URI: redis://127.0.0.1:6379
|
||||
run: npm run test:cache-integration:cluster
|
||||
|
||||
- name: Stop Redis Cluster
|
||||
if: always()
|
||||
|
||||
@@ -448,7 +448,7 @@ Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })}
|
||||
}
|
||||
if (!availableTools) {
|
||||
try {
|
||||
availableTools = await getMCPServerTools(serverName);
|
||||
availableTools = await getMCPServerTools(safeUser.id, serverName);
|
||||
} catch (error) {
|
||||
logger.error(`Error fetching available tools for MCP server ${serverName}:`, error);
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ const loadEphemeralAgent = async ({ req, spec, agent_id, endpoint, model_paramet
|
||||
/** @type {TEphemeralAgent | null} */
|
||||
const ephemeralAgent = req.body.ephemeralAgent;
|
||||
const mcpServers = new Set(ephemeralAgent?.mcp);
|
||||
const userId = req.user?.id; // note: userId cannot be undefined at runtime
|
||||
if (modelSpec?.mcpServers) {
|
||||
for (const mcpServer of modelSpec.mcpServers) {
|
||||
mcpServers.add(mcpServer);
|
||||
@@ -102,7 +103,7 @@ const loadEphemeralAgent = async ({ req, spec, agent_id, endpoint, model_paramet
|
||||
if (addedServers.has(mcpServer)) {
|
||||
continue;
|
||||
}
|
||||
const serverTools = await getMCPServerTools(mcpServer);
|
||||
const serverTools = await getMCPServerTools(userId, mcpServer);
|
||||
if (!serverTools) {
|
||||
tools.push(`${mcp_all}${mcp_delimiter}${mcpServer}`);
|
||||
addedServers.add(mcpServer);
|
||||
|
||||
@@ -1931,7 +1931,7 @@ describe('models/Agent', () => {
|
||||
});
|
||||
|
||||
// Mock getMCPServerTools to return tools for each server
|
||||
getMCPServerTools.mockImplementation(async (server) => {
|
||||
getMCPServerTools.mockImplementation(async (_userId, server) => {
|
||||
if (server === 'server1') {
|
||||
return { tool1_mcp_server1: {} };
|
||||
} else if (server === 'server2') {
|
||||
@@ -2125,7 +2125,7 @@ describe('models/Agent', () => {
|
||||
getCachedTools.mockResolvedValue(availableTools);
|
||||
|
||||
// Mock getMCPServerTools to return all tools for server1
|
||||
getMCPServerTools.mockImplementation(async (server) => {
|
||||
getMCPServerTools.mockImplementation(async (_userId, server) => {
|
||||
if (server === 'server1') {
|
||||
return availableTools; // All 100 tools belong to server1
|
||||
}
|
||||
@@ -2674,7 +2674,7 @@ describe('models/Agent', () => {
|
||||
});
|
||||
|
||||
// Mock getMCPServerTools to return only tools matching the server
|
||||
getMCPServerTools.mockImplementation(async (server) => {
|
||||
getMCPServerTools.mockImplementation(async (_userId, server) => {
|
||||
if (server === 'server1') {
|
||||
// Only return tool that correctly matches server1 format
|
||||
return { tool_mcp_server1: {} };
|
||||
|
||||
@@ -32,7 +32,7 @@ const getMCPTools = async (req, res) => {
|
||||
const mcpServers = {};
|
||||
|
||||
const cachePromises = configuredServers.map((serverName) =>
|
||||
getMCPServerTools(serverName).then((tools) => ({ serverName, tools })),
|
||||
getMCPServerTools(userId, serverName).then((tools) => ({ serverName, tools })),
|
||||
);
|
||||
const cacheResults = await Promise.all(cachePromises);
|
||||
|
||||
@@ -52,7 +52,7 @@ const getMCPTools = async (req, res) => {
|
||||
|
||||
if (Object.keys(serverTools).length > 0) {
|
||||
// Cache asynchronously without blocking
|
||||
cacheMCPServerTools({ serverName, serverTools }).catch((err) =>
|
||||
cacheMCPServerTools({ userId, serverName, serverTools }).catch((err) =>
|
||||
logger.error(`[getMCPTools] Failed to cache tools for ${serverName}:`, err),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ jest.mock('~/models', () => ({
|
||||
jest.mock('~/server/services/Config', () => ({
|
||||
setCachedTools: jest.fn(),
|
||||
getCachedTools: jest.fn(),
|
||||
getMCPServerTools: jest.fn(),
|
||||
loadCustomConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -205,6 +205,7 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
|
||||
|
||||
const tools = await userConnection.fetchTools();
|
||||
await updateMCPServerTools({
|
||||
userId: flowState.userId,
|
||||
serverName,
|
||||
tools,
|
||||
});
|
||||
|
||||
10
api/server/services/Config/__tests__/getCachedTools.spec.js
Normal file
10
api/server/services/Config/__tests__/getCachedTools.spec.js
Normal file
@@ -0,0 +1,10 @@
|
||||
const { ToolCacheKeys } = require('../getCachedTools');
|
||||
|
||||
describe('getCachedTools - Cache Isolation Security', () => {
|
||||
describe('ToolCacheKeys.MCP_SERVER', () => {
|
||||
it('should generate cache keys that include userId', () => {
|
||||
const key = ToolCacheKeys.MCP_SERVER('user123', 'github');
|
||||
expect(key).toBe('tools:mcp:user123:github');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,24 +7,25 @@ const getLogStores = require('~/cache/getLogStores');
|
||||
const ToolCacheKeys = {
|
||||
/** Global tools available to all users */
|
||||
GLOBAL: 'tools:global',
|
||||
/** MCP tools cached by server name */
|
||||
MCP_SERVER: (serverName) => `tools:mcp:${serverName}`,
|
||||
/** MCP tools cached by user ID and server name */
|
||||
MCP_SERVER: (userId, serverName) => `tools:mcp:${userId}:${serverName}`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves available tools from cache
|
||||
* @function getCachedTools
|
||||
* @param {Object} options - Options for retrieving tools
|
||||
* @param {string} [options.userId] - User ID for user-specific MCP tools
|
||||
* @param {string} [options.serverName] - MCP server name to get cached tools for
|
||||
* @returns {Promise<LCAvailableTools|null>} The available tools object or null if not cached
|
||||
*/
|
||||
async function getCachedTools(options = {}) {
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
const { serverName } = options;
|
||||
const { userId, serverName } = options;
|
||||
|
||||
// Return MCP server-specific tools if requested
|
||||
if (serverName) {
|
||||
return await cache.get(ToolCacheKeys.MCP_SERVER(serverName));
|
||||
if (serverName && userId) {
|
||||
return await cache.get(ToolCacheKeys.MCP_SERVER(userId, serverName));
|
||||
}
|
||||
|
||||
// Default to global tools
|
||||
@@ -36,17 +37,18 @@ async function getCachedTools(options = {}) {
|
||||
* @function setCachedTools
|
||||
* @param {Object} tools - The tools object to cache
|
||||
* @param {Object} options - Options for caching tools
|
||||
* @param {string} [options.userId] - User ID for user-specific MCP tools
|
||||
* @param {string} [options.serverName] - MCP server name for server-specific tools
|
||||
* @param {number} [options.ttl] - Time to live in milliseconds
|
||||
* @returns {Promise<boolean>} Whether the operation was successful
|
||||
*/
|
||||
async function setCachedTools(tools, options = {}) {
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
const { serverName, ttl } = options;
|
||||
const { userId, serverName, ttl } = options;
|
||||
|
||||
// Cache by MCP server if specified
|
||||
if (serverName) {
|
||||
return await cache.set(ToolCacheKeys.MCP_SERVER(serverName), tools, ttl);
|
||||
// Cache by MCP server if specified (requires userId)
|
||||
if (serverName && userId) {
|
||||
return await cache.set(ToolCacheKeys.MCP_SERVER(userId, serverName), tools, ttl);
|
||||
}
|
||||
|
||||
// Default to global cache
|
||||
@@ -57,13 +59,14 @@ async function setCachedTools(tools, options = {}) {
|
||||
* Invalidates cached tools
|
||||
* @function invalidateCachedTools
|
||||
* @param {Object} options - Options for invalidating tools
|
||||
* @param {string} [options.userId] - User ID for user-specific MCP tools
|
||||
* @param {string} [options.serverName] - MCP server name to invalidate
|
||||
* @param {boolean} [options.invalidateGlobal=false] - Whether to invalidate global tools
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function invalidateCachedTools(options = {}) {
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
const { serverName, invalidateGlobal = false } = options;
|
||||
const { userId, serverName, invalidateGlobal = false } = options;
|
||||
|
||||
const keysToDelete = [];
|
||||
|
||||
@@ -71,22 +74,23 @@ async function invalidateCachedTools(options = {}) {
|
||||
keysToDelete.push(ToolCacheKeys.GLOBAL);
|
||||
}
|
||||
|
||||
if (serverName) {
|
||||
keysToDelete.push(ToolCacheKeys.MCP_SERVER(serverName));
|
||||
if (serverName && userId) {
|
||||
keysToDelete.push(ToolCacheKeys.MCP_SERVER(userId, serverName));
|
||||
}
|
||||
|
||||
await Promise.all(keysToDelete.map((key) => cache.delete(key)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets MCP tools for a specific server from cache or merges with global tools
|
||||
* Gets MCP tools for a specific server from cache
|
||||
* @function getMCPServerTools
|
||||
* @param {string} userId - The user ID
|
||||
* @param {string} serverName - The MCP server name
|
||||
* @returns {Promise<LCAvailableTools|null>} The available tools for the server
|
||||
*/
|
||||
async function getMCPServerTools(serverName) {
|
||||
async function getMCPServerTools(userId, serverName) {
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
const serverTools = await cache.get(ToolCacheKeys.MCP_SERVER(serverName));
|
||||
const serverTools = await cache.get(ToolCacheKeys.MCP_SERVER(userId, serverName));
|
||||
|
||||
if (serverTools) {
|
||||
return serverTools;
|
||||
|
||||
@@ -6,11 +6,12 @@ const { getLogStores } = require('~/cache');
|
||||
/**
|
||||
* Updates MCP tools in the cache for a specific server
|
||||
* @param {Object} params - Parameters for updating MCP tools
|
||||
* @param {string} params.userId - User ID for user-specific caching
|
||||
* @param {string} params.serverName - MCP server name
|
||||
* @param {Array} params.tools - Array of tool objects from MCP server
|
||||
* @returns {Promise<LCAvailableTools>}
|
||||
*/
|
||||
async function updateMCPServerTools({ serverName, tools }) {
|
||||
async function updateMCPServerTools({ userId, serverName, tools }) {
|
||||
try {
|
||||
const serverTools = {};
|
||||
const mcpDelimiter = Constants.mcp_delimiter;
|
||||
@@ -27,14 +28,16 @@ async function updateMCPServerTools({ serverName, tools }) {
|
||||
};
|
||||
}
|
||||
|
||||
await setCachedTools(serverTools, { serverName });
|
||||
await setCachedTools(serverTools, { userId, serverName });
|
||||
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
await cache.delete(CacheKeys.TOOLS);
|
||||
logger.debug(`[MCP Cache] Updated ${tools.length} tools for server ${serverName}`);
|
||||
logger.debug(
|
||||
`[MCP Cache] Updated ${tools.length} tools for server ${serverName} (user: ${userId})`,
|
||||
);
|
||||
return serverTools;
|
||||
} catch (error) {
|
||||
logger.error(`[MCP Cache] Failed to update tools for ${serverName}:`, error);
|
||||
logger.error(`[MCP Cache] Failed to update tools for ${serverName} (user: ${userId}):`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -65,21 +68,22 @@ async function mergeAppTools(appTools) {
|
||||
/**
|
||||
* Caches MCP server tools (no longer merges with global)
|
||||
* @param {object} params
|
||||
* @param {string} params.userId - User ID for user-specific caching
|
||||
* @param {string} params.serverName
|
||||
* @param {import('@librechat/api').LCAvailableTools} params.serverTools
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function cacheMCPServerTools({ serverName, serverTools }) {
|
||||
async function cacheMCPServerTools({ userId, serverName, serverTools }) {
|
||||
try {
|
||||
const count = Object.keys(serverTools).length;
|
||||
if (!count) {
|
||||
return;
|
||||
}
|
||||
// Only cache server-specific tools, no merging with global
|
||||
await setCachedTools(serverTools, { serverName });
|
||||
logger.debug(`Cached ${count} MCP server tools for ${serverName}`);
|
||||
await setCachedTools(serverTools, { userId, serverName });
|
||||
logger.debug(`Cached ${count} MCP server tools for ${serverName} (user: ${userId})`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to cache MCP server tools for ${serverName}:`, error);
|
||||
logger.error(`Failed to cache MCP server tools for ${serverName} (user: ${userId}):`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ async function reinitMCPServer({
|
||||
if (connection && !oauthRequired) {
|
||||
tools = await connection.fetchTools();
|
||||
availableTools = await updateMCPServerTools({
|
||||
userId: user.id,
|
||||
serverName,
|
||||
tools,
|
||||
});
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { useState, useMemo, memo, useCallback } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Atom, ChevronDown } from 'lucide-react';
|
||||
import type { MouseEvent, FC } from 'react';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
const BUTTON_STYLES = {
|
||||
base: 'group mt-3 flex w-fit items-center justify-center rounded-xl bg-surface-tertiary px-3 py-2 text-xs leading-[18px] animate-thinking-appear',
|
||||
icon: 'icon-sm ml-1.5 transform-gpu text-text-primary transition-transform duration-200',
|
||||
} as const;
|
||||
|
||||
const CONTENT_STYLES = {
|
||||
wrapper: 'relative pl-3 text-text-secondary',
|
||||
border:
|
||||
'absolute left-0 h-[calc(100%-10px)] border-l-2 border-border-medium dark:border-border-heavy',
|
||||
partBorder:
|
||||
'absolute left-0 h-[calc(100%)] border-l-2 border-border-medium dark:border-border-heavy',
|
||||
text: 'whitespace-pre-wrap leading-[26px]',
|
||||
} as const;
|
||||
|
||||
export const ThinkingContent: FC<{ children: React.ReactNode; isPart?: boolean }> = memo(
|
||||
({ isPart, children }) => (
|
||||
<div className={CONTENT_STYLES.wrapper}>
|
||||
<div className={isPart === true ? CONTENT_STYLES.partBorder : CONTENT_STYLES.border} />
|
||||
<p className={CONTENT_STYLES.text}>{children}</p>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
|
||||
export const ThinkingButton = memo(
|
||||
({
|
||||
isExpanded,
|
||||
onClick,
|
||||
label,
|
||||
}: {
|
||||
isExpanded: boolean;
|
||||
onClick: (e: MouseEvent<HTMLButtonElement>) => void;
|
||||
label: string;
|
||||
}) => (
|
||||
<button type="button" onClick={onClick} className={BUTTON_STYLES.base}>
|
||||
<Atom size={14} className="mr-1.5 text-text-secondary" />
|
||||
{label}
|
||||
<ChevronDown className={`${BUTTON_STYLES.icon} ${isExpanded ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
),
|
||||
);
|
||||
|
||||
const Thinking: React.ElementType = memo(({ children }: { children: React.ReactNode }) => {
|
||||
const localize = useLocalize();
|
||||
const showThinking = useRecoilValue<boolean>(store.showThinking);
|
||||
const [isExpanded, setIsExpanded] = useState(showThinking);
|
||||
|
||||
const handleClick = useCallback((e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
setIsExpanded((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const label = useMemo(() => localize('com_ui_thoughts'), [localize]);
|
||||
|
||||
if (children == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-5">
|
||||
<ThinkingButton isExpanded={isExpanded} onClick={handleClick} label={label} />
|
||||
</div>
|
||||
<div
|
||||
className={cn('grid transition-all duration-300 ease-out', isExpanded && 'mb-8')}
|
||||
style={{
|
||||
gridTemplateRows: isExpanded ? '1fr' : '0fr',
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<ThinkingContent isPart={true}>{children}</ThinkingContent>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
ThinkingButton.displayName = 'ThinkingButton';
|
||||
ThinkingContent.displayName = 'ThinkingContent';
|
||||
Thinking.displayName = 'Thinking';
|
||||
|
||||
export default memo(Thinking);
|
||||
@@ -1,5 +1,4 @@
|
||||
import { memo, useMemo, useState } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import type {
|
||||
TMessageContentParts,
|
||||
@@ -7,14 +6,11 @@ import type {
|
||||
TAttachment,
|
||||
Agents,
|
||||
} from 'librechat-data-provider';
|
||||
import { ThinkingButton } from '~/components/Artifacts/Thinking';
|
||||
import { MessageContext, SearchContext } from '~/Providers';
|
||||
import MemoryArtifacts from './MemoryArtifacts';
|
||||
import Sources from '~/components/Web/Sources';
|
||||
import { mapAttachments } from '~/utils/map';
|
||||
import { EditTextPart } from './Parts';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import store from '~/store';
|
||||
import Part from './Part';
|
||||
|
||||
type ContentPartsProps = {
|
||||
@@ -52,32 +48,10 @@ const ContentParts = memo(
|
||||
siblingIdx,
|
||||
setSiblingIdx,
|
||||
}: ContentPartsProps) => {
|
||||
const localize = useLocalize();
|
||||
const [showThinking, setShowThinking] = useRecoilState<boolean>(store.showThinking);
|
||||
const [isExpanded, setIsExpanded] = useState(showThinking);
|
||||
const attachmentMap = useMemo(() => mapAttachments(attachments ?? []), [attachments]);
|
||||
|
||||
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
|
||||
|
||||
const hasReasoningParts = useMemo(() => {
|
||||
const hasThinkPart = content?.some((part) => part?.type === ContentTypes.THINK) ?? false;
|
||||
const allThinkPartsHaveContent =
|
||||
content?.every((part) => {
|
||||
if (part?.type !== ContentTypes.THINK) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof part.think === 'string') {
|
||||
const cleanedContent = part.think.replace(/<\/?think>/g, '').trim();
|
||||
return cleanedContent.length > 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}) ?? false;
|
||||
|
||||
return hasThinkPart && allThinkPartsHaveContent;
|
||||
}, [content]);
|
||||
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
@@ -126,57 +100,40 @@ const ContentParts = memo(
|
||||
<SearchContext.Provider value={{ searchResults }}>
|
||||
<MemoryArtifacts attachments={attachments} />
|
||||
<Sources messageId={messageId} conversationId={conversationId || undefined} />
|
||||
{hasReasoningParts && (
|
||||
<div className="mb-5">
|
||||
<ThinkingButton
|
||||
isExpanded={isExpanded}
|
||||
onClick={() =>
|
||||
setIsExpanded((prev) => {
|
||||
const val = !prev;
|
||||
setShowThinking(val);
|
||||
return val;
|
||||
})
|
||||
}
|
||||
label={
|
||||
effectiveIsSubmitting && isLast
|
||||
? localize('com_ui_thinking')
|
||||
: localize('com_ui_thoughts')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{content
|
||||
.filter((part) => part)
|
||||
.map((part, idx) => {
|
||||
const toolCallId =
|
||||
(part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? '';
|
||||
const attachments = attachmentMap[toolCallId];
|
||||
{content.map((part, idx) => {
|
||||
if (!part) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MessageContext.Provider
|
||||
key={`provider-${messageId}-${idx}`}
|
||||
value={{
|
||||
messageId,
|
||||
isExpanded,
|
||||
conversationId,
|
||||
partIndex: idx,
|
||||
nextType: content[idx + 1]?.type,
|
||||
isSubmitting: effectiveIsSubmitting,
|
||||
isLatestMessage,
|
||||
}}
|
||||
>
|
||||
<Part
|
||||
part={part}
|
||||
attachments={attachments}
|
||||
isSubmitting={effectiveIsSubmitting}
|
||||
key={`part-${messageId}-${idx}`}
|
||||
isCreatedByUser={isCreatedByUser}
|
||||
isLast={idx === content.length - 1}
|
||||
showCursor={idx === content.length - 1 && isLast}
|
||||
/>
|
||||
</MessageContext.Provider>
|
||||
);
|
||||
})}
|
||||
const toolCallId =
|
||||
(part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? '';
|
||||
const partAttachments = attachmentMap[toolCallId];
|
||||
|
||||
return (
|
||||
<MessageContext.Provider
|
||||
key={`provider-${messageId}-${idx}`}
|
||||
value={{
|
||||
messageId,
|
||||
isExpanded: true,
|
||||
conversationId,
|
||||
partIndex: idx,
|
||||
nextType: content[idx + 1]?.type,
|
||||
isSubmitting: effectiveIsSubmitting,
|
||||
isLatestMessage,
|
||||
}}
|
||||
>
|
||||
<Part
|
||||
part={part}
|
||||
attachments={partAttachments}
|
||||
isSubmitting={effectiveIsSubmitting}
|
||||
key={`part-${messageId}-${idx}`}
|
||||
isCreatedByUser={isCreatedByUser}
|
||||
isLast={idx === content.length - 1}
|
||||
showCursor={idx === content.length - 1 && isLast}
|
||||
/>
|
||||
</MessageContext.Provider>
|
||||
);
|
||||
})}
|
||||
</SearchContext.Provider>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -151,7 +151,7 @@ const EditMessage = ({
|
||||
|
||||
return (
|
||||
<Container message={message}>
|
||||
<div className="bg-token-main-surface-primary relative flex w-full flex-grow flex-col overflow-hidden rounded-2xl border border-border-medium text-text-primary [&:has(textarea:focus)]:border-border-heavy [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)]">
|
||||
<div className="bg-token-main-surface-primary relative mt-2 flex w-full flex-grow flex-col overflow-hidden rounded-2xl border border-border-medium text-text-primary [&:has(textarea:focus)]:border-border-heavy [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)]">
|
||||
<TextareaAutosize
|
||||
{...registerProps}
|
||||
ref={(e) => {
|
||||
|
||||
@@ -4,67 +4,89 @@ import { DelayedRender } from '@librechat/client';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import type { TMessageContentProps, TDisplayProps } from '~/common';
|
||||
import Error from '~/components/Messages/Content/Error';
|
||||
import Thinking from '~/components/Artifacts/Thinking';
|
||||
import { useMessageContext } from '~/Providers';
|
||||
import MarkdownLite from './MarkdownLite';
|
||||
import EditMessage from './EditMessage';
|
||||
import Thinking from './Parts/Thinking';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import Container from './Container';
|
||||
import Markdown from './Markdown';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
const ERROR_CONNECTION_TEXT = 'Error connecting to server, try refreshing the page.';
|
||||
const DELAYED_ERROR_TIMEOUT = 5500;
|
||||
const UNFINISHED_DELAY = 250;
|
||||
|
||||
const parseThinkingContent = (text: string) => {
|
||||
const thinkingMatch = text.match(/:::thinking([\s\S]*?):::/);
|
||||
return {
|
||||
thinkingContent: thinkingMatch ? thinkingMatch[1].trim() : '',
|
||||
regularContent: thinkingMatch ? text.replace(/:::thinking[\s\S]*?:::/, '').trim() : text,
|
||||
};
|
||||
};
|
||||
|
||||
const LoadingFallback = () => (
|
||||
<div className="text-message mb-[0.625rem] flex min-h-[20px] flex-col items-start gap-3 overflow-visible">
|
||||
<div className="markdown prose dark:prose-invert light w-full break-words dark:text-gray-100">
|
||||
<div className="absolute">
|
||||
<p className="submitting relative">
|
||||
<span className="result-thinking" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ErrorBox = ({
|
||||
children,
|
||||
className = '',
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className={cn(
|
||||
'rounded-xl border border-red-500/20 bg-red-500/5 px-3 py-2 text-sm text-gray-600 dark:text-gray-200',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const ConnectionError = ({ message }: { message?: TMessage }) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<DelayedRender delay={DELAYED_ERROR_TIMEOUT}>
|
||||
<Container message={message}>
|
||||
<div className="mt-2 rounded-xl border border-red-500/20 bg-red-50/50 px-4 py-3 text-sm text-red-700 shadow-sm transition-all dark:bg-red-950/30 dark:text-red-100">
|
||||
{localize('com_ui_error_connection')}
|
||||
</div>
|
||||
</Container>
|
||||
</DelayedRender>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export const ErrorMessage = ({
|
||||
text,
|
||||
message,
|
||||
className = '',
|
||||
}: Pick<TDisplayProps, 'text' | 'className'> & {
|
||||
message?: TMessage;
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
if (text === 'Error connecting to server, try refreshing the page.') {
|
||||
console.log('error message', message);
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="text-message mb-[0.625rem] flex min-h-[20px] flex-col items-start gap-3 overflow-visible">
|
||||
<div className="markdown prose dark:prose-invert light w-full break-words dark:text-gray-100">
|
||||
<div className="absolute">
|
||||
<p className="submitting relative">
|
||||
<span className="result-thinking" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DelayedRender delay={5500}>
|
||||
<Container message={message}>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-md border border-red-500 bg-red-500/10 px-3 py-2 text-sm text-gray-600 dark:text-gray-200',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{localize('com_ui_error_connection')}
|
||||
</div>
|
||||
</Container>
|
||||
</DelayedRender>
|
||||
</Suspense>
|
||||
);
|
||||
}: Pick<TDisplayProps, 'text' | 'className'> & { message?: TMessage }) => {
|
||||
if (text === ERROR_CONNECTION_TEXT) {
|
||||
return <ConnectionError message={message} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container message={message}>
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className={cn(
|
||||
'rounded-xl border border-red-500/20 bg-red-500/5 px-3 py-2 text-sm text-gray-600 dark:text-gray-200',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<ErrorBox className={className}>
|
||||
<Error text={text} />
|
||||
</div>
|
||||
</ErrorBox>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
@@ -72,27 +94,29 @@ export const ErrorMessage = ({
|
||||
const DisplayMessage = ({ text, isCreatedByUser, message, showCursor }: TDisplayProps) => {
|
||||
const { isSubmitting = false, isLatestMessage = false } = useMessageContext();
|
||||
const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown);
|
||||
|
||||
const showCursorState = useMemo(
|
||||
() => showCursor === true && isSubmitting,
|
||||
[showCursor, isSubmitting],
|
||||
);
|
||||
|
||||
let content: React.ReactElement;
|
||||
if (!isCreatedByUser) {
|
||||
content = <Markdown content={text} isLatestMessage={isLatestMessage} />;
|
||||
} else if (enableUserMsgMarkdown) {
|
||||
content = <MarkdownLite content={text} />;
|
||||
} else {
|
||||
content = <>{text}</>;
|
||||
}
|
||||
const content = useMemo(() => {
|
||||
if (!isCreatedByUser) {
|
||||
return <Markdown content={text} isLatestMessage={isLatestMessage} />;
|
||||
}
|
||||
if (enableUserMsgMarkdown) {
|
||||
return <MarkdownLite content={text} />;
|
||||
}
|
||||
return <>{text}</>;
|
||||
}, [isCreatedByUser, enableUserMsgMarkdown, text, isLatestMessage]);
|
||||
|
||||
return (
|
||||
<Container message={message}>
|
||||
<div
|
||||
className={cn(
|
||||
isSubmitting ? 'submitting' : '',
|
||||
showCursorState && !!text.length ? 'result-streaming' : '',
|
||||
'markdown prose message-content dark:prose-invert light w-full break-words',
|
||||
isSubmitting && 'submitting',
|
||||
showCursorState && text.length > 0 && 'result-streaming',
|
||||
isCreatedByUser && !enableUserMsgMarkdown && 'whitespace-pre-wrap',
|
||||
isCreatedByUser ? 'dark:text-gray-20' : 'dark:text-gray-100',
|
||||
)}
|
||||
@@ -103,7 +127,6 @@ const DisplayMessage = ({ text, isCreatedByUser, message, showCursor }: TDisplay
|
||||
);
|
||||
};
|
||||
|
||||
// Unfinished Message Component
|
||||
export const UnfinishedMessage = ({ message }: { message: TMessage }) => (
|
||||
<ErrorMessage
|
||||
message={message}
|
||||
@@ -123,21 +146,14 @@ const MessageContent = ({
|
||||
const { message } = props;
|
||||
const { messageId } = message;
|
||||
|
||||
const { thinkingContent, regularContent } = useMemo(() => {
|
||||
const thinkingMatch = text.match(/:::thinking([\s\S]*?):::/);
|
||||
return {
|
||||
thinkingContent: thinkingMatch ? thinkingMatch[1].trim() : '',
|
||||
regularContent: thinkingMatch ? text.replace(/:::thinking[\s\S]*?:::/, '').trim() : text,
|
||||
};
|
||||
}, [text]);
|
||||
|
||||
const { thinkingContent, regularContent } = useMemo(() => parseThinkingContent(text), [text]);
|
||||
const showRegularCursor = useMemo(() => isLast && isSubmitting, [isLast, isSubmitting]);
|
||||
|
||||
const unfinishedMessage = useMemo(
|
||||
() =>
|
||||
!isSubmitting && unfinished ? (
|
||||
<Suspense>
|
||||
<DelayedRender delay={250}>
|
||||
<DelayedRender delay={UNFINISHED_DELAY}>
|
||||
<UnfinishedMessage message={message} />
|
||||
</DelayedRender>
|
||||
</Suspense>
|
||||
@@ -146,8 +162,10 @@ const MessageContent = ({
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return <ErrorMessage message={props.message} text={text} />;
|
||||
} else if (edit) {
|
||||
return <ErrorMessage message={message} text={text} />;
|
||||
}
|
||||
|
||||
if (edit) {
|
||||
return <EditMessage text={text} isSubmitting={isSubmitting} {...props} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,10 @@ const Part = memo(
|
||||
if (part.tool_call_ids != null && !text) {
|
||||
return null;
|
||||
}
|
||||
/** Skip rendering if text is only whitespace to avoid empty Container */
|
||||
if (!isLast && text.length > 0 && /^\s*$/.test(text)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Container>
|
||||
<Text text={text} isCreatedByUser={isCreatedByUser} showCursor={showCursor} />
|
||||
@@ -75,7 +79,7 @@ const Part = memo(
|
||||
if (typeof reasoning !== 'string') {
|
||||
return null;
|
||||
}
|
||||
return <Reasoning reasoning={reasoning} />;
|
||||
return <Reasoning reasoning={reasoning} isLast={isLast ?? false} />;
|
||||
} else if (part.type === ContentTypes.TOOL_CALL) {
|
||||
const toolCall = part[ContentTypes.TOOL_CALL];
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useForm } from 'react-hook-form';
|
||||
import { TextareaAutosize } from '@librechat/client';
|
||||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { Lightbulb, MessageSquare } from 'lucide-react';
|
||||
import { useUpdateMessageContentMutation } from 'librechat-data-provider/react-query';
|
||||
import type { Agents } from 'librechat-data-provider';
|
||||
import type { TEditProps } from '~/common';
|
||||
@@ -153,6 +154,22 @@ const EditTextPart = ({
|
||||
|
||||
return (
|
||||
<Container message={message}>
|
||||
{part.type === ContentTypes.THINK && (
|
||||
<div className="mt-2 flex items-center gap-1.5 text-xs text-text-secondary">
|
||||
<span className="flex gap-2 rounded-lg bg-surface-tertiary px-1.5 py-1 font-medium">
|
||||
<Lightbulb className="size-3.5" />
|
||||
{localize('com_ui_thoughts')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{part.type !== ContentTypes.THINK && (
|
||||
<div className="mt-2 flex items-center gap-1.5 text-xs text-text-secondary">
|
||||
<span className="flex gap-2 rounded-lg bg-surface-tertiary px-1.5 py-1 font-medium">
|
||||
<MessageSquare className="size-3.5" />
|
||||
{localize('com_ui_response')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-token-main-surface-primary relative flex w-full flex-grow flex-col overflow-hidden rounded-2xl border border-border-medium text-text-primary [&:has(textarea:focus)]:border-border-heavy [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)]">
|
||||
<TextareaAutosize
|
||||
{...registerProps}
|
||||
|
||||
@@ -1,15 +1,47 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { memo, useMemo, useState, useCallback } from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import type { MouseEvent } from 'react';
|
||||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import { ThinkingContent } from '~/components/Artifacts/Thinking';
|
||||
import { ThinkingContent, ThinkingButton } from './Thinking';
|
||||
import { showThinkingAtom } from '~/store/showThinking';
|
||||
import { useMessageContext } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
type ReasoningProps = {
|
||||
reasoning: string;
|
||||
isLast: boolean;
|
||||
};
|
||||
|
||||
const Reasoning = memo(({ reasoning }: ReasoningProps) => {
|
||||
const { isExpanded, nextType } = useMessageContext();
|
||||
/**
|
||||
* Reasoning Component (MODERN SYSTEM)
|
||||
*
|
||||
* Used for structured content parts with ContentTypes.THINK type.
|
||||
* This handles modern message format where content is an array of typed parts.
|
||||
*
|
||||
* Pattern: `{ content: [{ type: "think", think: "<think>content</think>" }, ...] }`
|
||||
*
|
||||
* Used by:
|
||||
* - ContentParts.tsx → Part.tsx for structured messages
|
||||
* - Agent/Assistant responses (OpenAI Assistants, custom agents)
|
||||
* - O-series models (o1, o3) with reasoning capabilities
|
||||
* - Modern Claude responses with thinking blocks
|
||||
*
|
||||
* Key differences from legacy Thinking.tsx:
|
||||
* - Works with content parts array instead of plain text
|
||||
* - Strips `<think>` tags instead of `:::thinking:::` markers
|
||||
* - Each THINK part has its own independent toggle button
|
||||
* - Can be interleaved with other content types
|
||||
*
|
||||
* For legacy text-based messages, see Thinking.tsx component.
|
||||
*/
|
||||
const Reasoning = memo(({ reasoning, isLast }: ReasoningProps) => {
|
||||
const localize = useLocalize();
|
||||
const [showThinking] = useAtom(showThinkingAtom);
|
||||
const [isExpanded, setIsExpanded] = useState(showThinking);
|
||||
const { isSubmitting, isLatestMessage, nextType } = useMessageContext();
|
||||
|
||||
// Strip <think> tags from the reasoning content (modern format)
|
||||
const reasoningText = useMemo(() => {
|
||||
return reasoning
|
||||
.replace(/^<think>\s*/, '')
|
||||
@@ -17,22 +49,45 @@ const Reasoning = memo(({ reasoning }: ReasoningProps) => {
|
||||
.trim();
|
||||
}, [reasoning]);
|
||||
|
||||
const handleClick = useCallback((e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
setIsExpanded((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
|
||||
|
||||
const label = useMemo(
|
||||
() =>
|
||||
effectiveIsSubmitting && isLast ? localize('com_ui_thinking') : localize('com_ui_thoughts'),
|
||||
[effectiveIsSubmitting, localize, isLast],
|
||||
);
|
||||
|
||||
if (!reasoningText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid transition-all duration-300 ease-out',
|
||||
nextType !== ContentTypes.THINK && isExpanded && 'mb-8',
|
||||
)}
|
||||
style={{
|
||||
gridTemplateRows: isExpanded ? '1fr' : '0fr',
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<ThinkingContent isPart={true}>{reasoningText}</ThinkingContent>
|
||||
<div className="group/reasoning">
|
||||
<div className="sticky top-0 z-10 mb-2 bg-surface-secondary pb-2 pt-2">
|
||||
<ThinkingButton
|
||||
isExpanded={isExpanded}
|
||||
onClick={handleClick}
|
||||
label={label}
|
||||
content={reasoningText}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'grid transition-all duration-300 ease-out',
|
||||
nextType !== ContentTypes.THINK && isExpanded && 'mb-4',
|
||||
)}
|
||||
style={{
|
||||
gridTemplateRows: isExpanded ? '1fr' : '0fr',
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<ThinkingContent>{reasoningText}</ThinkingContent>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
172
client/src/components/Chat/Messages/Content/Parts/Thinking.tsx
Normal file
172
client/src/components/Chat/Messages/Content/Parts/Thinking.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { useState, useMemo, memo, useCallback } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Lightbulb, ChevronDown } from 'lucide-react';
|
||||
import { Clipboard, CheckMark } from '@librechat/client';
|
||||
import type { MouseEvent, FC } from 'react';
|
||||
import { showThinkingAtom } from '~/store/showThinking';
|
||||
import { fontSizeAtom } from '~/store/fontSize';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
/**
|
||||
* ThinkingContent - Displays the actual thinking/reasoning content
|
||||
* Used by both legacy text-based messages and modern content parts
|
||||
*/
|
||||
export const ThinkingContent: FC<{
|
||||
children: React.ReactNode;
|
||||
}> = memo(({ children }) => {
|
||||
const fontSize = useAtomValue(fontSizeAtom);
|
||||
|
||||
return (
|
||||
<div className="relative rounded-3xl border border-border-medium bg-surface-tertiary p-4 text-text-secondary">
|
||||
<p className={cn('whitespace-pre-wrap leading-[26px]', fontSize)}>{children}</p>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* ThinkingButton - Toggle button for expanding/collapsing thinking content
|
||||
* Shows lightbulb icon by default, chevron on hover
|
||||
* Shared between legacy Thinking component and modern ContentParts
|
||||
*/
|
||||
export const ThinkingButton = memo(
|
||||
({
|
||||
isExpanded,
|
||||
onClick,
|
||||
label,
|
||||
content,
|
||||
}: {
|
||||
isExpanded: boolean;
|
||||
onClick: (e: MouseEvent<HTMLButtonElement>) => void;
|
||||
label: string;
|
||||
content?: string;
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
const fontSize = useAtomValue(fontSizeAtom);
|
||||
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
const handleCopy = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
if (content) {
|
||||
navigator.clipboard.writeText(content);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
}
|
||||
},
|
||||
[content],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'group/button flex flex-1 items-center justify-start rounded-lg leading-[18px]',
|
||||
fontSize,
|
||||
)}
|
||||
>
|
||||
<span className="relative mr-1.5 inline-flex h-[18px] w-[18px] items-center justify-center">
|
||||
<Lightbulb className="icon-sm absolute text-text-secondary opacity-100 transition-opacity group-hover/button:opacity-0" />
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'icon-sm absolute transform-gpu text-text-primary opacity-0 transition-all duration-300 group-hover/button:opacity-100',
|
||||
isExpanded && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
{label}
|
||||
</button>
|
||||
{content && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
title={
|
||||
isCopied
|
||||
? localize('com_ui_copied_to_clipboard')
|
||||
: localize('com_ui_copy_thoughts_to_clipboard')
|
||||
}
|
||||
className={cn(
|
||||
'rounded-lg p-1.5 text-text-secondary-alt transition-colors duration-200',
|
||||
'hover:bg-surface-hover hover:text-text-primary',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white',
|
||||
)}
|
||||
>
|
||||
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard size="19" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Thinking Component (LEGACY SYSTEM)
|
||||
*
|
||||
* Used for simple text-based messages with `:::thinking:::` markers.
|
||||
* This handles the old message format where text contains embedded thinking blocks.
|
||||
*
|
||||
* Pattern: `:::thinking\n{content}\n:::\n{response}`
|
||||
*
|
||||
* Used by:
|
||||
* - MessageContent.tsx for plain text messages
|
||||
* - Legacy message format compatibility
|
||||
* - User messages when manually adding thinking content
|
||||
*
|
||||
* For modern structured content (agents/assistants), see Reasoning.tsx component.
|
||||
*/
|
||||
const Thinking: React.ElementType = memo(({ children }: { children: React.ReactNode }) => {
|
||||
const localize = useLocalize();
|
||||
const showThinking = useAtomValue(showThinkingAtom);
|
||||
const [isExpanded, setIsExpanded] = useState(showThinking);
|
||||
|
||||
const handleClick = useCallback((e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
setIsExpanded((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const label = useMemo(() => localize('com_ui_thoughts'), [localize]);
|
||||
|
||||
// Extract text content for copy functionality
|
||||
const textContent = useMemo(() => {
|
||||
if (typeof children === 'string') {
|
||||
return children;
|
||||
}
|
||||
return '';
|
||||
}, [children]);
|
||||
|
||||
if (children == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sticky top-0 z-10 mb-4 bg-surface-primary pb-2 pt-2">
|
||||
<ThinkingButton
|
||||
isExpanded={isExpanded}
|
||||
onClick={handleClick}
|
||||
label={label}
|
||||
content={textContent}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn('grid transition-all duration-300 ease-out', isExpanded && 'mb-8')}
|
||||
style={{
|
||||
gridTemplateRows: isExpanded ? '1fr' : '0fr',
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<ThinkingContent>{children}</ThinkingContent>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
ThinkingButton.displayName = 'ThinkingButton';
|
||||
ThinkingContent.displayName = 'ThinkingContent';
|
||||
Thinking.displayName = 'Thinking';
|
||||
|
||||
export default memo(Thinking);
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { memo, useState } from 'react';
|
||||
import React, { memo, useState, useMemo, useRef, useCallback, useEffect } from 'react';
|
||||
import { UserIcon, useAvatar } from '@librechat/client';
|
||||
import type { TUser } from 'librechat-data-provider';
|
||||
import type { IconProps } from '~/common';
|
||||
@@ -15,26 +15,49 @@ type UserAvatarProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Default avatar component - memoized outside to prevent recreation on every render
|
||||
*/
|
||||
const DefaultAvatar = memo(() => (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'rgb(121, 137, 255)',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
boxShadow: 'rgba(240, 246, 252, 0.1) 0px 0px 0px 1px',
|
||||
}}
|
||||
className="relative flex h-9 w-9 items-center justify-center rounded-sm p-1 text-white"
|
||||
>
|
||||
<UserIcon />
|
||||
</div>
|
||||
));
|
||||
|
||||
DefaultAvatar.displayName = 'DefaultAvatar';
|
||||
|
||||
const UserAvatar = memo(({ size, user, avatarSrc, username, className }: UserAvatarProps) => {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const imageLoadedRef = useRef(false);
|
||||
|
||||
const handleImageError = () => {
|
||||
const imageSrc = useMemo(() => (user?.avatar ?? '') || avatarSrc, [user?.avatar, avatarSrc]);
|
||||
|
||||
/** Reset loaded state and error state if image source changes */
|
||||
useEffect(() => {
|
||||
imageLoadedRef.current = false;
|
||||
setImageError(false);
|
||||
}, [imageSrc]);
|
||||
|
||||
const handleImageError = useCallback(() => {
|
||||
setImageError(true);
|
||||
};
|
||||
imageLoadedRef.current = false;
|
||||
}, []);
|
||||
|
||||
const renderDefaultAvatar = () => (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'rgb(121, 137, 255)',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
boxShadow: 'rgba(240, 246, 252, 0.1) 0px 0px 0px 1px',
|
||||
}}
|
||||
className="relative flex h-9 w-9 items-center justify-center rounded-sm p-1 text-white"
|
||||
>
|
||||
<UserIcon />
|
||||
</div>
|
||||
);
|
||||
const handleImageLoad = useCallback(() => {
|
||||
imageLoadedRef.current = true;
|
||||
setImageError(false);
|
||||
}, []);
|
||||
|
||||
const hasAvatar = useMemo(() => imageSrc !== '', [imageSrc]);
|
||||
const showImage = useMemo(() => hasAvatar && !imageError, [hasAvatar, imageError]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -45,14 +68,14 @@ const UserAvatar = memo(({ size, user, avatarSrc, username, className }: UserAva
|
||||
}}
|
||||
className={cn('relative flex items-center justify-center', className ?? '')}
|
||||
>
|
||||
{(!(user?.avatar ?? '') && (!(user?.username ?? '') || user?.username.trim() === '')) ||
|
||||
imageError ? (
|
||||
renderDefaultAvatar()
|
||||
{!showImage ? (
|
||||
<DefaultAvatar />
|
||||
) : (
|
||||
<img
|
||||
className="rounded-full"
|
||||
src={(user?.avatar ?? '') || avatarSrc}
|
||||
src={imageSrc}
|
||||
alt="avatar"
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
/>
|
||||
)}
|
||||
@@ -69,8 +92,12 @@ const Icon: React.FC<IconProps> = memo((props) => {
|
||||
const avatarSrc = useAvatar(user);
|
||||
const localize = useLocalize();
|
||||
|
||||
const username = useMemo(
|
||||
() => user?.name ?? user?.username ?? localize('com_nav_user'),
|
||||
[user?.name, user?.username, localize],
|
||||
);
|
||||
|
||||
if (isCreatedByUser) {
|
||||
const username = user?.name ?? user?.username ?? localize('com_nav_user');
|
||||
return (
|
||||
<UserAvatar
|
||||
size={size}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { memo } from 'react';
|
||||
import { showThinkingAtom } from '~/store/showThinking';
|
||||
import FontSizeSelector from './FontSizeSelector';
|
||||
import { ForkSettings } from './ForkSettings';
|
||||
import ChatDirection from './ChatDirection';
|
||||
@@ -28,7 +29,7 @@ const toggleSwitchConfigs = [
|
||||
key: 'centerFormOnLanding',
|
||||
},
|
||||
{
|
||||
stateAtom: store.showThinking,
|
||||
stateAtom: showThinkingAtom,
|
||||
localizationKey: 'com_nav_show_thinking',
|
||||
switchId: 'showThinking',
|
||||
hoverCardText: undefined,
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { useAtom } from 'jotai';
|
||||
import { Switch, InfoHoverCard, ESide } from '@librechat/client';
|
||||
import { showThinkingAtom } from '~/store/showThinking';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
||||
export default function SaveDraft({
|
||||
onCheckedChange,
|
||||
}: {
|
||||
onCheckedChange?: (value: boolean) => void;
|
||||
}) {
|
||||
const [showThinking, setSaveDrafts] = useRecoilState<boolean>(store.showThinking);
|
||||
const [showThinking, setShowThinking] = useAtom(showThinkingAtom);
|
||||
const localize = useLocalize();
|
||||
|
||||
const handleCheckedChange = (value: boolean) => {
|
||||
setSaveDrafts(value);
|
||||
setShowThinking(value);
|
||||
if (onCheckedChange) {
|
||||
onCheckedChange(value);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { WritableAtom, useAtom } from 'jotai';
|
||||
import { RecoilState, useRecoilState } from 'recoil';
|
||||
import { Switch, InfoHoverCard, ESide } from '@librechat/client';
|
||||
import { useLocalize } from '~/hooks';
|
||||
@@ -6,7 +7,7 @@ type LocalizeFn = ReturnType<typeof useLocalize>;
|
||||
type LocalizeKey = Parameters<LocalizeFn>[0];
|
||||
|
||||
interface ToggleSwitchProps {
|
||||
stateAtom: RecoilState<boolean>;
|
||||
stateAtom: RecoilState<boolean> | WritableAtom<boolean, [boolean], void>;
|
||||
localizationKey: LocalizeKey;
|
||||
hoverCardText?: LocalizeKey;
|
||||
switchId: string;
|
||||
@@ -16,13 +17,18 @@ interface ToggleSwitchProps {
|
||||
strongLabel?: boolean;
|
||||
}
|
||||
|
||||
const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
|
||||
function isRecoilState<T>(atom: unknown): atom is RecoilState<T> {
|
||||
return atom != null && typeof atom === 'object' && 'key' in atom;
|
||||
}
|
||||
|
||||
const RecoilToggle: React.FC<
|
||||
Omit<ToggleSwitchProps, 'stateAtom'> & { stateAtom: RecoilState<boolean> }
|
||||
> = ({
|
||||
stateAtom,
|
||||
localizationKey,
|
||||
hoverCardText,
|
||||
switchId,
|
||||
onCheckedChange,
|
||||
showSwitch = true,
|
||||
disabled = false,
|
||||
strongLabel = false,
|
||||
}) => {
|
||||
@@ -36,9 +42,47 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
|
||||
|
||||
const labelId = `${switchId}-label`;
|
||||
|
||||
if (!showSwitch) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div id={labelId}>
|
||||
{strongLabel ? <strong>{localize(localizationKey)}</strong> : localize(localizationKey)}
|
||||
</div>
|
||||
{hoverCardText && <InfoHoverCard side={ESide.Bottom} text={localize(hoverCardText)} />}
|
||||
</div>
|
||||
<Switch
|
||||
id={switchId}
|
||||
checked={switchState}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
disabled={disabled}
|
||||
className="ml-4"
|
||||
data-testid={switchId}
|
||||
aria-labelledby={labelId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const JotaiToggle: React.FC<
|
||||
Omit<ToggleSwitchProps, 'stateAtom'> & { stateAtom: WritableAtom<boolean, [boolean], void> }
|
||||
> = ({
|
||||
stateAtom,
|
||||
localizationKey,
|
||||
hoverCardText,
|
||||
switchId,
|
||||
onCheckedChange,
|
||||
disabled = false,
|
||||
strongLabel = false,
|
||||
}) => {
|
||||
const [switchState, setSwitchState] = useAtom(stateAtom);
|
||||
const localize = useLocalize();
|
||||
|
||||
const handleCheckedChange = (value: boolean) => {
|
||||
setSwitchState(value);
|
||||
onCheckedChange?.(value);
|
||||
};
|
||||
|
||||
const labelId = `${switchId}-label`;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -52,13 +96,29 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
|
||||
id={switchId}
|
||||
checked={switchState}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
disabled={disabled}
|
||||
className="ml-4"
|
||||
data-testid={switchId}
|
||||
aria-labelledby={labelId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ToggleSwitch: React.FC<ToggleSwitchProps> = (props) => {
|
||||
const { stateAtom, showSwitch = true } = props;
|
||||
|
||||
if (!showSwitch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isRecoil = isRecoilState(stateAtom);
|
||||
|
||||
if (isRecoil) {
|
||||
return <RecoilToggle {...props} stateAtom={stateAtom as RecoilState<boolean>} />;
|
||||
}
|
||||
|
||||
return <JotaiToggle {...props} stateAtom={stateAtom as WritableAtom<boolean, [boolean], void>} />;
|
||||
};
|
||||
|
||||
export default ToggleSwitch;
|
||||
|
||||
@@ -789,6 +789,8 @@
|
||||
"com_ui_copy_stack_trace": "Copy stack trace",
|
||||
"com_ui_copy_to_clipboard": "Copy to clipboard",
|
||||
"com_ui_copy_url_to_clipboard": "Copy URL to clipboard",
|
||||
"com_ui_copy_stack_trace": "Copy stack trace",
|
||||
"com_ui_copy_thoughts_to_clipboard": "Copy thoughts to clipboard",
|
||||
"com_ui_create": "Create",
|
||||
"com_ui_create_link": "Create link",
|
||||
"com_ui_create_memory": "Create Memory",
|
||||
@@ -1222,6 +1224,7 @@
|
||||
"com_ui_terms_of_service": "Terms of service",
|
||||
"com_ui_thinking": "Thinking...",
|
||||
"com_ui_thoughts": "Thoughts",
|
||||
"com_ui_response": "Response",
|
||||
"com_ui_token": "token",
|
||||
"com_ui_token_exchange_method": "Token Exchange Method",
|
||||
"com_ui_token_url": "Token URL",
|
||||
|
||||
@@ -1,54 +1,21 @@
|
||||
import { atom } from 'jotai';
|
||||
import { atomWithStorage } from 'jotai/utils';
|
||||
import { applyFontSize } from '@librechat/client';
|
||||
import { createStorageAtomWithEffect, initializeFromStorage } from './jotai-utils';
|
||||
|
||||
const DEFAULT_FONT_SIZE = 'text-base';
|
||||
|
||||
/**
|
||||
* Base storage atom for font size
|
||||
* This atom stores the user's font size preference
|
||||
*/
|
||||
const fontSizeStorageAtom = atomWithStorage<string>('fontSize', DEFAULT_FONT_SIZE, undefined, {
|
||||
getOnInit: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Derived atom that applies font size changes to the DOM
|
||||
* Read: returns the current font size
|
||||
* Write: updates storage and applies the font size to the DOM
|
||||
*/
|
||||
export const fontSizeAtom = atom(
|
||||
(get) => get(fontSizeStorageAtom),
|
||||
(get, set, newValue: string) => {
|
||||
set(fontSizeStorageAtom, newValue);
|
||||
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
|
||||
applyFontSize(newValue);
|
||||
}
|
||||
},
|
||||
export const fontSizeAtom = createStorageAtomWithEffect<string>(
|
||||
'fontSize',
|
||||
DEFAULT_FONT_SIZE,
|
||||
applyFontSize,
|
||||
);
|
||||
|
||||
/**
|
||||
* Initialize font size on app load
|
||||
* This function applies the saved font size from localStorage to the DOM
|
||||
*/
|
||||
export const initializeFontSize = () => {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const savedValue = localStorage.getItem('fontSize');
|
||||
|
||||
if (savedValue !== null) {
|
||||
try {
|
||||
const parsedValue = JSON.parse(savedValue);
|
||||
applyFontSize(parsedValue);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Error parsing localStorage key "fontSize", resetting to default. Error:',
|
||||
error,
|
||||
);
|
||||
localStorage.setItem('fontSize', JSON.stringify(DEFAULT_FONT_SIZE));
|
||||
applyFontSize(DEFAULT_FONT_SIZE);
|
||||
}
|
||||
} else {
|
||||
applyFontSize(DEFAULT_FONT_SIZE);
|
||||
}
|
||||
export const initializeFontSize = (): void => {
|
||||
initializeFromStorage('fontSize', DEFAULT_FONT_SIZE, applyFontSize);
|
||||
};
|
||||
|
||||
88
client/src/store/jotai-utils.ts
Normal file
88
client/src/store/jotai-utils.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { atom } from 'jotai';
|
||||
import { atomWithStorage } from 'jotai/utils';
|
||||
|
||||
/**
|
||||
* Create a simple atom with localStorage persistence
|
||||
* Uses Jotai's atomWithStorage with getOnInit for proper SSR support
|
||||
*
|
||||
* @param key - localStorage key
|
||||
* @param defaultValue - default value if no saved value exists
|
||||
* @returns Jotai atom with localStorage persistence
|
||||
*/
|
||||
export function createStorageAtom<T>(key: string, defaultValue: T) {
|
||||
return atomWithStorage<T>(key, defaultValue, undefined, {
|
||||
getOnInit: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an atom with localStorage persistence and side effects
|
||||
* Useful when you need to apply changes to the DOM or trigger other actions
|
||||
*
|
||||
* @param key - localStorage key
|
||||
* @param defaultValue - default value if no saved value exists
|
||||
* @param onWrite - callback function to run when the value changes
|
||||
* @returns Jotai atom with localStorage persistence and side effects
|
||||
*/
|
||||
export function createStorageAtomWithEffect<T>(
|
||||
key: string,
|
||||
defaultValue: T,
|
||||
onWrite: (value: T) => void,
|
||||
) {
|
||||
const baseAtom = createStorageAtom(key, defaultValue);
|
||||
|
||||
return atom(
|
||||
(get) => get(baseAtom),
|
||||
(get, set, newValue: T) => {
|
||||
set(baseAtom, newValue);
|
||||
if (typeof window !== 'undefined') {
|
||||
onWrite(newValue);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a value from localStorage and optionally apply it
|
||||
* Useful for applying saved values on app startup (e.g., theme, fontSize)
|
||||
*
|
||||
* @param key - localStorage key
|
||||
* @param defaultValue - default value if no saved value exists
|
||||
* @param onInit - optional callback to run with the loaded value
|
||||
* @returns The loaded value (or default if none exists)
|
||||
*/
|
||||
export function initializeFromStorage<T>(
|
||||
key: string,
|
||||
defaultValue: T,
|
||||
onInit?: (value: T) => void,
|
||||
): T {
|
||||
if (typeof window === 'undefined' || typeof localStorage === 'undefined') {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
try {
|
||||
const savedValue = localStorage.getItem(key);
|
||||
const value = savedValue ? (JSON.parse(savedValue) as T) : defaultValue;
|
||||
|
||||
if (onInit) {
|
||||
onInit(value);
|
||||
}
|
||||
|
||||
return value;
|
||||
} catch (error) {
|
||||
console.error(`Error initializing ${key} from localStorage, using default. Error:`, error);
|
||||
|
||||
// Reset corrupted value
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(defaultValue));
|
||||
} catch (setError) {
|
||||
console.error(`Error resetting corrupted ${key} in localStorage:`, setError);
|
||||
}
|
||||
|
||||
if (onInit) {
|
||||
onInit(defaultValue);
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
8
client/src/store/showThinking.ts
Normal file
8
client/src/store/showThinking.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createStorageAtom } from './jotai-utils';
|
||||
|
||||
const DEFAULT_SHOW_THINKING = false;
|
||||
|
||||
/**
|
||||
* This atom controls whether AI reasoning/thinking content is expanded by default.
|
||||
*/
|
||||
export const showThinkingAtom = createStorageAtom<boolean>('showThinking', DEFAULT_SHOW_THINKING);
|
||||
@@ -18,9 +18,10 @@
|
||||
"build:dev": "npm run clean && NODE_ENV=development rollup -c --bundleConfigAsCjs",
|
||||
"build:watch": "NODE_ENV=development rollup -c -w --bundleConfigAsCjs",
|
||||
"build:watch:prod": "rollup -c -w --bundleConfigAsCjs",
|
||||
"test": "jest --coverage --watch --testPathIgnorePatterns=\"\\.integration\\.\"",
|
||||
"test:ci": "jest --coverage --ci --testPathIgnorePatterns=\"\\.integration\\.\"",
|
||||
"test:cache:integration": "jest --testPathPattern=\"src/cache/.*\\.integration\\.spec\\.ts$\" --coverage=false",
|
||||
"test": "jest --coverage --watch --testPathIgnorePatterns=\"\\.*integration\\.\"",
|
||||
"test:ci": "jest --coverage --ci --testPathIgnorePatterns=\"\\.*integration\\.\"",
|
||||
"test:cache-integration:core": "jest --testPathPattern=\"src/cache/.*\\.cache_integration\\.spec\\.ts$\" --coverage=false",
|
||||
"test:cache-integration:cluster": "jest --testPathPattern=\"src/cluster/.*\\.cache_integration\\.spec\\.ts$\" --coverage=false --runInBand",
|
||||
"verify": "npm run test:ci",
|
||||
"b:clean": "bun run rimraf dist",
|
||||
"b:build": "bun run b:clean && bun run rollup -c --silent --bundleConfigAsCjs",
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import type { Keyv } from 'keyv';
|
||||
|
||||
// Mock GLOBAL_PREFIX_SEPARATOR
|
||||
jest.mock('../../redisClients', () => {
|
||||
const originalModule = jest.requireActual('../../redisClients');
|
||||
// Mock GLOBAL_PREFIX_SEPARATOR from cacheConfig
|
||||
jest.mock('../../cacheConfig', () => {
|
||||
const originalModule = jest.requireActual('../../cacheConfig');
|
||||
return {
|
||||
...originalModule,
|
||||
GLOBAL_PREFIX_SEPARATOR: '>>',
|
||||
cacheConfig: {
|
||||
...originalModule.cacheConfig,
|
||||
GLOBAL_PREFIX_SEPARATOR: '>>',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
1
packages/api/src/cache/cacheConfig.ts
vendored
1
packages/api/src/cache/cacheConfig.ts
vendored
@@ -65,6 +65,7 @@ const cacheConfig = {
|
||||
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
|
||||
REDIS_CA: getRedisCA(),
|
||||
REDIS_KEY_PREFIX: process.env[REDIS_KEY_PREFIX_VAR ?? ''] || REDIS_KEY_PREFIX || '',
|
||||
GLOBAL_PREFIX_SEPARATOR: '::',
|
||||
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 */
|
||||
|
||||
4
packages/api/src/cache/cacheFactory.ts
vendored
4
packages/api/src/cache/cacheFactory.ts
vendored
@@ -14,7 +14,7 @@ import { logger } from '@librechat/data-schemas';
|
||||
import session, { MemoryStore } from 'express-session';
|
||||
import { RedisStore as ConnectRedis } from 'connect-redis';
|
||||
import type { SendCommandFn } from 'rate-limit-redis';
|
||||
import { keyvRedisClient, ioredisClient, GLOBAL_PREFIX_SEPARATOR } from './redisClients';
|
||||
import { keyvRedisClient, ioredisClient } from './redisClients';
|
||||
import { cacheConfig } from './cacheConfig';
|
||||
import { violationFile } from './keyvFiles';
|
||||
|
||||
@@ -31,7 +31,7 @@ export const standardCache = (namespace: string, ttl?: number, fallbackStore?: o
|
||||
const keyvRedis = new KeyvRedis(keyvRedisClient);
|
||||
const cache = new Keyv(keyvRedis, { namespace, ttl });
|
||||
keyvRedis.namespace = cacheConfig.REDIS_KEY_PREFIX;
|
||||
keyvRedis.keyPrefixSeparator = GLOBAL_PREFIX_SEPARATOR;
|
||||
keyvRedis.keyPrefixSeparator = cacheConfig.GLOBAL_PREFIX_SEPARATOR;
|
||||
|
||||
cache.on('error', (err) => {
|
||||
logger.error(`Cache error in namespace ${namespace}:`, err);
|
||||
|
||||
6
packages/api/src/cache/redisClients.ts
vendored
6
packages/api/src/cache/redisClients.ts
vendored
@@ -5,8 +5,6 @@ import { createClient, createCluster } from '@keyv/redis';
|
||||
import type { RedisClientType, RedisClusterType } from '@redis/client';
|
||||
import { cacheConfig } from './cacheConfig';
|
||||
|
||||
const GLOBAL_PREFIX_SEPARATOR = '::';
|
||||
|
||||
const urls = cacheConfig.REDIS_URI?.split(',').map((uri) => new URL(uri)) || [];
|
||||
const username = urls?.[0]?.username || cacheConfig.REDIS_USERNAME;
|
||||
const password = urls?.[0]?.password || cacheConfig.REDIS_PASSWORD;
|
||||
@@ -18,7 +16,7 @@ if (cacheConfig.USE_REDIS) {
|
||||
username: username,
|
||||
password: password,
|
||||
tls: ca ? { ca } : undefined,
|
||||
keyPrefix: `${cacheConfig.REDIS_KEY_PREFIX}${GLOBAL_PREFIX_SEPARATOR}`,
|
||||
keyPrefix: `${cacheConfig.REDIS_KEY_PREFIX}${cacheConfig.GLOBAL_PREFIX_SEPARATOR}`,
|
||||
maxListeners: cacheConfig.REDIS_MAX_LISTENERS,
|
||||
retryStrategy: (times: number) => {
|
||||
if (
|
||||
@@ -192,4 +190,4 @@ if (cacheConfig.USE_REDIS) {
|
||||
});
|
||||
}
|
||||
|
||||
export { ioredisClient, keyvRedisClient, GLOBAL_PREFIX_SEPARATOR };
|
||||
export { ioredisClient, keyvRedisClient };
|
||||
|
||||
180
packages/api/src/cluster/LeaderElection.ts
Normal file
180
packages/api/src/cluster/LeaderElection.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { keyvRedisClient } from '~/cache/redisClients';
|
||||
import { cacheConfig as cache } from '~/cache/cacheConfig';
|
||||
import { clusterConfig as cluster } from './config';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
|
||||
/**
|
||||
* Distributed leader election implementation using Redis for coordination across multiple server instances.
|
||||
*
|
||||
* Leadership election:
|
||||
* - During bootup, every server attempts to become the leader by calling isLeader()
|
||||
* - Uses atomic Redis SET NX (set if not exists) to ensure only ONE server can claim leadership
|
||||
* - The first server to successfully set the key becomes the leader; others become followers
|
||||
* - Works with any number of servers (1 to infinite) - single server always becomes leader
|
||||
*
|
||||
* Leadership maintenance:
|
||||
* - Leader holds a key in Redis with a 25-second lease duration
|
||||
* - Leader renews this lease every 10 seconds to maintain leadership
|
||||
* - If leader crashes, the lease eventually expires, and the key disappears
|
||||
* - On shutdown, leader deletes its key to allow immediate re-election
|
||||
* - Followers check for leadership and attempt to claim it when the key is empty
|
||||
*/
|
||||
export class LeaderElection {
|
||||
// We can't use Keyv namespace here because we need direct Redis access for atomic operations
|
||||
static readonly LEADER_KEY = `${cache.REDIS_KEY_PREFIX}${cache.GLOBAL_PREFIX_SEPARATOR}LeadingServerUUID`;
|
||||
private static _instance = new LeaderElection();
|
||||
|
||||
readonly UUID: string = crypto.randomUUID();
|
||||
private refreshTimer: NodeJS.Timeout | undefined = undefined;
|
||||
|
||||
// DO NOT create new instances of this class directly.
|
||||
// Use the exported isLeader() function which uses a singleton instance.
|
||||
constructor() {
|
||||
if (LeaderElection._instance) return LeaderElection._instance;
|
||||
|
||||
process.on('SIGTERM', () => this.resign());
|
||||
process.on('SIGINT', () => this.resign());
|
||||
LeaderElection._instance = this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this instance is the current leader.
|
||||
* If no leader exists, waits upto 2 seconds (randomized to avoid thundering herd) then attempts self-election.
|
||||
* Always returns true in non-Redis mode (single-instance deployment).
|
||||
*/
|
||||
public async isLeader(): Promise<boolean> {
|
||||
if (!cache.USE_REDIS) return true;
|
||||
|
||||
try {
|
||||
const currentLeader = await LeaderElection.getLeaderUUID();
|
||||
// If we own the leadership lock, return true.
|
||||
// However, in case the leadership refresh retries have been exhausted, something has gone wrong.
|
||||
// This server is not considered the leader anymore, similar to a crash, to avoid split-brain scenario.
|
||||
if (currentLeader === this.UUID) return this.refreshTimer != null;
|
||||
if (currentLeader != null) return false; // someone holds leadership lock
|
||||
|
||||
const delay = Math.random() * 2000;
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
return await this.electSelf();
|
||||
} catch (error) {
|
||||
logger.error('Failed to check leadership status:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Steps down from leadership by stopping the refresh timer and releasing the leader key.
|
||||
* Atomically deletes the leader key (only if we still own it) so another server can become leader immediately.
|
||||
*/
|
||||
public async resign(): Promise<void> {
|
||||
if (!cache.USE_REDIS) return;
|
||||
|
||||
try {
|
||||
this.clearRefreshTimer();
|
||||
|
||||
// Lua script for atomic check-and-delete (only delete if we still own it)
|
||||
const script = `
|
||||
if redis.call("get", KEYS[1]) == ARGV[1] then
|
||||
redis.call("del", KEYS[1])
|
||||
end
|
||||
`;
|
||||
|
||||
await keyvRedisClient!.eval(script, {
|
||||
keys: [LeaderElection.LEADER_KEY],
|
||||
arguments: [this.UUID],
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to release leadership lock:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the UUID of the current leader from Redis.
|
||||
* Returns null if no leader exists or in non-Redis mode.
|
||||
* Useful for testing and observability.
|
||||
*/
|
||||
public static async getLeaderUUID(): Promise<string | null> {
|
||||
if (!cache.USE_REDIS) return null;
|
||||
return await keyvRedisClient!.get(LeaderElection.LEADER_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the refresh timer to stop leadership maintenance.
|
||||
* Called when resigning or failing to refresh leadership.
|
||||
* Calling this directly to simulate a crash in testing.
|
||||
*/
|
||||
public clearRefreshTimer(): void {
|
||||
clearInterval(this.refreshTimer);
|
||||
this.refreshTimer = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to claim leadership using atomic Redis SET NX (set if not exists).
|
||||
* If successful, starts a refresh timer to maintain leadership by extending the lease duration.
|
||||
* The NX flag ensures only one server can become leader even if multiple attempt simultaneously.
|
||||
*/
|
||||
private async electSelf(): Promise<boolean> {
|
||||
try {
|
||||
const result = await keyvRedisClient!.set(LeaderElection.LEADER_KEY, this.UUID, {
|
||||
NX: true,
|
||||
EX: cluster.LEADER_LEASE_DURATION,
|
||||
});
|
||||
|
||||
if (result !== 'OK') return false;
|
||||
|
||||
this.clearRefreshTimer();
|
||||
this.refreshTimer = setInterval(async () => {
|
||||
await this.renewLeadership();
|
||||
}, cluster.LEADER_RENEW_INTERVAL * 1000);
|
||||
this.refreshTimer.unref();
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Leader election failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renews leadership by extending the lease duration on the leader key.
|
||||
* Uses Lua script to atomically verify we still own the key before renewing (prevents race conditions).
|
||||
* If we've lost leadership (key was taken by another server), stops the refresh timer.
|
||||
* This is called every 10 seconds by the refresh timer.
|
||||
*/
|
||||
private async renewLeadership(attempts: number = 1): Promise<void> {
|
||||
try {
|
||||
// Lua script for atomic check-and-renew
|
||||
const script = `
|
||||
if redis.call("get", KEYS[1]) == ARGV[1] then
|
||||
return redis.call("expire", KEYS[1], ARGV[2])
|
||||
else
|
||||
return 0
|
||||
end
|
||||
`;
|
||||
|
||||
const result = await keyvRedisClient!.eval(script, {
|
||||
keys: [LeaderElection.LEADER_KEY],
|
||||
arguments: [this.UUID, cluster.LEADER_LEASE_DURATION.toString()],
|
||||
});
|
||||
|
||||
if (result === 0) {
|
||||
logger.warn('Lost leadership, clearing refresh timer');
|
||||
this.clearRefreshTimer();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to renew leadership (attempts No.${attempts}):`, error);
|
||||
if (attempts <= cluster.LEADER_RENEW_ATTEMPTS) {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, cluster.LEADER_RENEW_RETRY_DELAY * 1000),
|
||||
);
|
||||
await this.renewLeadership(attempts + 1);
|
||||
} else {
|
||||
logger.error('Exceeded maximum attempts to renew leadership.');
|
||||
this.clearRefreshTimer();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const defaultElection = new LeaderElection();
|
||||
export const isLeader = (): Promise<boolean> => defaultElection.isLeader();
|
||||
@@ -0,0 +1,220 @@
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
describe('LeaderElection with Redis', () => {
|
||||
let LeaderElection: typeof import('../LeaderElection').LeaderElection;
|
||||
let instances: InstanceType<typeof import('../LeaderElection').LeaderElection>[] = [];
|
||||
let keyvRedisClient: Awaited<typeof import('~/cache/redisClients')>['keyvRedisClient'];
|
||||
let ioredisClient: Awaited<typeof import('~/cache/redisClients')>['ioredisClient'];
|
||||
|
||||
beforeAll(async () => {
|
||||
// Set up environment variables for Redis
|
||||
process.env.USE_REDIS = 'true';
|
||||
process.env.REDIS_URI = process.env.REDIS_URI ?? 'redis://127.0.0.1:6379';
|
||||
process.env.REDIS_KEY_PREFIX = 'LeaderElection-IntegrationTest';
|
||||
|
||||
// Import modules after setting env vars
|
||||
const leaderElectionModule = await import('../LeaderElection');
|
||||
const redisClients = await import('~/cache/redisClients');
|
||||
|
||||
LeaderElection = leaderElectionModule.LeaderElection;
|
||||
keyvRedisClient = redisClients.keyvRedisClient;
|
||||
ioredisClient = redisClients.ioredisClient;
|
||||
|
||||
// Ensure Redis is connected
|
||||
if (!keyvRedisClient) {
|
||||
throw new Error('Redis client is not initialized');
|
||||
}
|
||||
|
||||
// Wait for Redis to be ready
|
||||
if (!keyvRedisClient.isOpen) {
|
||||
await keyvRedisClient.connect();
|
||||
}
|
||||
|
||||
// Increase max listeners to handle many instances in tests
|
||||
process.setMaxListeners(200);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(instances.map((instance) => instance.resign()));
|
||||
instances = [];
|
||||
|
||||
// Clean up: clear the leader key directly from Redis
|
||||
if (keyvRedisClient) {
|
||||
await keyvRedisClient.del(LeaderElection.LEADER_KEY);
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Close both Redis clients to prevent hanging
|
||||
if (keyvRedisClient?.isOpen) await keyvRedisClient.disconnect();
|
||||
if (ioredisClient?.status === 'ready') await ioredisClient.quit();
|
||||
});
|
||||
|
||||
describe('Test Case 1: Simulate shutdown of the leader', () => {
|
||||
it('should elect a new leader after the current leader resigns', async () => {
|
||||
// Create 100 instances
|
||||
instances = Array.from({ length: 100 }, () => new LeaderElection());
|
||||
|
||||
// Call isLeader on all instances and get leadership status
|
||||
const resultsWithInstances = await Promise.all(
|
||||
instances.map(async (instance) => ({
|
||||
instance,
|
||||
isLeader: await instance.isLeader(),
|
||||
})),
|
||||
);
|
||||
|
||||
// Find leader and followers
|
||||
const leaders = resultsWithInstances.filter((r) => r.isLeader);
|
||||
const followers = resultsWithInstances.filter((r) => !r.isLeader);
|
||||
const leader = leaders[0].instance;
|
||||
const nextLeader = followers[0].instance;
|
||||
|
||||
// Verify only one is leader
|
||||
expect(leaders.length).toBe(1);
|
||||
|
||||
// Verify getLeaderUUID matches the leader's UUID
|
||||
expect(await LeaderElection.getLeaderUUID()).toBe(leader.UUID);
|
||||
|
||||
// Leader resigns
|
||||
await leader.resign();
|
||||
|
||||
// Verify getLeaderUUID returns null after resignation
|
||||
expect(await LeaderElection.getLeaderUUID()).toBeNull();
|
||||
|
||||
// Next instance to call isLeader should become the new leader
|
||||
expect(await nextLeader.isLeader()).toBe(true);
|
||||
}, 30000); // 30 second timeout for 100 instances
|
||||
});
|
||||
|
||||
describe('Test Case 2: Simulate crash of the leader', () => {
|
||||
it('should allow re-election after leader crashes (lease expires)', async () => {
|
||||
// Mock config with short lease duration
|
||||
const clusterConfigModule = await import('../config');
|
||||
const originalConfig = { ...clusterConfigModule.clusterConfig };
|
||||
|
||||
// Override config values for this test
|
||||
Object.assign(clusterConfigModule.clusterConfig, {
|
||||
LEADER_LEASE_DURATION: 2,
|
||||
LEADER_RENEW_INTERVAL: 4,
|
||||
});
|
||||
|
||||
try {
|
||||
// Create 1 instance with mocked config
|
||||
const instance = new LeaderElection();
|
||||
instances.push(instance);
|
||||
|
||||
// Become leader
|
||||
expect(await instance.isLeader()).toBe(true);
|
||||
|
||||
// Verify leader UUID is set
|
||||
expect(await LeaderElection.getLeaderUUID()).toBe(instance.UUID);
|
||||
|
||||
// Simulate crash by clearing refresh timer
|
||||
instance.clearRefreshTimer();
|
||||
|
||||
// The instance no longer considers itself leader even though it still holds the key
|
||||
expect(await LeaderElection.getLeaderUUID()).toBe(instance.UUID);
|
||||
expect(await instance.isLeader()).toBe(false);
|
||||
|
||||
// Wait for lease to expire (3 seconds > 2 second lease)
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
|
||||
// Verify leader UUID is null after lease expiration
|
||||
expect(await LeaderElection.getLeaderUUID()).toBeNull();
|
||||
} finally {
|
||||
// Restore original config values
|
||||
Object.assign(clusterConfigModule.clusterConfig, originalConfig);
|
||||
}
|
||||
}, 15000); // 15 second timeout
|
||||
});
|
||||
|
||||
describe('Test Case 3: Stress testing', () => {
|
||||
it('should ensure only one instance becomes leader even when multiple instances call electSelf() at once', async () => {
|
||||
// Create 10 instances
|
||||
instances = Array.from({ length: 10 }, () => new LeaderElection());
|
||||
|
||||
// Call electSelf on all instances in parallel
|
||||
const results = await Promise.all(instances.map((instance) => instance['electSelf']()));
|
||||
|
||||
// Verify only one returned true
|
||||
const successCount = results.filter((success) => success).length;
|
||||
expect(successCount).toBe(1);
|
||||
|
||||
// Find the winning instance
|
||||
const winnerInstance = instances.find((_, index) => results[index]);
|
||||
|
||||
// Verify getLeaderUUID matches the winner's UUID
|
||||
expect(await LeaderElection.getLeaderUUID()).toBe(winnerInstance?.UUID);
|
||||
}, 15000); // 15 second timeout
|
||||
});
|
||||
});
|
||||
|
||||
describe('LeaderElection without Redis', () => {
|
||||
let LeaderElection: typeof import('../LeaderElection').LeaderElection;
|
||||
let instances: InstanceType<typeof import('../LeaderElection').LeaderElection>[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
// Set up environment variables for non-Redis mode
|
||||
process.env.USE_REDIS = 'false';
|
||||
|
||||
// Reset all modules to force re-evaluation with new env vars
|
||||
jest.resetModules();
|
||||
|
||||
// Import modules after setting env vars and resetting modules
|
||||
const leaderElectionModule = await import('../LeaderElection');
|
||||
LeaderElection = leaderElectionModule.LeaderElection;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(instances.map((instance) => instance.resign()));
|
||||
instances = [];
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Restore environment variables
|
||||
process.env.USE_REDIS = 'true';
|
||||
|
||||
// Reset all modules to ensure next test runs get fresh imports
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
it('should allow all instances to be leaders when USE_REDIS is false', async () => {
|
||||
// Create 10 instances
|
||||
instances = Array.from({ length: 10 }, () => new LeaderElection());
|
||||
|
||||
// Call isLeader on all instances
|
||||
const results = await Promise.all(instances.map((instance) => instance.isLeader()));
|
||||
|
||||
// Verify all instances report themselves as leaders
|
||||
expect(results.every((isLeader) => isLeader)).toBe(true);
|
||||
expect(results.filter((isLeader) => isLeader).length).toBe(10);
|
||||
});
|
||||
|
||||
it('should return null for getLeaderUUID when USE_REDIS is false', async () => {
|
||||
// Create a few instances
|
||||
instances = Array.from({ length: 3 }, () => new LeaderElection());
|
||||
|
||||
// Call isLeader on all instances to make them "leaders"
|
||||
await Promise.all(instances.map((instance) => instance.isLeader()));
|
||||
|
||||
// Verify getLeaderUUID returns null in non-Redis mode
|
||||
expect(await LeaderElection.getLeaderUUID()).toBeNull();
|
||||
});
|
||||
|
||||
it('should allow resign() to be called without throwing errors', async () => {
|
||||
// Create multiple instances
|
||||
instances = Array.from({ length: 5 }, () => new LeaderElection());
|
||||
|
||||
// Make them all leaders
|
||||
await Promise.all(instances.map((instance) => instance.isLeader()));
|
||||
|
||||
// Call resign on all instances - should not throw
|
||||
await expect(
|
||||
Promise.all(instances.map((instance) => instance.resign())),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
// Verify they're still leaders after resigning (since there's no shared state)
|
||||
const results = await Promise.all(instances.map((instance) => instance.isLeader()));
|
||||
expect(results.every((isLeader) => isLeader)).toBe(true);
|
||||
});
|
||||
});
|
||||
14
packages/api/src/cluster/config.ts
Normal file
14
packages/api/src/cluster/config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { math } from '~/utils';
|
||||
|
||||
const clusterConfig = {
|
||||
/** Duration in seconds that the leader lease is valid before it expires */
|
||||
LEADER_LEASE_DURATION: math(process.env.LEADER_LEASE_DURATION, 25),
|
||||
/** Interval in seconds at which the leader renews its lease */
|
||||
LEADER_RENEW_INTERVAL: math(process.env.LEADER_RENEW_INTERVAL, 10),
|
||||
/** Maximum number of retry attempts when renewing the lease fails */
|
||||
LEADER_RENEW_ATTEMPTS: math(process.env.LEADER_RENEW_ATTEMPTS, 3),
|
||||
/** Delay in seconds between retry attempts when renewing the lease */
|
||||
LEADER_RENEW_RETRY_DELAY: math(process.env.LEADER_RENEW_RETRY_DELAY, 0.5),
|
||||
};
|
||||
|
||||
export { clusterConfig };
|
||||
1
packages/api/src/cluster/index.ts
Normal file
1
packages/api/src/cluster/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { isLeader } from './LeaderElection';
|
||||
Reference in New Issue
Block a user