apps/chat/src/components/Chat/ChatMessage/ChatMessageContent/UserMessage.tsx (512 lines of code) (raw):

import { IconPaperclip } from '@tabler/icons-react'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'next-i18next'; import classNames from 'classnames'; import { isEntityNameOrPathInvalid } from '@/src/utils/app/common'; import { getDialFilesFromAttachments, getDialFoldersFromAttachments, getDialLinksFromAttachments, getUserCustomContent, } from '@/src/utils/app/file'; import { getMessageFormValue, isMessageInputDisabled, } from '@/src/utils/app/form-schema'; import { isFolderId } from '@/src/utils/app/id'; import { isSmallScreen } from '@/src/utils/app/mobile'; import { getEntitiesFromTemplateMapping } from '@/src/utils/app/prompts'; import { ApiUtils } from '@/src/utils/server/api'; import { Conversation } from '@/src/types/chat'; import { DialFile, DialLink, FileFolderInterface } from '@/src/types/files'; import { FolderInterface } from '@/src/types/folder'; import { Translation } from '@/src/types/translation'; import { ConversationsSelectors } from '@/src/store/conversations/conversations.reducers'; import { FilesActions, FilesSelectors } from '@/src/store/files/files.reducers'; import { useAppDispatch, useAppSelector } from '@/src/store/hooks'; import { SettingsSelectors } from '@/src/store/settings/settings.reducers'; import { UISelectors } from '@/src/store/ui/ui.reducers'; import { FOLDER_ATTACHMENT_CONTENT_TYPE } from '@/src/constants/folders'; import { ChatInputAttachments } from '@/src/components/Chat/ChatInput/ChatInputAttachments'; import { AdjustedTextarea } from '@/src/components/Chat/ChatMessage/AdjustedTextarea'; import { MessageUserButtons } from '@/src/components/Chat/ChatMessage/MessageButtons'; import { UserSchema } from '@/src/components/Chat/ChatMessage/MessageSchema/MessageSchema'; import { MessageAttachments } from '@/src/components/Chat/MessageAttachments'; import { AttachButton } from '@/src/components/Files/AttachButton'; import { Feature, Message, MessageFormValue, UploadStatus, } from '@epam/ai-dial-shared'; import isEqual from 'lodash-es/isEqual'; import uniq from 'lodash-es/uniq'; interface UserMessageProps { message: Message; conversation: Conversation; messageIndex: number; isEditing: boolean; isEditingTemplates: boolean; toggleEditing: (value: boolean) => void; toggleEditingTemplates: (value: boolean) => void; withButtons?: boolean; editDisabled?: boolean; onEdit?: (editedMessage: Message, index: number) => void; onDelete?: () => void; } export const UserMessage = memo(function UserMessage({ message, conversation, messageIndex, isEditing, isEditingTemplates, toggleEditing, toggleEditingTemplates, withButtons, editDisabled, onEdit, onDelete, }: UserMessageProps) { const { t } = useTranslation(Translation.Chat); const dispatch = useAppDispatch(); const anchorRef = useRef<HTMLDivElement>(null); const isReplay = useAppSelector( ConversationsSelectors.selectIsReplaySelectedConversations, ); const isExternal = useAppSelector( ConversationsSelectors.selectAreSelectedConversationsExternal, ); const isPlayback = useAppSelector( ConversationsSelectors.selectIsPlaybackSelectedConversations, ); const isOverlay = useAppSelector(SettingsSelectors.selectIsOverlay); const canAttachFolders = useAppSelector( ConversationsSelectors.selectCanAttachFolders, ); const files = useAppSelector(FilesSelectors.selectFiles); const folders = useAppSelector(FilesSelectors.selectFolders); const canAttachFiles = useAppSelector( ConversationsSelectors.selectCanAttachFile, ); const canAttachLinks = useAppSelector( ConversationsSelectors.selectCanAttachLink, ); const isMessageTemplatesEnabled = useAppSelector((state) => SettingsSelectors.isFeatureEnabled(state, Feature.MessageTemplates), ); const isChatFullWidth = useAppSelector(UISelectors.selectIsChatFullWidth); const isMobileOrOverlay = isSmallScreen() || isOverlay; const isInputDisabled = isMessageInputDisabled( messageIndex, conversation.messages, ); const [messageContent, setMessageContent] = useState(message.content); const [formValue, setFormValue] = useState(getMessageFormValue(message)); const [isTyping, setIsTyping] = useState<boolean>(false); const [shouldScroll, setShouldScroll] = useState(false); const [selectedDialLinks, setSelectedDialLinks] = useState<DialLink[]>([]); const showUserButtons = !isReplay && !isPlayback && !isEditing && !isExternal && withButtons; const isConversationInvalid = isEntityNameOrPathInvalid(conversation); const mappedUserEditableAttachments = useMemo(() => { return [ ...(getDialFoldersFromAttachments( message.custom_content?.attachments, ) as unknown as Omit<DialFile, 'contentLength'>[]), ...getDialFilesFromAttachments(message.custom_content?.attachments), ]; }, [message.custom_content?.attachments]); const mappedUserEditableAttachmentsIds = useMemo(() => { return mappedUserEditableAttachments.map(({ id }) => id); }, [mappedUserEditableAttachments]); const [newEditableAttachmentsIds, setNewEditableAttachmentsIds] = useState< string[] >(mappedUserEditableAttachmentsIds); const newEditableAttachments = useMemo(() => { const newIds = newEditableAttachmentsIds.filter( (id) => !mappedUserEditableAttachmentsIds.includes(id), ); const newFiles = newIds .map((id) => files.find((file) => file.id === id)) .filter(Boolean) as DialFile[]; const newFolders = newIds .map( (id) => canAttachFolders && folders.find((folder) => folder.id === id), ) .filter(Boolean) .map((folder) => ({ ...folder, contentType: FOLDER_ATTACHMENT_CONTENT_TYPE, })) as DialFile[]; return mappedUserEditableAttachments .filter(({ id }) => newEditableAttachmentsIds.includes(id)) .concat(newFiles) .concat(newFolders); }, [ canAttachFolders, files, folders, mappedUserEditableAttachments, mappedUserEditableAttachmentsIds, newEditableAttachmentsIds, ]); const fileAttachments = useMemo( () => newEditableAttachments.filter( (f) => f.contentType !== FOLDER_ATTACHMENT_CONTENT_TYPE, ), [newEditableAttachments], ); const folderAttachments = useMemo( () => canAttachFolders ? (newEditableAttachments.filter( (f) => f.contentType === FOLDER_ATTACHMENT_CONTENT_TYPE, ) as unknown as FileFolderInterface[]) : undefined, [canAttachFolders, newEditableAttachments], ); const isUploadingAttachmentPresent = useMemo( () => newEditableAttachments.some( (item) => item.status === UploadStatus.LOADING, ), [newEditableAttachments], ); const isContentEmptyAndNoAttachments = useMemo( () => messageContent.trim().length <= 0 && newEditableAttachments.length <= 0, [messageContent, newEditableAttachments], ); const selectedFileIds = useMemo( () => newEditableAttachments.map((f) => f.contentType === FOLDER_ATTACHMENT_CONTENT_TYPE ? ApiUtils.decodeApiUrl(f.id).replace(new RegExp('^metadata/'), '') + '/' : f.id, ), [newEditableAttachments], ); const isInputHidden = isInputDisabled && !messageContent && !newEditableAttachments.length && !selectedDialLinks.length; const handleInputChange = useCallback( (event: React.ChangeEvent<HTMLTextAreaElement>) => { setMessageContent(event.target.value); }, [], ); const handleToggleEditing = useCallback( (value?: boolean) => { toggleEditing(value ?? !isEditing); setShouldScroll(true); }, [isEditing, toggleEditing], ); const handleAddLinkToMessage = useCallback((link: DialLink) => { setSelectedDialLinks((links) => links.concat([link])); }, []); const handleUnselectLink = useCallback((unselectedIndex: number) => { setSelectedDialLinks((links) => links.filter((_link, index) => unselectedIndex !== index), ); }, []); const handleEditMessage = useCallback( (formValue?: MessageFormValue, newContent?: string) => { const attachments = getUserCustomContent( newEditableAttachments.filter( (a) => !(a as unknown as FolderInterface).type && a.contentType !== FOLDER_ATTACHMENT_CONTENT_TYPE, ), newEditableAttachments.filter( (a) => !!(a as unknown as FolderInterface).type || a.contentType === FOLDER_ATTACHMENT_CONTENT_TYPE, ) as unknown as FolderInterface[], selectedDialLinks, ); const isAttachmentsSame = isEqual( message.custom_content?.attachments, attachments?.attachments, ); const isFormValueChanged = !isEqual( getMessageFormValue(message), formValue, ); const isContentChanged = message.content !== (newContent ?? messageContent); if (isContentChanged || !isAttachmentsSame || isFormValueChanged) { if (conversation && onEdit) { onEdit( { ...message, content: newContent ?? messageContent, custom_content: { attachments: message.custom_content?.attachments && !attachments ? [] : attachments?.attachments, ...(getMessageFormValue(message) && { form_value: formValue ?? getMessageFormValue(message), }), }, templateMapping: getEntitiesFromTemplateMapping( message.templateMapping, ).filter(([key]) => messageContent.includes(key)), }, messageIndex, ); setSelectedDialLinks([]); } } handleToggleEditing(false); }, [ message, messageContent, handleToggleEditing, conversation, onEdit, newEditableAttachments, selectedDialLinks, messageIndex, ], ); const handlePressEnter = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { if (e.key === 'Enter' && !isTyping && !e.shiftKey) { e.preventDefault(); handleEditMessage(); } }; const handleUnselectFile = useCallback( (fileId: string) => { dispatch(FilesActions.uploadFileCancel({ id: fileId })); const fid = isFolderId(fileId) ? fileId.slice(0, -1) : fileId; setNewEditableAttachmentsIds((ids) => ids.filter((id) => id !== fid)); }, [dispatch], ); const handleRetry = useCallback( (fileId: string) => { return () => dispatch(FilesActions.reuploadFile({ fileId })); }, [dispatch], ); const handleSelectAlreadyUploaded = useCallback((result: unknown) => { if (typeof result === 'object') { const selectedFilesIds = result as string[]; const uniqueFilesIds = uniq(selectedFilesIds); setNewEditableAttachmentsIds( uniqueFilesIds.map((id) => (isFolderId(id) ? id.slice(0, -1) : id)), ); } }, []); const handleUploadFromDevice = useCallback( ( selectedFiles: Required<Pick<DialFile, 'fileContent' | 'id' | 'name'>>[], folderPath: string | undefined, ) => { selectedFiles.forEach((file) => { dispatch( FilesActions.uploadFile({ fileContent: file.fileContent, id: file.id, relativePath: folderPath, name: file.name, }), ); }); setNewEditableAttachmentsIds((ids) => uniq(ids.concat(selectedFiles.map(({ id }) => id))), ); }, [dispatch], ); const handleToggleEditingTemplates = useCallback( (value?: boolean) => { toggleEditingTemplates(value ?? !isEditingTemplates); }, [isEditingTemplates, toggleEditingTemplates], ); useEffect(() => { setMessageContent(message.content); }, [message.content]); useEffect(() => { if (getMessageFormValue(message)) { setFormValue(getMessageFormValue(message)); } }, [message]); useEffect(() => { const links = getDialLinksFromAttachments( message.custom_content?.attachments, ); setSelectedDialLinks(links); }, [message.custom_content?.attachments]); useEffect(() => { setNewEditableAttachmentsIds(mappedUserEditableAttachmentsIds); }, [mappedUserEditableAttachmentsIds]); useEffect(() => { if (isEditing) { setShouldScroll(true); } }, [isEditing]); useEffect(() => { if (shouldScroll) { anchorRef.current?.scrollIntoView({ block: 'end' }); setShouldScroll(false); } }, [shouldScroll]); if (isEditing) return ( <div className="flex w-full flex-col gap-3"> <UserSchema messageIndex={messageIndex} allMessages={conversation.messages} isEditing={isEditing} setInputValue={setMessageContent} onSubmit={handleEditMessage} disabled={isUploadingAttachmentPresent} formValue={formValue} setFormValue={setFormValue} /> {!isInputHidden && ( <div className={classNames( 'relative min-h-[100px] rounded border border-primary bg-layer-3 px-3 py-2 focus-within:border-accent-primary', !isOverlay && 'text-base', )} > <AdjustedTextarea className="w-full grow resize-none whitespace-pre-wrap bg-transparent focus-visible:outline-none" value={messageContent} onChange={handleInputChange} onKeyDown={handlePressEnter} disabled={isInputDisabled} onCompositionStart={() => setIsTyping(true)} onCompositionEnd={() => setIsTyping(false)} style={{ fontFamily: 'inherit', fontSize: 'inherit', lineHeight: 'inherit', margin: '0', overflow: 'hidden', }} /> {(newEditableAttachments.length > 0 || selectedDialLinks.length > 0) && ( <div className="mb-2.5 grid max-h-[100px] grid-cols-1 gap-1 overflow-auto sm:grid-cols-2 md:grid-cols-3" data-qa="attachment-container" > <ChatInputAttachments files={fileAttachments} folders={folderAttachments} links={selectedDialLinks} onUnselectFile={handleUnselectFile} onRetryFile={handleRetry} onUnselectLink={handleUnselectLink} /> </div> )} </div> )} <div className={classNames( 'flex items-center', !canAttachFiles && !canAttachFolders && !canAttachLinks ? 'justify-end' : 'justify-between', )} > <div className="size-[34px]"> <AttachButton contextMenuPlacement="bottom-start" TriggerCustomRenderer={ <div className="flex size-[34px] cursor-pointer items-center justify-center rounded hover:bg-accent-primary-alpha"> <IconPaperclip strokeWidth="1.5" size={24} width={24} height={24} /> </div> } selectedFilesIds={selectedFileIds} onSelectAlreadyUploaded={handleSelectAlreadyUploaded} onUploadFromDevice={handleUploadFromDevice} onAddLinkToMessage={handleAddLinkToMessage} /> </div> <div className="relative flex gap-3"> <button className="button button-secondary" onClick={() => { setMessageContent(message.content); setNewEditableAttachmentsIds(mappedUserEditableAttachmentsIds); handleToggleEditing(false); }} data-qa="cancel" > {t('Cancel')} </button> {!isInputHidden && ( <button className="button button-primary" onClick={() => handleEditMessage()} disabled={ isUploadingAttachmentPresent || isContentEmptyAndNoAttachments } data-qa="save-and-submit" > {t('Save & Submit')} </button> )} <div ref={anchorRef} className="absolute bottom-0"></div> </div> </div> </div> ); return ( <> <div className="relative mr-2 flex w-full flex-col gap-5"> <UserSchema formValue={formValue} messageIndex={messageIndex} allMessages={conversation.messages} isEditing={isEditing} /> {message.content && ( <div className={classNames( 'prose min-w-full flex-1 whitespace-pre-wrap', { 'max-w-none': isChatFullWidth, 'text-sm': isOverlay, 'leading-[150%]': isMobileOrOverlay, }, )} > {message.content} </div> )} <MessageAttachments attachments={message.custom_content?.attachments} /> <div ref={anchorRef} className="absolute bottom-[-140px]"></div> </div> {showUserButtons && !isConversationInvalid && ( <MessageUserButtons isMessageStreaming={!!conversation.isMessageStreaming} isEditAvailable={!!onEdit} editDisabled={editDisabled} onDelete={() => onDelete?.()} toggleEditing={handleToggleEditing} isEditTemplatesAvailable={!isExternal && isMessageTemplatesEnabled} onToggleTemplatesEditing={handleToggleEditingTemplates} /> )} </> ); });