import { memo, useRef, useMemo, useEffect, useState, useCallback } from 'react'; import { useWatch } from 'react-hook-form'; import { useRecoilState, useRecoilValue } from 'recoil'; import { Constants, isAssistantsEndpoint, isAgentsEndpoint } from 'librechat-data-provider'; import { useChatContext, useChatFormContext, useAddedChatContext, useAssistantsMapContext, } from '~/Providers'; import { useTextarea, useAutoSave, useRequiresKey, useHandleKeyUp, useQueryParams, useSubmitMessage, } from '~/hooks'; import { mainTextareaId, BadgeItem } from '~/common'; import AttachFileChat from './Files/AttachFileChat'; import FileFormChat from './Files/FileFormChat'; import { TextareaAutosize } from '~/components'; import { cn, removeFocusRings } from '~/utils'; import TextareaHeader from './TextareaHeader'; import PromptsCommand from './PromptsCommand'; import AudioRecorder from './AudioRecorder'; import CollapseChat from './CollapseChat'; import StreamAudio from './StreamAudio'; import StopButton from './StopButton'; import SendButton from './SendButton'; import EditBadges from './EditBadges'; import BadgeRow from './BadgeRow'; import Mention from './Mention'; import store from '~/store'; const ChatForm = memo(({ index = 0 }: { index?: number }) => { const submitButtonRef = useRef(null); const textAreaRef = useRef(null); const [isCollapsed, setIsCollapsed] = useState(false); const [, setIsScrollable] = useState(false); const [visualRowCount, setVisualRowCount] = useState(1); const [isTextAreaFocused, setIsTextAreaFocused] = useState(false); const [backupBadges, setBackupBadges] = useState[]>([]); const search = useRecoilValue(store.search); const SpeechToText = useRecoilValue(store.speechToText); const TextToSpeech = useRecoilValue(store.textToSpeech); const chatDirection = useRecoilValue(store.chatDirection); const automaticPlayback = useRecoilValue(store.automaticPlayback); const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace); const centerFormOnLanding = useRecoilValue(store.centerFormOnLanding); const isTemporary = useRecoilValue(store.isTemporary); const [badges, setBadges] = useRecoilState(store.chatBadges); const [isEditingBadges, setIsEditingBadges] = useRecoilState(store.isEditingBadges); const [showStopButton, setShowStopButton] = useRecoilState(store.showStopButtonByIndex(index)); const [showPlusPopover, setShowPlusPopover] = useRecoilState(store.showPlusPopoverFamily(index)); const [showMentionPopover, setShowMentionPopover] = useRecoilState( store.showMentionPopoverFamily(index), ); const { requiresKey } = useRequiresKey(); const methods = useChatFormContext(); const { files, setFiles, conversation, isSubmitting, filesLoading, newConversation, handleStopGenerating, } = useChatContext(); const { addedIndex, generateConversation, conversation: addedConvo, setConversation: setAddedConvo, isSubmitting: isSubmittingAdded, } = useAddedChatContext(); const assistantMap = useAssistantsMapContext(); const showStopAdded = useRecoilValue(store.showStopButtonByIndex(addedIndex)); const endpoint = useMemo( () => conversation?.endpointType ?? conversation?.endpoint, [conversation?.endpointType, conversation?.endpoint], ); const conversationId = useMemo( () => conversation?.conversationId ?? Constants.NEW_CONVO, [conversation?.conversationId], ); const isRTL = useMemo( () => (chatDirection != null ? chatDirection?.toLowerCase() === 'rtl' : false), [chatDirection], ); const invalidAssistant = useMemo( () => isAssistantsEndpoint(endpoint) && (!(conversation?.assistant_id ?? '') || !assistantMap?.[endpoint ?? '']?.[conversation?.assistant_id ?? '']), [conversation?.assistant_id, endpoint, assistantMap], ); const disableInputs = useMemo( () => requiresKey || invalidAssistant, [requiresKey, invalidAssistant], ); const handleContainerClick = useCallback(() => { textAreaRef.current?.focus(); }, []); const handleFocusOrClick = useCallback(() => { if (isCollapsed) { setIsCollapsed(false); } }, [isCollapsed]); useAutoSave({ files, setFiles, textAreaRef, conversationId, }); const { submitMessage, submitPrompt } = useSubmitMessage(); const handleKeyUp = useHandleKeyUp({ index, textAreaRef, setShowPlusPopover, setShowMentionPopover, }); const { handlePaste, handleKeyDown, handleCompositionStart, handleCompositionEnd } = useTextarea({ textAreaRef, submitButtonRef, setIsScrollable, disabled: disableInputs, }); useQueryParams({ textAreaRef }); const { ref, ...registerProps } = methods.register('text', { required: true, onChange: useCallback( (e: React.ChangeEvent) => methods.setValue('text', e.target.value, { shouldValidate: true }), [methods], ), }); const textValue = useWatch({ control: methods.control, name: 'text' }); useEffect(() => { if (!search.isSearching && textAreaRef.current && !disableInputs) { textAreaRef.current.focus(); } }, [search.isSearching, disableInputs]); useEffect(() => { if (textAreaRef.current) { const style = window.getComputedStyle(textAreaRef.current); const lineHeight = parseFloat(style.lineHeight); setVisualRowCount(Math.floor(textAreaRef.current.scrollHeight / lineHeight)); } }, [textValue]); useEffect(() => { if (isEditingBadges && backupBadges.length === 0) { setBackupBadges([...badges]); } }, [isEditingBadges, badges, backupBadges.length]); const handleSaveBadges = useCallback(() => { setIsEditingBadges(false); setBackupBadges([]); }, [setIsEditingBadges, setBackupBadges]); const handleCancelBadges = useCallback(() => { if (backupBadges.length > 0) { setBadges([...backupBadges]); } setIsEditingBadges(false); setBackupBadges([]); }, [backupBadges, setBadges, setIsEditingBadges]); const isMoreThanThreeRows = visualRowCount > 3; const baseClasses = useMemo( () => cn( 'md:py-3.5 m-0 w-full resize-none py-[13px] placeholder-black/50 bg-transparent dark:placeholder-white/50 [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)]', isCollapsed ? 'max-h-[52px]' : 'max-h-[45vh] md:max-h-[55vh]', isMoreThanThreeRows ? 'pl-5' : 'px-5', ), [isCollapsed, isMoreThanThreeRows], ); return (
{showPlusPopover && !isAssistantsEndpoint(endpoint) && ( )} {showMentionPopover && ( )}
{endpoint && (
{ ref(e); (textAreaRef as React.MutableRefObject).current = e; }} disabled={disableInputs} onPaste={handlePaste} onKeyDown={handleKeyDown} onKeyUp={handleKeyUp} onCompositionStart={handleCompositionStart} onCompositionEnd={handleCompositionEnd} id={mainTextareaId} tabIndex={0} data-testid="text-input" rows={1} onFocus={() => { handleFocusOrClick(); setIsTextAreaFocused(true); }} onBlur={setIsTextAreaFocused.bind(null, false)} onClick={handleFocusOrClick} style={{ height: 44, overflowY: 'auto' }} className={cn( baseClasses, removeFocusRings, 'transition-[max-height] duration-200', )} />
)}
= 1 } />
{SpeechToText && ( )}
{(isSubmitting || isSubmittingAdded) && (showStopButton || showStopAdded) ? ( ) : ( endpoint && ( ) )}
{TextToSpeech && automaticPlayback && }
); }); export default ChatForm;