👻 refactor: LocalStorage Cleanup and MCP State Optimization (#9528)

* 👻 refactor: MCP Select State with Jotai Atoms

* refactor: Implement timestamp management for ChatArea localStorage entries

* refactor: Integrate MCP Server Manager into BadgeRow context and components to avoid double-calling within BadgeRow

* refactor: add try/catch

* chore: remove comment
This commit is contained in:
Danny Avila 2025-09-09 17:32:10 -04:00 committed by GitHub
parent 519645c0b0
commit 751c2e1d17
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 435 additions and 115 deletions

View file

@ -3,12 +3,14 @@ import { useSetRecoilState } from 'recoil';
import { Tools, Constants, LocalStorageKeys, AgentCapabilities } from 'librechat-data-provider'; import { Tools, Constants, LocalStorageKeys, AgentCapabilities } from 'librechat-data-provider';
import type { TAgentsEndpoint } from 'librechat-data-provider'; import type { TAgentsEndpoint } from 'librechat-data-provider';
import { import {
useMCPServerManager,
useSearchApiKeyForm, useSearchApiKeyForm,
useGetAgentsConfig, useGetAgentsConfig,
useCodeApiKeyForm, useCodeApiKeyForm,
useGetMCPTools, useGetMCPTools,
useToolToggle, useToolToggle,
} from '~/hooks'; } from '~/hooks';
import { getTimestampedValue, setTimestamp } from '~/utils/timestamps';
import { ephemeralAgentByConvoId } from '~/store'; import { ephemeralAgentByConvoId } from '~/store';
interface BadgeRowContextType { interface BadgeRowContextType {
@ -21,6 +23,7 @@ interface BadgeRowContextType {
codeInterpreter: ReturnType<typeof useToolToggle>; codeInterpreter: ReturnType<typeof useToolToggle>;
codeApiKeyForm: ReturnType<typeof useCodeApiKeyForm>; codeApiKeyForm: ReturnType<typeof useCodeApiKeyForm>;
searchApiKeyForm: ReturnType<typeof useSearchApiKeyForm>; searchApiKeyForm: ReturnType<typeof useSearchApiKeyForm>;
mcpServerManager: ReturnType<typeof useMCPServerManager>;
} }
const BadgeRowContext = createContext<BadgeRowContextType | undefined>(undefined); const BadgeRowContext = createContext<BadgeRowContextType | undefined>(undefined);
@ -62,16 +65,15 @@ export default function BadgeRowProvider({
hasInitializedRef.current = true; hasInitializedRef.current = true;
lastKeyRef.current = key; lastKeyRef.current = key;
// Load all localStorage values
const codeToggleKey = `${LocalStorageKeys.LAST_CODE_TOGGLE_}${key}`; const codeToggleKey = `${LocalStorageKeys.LAST_CODE_TOGGLE_}${key}`;
const webSearchToggleKey = `${LocalStorageKeys.LAST_WEB_SEARCH_TOGGLE_}${key}`; const webSearchToggleKey = `${LocalStorageKeys.LAST_WEB_SEARCH_TOGGLE_}${key}`;
const fileSearchToggleKey = `${LocalStorageKeys.LAST_FILE_SEARCH_TOGGLE_}${key}`; const fileSearchToggleKey = `${LocalStorageKeys.LAST_FILE_SEARCH_TOGGLE_}${key}`;
const artifactsToggleKey = `${LocalStorageKeys.LAST_ARTIFACTS_TOGGLE_}${key}`; const artifactsToggleKey = `${LocalStorageKeys.LAST_ARTIFACTS_TOGGLE_}${key}`;
const codeToggleValue = localStorage.getItem(codeToggleKey); const codeToggleValue = getTimestampedValue(codeToggleKey);
const webSearchToggleValue = localStorage.getItem(webSearchToggleKey); const webSearchToggleValue = getTimestampedValue(webSearchToggleKey);
const fileSearchToggleValue = localStorage.getItem(fileSearchToggleKey); const fileSearchToggleValue = getTimestampedValue(fileSearchToggleKey);
const artifactsToggleValue = localStorage.getItem(artifactsToggleKey); const artifactsToggleValue = getTimestampedValue(artifactsToggleKey);
const initialValues: Record<string, any> = {}; const initialValues: Record<string, any> = {};
@ -107,15 +109,37 @@ export default function BadgeRowProvider({
} }
} }
// Always set values for all tools (use defaults if not in localStorage) /**
// If ephemeralAgent is null, create a new object with just our tool values * Always set values for all tools (use defaults if not in `localStorage`)
setEphemeralAgent((prev) => ({ * If `ephemeralAgent` is `null`, create a new object with just our tool values
...(prev || {}), */
const finalValues = {
[Tools.execute_code]: initialValues[Tools.execute_code] ?? false, [Tools.execute_code]: initialValues[Tools.execute_code] ?? false,
[Tools.web_search]: initialValues[Tools.web_search] ?? false, [Tools.web_search]: initialValues[Tools.web_search] ?? false,
[Tools.file_search]: initialValues[Tools.file_search] ?? false, [Tools.file_search]: initialValues[Tools.file_search] ?? false,
[AgentCapabilities.artifacts]: initialValues[AgentCapabilities.artifacts] ?? false, [AgentCapabilities.artifacts]: initialValues[AgentCapabilities.artifacts] ?? false,
};
setEphemeralAgent((prev) => ({
...(prev || {}),
...finalValues,
})); }));
Object.entries(finalValues).forEach(([toolKey, value]) => {
if (value !== false) {
let storageKey = artifactsToggleKey;
if (toolKey === Tools.execute_code) {
storageKey = codeToggleKey;
} else if (toolKey === Tools.web_search) {
storageKey = webSearchToggleKey;
} else if (toolKey === Tools.file_search) {
storageKey = fileSearchToggleKey;
}
// Store the value and set timestamp for existing values
localStorage.setItem(storageKey, JSON.stringify(value));
setTimestamp(storageKey);
}
});
} }
}, [key, isSubmitting, setEphemeralAgent]); }, [key, isSubmitting, setEphemeralAgent]);
@ -165,6 +189,8 @@ export default function BadgeRowProvider({
isAuthenticated: true, isAuthenticated: true,
}); });
const mcpServerManager = useMCPServerManager({ conversationId });
const mcpServerNames = useMemo(() => { const mcpServerNames = useMemo(() => {
return (mcpToolDetails ?? []).map((tool) => tool.name); return (mcpToolDetails ?? []).map((tool) => tool.name);
}, [mcpToolDetails]); }, [mcpToolDetails]);
@ -179,6 +205,7 @@ export default function BadgeRowProvider({
codeApiKeyForm, codeApiKeyForm,
codeInterpreter, codeInterpreter,
searchApiKeyForm, searchApiKeyForm,
mcpServerManager,
}; };
return <BadgeRowContext.Provider value={value}>{children}</BadgeRowContext.Provider>; return <BadgeRowContext.Provider value={value}>{children}</BadgeRowContext.Provider>;

