LibreChat/client/src/hooks/Input/useAutoSave.ts
Danny Avila 5740ca59d8
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Has been cancelled
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Has been cancelled
🔧 fix: Reduce debounce time for rapid text input in useAutoSave hook from 65ms to 25ms for improved responsiveness
2025-12-19 12:14:53 -05:00

250 lines
8.2 KiB
TypeScript

import debounce from 'lodash/debounce';
import { SetterOrUpdater, useRecoilValue } from 'recoil';
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { LocalStorageKeys, Constants } from 'librechat-data-provider';
import type { TFile } from 'librechat-data-provider';
import type { ExtendedFile } from '~/common';
import { clearDraft, getDraft, setDraft } from '~/utils';
import { useChatFormContext } from '~/Providers';
import { useGetFiles } from '~/data-provider';
import store from '~/store';
export const useAutoSave = ({
isSubmitting,
conversationId: _conversationId,
textAreaRef,
setFiles,
files,
}: {
isSubmitting?: boolean;
conversationId?: string | null;
textAreaRef?: React.RefObject<HTMLTextAreaElement>;
files: Map<string, ExtendedFile>;
setFiles: SetterOrUpdater<Map<string, ExtendedFile>>;
}) => {
// setting for auto-save
const { setValue } = useChatFormContext();
const saveDrafts = useRecoilValue<boolean>(store.saveDrafts);
const conversationId = isSubmitting ? Constants.PENDING_CONVO : _conversationId;
const [currentConversationId, setCurrentConversationId] = useState<string | null>(null);
const fileIds = useMemo(() => Array.from(files.keys()), [files]);
const { data: fileList } = useGetFiles<TFile[]>();
const restoreFiles = useCallback(
(id: string) => {
const filesDraft = JSON.parse(
(localStorage.getItem(`${LocalStorageKeys.FILES_DRAFT}${id}`) ?? '') || '[]',
) as string[];
if (filesDraft.length === 0) {
setFiles(new Map());
return;
}
// Retrieve files stored in localStorage from files in fileList and set them to `setFiles`
// If a file is found with `temp_file_id`, use `temp_file_id` as a key in `setFiles`
filesDraft.forEach((fileId) => {
const fileData = fileList?.find((f) => f.file_id === fileId);
const tempFileData = fileList?.find((f) => f.temp_file_id === fileId);
const { fileToRecover, fileIdToRecover } = fileData
? { fileToRecover: fileData, fileIdToRecover: fileId }
: {
fileToRecover: tempFileData,
fileIdToRecover: (tempFileData?.temp_file_id ?? '') || fileId,
};
if (fileToRecover) {
setFiles((currentFiles) => {
const updatedFiles = new Map(currentFiles);
updatedFiles.set(fileIdToRecover, {
...fileToRecover,
progress: 1,
attached: true,
size: fileToRecover.bytes,
});
return updatedFiles;
});
}
});
},
[fileList, setFiles],
);
const restoreText = useCallback(
(id: string) => {
const savedDraft = getDraft(id);
if (!savedDraft) {
return;
}
setValue('text', savedDraft);
},
[setValue],
);
const saveText = useCallback(
(id: string) => {
if (!textAreaRef?.current) {
return;
}
// Save the draft of the current conversation before switching
if (textAreaRef.current.value === '' || textAreaRef.current.value.length === 1) {
clearDraft(id);
} else {
setDraft({ id, value: textAreaRef.current.value });
}
},
[textAreaRef],
);
useEffect(() => {
// This useEffect is responsible for setting up and cleaning up the auto-save functionality
// for the text area input. It saves the text to localStorage with a debounce to prevent
// excessive writes.
if (!saveDrafts || conversationId == null || conversationId === '') {
return;
}
/** Use shorter debounce for saving text (25ms) to capture rapid typing */
const handleInputFast = debounce(
(value: string) => setDraft({ id: conversationId, value }),
25,
);
/** Use longer debounce for clearing empty values (850ms) to prevent accidental draft loss */
const handleInputSlow = debounce(
(value: string) => setDraft({ id: conversationId, value }),
850,
);
const eventListener = (e: Event) => {
const target = e.target as HTMLTextAreaElement;
const value = target.value;
/** Cancel any pending operations to avoid conflicts */
handleInputFast.cancel();
handleInputSlow.cancel();
/** If empty, use long delay to prevent accidental clearing
* Otherwise use short delay to capture rapid typing */
if (value === '') {
handleInputSlow(value);
} else {
handleInputFast(value);
}
};
const textArea = textAreaRef?.current;
if (textArea) {
textArea.addEventListener('input', eventListener);
}
return () => {
if (textArea) {
textArea.removeEventListener('input', eventListener);
}
handleInputFast.cancel();
handleInputSlow.cancel();
};
}, [conversationId, saveDrafts, textAreaRef]);
const prevConversationIdRef = useRef<string | null>(null);
useEffect(() => {
// This useEffect is responsible for saving the current conversation's draft and
// restoring the new conversation's draft when switching between conversations.
// It handles both text and file drafts, ensuring that the user's input is preserved
// across different conversations.
if (!saveDrafts || conversationId == null || conversationId === '') {
return;
}
if (conversationId === currentConversationId) {
return;
}
// clear attachment files when switching conversation
setFiles(new Map());
try {
// Check for transition from PENDING_CONVO to a valid conversationId
if (
prevConversationIdRef.current === Constants.PENDING_CONVO &&
conversationId !== Constants.PENDING_CONVO &&
conversationId.length > 3
) {
const pendingDraft = localStorage.getItem(
`${LocalStorageKeys.TEXT_DRAFT}${Constants.PENDING_CONVO}`,
);
// Clear the pending text draft, if it exists, and save the current draft to the new conversationId;
// otherwise, save the current text area value to the new conversationId
localStorage.removeItem(`${LocalStorageKeys.TEXT_DRAFT}${Constants.PENDING_CONVO}`);
if (pendingDraft) {
localStorage.setItem(`${LocalStorageKeys.TEXT_DRAFT}${conversationId}`, pendingDraft);
} else if (textAreaRef?.current?.value) {
setDraft({ id: conversationId, value: textAreaRef.current.value });
}
const pendingFileDraft = localStorage.getItem(
`${LocalStorageKeys.FILES_DRAFT}${Constants.PENDING_CONVO}`,
);
if (pendingFileDraft) {
localStorage.setItem(
`${LocalStorageKeys.FILES_DRAFT}${conversationId}`,
pendingFileDraft,
);
localStorage.removeItem(`${LocalStorageKeys.FILES_DRAFT}${Constants.PENDING_CONVO}`);
const filesDraft = JSON.parse(pendingFileDraft || '[]') as string[];
if (filesDraft.length > 0) {
restoreFiles(conversationId);
}
}
} else if (currentConversationId != null && currentConversationId) {
saveText(currentConversationId);
}
restoreText(conversationId);
restoreFiles(conversationId);
} catch (e) {
console.error(e);
}
prevConversationIdRef.current = conversationId;
setCurrentConversationId(conversationId);
}, [
currentConversationId,
conversationId,
restoreFiles,
textAreaRef,
restoreText,
saveDrafts,
saveText,
setFiles,
]);
useEffect(() => {
// This useEffect is responsible for saving or removing the current conversation's file drafts
// in localStorage whenever the file attachments change.
// It ensures that the file drafts are kept up-to-date and can be restored
// when the conversation is revisited.
if (
!saveDrafts ||
conversationId == null ||
conversationId === '' ||
currentConversationId !== conversationId
) {
return;
}
if (fileIds.length === 0) {
localStorage.removeItem(`${LocalStorageKeys.FILES_DRAFT}${conversationId}`);
} else {
localStorage.setItem(
`${LocalStorageKeys.FILES_DRAFT}${conversationId}`,
JSON.stringify(fileIds),
);
}
}, [files, conversationId, saveDrafts, currentConversationId, fileIds]);
};