| 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 { globalAudioId } from '~/common'; |
| import { useLocalize } from '~/hooks'; |
| import store from '~/store'; |
|
|
| type KeyEvent = KeyboardEvent<HTMLTextAreaElement>; |
|
|
| export default function useTextarea({ |
| textAreaRef, |
| submitButtonRef, |
| setIsScrollable, |
| disabled = false, |
| }: { |
| textAreaRef: React.RefObject<HTMLTextAreaElement>; |
| submitButtonRef: React.RefObject<HTMLButtonElement>; |
| setIsScrollable: React.Dispatch<React.SetStateAction<boolean>>; |
| 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; |
| |
|
|
| 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); |
|
|
| |
| 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<HTMLTextAreaElement>) => { |
| 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, |
| isNotAppendable, |
| handleCompositionEnd, |
| handleCompositionStart, |
| }; |
| } |
|
|