mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 17:00:15 +01: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 { 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>;
|
||||||
|
|
|
||||||
|
|
@ -368,7 +368,7 @@ function BadgeRow({
|
||||||
<CodeInterpreter />
|
<CodeInterpreter />
|
||||||
<FileSearch />
|
<FileSearch />
|
||||||
<Artifacts />
|
<Artifacts />
|
||||||
<MCPSelect conversationId={conversationId} />
|
<MCPSelect />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{ghostBadge && (
|
{ghostBadge && (
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 ?? '';
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
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 './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';
|
||||||
|
|
|
||||||
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