| import { memo, useRef, useMemo, useEffect, useState, useCallback } from 'react'; |
| import { useWatch } from 'react-hook-form'; |
| import { TextareaAutosize } from '@librechat/client'; |
| import { useRecoilState, useRecoilValue } from 'recoil'; |
| import { Constants, isAssistantsEndpoint, isAgentsEndpoint } from 'librechat-data-provider'; |
| import { |
| useChatContext, |
| useChatFormContext, |
| useAddedChatContext, |
| useAssistantsMapContext, |
| } from '~/Providers'; |
| import { |
| useTextarea, |
| useAutoSave, |
| useLocalize, |
| useRequiresKey, |
| useHandleKeyUp, |
| useQueryParams, |
| useSubmitMessage, |
| useFocusChatEffect, |
| } from '~/hooks'; |
| import { mainTextareaId, BadgeItem } from '~/common'; |
| import AttachFileChat from './Files/AttachFileChat'; |
| import FileFormChat from './Files/FileFormChat'; |
| 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<HTMLButtonElement>(null); |
| const textAreaRef = useRef<HTMLTextAreaElement>(null); |
| useFocusChatEffect(textAreaRef); |
| const localize = useLocalize(); |
|
|
| const [isCollapsed, setIsCollapsed] = useState(false); |
| const [, setIsScrollable] = useState(false); |
| const [visualRowCount, setVisualRowCount] = useState(1); |
| const [isTextAreaFocused, setIsTextAreaFocused] = useState(false); |
| const [backupBadges, setBackupBadges] = useState<Pick<BadgeItem, 'id'>[]>([]); |
|
|
| 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(() => { |
| |
| if (window.matchMedia?.('(pointer: coarse)').matches) { |
| return; |
| } |
| textAreaRef.current?.focus(); |
| }, []); |
|
|
| const handleFocusOrClick = useCallback(() => { |
| if (isCollapsed) { |
| setIsCollapsed(false); |
| } |
| }, [isCollapsed]); |
|
|
| useAutoSave({ |
| files, |
| setFiles, |
| textAreaRef, |
| conversationId, |
| isSubmitting: isSubmitting || isSubmittingAdded, |
| }); |
|
|
| const { submitMessage, submitPrompt } = useSubmitMessage(); |
|
|
| const handleKeyUp = useHandleKeyUp({ |
| index, |
| textAreaRef, |
| setShowPlusPopover, |
| setShowMentionPopover, |
| }); |
| const { |
| isNotAppendable, |
| 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<HTMLTextAreaElement>) => |
| methods.setValue('text', e.target.value, { shouldValidate: true }), |
| [methods], |
| ), |
| }); |
|
|
| const textValue = useWatch({ control: methods.control, name: 'text' }); |
|
|
| 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 ( |
| <form |
| onSubmit={methods.handleSubmit(submitMessage)} |
| className={cn( |
| 'mx-auto flex w-full flex-row gap-3 transition-[max-width] duration-300 sm:px-2', |
| maximizeChatSpace ? 'max-w-full' : 'md:max-w-3xl xl:max-w-4xl', |
| centerFormOnLanding && |
| (conversationId == null || conversationId === Constants.NEW_CONVO) && |
| !isSubmitting && |
| conversation?.messages?.length === 0 |
| ? 'transition-all duration-200 sm:mb-28' |
| : 'sm:mb-10', |
| )} |
| > |
| <div className="relative flex h-full flex-1 items-stretch md:flex-col"> |
| <div className={cn('flex w-full items-center', isRTL && 'flex-row-reverse')}> |
| {showPlusPopover && !isAssistantsEndpoint(endpoint) && ( |
| <Mention |
| conversation={conversation} |
| setShowMentionPopover={setShowPlusPopover} |
| newConversation={generateConversation} |
| textAreaRef={textAreaRef} |
| commandChar="+" |
| placeholder="com_ui_add_model_preset" |
| includeAssistants={false} |
| /> |
| )} |
| {showMentionPopover && ( |
| <Mention |
| conversation={conversation} |
| setShowMentionPopover={setShowMentionPopover} |
| newConversation={newConversation} |
| textAreaRef={textAreaRef} |
| /> |
| )} |
| <PromptsCommand index={index} textAreaRef={textAreaRef} submitPrompt={submitPrompt} /> |
| <div |
| onClick={handleContainerClick} |
| className={cn( |
| 'relative flex w-full flex-grow flex-col overflow-hidden rounded-t-3xl border pb-4 text-text-primary transition-all duration-200 sm:rounded-3xl sm:pb-0', |
| isTextAreaFocused ? 'shadow-lg' : 'shadow-md', |
| isTemporary |
| ? 'border-violet-800/60 bg-violet-950/10' |
| : 'border-border-light bg-surface-chat', |
| )} |
| > |
| <TextareaHeader addedConvo={addedConvo} setAddedConvo={setAddedConvo} /> |
| <EditBadges |
| isEditingChatBadges={isEditingBadges} |
| handleCancelBadges={handleCancelBadges} |
| handleSaveBadges={handleSaveBadges} |
| setBadges={setBadges} |
| /> |
| <FileFormChat conversation={conversation} /> |
| {endpoint && ( |
| <div className={cn('flex', isRTL ? 'flex-row-reverse' : 'flex-row')}> |
| <div className="relative flex-1"> |
| <TextareaAutosize |
| {...registerProps} |
| ref={(e) => { |
| ref(e); |
| (textAreaRef as React.MutableRefObject<HTMLTextAreaElement | null>).current = |
| e; |
| }} |
| disabled={disableInputs || isNotAppendable} |
| 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)} |
| aria-label={localize('com_ui_message_input')} |
| onClick={handleFocusOrClick} |
| style={{ height: 44, overflowY: 'auto' }} |
| className={cn( |
| baseClasses, |
| removeFocusRings, |
| 'scrollbar-hover transition-[max-height] duration-200 disabled:cursor-not-allowed', |
| )} |
| /> |
| {isCollapsed && ( |
| <div |
| className="pointer-events-none absolute bottom-0 left-0 right-0 h-10 transition-all duration-200" |
| style={{ |
| backdropFilter: 'blur(2px)', |
| WebkitMaskImage: 'linear-gradient(to top, black 15%, transparent 75%)', |
| maskImage: 'linear-gradient(to top, black 15%, transparent 75%)', |
| }} |
| /> |
| )} |
| </div> |
| <div className="flex flex-col items-start justify-start pr-2.5 pt-1.5"> |
| <CollapseChat |
| isCollapsed={isCollapsed} |
| isScrollable={isMoreThanThreeRows} |
| setIsCollapsed={setIsCollapsed} |
| /> |
| </div> |
| </div> |
| )} |
| <div |
| className={cn( |
| '@container items-between flex gap-2 pb-2', |
| isRTL ? 'flex-row-reverse' : 'flex-row', |
| )} |
| > |
| <div className={`${isRTL ? 'mr-2' : 'ml-2'}`}> |
| <AttachFileChat conversation={conversation} disableInputs={disableInputs} /> |
| </div> |
| <BadgeRow |
| showEphemeralBadges={!isAgentsEndpoint(endpoint) && !isAssistantsEndpoint(endpoint)} |
| isSubmitting={isSubmitting || isSubmittingAdded} |
| conversationId={conversationId} |
| onChange={setBadges} |
| isInChat={ |
| Array.isArray(conversation?.messages) && conversation.messages.length >= 1 |
| } |
| /> |
| <div className="mx-auto flex" /> |
| {SpeechToText && ( |
| <AudioRecorder |
| methods={methods} |
| ask={submitMessage} |
| textAreaRef={textAreaRef} |
| disabled={disableInputs || isNotAppendable} |
| isSubmitting={isSubmitting} |
| /> |
| )} |
| <div className={`${isRTL ? 'ml-2' : 'mr-2'}`}> |
| {(isSubmitting || isSubmittingAdded) && (showStopButton || showStopAdded) ? ( |
| <StopButton stop={handleStopGenerating} setShowStopButton={setShowStopButton} /> |
| ) : ( |
| endpoint && ( |
| <SendButton |
| ref={submitButtonRef} |
| control={methods.control} |
| disabled={filesLoading || isSubmitting || disableInputs || isNotAppendable} |
| /> |
| ) |
| )} |
| </div> |
| </div> |
| {TextToSpeech && automaticPlayback && <StreamAudio index={index} />} |
| </div> |
| </div> |
| </div> |
| </form> |
| ); |
| }); |
| |
| export default ChatForm; |
| |