import { useEffect } from 'react'; import { atom, selector, atomFamily, DefaultValue, selectorFamily, useRecoilState, useRecoilValue, useSetRecoilState, useRecoilCallback, } from 'recoil'; import { LocalStorageKeys, Constants } from 'librechat-data-provider'; import type { TMessage, TPreset, TConversation, TSubmission } from 'librechat-data-provider'; import type { TOptionSettings, ExtendedFile } from '~/common'; import { useSetConvoContext } from '~/Providers/SetConvoContext'; import { storeEndpointSettings, logger, createChatSearchParams } from '~/utils'; import { createSearchParams } from 'react-router-dom'; const latestMessageKeysAtom = atom<(string | number)[]>({ key: 'latestMessageKeys', default: [], }); const submissionKeysAtom = atom<(string | number)[]>({ key: 'submissionKeys', default: [], }); const latestMessageFamily = atomFamily({ key: 'latestMessageByIndex', default: null, effects: [ ({ onSet, node }) => { onSet(async (newValue) => { const key = Number(node.key.split(Constants.COMMON_DIVIDER)[1]); logger.log('Recoil Effect: Setting latestMessage', { key, newValue }); }); }, ] as const, }); const submissionByIndex = atomFamily({ key: 'submissionByIndex', default: null, }); const latestMessageKeysSelector = selector<(string | number)[]>({ key: 'latestMessageKeysSelector', get: ({ get }) => { const keys = get(conversationKeysAtom); return keys.filter((key) => get(latestMessageFamily(key)) !== null); }, set: ({ set }, newKeys) => { logger.log('setting latestMessageKeys', { newKeys }); set(latestMessageKeysAtom, newKeys); }, }); const submissionKeysSelector = selector<(string | number)[]>({ key: 'submissionKeysSelector', get: ({ get }) => { const keys = get(conversationKeysAtom); return keys.filter((key) => get(submissionByIndex(key)) !== null); }, set: ({ set }, newKeys) => { logger.log('setting submissionKeysAtom', newKeys); set(submissionKeysAtom, newKeys); }, }); const conversationByIndex = atomFamily({ key: 'conversationByIndex', default: null, effects: [ ({ onSet, node }) => { onSet(async (newValue, oldValue) => { const index = Number(node.key.split('__')[1]); logger.log('conversation', 'Setting conversation:', { index, newValue, oldValue }); if (newValue?.assistant_id != null && newValue.assistant_id) { localStorage.setItem( `${LocalStorageKeys.ASST_ID_PREFIX}${index}${newValue.endpoint}`, newValue.assistant_id, ); } if (newValue?.agent_id != null && newValue.agent_id) { localStorage.setItem(`${LocalStorageKeys.AGENT_ID_PREFIX}${index}`, newValue.agent_id); } if (newValue?.spec != null && newValue.spec) { localStorage.setItem(LocalStorageKeys.LAST_SPEC, newValue.spec); } if (newValue?.tools && Array.isArray(newValue.tools)) { localStorage.setItem( LocalStorageKeys.LAST_TOOLS, JSON.stringify(newValue.tools.filter((el) => !!el)), ); } if (!newValue) { return; } storeEndpointSettings(newValue); localStorage.setItem( `${LocalStorageKeys.LAST_CONVO_SETUP}_${index}`, JSON.stringify(newValue), ); const disableParams = newValue.disableParams === true; const shouldUpdateParams = index === 0 && !disableParams && newValue.createdAt === '' && JSON.stringify(newValue) !== JSON.stringify(oldValue) && (oldValue as TConversation)?.conversationId === Constants.NEW_CONVO; if (shouldUpdateParams) { const newParams = createChatSearchParams(newValue); const searchParams = createSearchParams(newParams); const url = `${window.location.pathname}?${searchParams.toString()}`; window.history.pushState({}, '', url); } }); }, ] as const, }); const filesByIndex = atomFamily, string | number>({ key: 'filesByIndex', default: new Map(), }); const conversationKeysAtom = atom<(string | number)[]>({ key: 'conversationKeys', default: [], }); const allConversationsSelector = selector({ key: 'allConversationsSelector', get: ({ get }) => { const keys = get(conversationKeysAtom); return keys.map((key) => get(conversationByIndex(key))).map((convo) => convo?.conversationId); }, }); const presetByIndex = atomFamily({ key: 'presetByIndex', default: null, }); const textByIndex = atomFamily({ key: 'textByIndex', default: '', }); const showStopButtonByIndex = atomFamily({ key: 'showStopButtonByIndex', default: false, }); const abortScrollFamily = atomFamily({ key: 'abortScrollByIndex', default: false, effects: [ ({ onSet, node }) => { onSet(async (newValue) => { const key = Number(node.key.split(Constants.COMMON_DIVIDER)[1]); logger.log('message_scrolling', 'Recoil Effect: Setting abortScrollByIndex', { key, newValue, }); }); }, ] as const, }); const isSubmittingFamily = atomFamily({ key: 'isSubmittingByIndex', default: false, effects: [ ({ onSet, node }) => { onSet(async (newValue) => { const key = Number(node.key.split(Constants.COMMON_DIVIDER)[1]); logger.log('message_stream', 'Recoil Effect: Setting isSubmittingByIndex', { key, newValue, }); }); }, ], }); const anySubmittingSelector = selector({ key: 'anySubmittingSelector', get: ({ get }) => { const keys = get(conversationKeysAtom); return keys.some((key) => get(isSubmittingFamily(key)) === true); }, }); const optionSettingsFamily = atomFamily({ key: 'optionSettingsByIndex', default: {}, }); const showPopoverFamily = atomFamily({ key: 'showPopoverByIndex', default: false, }); const activePromptByIndex = atomFamily({ key: 'activePromptByIndex', default: undefined, }); const showMentionPopoverFamily = atomFamily({ key: 'showMentionPopoverByIndex', default: false, }); const showPlusPopoverFamily = atomFamily({ key: 'showPlusPopoverByIndex', default: false, }); const showPromptsPopoverFamily = atomFamily({ key: 'showPromptsPopoverByIndex', default: false, }); const globalAudioURLFamily = atomFamily({ key: 'globalAudioURLByIndex', default: null, }); const globalAudioFetchingFamily = atomFamily({ key: 'globalAudioisFetchingByIndex', default: false, }); const globalAudioPlayingFamily = atomFamily({ key: 'globalAudioisPlayingByIndex', default: false, }); const activeRunFamily = atomFamily({ key: 'activeRunByIndex', default: null, }); const audioRunFamily = atomFamily({ key: 'audioRunByIndex', default: null, }); const messagesSiblingIdxFamily = atomFamily({ key: 'messagesSiblingIdx', default: 0, }); function useCreateConversationAtom(key: string | number) { const hasSetConversation = useSetConvoContext(); const [keys, setKeys] = useRecoilState(conversationKeysAtom); const setConversation = useSetRecoilState(conversationByIndex(key)); const conversation = useRecoilValue(conversationByIndex(key)); useEffect(() => { if (!keys.includes(key)) { setKeys([...keys, key]); } }, [key, keys, setKeys]); return { hasSetConversation, conversation, setConversation }; } function useClearConvoState() { /** Clears all active conversations. Pass `true` to skip the first or root conversation */ const clearAllConversations = useRecoilCallback( ({ reset, snapshot }) => async (skipFirst?: boolean) => { const conversationKeys = await snapshot.getPromise(conversationKeysAtom); for (const conversationKey of conversationKeys) { if (skipFirst === true && conversationKey == 0) { continue; } reset(conversationByIndex(conversationKey)); const conversation = await snapshot.getPromise(conversationByIndex(conversationKey)); if (conversation) { reset(latestMessageFamily(conversationKey)); } } reset(conversationKeysAtom); }, [], ); return clearAllConversations; } const conversationByKeySelector = selectorFamily({ key: 'conversationByKeySelector', get: (index: string | number) => ({ get }) => { const conversation = get(conversationByIndex(index)); return conversation; }, }); function useClearSubmissionState() { const clearAllSubmissions = useRecoilCallback( ({ reset, set, snapshot }) => async (skipFirst?: boolean) => { const submissionKeys = await snapshot.getPromise(submissionKeysSelector); logger.log('submissionKeys', submissionKeys); for (const key of submissionKeys) { if (skipFirst === true && key == 0) { continue; } logger.log('resetting submission', key); reset(submissionByIndex(key)); } set(submissionKeysSelector, []); }, [], ); return clearAllSubmissions; } function useClearLatestMessages(context?: string) { const clearAllLatestMessages = useRecoilCallback( ({ reset, set, snapshot }) => async (skipFirst?: boolean) => { const latestMessageKeys = await snapshot.getPromise(latestMessageKeysSelector); logger.log('[clearAllLatestMessages] latestMessageKeys', latestMessageKeys); if (context != null && context) { logger.log(`[clearAllLatestMessages] context: ${context}`); } for (const key of latestMessageKeys) { if (skipFirst === true && key == 0) { continue; } logger.log(`[clearAllLatestMessages] resetting latest message; key: ${key}`); reset(latestMessageFamily(key)); } set(latestMessageKeysSelector, []); }, [], ); return clearAllLatestMessages; } const updateConversationSelector = selectorFamily({ key: 'updateConversationSelector', get: () => () => null as Partial | null, set: (conversationId: string) => ({ set, get }, newPartialConversation) => { if (newPartialConversation instanceof DefaultValue) { return; } const keys = get(conversationKeysAtom); keys.forEach((key) => { set(conversationByIndex(key), (prevConversation) => { if (prevConversation && prevConversation.conversationId === conversationId) { return { ...prevConversation, ...newPartialConversation, }; } return prevConversation; }); }); }, }); export default { conversationKeysAtom, conversationByIndex, filesByIndex, presetByIndex, submissionByIndex, textByIndex, showStopButtonByIndex, abortScrollFamily, isSubmittingFamily, optionSettingsFamily, showPopoverFamily, latestMessageFamily, messagesSiblingIdxFamily, anySubmittingSelector, allConversationsSelector, conversationByKeySelector, useClearConvoState, useCreateConversationAtom, showMentionPopoverFamily, globalAudioURLFamily, activeRunFamily, audioRunFamily, globalAudioPlayingFamily, globalAudioFetchingFamily, showPlusPopoverFamily, activePromptByIndex, useClearSubmissionState, useClearLatestMessages, showPromptsPopoverFamily, updateConversationSelector, };