import React, { useCallback, useState } from 'react'; import { DialogState, Medium } from '@memori.ai/memori-api-client/dist/types'; import { useTranslation } from 'react-i18next'; import toast from 'react-hot-toast'; import ChatTextArea from '../ChatTextArea/ChatTextArea'; import Button from '../ui/Button'; import Send from '../icons/Send'; import MicrophoneButton from '../MicrophoneButton/MicrophoneButton'; import cx from 'classnames'; import Microphone from '../icons/Microphone'; import UploadButton from '../UploadButton/UploadButton'; import FilePreview from '../FilePreview/FilePreview'; import memoriApiClient from '@memori.ai/memori-api-client'; import Plus from '../icons/Plus'; export interface Props { dialogState?: DialogState; instruct?: boolean; sendOnEnter?: 'keypress' | 'click'; setSendOnEnter: (sendOnEnter: 'keypress' | 'click') => void; attachmentsMenuOpen?: 'link' | 'media'; setAttachmentsMenuOpen: (attachmentsMenuOpen: 'link' | 'media') => void; userMessage?: string; onChangeUserMessage: (userMessage: string) => void; sendMessage: (msg: string, media?: (Medium & { type: string })[]) => void; onTextareaFocus: () => void; onTextareaBlur: () => void; listening?: boolean; isPlayingAudio?: boolean; stopAudio: () => void; startListening: () => void; stopListening: () => void; showMicrophone?: boolean; microphoneMode?: 'CONTINUOUS' | 'HOLD_TO_TALK'; authToken?: string; showUpload?: boolean; isTyping?: boolean; sessionID?: string; memoriID?: string; client?: ReturnType; onTextareaExpanded?: (expanded: boolean) => void; /** Override total document payload limit (character count). */ maxTotalMessagePayload?: number; /** Max characters in textarea; shows counter and enforces pasted content + existing text does not exceed this limit. */ maxTextareaCharacters?: number; /** Max attachments (docs + images) per message. */ maxDocumentsPerMessage?: number; /** Per-document content character limit. */ maxDocumentContentLength?: number; /** When pasted text has more than this many lines, it is added as a document card. */ pasteAsCardLineThreshold?: number; /** When pasted text exceeds this length, it is added as a document card. */ pasteAsCardCharThreshold?: number; } const ChatInputs: React.FC = ({ dialogState, userMessage = '', sendOnEnter, onChangeUserMessage, sendMessage, onTextareaFocus, onTextareaBlur, showMicrophone = false, microphoneMode = 'HOLD_TO_TALK', listening = false, stopAudio, startListening, stopListening, showUpload = false, isTyping = false, sessionID, authToken, memoriID, client, onTextareaExpanded, maxTotalMessagePayload, maxTextareaCharacters, maxDocumentsPerMessage, maxDocumentContentLength, pasteAsCardLineThreshold, pasteAsCardCharThreshold, }) => { const { t } = useTranslation(); // State for textarea expansion const [isExpanded, setIsExpanded] = useState(false); // State for document preview files const [documentPreviewFiles, setDocumentPreviewFiles] = useState< { name: string; id: string; content: string; mediumID: string | undefined; mimeType: string; url?: string; type: string; }[] >([]); // Client const { dialog } = client || { dialog: { postMediumDeselectedEvent: null }, }; /** * Handles sending a message, including any attached files */ const onSendMessage = ( files: { name: string; id: string; content: string; mediumID: string | undefined; mimeType: string; type: string; url?: string; }[] ) => { if (isTyping) return; const mediaWithIds = files.map((file, index) => { const generatedMediumID = file.mediumID || `file_${Date.now()}_${index}_${Math.random() .toString(36) .substr(2, 9)}`; return { mediumID: generatedMediumID, mimeType: file.mimeType, content: file.content, title: file.name, properties: { isAttachedFile: true }, type: file.type, url: file.url, }; }); sendMessage(userMessage, mediaWithIds); // Reset states after sending setDocumentPreviewFiles([]); stopAudio(); speechSynthesis.speak(new SpeechSynthesisUtterance('')); }; /** * Handles enter key press in textarea */ const onTextareaPressEnter = ( e: React.KeyboardEvent ) => { // Alt/Option+Enter should create a newline (do not send) if (e.altKey) return; // Prevent default newline on Enter to keep behavior consistent e.preventDefault(); // While the agent is typing, ignore Enter (no send, no newline) if (isTyping) return; if (sendOnEnter === 'keypress' && userMessage?.length > 0) { stopListening(); const mediaWithIds = documentPreviewFiles.map((file, index) => { const generatedMediumID = file.mediumID || `file_${Date.now()}_${index}_${Math.random() .toString(36) .substr(2, 9)}`; return { mediumID: generatedMediumID, mimeType: file.mimeType, content: file.content, title: file.name, properties: { isAttachedFile: true }, type: file.type, url: file.url, }; }); sendMessage(userMessage, mediaWithIds); setDocumentPreviewFiles([]); onChangeUserMessage(''); } }; /** * Removes a file from the preview list */ const removeFile = async (fileId: string, mediumID: string | undefined) => { // Call the MediumDeselected event if dialog API is available if (dialog.postMediumDeselectedEvent && sessionID && mediumID) { await dialog.postMediumDeselectedEvent(sessionID, mediumID); } setDocumentPreviewFiles( ( prev: { name: string; id: string; content: string; mediumID: string | undefined; mimeType: string; type: string; url?: string; }[] ) => prev.filter((file: { id: string }) => file.id !== fileId) ); }; /** * Handles textarea expansion change */ const handleTextareaExpanded = (expanded: boolean) => { setIsExpanded(expanded); if (onTextareaExpanded) { onTextareaExpanded(expanded); } }; /** * Pasted text is added as a document attachment only when it exceeds the char or line threshold. * Otherwise the default paste (inline into textarea) is allowed. * When maxTextareaCharacters is set, pasted content + existing text must not exceed it. */ const handleTextareaPaste = useCallback( (e: React.ClipboardEvent) => { if (e.clipboardData.files?.length) return; const text = e.clipboardData.getData('text/plain'); if (!text?.trim()) return; const target = e.target as HTMLTextAreaElement; const selectionLength = target.selectionEnd - target.selectionStart; const lengthAfterPaste = userMessage.length - selectionLength + text.length; if ( maxTextareaCharacters != null && lengthAfterPaste > maxTextareaCharacters ) { e.preventDefault(); toast(t('upload.pasteContentExceedsLimit', { defaultValue: 'Pasted content exceeds the size limit. Try shortening the text or splitting it into smaller parts.', }), { icon: '⚠️' }); return; } const lineCount = text.split(/\r?\n/).length; const charThreshold = pasteAsCardCharThreshold ?? 4200; const lineThreshold = pasteAsCardLineThreshold ?? 100; const exceedsCharThreshold = text.length > charThreshold; const exceedsLineThreshold = lineCount > lineThreshold; if (!exceedsCharThreshold && !exceedsLineThreshold) { return; // allow default paste (inline) } // Critical: max attachments reached – prevent dumping long text into textarea, show feedback const maxDocs = maxDocumentsPerMessage ?? 10; if (documentPreviewFiles.length >= maxDocs) { e.preventDefault(); toast.error( t('upload.pasteMaxAttachmentsReached', { max: maxDocs, defaultValue: `Maximum ${maxDocs} attachments. Remove one to add this as a file.`, }) ); return; } // Only enforce a per-document limit. `maxTotalMessagePayload` is kept for backward compatibility // and now acts as the per-document content length override. const perDocumentLimit = maxTotalMessagePayload ?? maxDocumentContentLength ?? 300000; if (text.length > perDocumentLimit) { e.preventDefault(); toast(t('upload.pasteContentExceedsLimit', { defaultValue: 'Pasted content exceeds the size limit. Try shortening the text or splitting it into smaller parts.', }), { icon: '⚠️' }); return; } e.preventDefault(); const displayName = t('upload.pastedText') || 'pasted-text'; const wrappedContent = ` ${text} `; const newFile = { name: displayName, id: `paste_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`, content: wrappedContent, mediumID: undefined as string | undefined, mimeType: 'text/plain', type: 'document', }; setDocumentPreviewFiles( ( prev: { name: string; id: string; content: string; mediumID: string | undefined; mimeType: string; type: string; url?: string; }[] ) => [...prev, newFile] ); }, [ documentPreviewFiles, maxTextareaCharacters, maxTotalMessagePayload, userMessage.length, t, ] ); const isDisabled = dialogState?.state === 'X2a' || dialogState?.state === 'X3'; const textareaDisabled = ['R2', 'R3', 'R4', 'R5', 'G3', 'X3'].includes( dialogState?.state || '' ); return (
{/* Preview for document files (show when upload enabled or when paste added cards) */} {(showUpload || documentPreviewFiles.length > 0) && (
)}
{/* Leading area - Plus button */}
{showUpload && (
)}
{/* Primary area - Textarea */}
{/* Trailing area - Microphone and Send button */}
{showMicrophone && microphoneMode === 'CONTINUOUS' && ( )} {showMicrophone && microphoneMode === 'HOLD_TO_TALK' && ( { stopListening(); if (listening && !!userMessage?.length) { sendMessage(userMessage); } }} stopAudio={stopAudio} /> )}
{/* Disclaimer */} {/*
{t( 'chat.disclaimer', 'AIsuru può commettere errori. Assicurati di verificare le informazioni importanti.' )}
*/}
); }; export default ChatInputs;