View file

@ -368,7 +368,7 @@ function BadgeRow({
<CodeInterpreter /> <CodeInterpreter />
<FileSearch /> <FileSearch />
<Artifacts /> <Artifacts />
<MCPSelect conversationId={conversationId} /> <MCPSelect />
</> </>
)} )}
{ghostBadge && ( {ghostBadge && (

View file

@ -3,22 +3,20 @@ import { MultiSelect, MCPIcon } from '@librechat/client';
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon'; import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog'; import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
import { useBadgeRowContext } from '~/Providers'; import { useBadgeRowContext } from '~/Providers';
import { useMCPServerManager } from '~/hooks';
type MCPSelectProps = { conversationId?: string | null }; function MCPSelectContent() {
const { conversationId, mcpServerManager } = useBadgeRowContext();
function MCPSelectContent({ conversationId }: MCPSelectProps) {
const { const {
configuredServers,
mcpValues,
isPinned,
placeholderText,
batchToggleServers,
getServerStatusIconProps,
getConfigDialogProps,
isInitializing,
localize, localize,
} = useMCPServerManager({ conversationId }); isPinned,
mcpValues,
isInitializing,
placeholderText,
configuredServers,
batchToggleServers,
getConfigDialogProps,
getServerStatusIconProps,
} = mcpServerManager;
const renderSelectedValues = useCallback( const renderSelectedValues = useCallback(
(values: string[], placeholder?: string) => { (values: string[], placeholder?: string) => {
@ -103,10 +101,10 @@ function MCPSelectContent({ conversationId }: MCPSelectProps) {
); );
} }
function MCPSelect(props: MCPSelectProps) { function MCPSelect() {
const { mcpServerNames } = useBadgeRowContext(); const { mcpServerNames } = useBadgeRowContext();
if ((mcpServerNames?.length ?? 0) === 0) return null; if ((mcpServerNames?.length ?? 0) === 0) return null;
return <MCPSelectContent {...props} />; return <MCPSelectContent />;
} }
export default memo(MCPSelect); export default memo(MCPSelect);

View file

@ -4,27 +4,27 @@ import { ChevronRight } from 'lucide-react';
import { PinIcon, MCPIcon } from '@librechat/client'; import { PinIcon, MCPIcon } from '@librechat/client';
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon'; import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog'; import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
import { useMCPServerManager } from '~/hooks'; import { useBadgeRowContext } from '~/Providers';
import { cn } from '~/utils'; import { cn } from '~/utils';
interface MCPSubMenuProps { interface MCPSubMenuProps {
placeholder?: string; placeholder?: string;
conversationId?: string | null;
} }
const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>( const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
({ placeholder, conversationId, ...props }, ref) => { ({ placeholder, ...props }, ref) => {
const { mcpServerManager } = useBadgeRowContext();
const { const {
configuredServers,
mcpValues,
isPinned, isPinned,
mcpValues,
setIsPinned, setIsPinned,
isInitializing,
placeholderText, placeholderText,
configuredServers,
getConfigDialogProps,
toggleServerSelection, toggleServerSelection,
getServerStatusIconProps, getServerStatusIconProps,
getConfigDialogProps, } = mcpServerManager;
isInitializing,
} = useMCPServerManager({ conversationId });
const menuStore = Ariakit.useMenuStore({ const menuStore = Ariakit.useMenuStore({
focusLoop: true, focusLoop: true,

View file

@ -31,7 +31,6 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
fileSearch, fileSearch,
agentsConfig, agentsConfig,
mcpServerNames, mcpServerNames,
conversationId,
codeApiKeyForm, codeApiKeyForm,
codeInterpreter, codeInterpreter,
searchApiKeyForm, searchApiKeyForm,
@ -290,9 +289,7 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
if (mcpServerNames && mcpServerNames.length > 0) { if (mcpServerNames && mcpServerNames.length > 0) {
dropdownItems.push({ dropdownItems.push({
hideOnClick: false, hideOnClick: false,
render: (props) => ( render: (props) => <MCPSubMenu {...props} placeholder={mcpPlaceholder} />,
<MCPSubMenu {...props} placeholder={mcpPlaceholder} conversationId={conversationId} />
),
}); });
} }

View file

@ -5,6 +5,7 @@ import { LocalStorageKeys } from 'librechat-data-provider';
import { useAvailablePluginsQuery } from 'librechat-data-provider/react-query'; import { useAvailablePluginsQuery } from 'librechat-data-provider/react-query';
import type { TStartupConfig, TPlugin, TUser } from 'librechat-data-provider'; import type { TStartupConfig, TPlugin, TUser } from 'librechat-data-provider';
import { mapPlugins, selectPlugins, processPlugins } from '~/utils'; import { mapPlugins, selectPlugins, processPlugins } from '~/utils';
import { cleanupTimestampedStorage } from '~/utils/timestamps';
import useSpeechSettingsInit from './useSpeechSettingsInit'; import useSpeechSettingsInit from './useSpeechSettingsInit';
import store from '~/store'; import store from '~/store';
@ -34,6 +35,11 @@ export default function useAppStartup({
useSpeechSettingsInit(!!user); useSpeechSettingsInit(!!user);
/** Clean up old localStorage entries on startup */
useEffect(() => {
cleanupTimestampedStorage();
}, []);
/** Set the app title */ /** Set the app title */
useEffect(() => { useEffect(() => {
const appTitle = startupConfig?.appTitle ?? ''; const appTitle = startupConfig?.appTitle ?? '';

View file

@ -1,66 +1,50 @@
import { useRef, useCallback, useMemo } from 'react'; import { useCallback, useEffect } from 'react';
import { useAtom } from 'jotai';
import { useRecoilState } from 'recoil'; import { useRecoilState } from 'recoil';
import { Constants, LocalStorageKeys } from 'librechat-data-provider'; import { Constants, LocalStorageKeys } from 'librechat-data-provider';
import useLocalStorage from '~/hooks/useLocalStorageAlt'; import { ephemeralAgentByConvoId, mcpValuesAtomFamily, mcpPinnedAtom } from '~/store';
import { ephemeralAgentByConvoId } from '~/store'; import { setTimestamp } from '~/utils/timestamps';
const storageCondition = (value: unknown, rawCurrentValue?: string | null) => {
if (rawCurrentValue) {
try {
const currentValue = rawCurrentValue?.trim() ?? '';
if (currentValue.length > 2) {
return true;
}
} catch (e) {
console.error(e);
}
}
return Array.isArray(value) && value.length > 0;
};
export function useMCPSelect({ conversationId }: { conversationId?: string | null }) { export function useMCPSelect({ conversationId }: { conversationId?: string | null }) {
const key = conversationId ?? Constants.NEW_CONVO; const key = conversationId ?? Constants.NEW_CONVO;
const [isPinned, setIsPinned] = useAtom(mcpPinnedAtom);
const [mcpValues, setMCPValuesRaw] = useAtom(mcpValuesAtomFamily(key));
const [ephemeralAgent, setEphemeralAgent] = useRecoilState(ephemeralAgentByConvoId(key)); const [ephemeralAgent, setEphemeralAgent] = useRecoilState(ephemeralAgentByConvoId(key));
const storageKey = `${LocalStorageKeys.LAST_MCP_}${key}`; // Sync Jotai state with ephemeral agent state
const mcpState = useMemo(() => { useEffect(() => {
return ephemeralAgent?.mcp ?? []; if (ephemeralAgent?.mcp && ephemeralAgent.mcp.length > 0) {
}, [ephemeralAgent?.mcp]); setMCPValuesRaw(ephemeralAgent.mcp);
}
}, [ephemeralAgent?.mcp, setMCPValuesRaw]);
const setSelectedValues = useCallback( // Update ephemeral agent when Jotai state changes
(values: string[] | null | undefined) => { useEffect(() => {
if (!values) { if (mcpValues.length > 0 && JSON.stringify(mcpValues) !== JSON.stringify(ephemeralAgent?.mcp)) {
return;
}
if (!Array.isArray(values)) {
return;
}
setEphemeralAgent((prev) => ({ setEphemeralAgent((prev) => ({
...prev, ...prev,
mcp: values, mcp: mcpValues,
})); }));
}
}, [mcpValues, ephemeralAgent?.mcp, setEphemeralAgent]);
useEffect(() => {
const mcpStorageKey = `${LocalStorageKeys.LAST_MCP_}${key}`;
if (mcpValues.length > 0) {
setTimestamp(mcpStorageKey);
}
}, [mcpValues, key]);
/** Stable memoized setter */
const setMCPValues = useCallback(
(value: string[]) => {
if (!Array.isArray(value)) {
return;
}
setMCPValuesRaw(value);
}, },
[setEphemeralAgent], [setMCPValuesRaw],
);
const [mcpValues, setMCPValuesRaw] = useLocalStorage<string[]>(
storageKey,
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,
); );
return { return {

View file

@ -25,9 +25,8 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { showToast } = useToastContext(); const { showToast } = useToastContext();
const { mcpToolDetails } = useGetMCPTools(); const { mcpToolDetails } = useGetMCPTools();
const mcpSelect = useMCPSelect({ conversationId });
const { data: startupConfig } = useGetStartupConfig(); const { data: startupConfig } = useGetStartupConfig();
const { mcpValues, setMCPValues, isPinned, setIsPinned } = mcpSelect; const { mcpValues, setMCPValues, isPinned, setIsPinned } = useMCPSelect({ conversationId });
const [isConfigModalOpen, setIsConfigModalOpen] = useState(false); const [isConfigModalOpen, setIsConfigModalOpen] = useState(false);
const [selectedToolForConfig, setSelectedToolForConfig] = useState<TPlugin | null>(null); const [selectedToolForConfig, setSelectedToolForConfig] = useState<TPlugin | null>(null);

View file

@ -5,23 +5,10 @@ import { Constants, LocalStorageKeys } from 'librechat-data-provider';
import type { VerifyToolAuthResponse } from 'librechat-data-provider'; import type { VerifyToolAuthResponse } from 'librechat-data-provider';
import type { UseQueryOptions } from '@tanstack/react-query'; import type { UseQueryOptions } from '@tanstack/react-query';
import { useVerifyAgentToolAuth } from '~/data-provider'; import { useVerifyAgentToolAuth } from '~/data-provider';
import { setTimestamp } from '~/utils/timestamps';
import useLocalStorage from '~/hooks/useLocalStorageAlt'; import useLocalStorage from '~/hooks/useLocalStorageAlt';
import { ephemeralAgentByConvoId } from '~/store'; import { ephemeralAgentByConvoId } from '~/store';
const storageCondition = (value: unknown, rawCurrentValue?: string | null) => {
if (rawCurrentValue) {
try {
const currentValue = rawCurrentValue?.trim() ?? '';
if (currentValue === 'true' && value === false) {
return true;
}
} catch (e) {
console.error(e);
}
}
return value !== undefined && value !== null;
};
type ToolValue = boolean | string; type ToolValue = boolean | string;
interface UseToolToggleOptions { interface UseToolToggleOptions {
@ -39,7 +26,7 @@ interface UseToolToggleOptions {
export function useToolToggle({ export function useToolToggle({
conversationId, conversationId,
toolKey, toolKey: _toolKey,
localStorageKey, localStorageKey,
isAuthenticated: externalIsAuthenticated, isAuthenticated: externalIsAuthenticated,
setIsDialogOpen, setIsDialogOpen,
@ -62,13 +49,8 @@ export function useToolToggle({
[externalIsAuthenticated, authConfig, authQuery.data?.authenticated], [externalIsAuthenticated, authConfig, authQuery.data?.authenticated],
); );
// Keep localStorage in sync const toolKey = useMemo(() => _toolKey, [_toolKey]);
const [, setLocalStorageValue] = useLocalStorage<ToolValue>( const storageKey = useMemo(() => `${localStorageKey}${key}`, [localStorageKey, key]);
`${localStorageKey}${key}`,
false,
undefined,
storageCondition,
);
// The actual current value comes from ephemeralAgent // The actual current value comes from ephemeralAgent
const toolValue = useMemo(() => { const toolValue = useMemo(() => {
@ -83,13 +65,14 @@ export function useToolToggle({
return toolValue === true; return toolValue === true;
}, [toolValue]); }, [toolValue]);
// Sync to localStorage when ephemeralAgent changes // Sync to localStorage with timestamps when ephemeralAgent changes
useEffect(() => { useEffect(() => {
const value = ephemeralAgent?.[toolKey]; const value = ephemeralAgent?.[toolKey];
if (value !== undefined) { if (value !== undefined) {
setLocalStorageValue(value); localStorage.setItem(storageKey, JSON.stringify(value));
setTimestamp(storageKey);
} }
}, [ephemeralAgent, toolKey, setLocalStorageValue]); }, [ephemeralAgent, toolKey, storageKey]);
const [isPinned, setIsPinned] = useLocalStorage<boolean>(`${localStorageKey}pinned`, false); const [isPinned, setIsPinned] = useLocalStorage<boolean>(`${localStorageKey}pinned`, false);

View file

@ -13,6 +13,7 @@ import settings from './settings';
import misc from './misc'; import misc from './misc';
import isTemporary from './temporary'; import isTemporary from './temporary';
export * from './agents'; export * from './agents';
export * from './mcp';
export default { export default {
...artifacts, ...artifacts,

20
client/src/store/mcp.ts Normal file
View file

@ -0,0 +1,20 @@
import { atomFamily, atomWithStorage } from 'jotai/utils';
import { Constants, LocalStorageKeys } from 'librechat-data-provider';
/**
* Creates a storage atom for MCP values per conversation
* Uses atomFamily to create unique atoms for each conversation ID
*/
export const mcpValuesAtomFamily = atomFamily((conversationId: string | null) => {
const key = conversationId ?? Constants.NEW_CONVO;
const storageKey = `${LocalStorageKeys.LAST_MCP_}${key}`;
return atomWithStorage<string[]>(storageKey, [], undefined, { getOnInit: true });
});
/**
* Global storage atom for MCP pinned state (shared across all conversations)
*/
export const mcpPinnedAtom = atomWithStorage<boolean>(LocalStorageKeys.PIN_MCP_, true, undefined, {
getOnInit: true,
});

View file

@ -0,0 +1,163 @@
import { LocalStorageKeys } from 'librechat-data-provider';
import {
setTimestamp,
setTimestampedValue,
getTimestampedValue,
removeTimestampedValue,
cleanupTimestampedStorage,
migrateExistingEntries,
} from '../timestamps';
describe('timestamps', () => {
beforeEach(() => {
localStorage.clear();
jest.clearAllMocks();
});
describe('setTimestamp', () => {
it('should set only timestamp in localStorage', () => {
const key = 'test-key';
setTimestamp(key);
expect(localStorage.getItem(`${key}_TIMESTAMP`)).toBeTruthy();
expect(localStorage.getItem(key)).toBeNull();
});
});
describe('setTimestampedValue', () => {
it('should set value and timestamp in localStorage', () => {
const key = 'test-key';
const value = { data: 'test' };
setTimestampedValue(key, value);
expect(localStorage.getItem(key)).toBe(JSON.stringify(value));
expect(localStorage.getItem(`${key}_TIMESTAMP`)).toBeTruthy();
});
});
describe('getTimestampedValue', () => {
it('should return value if timestamp is valid', () => {
const key = 'test-key';
const value = { data: 'test' };
localStorage.setItem(key, JSON.stringify(value));
localStorage.setItem(`${key}_TIMESTAMP`, Date.now().toString());
const result = getTimestampedValue(key);
expect(result).toBe(JSON.stringify(value));
});
it('should return null and clean up if timestamp is too old', () => {
const key = 'test-key';
const value = { data: 'test' };
const oldTimestamp = Date.now() - 3 * 24 * 60 * 60 * 1000; // 3 days ago
localStorage.setItem(key, JSON.stringify(value));
localStorage.setItem(`${key}_TIMESTAMP`, oldTimestamp.toString());
const result = getTimestampedValue(key);
expect(result).toBeNull();
expect(localStorage.getItem(key)).toBeNull();
expect(localStorage.getItem(`${key}_TIMESTAMP`)).toBeNull();
});
it('should return value if no timestamp exists (backward compatibility)', () => {
const key = 'test-key';
const value = { data: 'test' };
localStorage.setItem(key, JSON.stringify(value));
const result = getTimestampedValue(key);
expect(result).toBe(JSON.stringify(value));
});
});
describe('removeTimestampedValue', () => {
it('should remove both value and timestamp', () => {
const key = 'test-key';
localStorage.setItem(key, 'value');
localStorage.setItem(`${key}_TIMESTAMP`, Date.now().toString());
removeTimestampedValue(key);
expect(localStorage.getItem(key)).toBeNull();
expect(localStorage.getItem(`${key}_TIMESTAMP`)).toBeNull();
});
});
describe('cleanupTimestampedStorage', () => {
it('should remove entries without timestamps', () => {
const key = `${LocalStorageKeys.LAST_MCP_}convo-123`;
localStorage.setItem(key, 'value');
cleanupTimestampedStorage();
expect(localStorage.getItem(key)).toBeNull();
});
it('should remove old entries with timestamps', () => {
const key = `${LocalStorageKeys.LAST_CODE_TOGGLE_}convo-456`;
const oldTimestamp = Date.now() - 3 * 24 * 60 * 60 * 1000; // 3 days ago
localStorage.setItem(key, 'true');
localStorage.setItem(`${key}_TIMESTAMP`, oldTimestamp.toString());
cleanupTimestampedStorage();
expect(localStorage.getItem(key)).toBeNull();
expect(localStorage.getItem(`${key}_TIMESTAMP`)).toBeNull();
});
it('should keep recent entries', () => {
const key = `${LocalStorageKeys.LAST_WEB_SEARCH_TOGGLE_}convo-789`;
const recentTimestamp = Date.now() - 1 * 60 * 60 * 1000; // 1 hour ago
localStorage.setItem(key, 'false');
localStorage.setItem(`${key}_TIMESTAMP`, recentTimestamp.toString());
cleanupTimestampedStorage();
expect(localStorage.getItem(key)).toBe('false');
expect(localStorage.getItem(`${key}_TIMESTAMP`)).toBe(recentTimestamp.toString());
});
it('should not affect non-timestamped keys', () => {
const regularKey = 'regular-key';
localStorage.setItem(regularKey, 'value');
cleanupTimestampedStorage();
expect(localStorage.getItem(regularKey)).toBe('value');
});
});
describe('migrateExistingEntries', () => {
it('should add timestamps to existing timestamped keys', () => {
const key1 = `${LocalStorageKeys.LAST_MCP_}convo-111`;
const key2 = `${LocalStorageKeys.PIN_MCP_}convo-222`;
localStorage.setItem(key1, '["mcp1", "mcp2"]');
localStorage.setItem(key2, 'true');
migrateExistingEntries();
expect(localStorage.getItem(`${key1}_TIMESTAMP`)).toBeTruthy();
expect(localStorage.getItem(`${key2}_TIMESTAMP`)).toBeTruthy();
});
it('should not overwrite existing timestamps', () => {
const key = `${LocalStorageKeys.LAST_FILE_SEARCH_TOGGLE_}convo-333`;
const existingTimestamp = '1234567890';
localStorage.setItem(key, 'true');
localStorage.setItem(`${key}_TIMESTAMP`, existingTimestamp);
migrateExistingEntries();
expect(localStorage.getItem(`${key}_TIMESTAMP`)).toBe(existingTimestamp);
});
});
});

View file

@ -19,6 +19,7 @@ export * from './roles';
export * from './localStorage'; export * from './localStorage';
export * from './promptGroups'; export * from './promptGroups';
export * from './email'; export * from './email';
export * from './timestamps';
export { default as cn } from './cn'; export { default as cn } from './cn';
export { default as logger } from './logger'; export { default as logger } from './logger';
export { default as scaleImage } from './scaleImage'; export { default as scaleImage } from './scaleImage';

View file

@ -0,0 +1,141 @@
import { LocalStorageKeys } from 'librechat-data-provider';
/** Suffix for timestamp entries */
const TIMESTAMP_SUFFIX = '_TIMESTAMP';
/** Duration in milliseconds (2 days) */
const CLEANUP_THRESHOLD = 2 * 24 * 60 * 60 * 1000;
/**
* Storage keys that should be cleaned up based on timestamps
* These are conversation-specific keys that can accumulate over time
*/
const TIMESTAMPED_KEYS = [
LocalStorageKeys.LAST_MCP_,
LocalStorageKeys.LAST_CODE_TOGGLE_,
LocalStorageKeys.LAST_WEB_SEARCH_TOGGLE_,
LocalStorageKeys.LAST_FILE_SEARCH_TOGGLE_,
LocalStorageKeys.LAST_ARTIFACTS_TOGGLE_,
LocalStorageKeys.PIN_MCP_,
];
/**
* Set only a timestamp for a key (when the value is handled elsewhere)
*/
export function setTimestamp(key: string): void {
localStorage.setItem(`${key}${TIMESTAMP_SUFFIX}`, Date.now().toString());
}
/**
* Set a value in localStorage with an associated timestamp
*/
export function setTimestampedValue(key: string, value: any): void {
localStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
localStorage.setItem(`${key}${TIMESTAMP_SUFFIX}`, Date.now().toString());
}
/**
* Get a value from localStorage, checking if it has a valid timestamp
* Returns null if the value is too old or has no timestamp
*/
export function getTimestampedValue(key: string): string | null {
const timestampKey = `${key}${TIMESTAMP_SUFFIX}`;
const timestamp = localStorage.getItem(timestampKey);
if (!timestamp) {
// No timestamp exists, return the value but it will be cleaned up on next startup
return localStorage.getItem(key);
}
const age = Date.now() - parseInt(timestamp, 10);
if (age > CLEANUP_THRESHOLD) {
// Value is too old, clean it up
localStorage.removeItem(key);
localStorage.removeItem(timestampKey);
return null;
}
return localStorage.getItem(key);
}
/**
* Remove a value and its timestamp from localStorage
*/
export function removeTimestampedValue(key: string): void {
localStorage.removeItem(key);
localStorage.removeItem(`${key}${TIMESTAMP_SUFFIX}`);
}
/**
* Clean up old localStorage entries based on timestamps
* This should be called on app startup
*/
export function cleanupTimestampedStorage(): void {
try {
const keysToRemove: string[] = [];
const now = Date.now();
// Iterate through all localStorage keys
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (!key) continue;
if (key === LocalStorageKeys.PIN_MCP_) {
continue;
}
// Check if this key should be timestamped
const isTimestampedKey = TIMESTAMPED_KEYS.some((prefix) => key.startsWith(prefix));
if (isTimestampedKey && !key.endsWith(TIMESTAMP_SUFFIX)) {
const timestampKey = `${key}${TIMESTAMP_SUFFIX}`;
const timestamp = localStorage.getItem(timestampKey);
if (!timestamp) {
// No timestamp exists for a key that should have one - mark for cleanup
keysToRemove.push(key);
continue;
}
const age = now - parseInt(timestamp, 10);
if (age > CLEANUP_THRESHOLD) {
// Entry is too old - mark for cleanup
keysToRemove.push(key);
keysToRemove.push(timestampKey);
}
}
}
keysToRemove.forEach((key) => localStorage.removeItem(key));
if (keysToRemove.length > 0) {
console.log(`Cleaned up ${keysToRemove.length} old localStorage entries`);
}
} catch (error) {
console.error('Error during cleanup of timestamped storage:', error);
}
}
/**
* Migration function to add timestamps to existing entries
* This ensures existing entries don't get immediately cleaned up
*/
export function migrateExistingEntries(): void {
const now = Date.now().toString();
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (!key) continue;
const isTimestampedKey = TIMESTAMPED_KEYS.some((prefix) => key.startsWith(prefix));
if (isTimestampedKey && !key.endsWith(TIMESTAMP_SUFFIX)) {
const timestampKey = `${key}${TIMESTAMP_SUFFIX}`;
const hasTimestamp = localStorage.getItem(timestampKey);
if (!hasTimestamp) {
// Add timestamp to existing entry
localStorage.setItem(timestampKey, now);
}
}
}
}