diff --git a/client/src/Providers/BadgeRowContext.tsx b/client/src/Providers/BadgeRowContext.tsx index 4117a4234..1e33827a6 100644 --- a/client/src/Providers/BadgeRowContext.tsx +++ b/client/src/Providers/BadgeRowContext.tsx @@ -3,12 +3,14 @@ import { useSetRecoilState } from 'recoil'; import { Tools, Constants, LocalStorageKeys, AgentCapabilities } from 'librechat-data-provider'; import type { TAgentsEndpoint } from 'librechat-data-provider'; import { + useMCPServerManager, useSearchApiKeyForm, useGetAgentsConfig, useCodeApiKeyForm, useGetMCPTools, useToolToggle, } from '~/hooks'; +import { getTimestampedValue, setTimestamp } from '~/utils/timestamps'; import { ephemeralAgentByConvoId } from '~/store'; interface BadgeRowContextType { @@ -21,6 +23,7 @@ interface BadgeRowContextType { codeInterpreter: ReturnType; codeApiKeyForm: ReturnType; searchApiKeyForm: ReturnType; + mcpServerManager: ReturnType; } const BadgeRowContext = createContext(undefined); @@ -62,16 +65,15 @@ export default function BadgeRowProvider({ hasInitializedRef.current = true; lastKeyRef.current = key; - // Load all localStorage values const codeToggleKey = `${LocalStorageKeys.LAST_CODE_TOGGLE_}${key}`; const webSearchToggleKey = `${LocalStorageKeys.LAST_WEB_SEARCH_TOGGLE_}${key}`; const fileSearchToggleKey = `${LocalStorageKeys.LAST_FILE_SEARCH_TOGGLE_}${key}`; const artifactsToggleKey = `${LocalStorageKeys.LAST_ARTIFACTS_TOGGLE_}${key}`; - const codeToggleValue = localStorage.getItem(codeToggleKey); - const webSearchToggleValue = localStorage.getItem(webSearchToggleKey); - const fileSearchToggleValue = localStorage.getItem(fileSearchToggleKey); - const artifactsToggleValue = localStorage.getItem(artifactsToggleKey); + const codeToggleValue = getTimestampedValue(codeToggleKey); + const webSearchToggleValue = getTimestampedValue(webSearchToggleKey); + const fileSearchToggleValue = getTimestampedValue(fileSearchToggleKey); + const artifactsToggleValue = getTimestampedValue(artifactsToggleKey); const initialValues: Record = {}; @@ -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 - setEphemeralAgent((prev) => ({ - ...(prev || {}), + /** + * 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 + */ + const finalValues = { [Tools.execute_code]: initialValues[Tools.execute_code] ?? false, [Tools.web_search]: initialValues[Tools.web_search] ?? false, [Tools.file_search]: initialValues[Tools.file_search] ?? 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]); @@ -165,6 +189,8 @@ export default function BadgeRowProvider({ isAuthenticated: true, }); + const mcpServerManager = useMCPServerManager({ conversationId }); + const mcpServerNames = useMemo(() => { return (mcpToolDetails ?? []).map((tool) => tool.name); }, [mcpToolDetails]); @@ -179,6 +205,7 @@ export default function BadgeRowProvider({ codeApiKeyForm, codeInterpreter, searchApiKeyForm, + mcpServerManager, }; return {children}; diff --git a/client/src/components/Chat/Input/BadgeRow.tsx b/client/src/components/Chat/Input/BadgeRow.tsx index 5c45c6b4b..5036dcd5e 100644 --- a/client/src/components/Chat/Input/BadgeRow.tsx +++ b/client/src/components/Chat/Input/BadgeRow.tsx @@ -368,7 +368,7 @@ function BadgeRow({ - + )} {ghostBadge && ( diff --git a/client/src/components/Chat/Input/MCPSelect.tsx b/client/src/components/Chat/Input/MCPSelect.tsx index 5ed6a08b8..6d50f1580 100644 --- a/client/src/components/Chat/Input/MCPSelect.tsx +++ b/client/src/components/Chat/Input/MCPSelect.tsx @@ -3,22 +3,20 @@ import { MultiSelect, MCPIcon } from '@librechat/client'; import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon'; import MCPConfigDialog from '~/components/MCP/MCPConfigDialog'; import { useBadgeRowContext } from '~/Providers'; -import { useMCPServerManager } from '~/hooks'; -type MCPSelectProps = { conversationId?: string | null }; - -function MCPSelectContent({ conversationId }: MCPSelectProps) { +function MCPSelectContent() { + const { conversationId, mcpServerManager } = useBadgeRowContext(); const { - configuredServers, - mcpValues, - isPinned, - placeholderText, - batchToggleServers, - getServerStatusIconProps, - getConfigDialogProps, - isInitializing, localize, - } = useMCPServerManager({ conversationId }); + isPinned, + mcpValues, + isInitializing, + placeholderText, + configuredServers, + batchToggleServers, + getConfigDialogProps, + getServerStatusIconProps, + } = mcpServerManager; const renderSelectedValues = useCallback( (values: string[], placeholder?: string) => { @@ -103,10 +101,10 @@ function MCPSelectContent({ conversationId }: MCPSelectProps) { ); } -function MCPSelect(props: MCPSelectProps) { +function MCPSelect() { const { mcpServerNames } = useBadgeRowContext(); if ((mcpServerNames?.length ?? 0) === 0) return null; - return ; + return ; } export default memo(MCPSelect); diff --git a/client/src/components/Chat/Input/MCPSubMenu.tsx b/client/src/components/Chat/Input/MCPSubMenu.tsx index 8bd3386d2..b329b5cf6 100644 --- a/client/src/components/Chat/Input/MCPSubMenu.tsx +++ b/client/src/components/Chat/Input/MCPSubMenu.tsx @@ -4,27 +4,27 @@ import { ChevronRight } from 'lucide-react'; import { PinIcon, MCPIcon } from '@librechat/client'; import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon'; import MCPConfigDialog from '~/components/MCP/MCPConfigDialog'; -import { useMCPServerManager } from '~/hooks'; +import { useBadgeRowContext } from '~/Providers'; import { cn } from '~/utils'; interface MCPSubMenuProps { placeholder?: string; - conversationId?: string | null; } const MCPSubMenu = React.forwardRef( - ({ placeholder, conversationId, ...props }, ref) => { + ({ placeholder, ...props }, ref) => { + const { mcpServerManager } = useBadgeRowContext(); const { - configuredServers, - mcpValues, isPinned, + mcpValues, setIsPinned, + isInitializing, placeholderText, + configuredServers, + getConfigDialogProps, toggleServerSelection, getServerStatusIconProps, - getConfigDialogProps, - isInitializing, - } = useMCPServerManager({ conversationId }); + } = mcpServerManager; const menuStore = Ariakit.useMenuStore({ focusLoop: true, diff --git a/client/src/components/Chat/Input/ToolsDropdown.tsx b/client/src/components/Chat/Input/ToolsDropdown.tsx index 1041233de..0caa2fe61 100644 --- a/client/src/components/Chat/Input/ToolsDropdown.tsx +++ b/client/src/components/Chat/Input/ToolsDropdown.tsx @@ -31,7 +31,6 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => { fileSearch, agentsConfig, mcpServerNames, - conversationId, codeApiKeyForm, codeInterpreter, searchApiKeyForm, @@ -290,9 +289,7 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => { if (mcpServerNames && mcpServerNames.length > 0) { dropdownItems.push({ hideOnClick: false, - render: (props) => ( - - ), + render: (props) => , }); } diff --git a/client/src/hooks/Config/useAppStartup.ts b/client/src/hooks/Config/useAppStartup.ts index 77e9e7af8..2d5329760 100644 --- a/client/src/hooks/Config/useAppStartup.ts +++ b/client/src/hooks/Config/useAppStartup.ts @@ -5,6 +5,7 @@ import { LocalStorageKeys } from 'librechat-data-provider'; import { useAvailablePluginsQuery } from 'librechat-data-provider/react-query'; import type { TStartupConfig, TPlugin, TUser } from 'librechat-data-provider'; import { mapPlugins, selectPlugins, processPlugins } from '~/utils'; +import { cleanupTimestampedStorage } from '~/utils/timestamps'; import useSpeechSettingsInit from './useSpeechSettingsInit'; import store from '~/store'; @@ -34,6 +35,11 @@ export default function useAppStartup({ useSpeechSettingsInit(!!user); + /** Clean up old localStorage entries on startup */ + useEffect(() => { + cleanupTimestampedStorage(); + }, []); + /** Set the app title */ useEffect(() => { const appTitle = startupConfig?.appTitle ?? ''; diff --git a/client/src/hooks/MCP/useMCPSelect.ts b/client/src/hooks/MCP/useMCPSelect.ts index fd730cb72..92fe71a5f 100644 --- a/client/src/hooks/MCP/useMCPSelect.ts +++ b/client/src/hooks/MCP/useMCPSelect.ts @@ -1,66 +1,50 @@ -import { useRef, useCallback, useMemo } from 'react'; +import { useCallback, useEffect } from 'react'; +import { useAtom } from 'jotai'; import { useRecoilState } from 'recoil'; import { Constants, LocalStorageKeys } from 'librechat-data-provider'; -import useLocalStorage from '~/hooks/useLocalStorageAlt'; -import { ephemeralAgentByConvoId } from '~/store'; - -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; -}; +import { ephemeralAgentByConvoId, mcpValuesAtomFamily, mcpPinnedAtom } from '~/store'; +import { setTimestamp } from '~/utils/timestamps'; export function useMCPSelect({ conversationId }: { conversationId?: string | null }) { const key = conversationId ?? Constants.NEW_CONVO; + + const [isPinned, setIsPinned] = useAtom(mcpPinnedAtom); + const [mcpValues, setMCPValuesRaw] = useAtom(mcpValuesAtomFamily(key)); const [ephemeralAgent, setEphemeralAgent] = useRecoilState(ephemeralAgentByConvoId(key)); - const storageKey = `${LocalStorageKeys.LAST_MCP_}${key}`; - const mcpState = useMemo(() => { - return ephemeralAgent?.mcp ?? []; - }, [ephemeralAgent?.mcp]); + // Sync Jotai state with ephemeral agent state + useEffect(() => { + if (ephemeralAgent?.mcp && ephemeralAgent.mcp.length > 0) { + setMCPValuesRaw(ephemeralAgent.mcp); + } + }, [ephemeralAgent?.mcp, setMCPValuesRaw]); - const setSelectedValues = useCallback( - (values: string[] | null | undefined) => { - if (!values) { - return; - } - if (!Array.isArray(values)) { - return; - } + // Update ephemeral agent when Jotai state changes + useEffect(() => { + if (mcpValues.length > 0 && JSON.stringify(mcpValues) !== JSON.stringify(ephemeralAgent?.mcp)) { setEphemeralAgent((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], - ); - - const [mcpValues, setMCPValuesRaw] = useLocalStorage( - 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( - `${LocalStorageKeys.PIN_MCP_}${key}`, - true, + [setMCPValuesRaw], ); return { diff --git a/client/src/hooks/MCP/useMCPServerManager.ts b/client/src/hooks/MCP/useMCPServerManager.ts index e3a0ea2e9..733168c1a 100644 --- a/client/src/hooks/MCP/useMCPServerManager.ts +++ b/client/src/hooks/MCP/useMCPServerManager.ts @@ -25,9 +25,8 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin const queryClient = useQueryClient(); const { showToast } = useToastContext(); const { mcpToolDetails } = useGetMCPTools(); - const mcpSelect = useMCPSelect({ conversationId }); const { data: startupConfig } = useGetStartupConfig(); - const { mcpValues, setMCPValues, isPinned, setIsPinned } = mcpSelect; + const { mcpValues, setMCPValues, isPinned, setIsPinned } = useMCPSelect({ conversationId }); const [isConfigModalOpen, setIsConfigModalOpen] = useState(false); const [selectedToolForConfig, setSelectedToolForConfig] = useState(null); diff --git a/client/src/hooks/Plugins/useToolToggle.ts b/client/src/hooks/Plugins/useToolToggle.ts index cce9e4fa2..3b12e87d5 100644 --- a/client/src/hooks/Plugins/useToolToggle.ts +++ b/client/src/hooks/Plugins/useToolToggle.ts @@ -5,23 +5,10 @@ import { Constants, LocalStorageKeys } from 'librechat-data-provider'; import type { VerifyToolAuthResponse } from 'librechat-data-provider'; import type { UseQueryOptions } from '@tanstack/react-query'; import { useVerifyAgentToolAuth } from '~/data-provider'; +import { setTimestamp } from '~/utils/timestamps'; import useLocalStorage from '~/hooks/useLocalStorageAlt'; 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; interface UseToolToggleOptions { @@ -39,7 +26,7 @@ interface UseToolToggleOptions { export function useToolToggle({ conversationId, - toolKey, + toolKey: _toolKey, localStorageKey, isAuthenticated: externalIsAuthenticated, setIsDialogOpen, @@ -62,13 +49,8 @@ export function useToolToggle({ [externalIsAuthenticated, authConfig, authQuery.data?.authenticated], ); - // Keep localStorage in sync - const [, setLocalStorageValue] = useLocalStorage( - `${localStorageKey}${key}`, - false, - undefined, - storageCondition, - ); + const toolKey = useMemo(() => _toolKey, [_toolKey]); + const storageKey = useMemo(() => `${localStorageKey}${key}`, [localStorageKey, key]); // The actual current value comes from ephemeralAgent const toolValue = useMemo(() => { @@ -83,13 +65,14 @@ export function useToolToggle({ return toolValue === true; }, [toolValue]); - // Sync to localStorage when ephemeralAgent changes + // Sync to localStorage with timestamps when ephemeralAgent changes useEffect(() => { const value = ephemeralAgent?.[toolKey]; if (value !== undefined) { - setLocalStorageValue(value); + localStorage.setItem(storageKey, JSON.stringify(value)); + setTimestamp(storageKey); } - }, [ephemeralAgent, toolKey, setLocalStorageValue]); + }, [ephemeralAgent, toolKey, storageKey]); const [isPinned, setIsPinned] = useLocalStorage(`${localStorageKey}pinned`, false); diff --git a/client/src/store/index.ts b/client/src/store/index.ts index 7b7e0f083..054fac25a 100644 --- a/client/src/store/index.ts +++ b/client/src/store/index.ts @@ -13,6 +13,7 @@ import settings from './settings'; import misc from './misc'; import isTemporary from './temporary'; export * from './agents'; +export * from './mcp'; export default { ...artifacts, diff --git a/client/src/store/mcp.ts b/client/src/store/mcp.ts new file mode 100644 index 000000000..81f7137bb --- /dev/null +++ b/client/src/store/mcp.ts @@ -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(storageKey, [], undefined, { getOnInit: true }); +}); + +/** + * Global storage atom for MCP pinned state (shared across all conversations) + */ +export const mcpPinnedAtom = atomWithStorage(LocalStorageKeys.PIN_MCP_, true, undefined, { + getOnInit: true, +}); diff --git a/client/src/utils/__tests__/timestamps.test.ts b/client/src/utils/__tests__/timestamps.test.ts new file mode 100644 index 000000000..2a89c71bc --- /dev/null +++ b/client/src/utils/__tests__/timestamps.test.ts @@ -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); + }); + }); +}); diff --git a/client/src/utils/index.ts b/client/src/utils/index.ts index ba3ba1aa3..961f8537f 100644 --- a/client/src/utils/index.ts +++ b/client/src/utils/index.ts @@ -19,6 +19,7 @@ export * from './roles'; export * from './localStorage'; export * from './promptGroups'; export * from './email'; +export * from './timestamps'; export { default as cn } from './cn'; export { default as logger } from './logger'; export { default as scaleImage } from './scaleImage'; diff --git a/client/src/utils/timestamps.ts b/client/src/utils/timestamps.ts new file mode 100644 index 000000000..1fdb6923d --- /dev/null +++ b/client/src/utils/timestamps.ts @@ -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); + } + } + } +}