import React, { useCallback, useEffect, memo, useMemo, useRef, useState, } from 'react'; import cx from 'classnames'; import { DialogState, ExpertReference, Medium, Memori, Message, Tenant, User, } from '@memori.ai/memori-api-client/dist/types'; import { hasTouchscreen } from '../../helpers/utils'; import { getResourceUrl } from '../../helpers/media'; import ChatBubble from '../ChatBubble/ChatBubble'; import MediaWidget, { Props as MediaWidgetProps, } from '../MediaWidget/MediaWidget'; import { Props as MemoriProps } from '../MemoriWidget/MemoriWidget'; import memoriApiClient from '@memori.ai/memori-api-client'; import ChatInputs from '../ChatInputs/ChatInputs'; import Typing from '../Typing/Typing'; import { boardOfExpertsLoadingSentences } from '../../helpers/constants'; import ArtifactHandler from '../MemoriArtifactSystem/components/ArtifactHandler/ArtifactHandler'; import { DocumentIcon } from '../icons/Document'; import { useTranslation } from 'react-i18next'; import { maxDocumentsPerMessage, maxDocumentContentLength, pasteAsCardLineThreshold, pasteAsCardCharThreshold } from '../../helpers/constants'; import Modal from '../ui/Modal'; import Tooltip from '../ui/Tooltip'; import { BADGE_EMOJI, buildLlmUsageHtml, formatDuration, formatImpactWithApiUnit, formatIntegerValue, getImpactComparison, getMetricValue, LlmUsageLabels, LlmUsageOnLine, UsageBadgeType, } from '../../helpers/llmUsage'; export interface Props { memori: Memori; tenant?: Tenant; sessionID: string; translateTo?: string; baseUrl?: string; apiUrl?: string; memoriTyping?: boolean; typingText?: string; showTypingText?: boolean; history: Message[]; authToken?: string; dialogState?: DialogState; pushMessage: (message: Message) => void; simulateUserPrompt: (text: string, translatedText?: string) => void; showDates?: boolean; showUpload?: boolean; showContextPerLine?: boolean; showAIicon?: boolean; showTranslationOriginal?: boolean; showWhyThisAnswer?: boolean; showReasoning?: boolean; showMessageConsumption?: boolean; client?: ReturnType; preview?: boolean; microphoneMode?: 'CONTINUOUS' | 'HOLD_TO_TALK'; sendOnEnter?: 'keypress' | 'click'; setSendOnEnter: (sendOnEnter: 'keypress' | 'click') => void; attachmentsMenuOpen?: 'link' | 'media'; setAttachmentsMenuOpen: (attachmentsMenuOpen: 'link' | 'media') => void; instruct?: boolean; showCopyButton?: boolean; showInputs?: boolean; showMicrophone?: boolean; userMessage?: string; onChangeUserMessage: (userMessage: string) => void; sendMessage: (msg: string, media?: (Medium & { type: string })[]) => void; listening?: boolean; setEnableFocusChatInput: (enableFocusChatInput: boolean) => void; isPlayingAudio?: boolean; stopAudio: () => void; startListening: () => void; stopListening: () => void; customMediaRenderer?: MediaWidgetProps['customMediaRenderer']; layout?: MemoriProps['layout']; userAvatar?: MemoriProps['userAvatar']; user?: User; experts?: ExpertReference[]; useMathFormatting?: boolean; isHistoryView?: boolean; isChatlogPanel?: boolean; showFunctionCache?: boolean; /** Override total document payload and per-document content limit (character count). */ maxTotalMessagePayload?: number; /** Max characters in chat textarea; shows counter and enforces paste + existing text does not exceed this limit. */ maxTextareaCharacters?: number; /** Max attachments (docs + images) per message. */ } type MessageWithLlmUsage = Message & { llmUsage?: LlmUsageOnLine }; interface UsageBadgeModalState { type: UsageBadgeType; usage: LlmUsageOnLine; } const Chat: React.FC = ({ memori, tenant, sessionID, baseUrl, apiUrl, client, translateTo, memoriTyping, typingText, showTypingText = false, history = [], authToken, dialogState, simulateUserPrompt, showDates = false, showContextPerLine = false, showAIicon = true, showWhyThisAnswer = true, showCopyButton = true, showTranslationOriginal = false, showReasoning = false, showMessageConsumption = false, preview = false, instruct = false, showInputs = true, showMicrophone = false, microphoneMode = 'HOLD_TO_TALK', sendOnEnter, setSendOnEnter, attachmentsMenuOpen, setAttachmentsMenuOpen, userMessage = '', onChangeUserMessage, sendMessage, listening, setEnableFocusChatInput, isPlayingAudio, stopAudio, startListening, stopListening, customMediaRenderer, user, userAvatar, showUpload = false, experts, useMathFormatting = false, isHistoryView = false, isChatlogPanel = false, showFunctionCache = false, maxTotalMessagePayload, maxTextareaCharacters, }) => { const [isTextareaExpanded, setIsTextareaExpanded] = useState(false); const [isDragging, setIsDragging] = useState(false); const [activeUsageBadge, setActiveUsageBadge] = useState(null); const chatWrapperRef = useRef(null); const { t } = useTranslation(); const locale = (translateTo || memori.culture || 'it-IT').replace('_', '-'); const llmUsageLabels = useMemo( () => ({ llm: t('chatLogs.llm', { defaultValue: 'LLM' }), model: t('chatLogs.model', { defaultValue: 'Model' }), provider: t('chatLogs.provider', { defaultValue: 'Provider' }), tokens: t('chatLogs.tokens', { defaultValue: 'Tokens' }), input: t('chatLogs.input', { defaultValue: 'Input' }), output: t('chatLogs.output', { defaultValue: 'Output' }), cacheRead: t('chatLogs.cacheRead', { defaultValue: 'Cache read' }), cacheWrite: t('chatLogs.cacheWrite', { defaultValue: 'Cache write' }), duration: t('chatLogs.duration', { defaultValue: 'Duration' }), energy: t('chatLogs.energy', { defaultValue: 'Energy' }), co2: t('chatLogs.co2', { defaultValue: 'CO2' }), water: t('chatLogs.water', { defaultValue: 'Water' }), usageBadgesHint: t('chatLogs.usageBadgesHint', { defaultValue: 'Click one of these buttons to show more information', }), }), [t], ); const usageHtmlByIndex = useMemo( () => history.map((message, index) => { const messageWithUsage = message as MessageWithLlmUsage; return showMessageConsumption && !message.fromUser && messageWithUsage.llmUsage ? buildLlmUsageHtml( messageWithUsage.llmUsage, llmUsageLabels, index, locale, ) : ''; }), [history, llmUsageLabels, locale, showMessageConsumption], ); const scrollToBottom = useCallback(() => { if (isHistoryView) return; setTimeout(() => { let userMsgs = document.querySelectorAll('.memori-chat-scroll-item'); userMsgs[userMsgs.length - 1]?.scrollIntoView?.(); }, 200); }, [isHistoryView]); // Avoid re-scrolling when `history` is recreated with same content (e.g. on every keystroke). const lastAutoscrollSignatureRef = useRef(null); const lastMessage = history?.[history.length - 1]; const lastMessageSignature = `${history?.length ?? 0}|${ lastMessage?.timestamp ?? '' }|${lastMessage?.fromUser ? 'u' : 'm'}|${lastMessage?.text?.length ?? 0}|${ lastMessage?.translatedText?.length ?? 0 }`; useEffect(() => { // if we are in preview mode or in history view, don't scroll to the bottom if (preview || isHistoryView) return; // if the last message signature is the same as the previous one, don't scroll to the bottom if (lastAutoscrollSignatureRef.current === lastMessageSignature) return; // set the last autoscroll signature to the current one lastAutoscrollSignatureRef.current = lastMessageSignature; // scroll to the bottom scrollToBottom(); }, [preview, isHistoryView, lastMessageSignature, scrollToBottom]); // Scroll to bottom when textarea is expanded // useEffect(() => { // if (isTextareaExpanded && !isHistoryView) { // setTimeout(() => { // scrollToBottom(); // }, 250); // } // }, [isTextareaExpanded, isHistoryView]); const onTextareaFocus = () => { stopListening(); const hasTouch = hasTouchscreen(); if (hasTouch) setEnableFocusChatInput(true); // if the user is on mobile and had not recorded audio, add the chat-focused class to the chat wrapper // if (hasTouch && window.innerWidth <= 768) { // document.getElementById('chat-wrapper')?.classList?.add('chat-focused'); // // add the chat-focused class to the memori widget // document // .querySelector('.memori.memori-widget') // ?.classList?.add('chat-focused'); // setTimeout(() => { // scrollToBottom(); // }, 300); // } }; const onTextareaBlur = () => { if ( document .getElementById('chat-wrapper') ?.classList?.contains('chat-focused') ) { document.getElementById('chat-wrapper')?.classList.remove('chat-focused'); document .querySelector('.memori.memori-widget') ?.classList?.remove('chat-focused'); scrollToBottom(); } }; const onTextareaExpanded = (expanded: boolean) => { setIsTextareaExpanded(expanded); }; // Drag and drop handlers for overlay useEffect(() => { // Only enable drag and drop when showUpload is true if (!showUpload) return; let dragCounter = 0; const chatWrapper = document.getElementById('chat-wrapper'); const handleDragEnter = (e: DragEvent) => { // Only show overlay if dragging files (not text/links) if (e.dataTransfer?.types.includes('Files')) { dragCounter++; if (dragCounter === 1) { setIsDragging(true); } } }; const handleDragLeave = (e: DragEvent) => { if (e.dataTransfer?.types.includes('Files')) { dragCounter--; if (dragCounter === 0) { setIsDragging(false); } } }; const handleDragOver = (e: DragEvent) => { // Prevent default to allow drop, but don't stop propagation if (e.dataTransfer?.types.includes('Files')) { e.preventDefault(); } }; const handleDrop = (e: DragEvent) => { // Reset dragging state, but let UploadButton handle the actual drop if (e.dataTransfer?.types.includes('Files')) { dragCounter = 0; setIsDragging(false); } }; if (chatWrapper) { chatWrapper.addEventListener('dragenter', handleDragEnter); chatWrapper.addEventListener('dragleave', handleDragLeave); chatWrapper.addEventListener('dragover', handleDragOver); chatWrapper.addEventListener('drop', handleDrop); } return () => { if (chatWrapper) { chatWrapper.removeEventListener('dragenter', handleDragEnter); chatWrapper.removeEventListener('dragleave', handleDragLeave); chatWrapper.removeEventListener('dragover', handleDragOver); chatWrapper.removeEventListener('drop', handleDrop); } }; }, [showUpload]); useEffect(() => { const wrapper = chatWrapperRef.current; if (!wrapper || !showMessageConsumption) return; const handleUsageBadgeClick = (event: MouseEvent) => { const target = event.target as HTMLElement | null; const button = target?.closest( '[data-llm-badge-type][data-line-index]', ); if (!button) return; const lineIndex = Number(button.dataset.lineIndex); const badgeType = button.dataset.llmBadgeType as UsageBadgeType | undefined; if (!Number.isInteger(lineIndex) || !badgeType) return; const line = (history?.[lineIndex] as MessageWithLlmUsage) ?? null; if (!line?.llmUsage) return; event.preventDefault(); setActiveUsageBadge({ type: badgeType, usage: line.llmUsage, }); }; wrapper.addEventListener('click', handleUsageBadgeClick); return () => { wrapper.removeEventListener('click', handleUsageBadgeClick); }; }, [history, showMessageConsumption]); return (
{/* Drag and drop overlay */} {isDragging && (
{t('upload.dragAndDropFiles') ?? 'Drag and drop files here to add them to the chat'}
)}
{history.map((message, index) => ( !m.properties?.functionSignature) ?.filter(m => m.mimeType === 'text/html' && !!m.url) || []) as Medium[] } media={[ // Non-function-cache media items (exclude HTML links; those go into `links`) ...(message?.media ?.filter(m => !m.properties?.functionSignature) ?.filter(m => !(m.mimeType === 'text/html' && !!m.url)) || []), // Extract document attachments that are embedded in the message text ...(() => { // Get the translated or original message text const text = message.translatedText || message.text; // Regex to match document attachments in format: // content const documentAttachmentRegex = /([\s\S]*?)<\/document_attachment>/g; const attachments: (Medium & { type?: string })[] = []; let match; let attachmentIndex = 0; // Find all document attachments in the text while ( (match = documentAttachmentRegex.exec(text)) !== null ) { const [, filename, type, content] = match; // Create a Medium object for each attachment with: // - Unique ID using timestamp and random string // - Empty URL since content is embedded // - Original mime type and filename // - Trimmed content from the attachment // - Properties to mark it as a document attachment attachments.push({ mediumID: `doc_${Date.now()}_${attachmentIndex}_${Math.random() .toString(36) .substr(2, 9)}`, url: '', mimeType: type, title: filename, content: content.trim(), properties: { isDocumentAttachment: true }, type: 'document', }); attachmentIndex++; } return attachments; })(), ]} sessionID={sessionID} baseUrl={baseUrl} apiUrl={apiUrl} translateTo={translateTo} customMediaRenderer={customMediaRenderer} fromUser={message.fromUser} /> {showDates && !!message.timestamp && ( {new Intl.DateTimeFormat('it', { hour: '2-digit', minute: '2-digit', second: '2-digit', }).format( new Date( message.timestamp.endsWith('Z') ? message.timestamp : `${message.timestamp}Z` ) )} )} {showContextPerLine && !!Object.keys(message.contextVars ?? {}).length && (
{Object.keys(message.contextVars ?? {}).map(key => message.contextVars?.[key] === '-' ? (
{key}
) : message.contextVars?.[key] === '✔️' ? (
{key}
) : (
{key}: {message.contextVars?.[key]}
) )}
)} {!isHistoryView && !message.fromUser && ( )}
))} {dialogState?.hints && dialogState.hints.length > 0 && !memoriTyping && ( ({ text: h, originalText: h, })) } /> )} {!!memoriTyping && ( )}
{showInputs && ( )} setActiveUsageBadge(null)} title={ activeUsageBadge?.type ? `${BADGE_EMOJI[activeUsageBadge.type]} ${ llmUsageLabels[activeUsageBadge.type] }` : undefined } className="memori-chat--usage-modal" > {activeUsageBadge?.type === 'llm' && (
{llmUsageLabels.provider}
{activeUsageBadge.usage.provider ?? '—'}
{llmUsageLabels.model}
{activeUsageBadge.usage.model ?? '—'}
{llmUsageLabels.tokens} {llmUsageLabels.input}
{formatIntegerValue( activeUsageBadge.usage.totalInputTokens ?? 0, locale, )}
{llmUsageLabels.tokens} {llmUsageLabels.output}
{formatIntegerValue(activeUsageBadge.usage.outputTokens ?? 0, locale)}
)} {activeUsageBadge?.type === 'energy' && (
{formatImpactWithApiUnit( getMetricValue(activeUsageBadge.usage.energyImpact?.energy) ?? 0, activeUsageBadge.usage.energyImpact?.energyUnit, 'kWh', 'energy', locale, )}

{getImpactComparison( getMetricValue(activeUsageBadge.usage.energyImpact?.energy) ?? 0, 'energy', locale, t, )}

{t('chatLogs.energyImpactDescription')}

)} {activeUsageBadge?.type === 'co2' && (
{formatImpactWithApiUnit( getMetricValue(activeUsageBadge.usage.energyImpact?.gwp) ?? 0, activeUsageBadge.usage.energyImpact?.gwpUnit, 'kgCO2eq', 'co2', locale, )}

{getImpactComparison( getMetricValue(activeUsageBadge.usage.energyImpact?.gwp) ?? 0, 'co2', locale, t, )}

{t('chatLogs.co2ImpactDescription')}

)} {activeUsageBadge?.type === 'water' && (
{formatImpactWithApiUnit( getMetricValue(activeUsageBadge.usage.energyImpact?.wcf) ?? 0, activeUsageBadge.usage.energyImpact?.wcfUnit, 'L', 'water', locale, )}

{getImpactComparison( getMetricValue(activeUsageBadge.usage.energyImpact?.wcf) ?? 0, 'water', locale, t, )}

{t('chatLogs.waterImpactDescription')}

)}
); }; export default memo(Chat);