import debounce from 'lodash/debounce'; import { useEffect, useRef, useCallback } from 'react'; import { useRecoilValue, useRecoilState } from 'recoil'; import type { TEndpointOption } from 'librechat-data-provider'; import type { KeyboardEvent } from 'react'; import { forceResize, insertTextAtCursor, getEntityName, getEntity, checkIfScrollable, } from '~/utils'; import { useAssistantsMapContext } from '~/Providers/AssistantsMapContext'; import { useAgentsMapContext } from '~/Providers/AgentsMapContext'; import useGetSender from '~/hooks/Conversations/useGetSender'; import useFileHandling from '~/hooks/Files/useFileHandling'; import { useInteractionHealthCheck } from '~/data-provider'; import { useChatContext } from '~/Providers/ChatContext'; import useLocalize from '~/hooks/useLocalize'; import { globalAudioId } from '~/common'; import store from '~/store'; type KeyEvent = KeyboardEvent; export default function useTextarea({ textAreaRef, submitButtonRef, setIsScrollable, disabled = false, }: { textAreaRef: React.RefObject; submitButtonRef: React.RefObject; setIsScrollable: React.Dispatch>; disabled?: boolean; }) { const localize = useLocalize(); const getSender = useGetSender(); const isComposing = useRef(false); const agentsMap = useAgentsMapContext(); const { handleFiles } = useFileHandling(); const assistantMap = useAssistantsMapContext(); const checkHealth = useInteractionHealthCheck(); const enterToSend = useRecoilValue(store.enterToSend); const { index, conversation, isSubmitting, filesLoading, latestMessage, setFilesLoading } = useChatContext(); const [activePrompt, setActivePrompt] = useRecoilState(store.activePromptByIndex(index)); const { endpoint = '' } = conversation || {}; const { entity, isAgent, isAssistant } = getEntity({ endpoint, agentsMap, assistantMap, agent_id: conversation?.agent_id, assistant_id: conversation?.assistant_id, }); const entityName = entity?.name ?? ''; const isNotAppendable = (((latestMessage?.unfinished ?? false) && !isSubmitting) || (latestMessage?.error ?? false)) && !isAssistant; // && (conversationId?.length ?? 0) > 6; // also ensures that we don't show the wrong placeholder useEffect(() => { const prompt = activePrompt ?? ''; if (prompt && textAreaRef.current) { insertTextAtCursor(textAreaRef.current, prompt); forceResize(textAreaRef.current); setActivePrompt(undefined); } }, [activePrompt, setActivePrompt, textAreaRef]); useEffect(() => { const currentValue = textAreaRef.current?.value ?? ''; if (currentValue) { return; } const getPlaceholderText = () => { if (disabled) { return localize('com_endpoint_config_placeholder'); } const currentEndpoint = conversation?.endpoint ?? ''; const currentAgentId = conversation?.agent_id ?? ''; const currentAssistantId = conversation?.assistant_id ?? ''; if (isAgent && (!currentAgentId || !agentsMap?.[currentAgentId])) { return localize('com_endpoint_agent_placeholder'); } else if ( isAssistant && (!currentAssistantId || !assistantMap?.[currentEndpoint]?.[currentAssistantId]) ) { return localize('com_endpoint_assistant_placeholder'); } if (isNotAppendable) { return localize('com_endpoint_message_not_appendable'); } const sender = isAssistant || isAgent ? getEntityName({ name: entityName, isAgent, localize }) : getSender(conversation as TEndpointOption); return `${localize( 'com_endpoint_message_new', { 0: sender ? sender : localize('com_endpoint_ai') }, )}`; }; const placeholder = getPlaceholderText(); if (textAreaRef.current?.getAttribute('placeholder') === placeholder) { return; } const setPlaceholder = () => { const placeholder = getPlaceholderText(); if (textAreaRef.current?.getAttribute('placeholder') !== placeholder) { textAreaRef.current?.setAttribute('placeholder', placeholder); forceResize(textAreaRef.current); } }; const debouncedSetPlaceholder = debounce(setPlaceholder, 80); debouncedSetPlaceholder(); return () => debouncedSetPlaceholder.cancel(); }, [ isAgent, localize, disabled, getSender, agentsMap, entityName, textAreaRef, isAssistant, assistantMap, conversation, latestMessage, isNotAppendable, ]); const handleKeyDown = useCallback( (e: KeyEvent) => { if (textAreaRef.current && checkIfScrollable(textAreaRef.current)) { const scrollable = checkIfScrollable(textAreaRef.current); scrollable && setIsScrollable(scrollable); } if (e.key === 'Enter' && isSubmitting) { return; } checkHealth(); const isNonShiftEnter = e.key === 'Enter' && !e.shiftKey; const isCtrlEnter = e.key === 'Enter' && (e.ctrlKey || e.metaKey); // NOTE: isComposing and e.key behave differently in Safari compared to other browsers, forcing us to use e.keyCode instead const isComposingInput = isComposing.current || e.key === 'Process' || e.keyCode === 229; if (isNonShiftEnter && filesLoading) { e.preventDefault(); } if (isNonShiftEnter) { e.preventDefault(); } if ( e.key === 'Enter' && !enterToSend && !isCtrlEnter && textAreaRef.current && !isComposingInput ) { e.preventDefault(); insertTextAtCursor(textAreaRef.current, '\n'); forceResize(textAreaRef.current); return; } if ((isNonShiftEnter || isCtrlEnter) && !isComposingInput) { const globalAudio = document.getElementById(globalAudioId) as HTMLAudioElement | undefined; if (globalAudio) { console.log('Unmuting global audio'); globalAudio.muted = false; } submitButtonRef.current?.click(); } }, [ isSubmitting, checkHealth, filesLoading, enterToSend, setIsScrollable, textAreaRef, submitButtonRef, ], ); const handleCompositionStart = () => { isComposing.current = true; }; const handleCompositionEnd = () => { isComposing.current = false; }; const handlePaste = useCallback( (e: React.ClipboardEvent) => { const textArea = textAreaRef.current; if (!textArea) { return; } const clipboardData = e.clipboardData as DataTransfer | undefined; if (!clipboardData) { return; } if (clipboardData.files.length > 0) { setFilesLoading(true); const timestampedFiles: File[] = []; for (const file of clipboardData.files) { const newFile = new File([file], `clipboard_${+new Date()}_${file.name}`, { type: file.type, }); timestampedFiles.push(newFile); } handleFiles(timestampedFiles); } }, [handleFiles, setFilesLoading, textAreaRef], ); return { textAreaRef, handlePaste, handleKeyDown, handleCompositionStart, handleCompositionEnd, }; }