mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-22 06:00:56 +02:00
👻 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:
parent
519645c0b0
commit
751c2e1d17
14 changed files with 435 additions and 115 deletions
|
@ -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<typeof useToolToggle>;
|
||||
codeApiKeyForm: ReturnType<typeof useCodeApiKeyForm>;
|
||||
searchApiKeyForm: ReturnType<typeof useSearchApiKeyForm>;
|
||||
mcpServerManager: ReturnType<typeof useMCPServerManager>;
|
||||
}
|
||||
|
||||
const BadgeRowContext = createContext<BadgeRowContextType | undefined>(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<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
|
||||
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 <BadgeRowContext.Provider value={value}>{children}</BadgeRowContext.Provider>;
|
||||
|
|
|
@ -368,7 +368,7 @@ function BadgeRow({
|
|||
<CodeInterpreter />
|
||||
<FileSearch />
|
||||
<Artifacts />
|
||||
<MCPSelect conversationId={conversationId} />
|
||||
<MCPSelect />
|
||||
</>
|
||||
)}
|
||||
{ghostBadge && (
|
||||
|
|
|
@ -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 <MCPSelectContent {...props} />;
|
||||
return <MCPSelectContent />;
|
||||
}
|
||||
|
||||
export default memo(MCPSelect);
|
||||
|
|
|
@ -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<HTMLDivElement, MCPSubMenuProps>(
|
||||
({ 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,
|
||||
|
|
|
@ -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) => (
|
||||
<MCPSubMenu {...props} placeholder={mcpPlaceholder} conversationId={conversationId} />
|
||||
),
|
||||
render: (props) => <MCPSubMenu {...props} placeholder={mcpPlaceholder} />,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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 ?? '';
|
||||
|
|
|
@ -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<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,
|
||||
[setMCPValuesRaw],
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
|
@ -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<TPlugin | null>(null);
|
||||
|
|
|
@ -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<ToolValue>(
|
||||
`${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<boolean>(`${localStorageKey}pinned`, false);
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
20
client/src/store/mcp.ts
Normal file
20
client/src/store/mcp.ts
Normal 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,
|
||||
});
|
163
client/src/utils/__tests__/timestamps.test.ts
Normal file
163
client/src/utils/__tests__/timestamps.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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';
|
||||
|
|
141
client/src/utils/timestamps.ts
Normal file
141
client/src/utils/timestamps.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue