diff --git a/package.json b/package.json index 3e7d249a0..0f8fbbfd2 100644 --- a/package.json +++ b/package.json @@ -312,6 +312,7 @@ "react-hot-toast": "^2.4.1", "react-i18next": "12.0.0", "react-leaflet": "^4.2.1", + "react-virtuoso": "^4.12.7", "standardized-audio-context": "25.3.34", "three": "0.144.0", "tiny-invariant": "^1.3.3" diff --git a/src/components/Chat/Chat.stories.tsx b/src/components/Chat/Chat.stories.tsx index 5aae417b5..0eac998e2 100644 --- a/src/components/Chat/Chat.stories.tsx +++ b/src/components/Chat/Chat.stories.tsx @@ -297,6 +297,7 @@ WithExpandable.args = { history: historyWithExpandable, dialogState, layout: 'DEFAULT', + mode: 'log', simulateUserPrompt: () => {}, sendMessage: (msg: string) => console.log(msg), stopListening: () => {}, diff --git a/src/components/Chat/Chat.tsx b/src/components/Chat/Chat.tsx index 20eafe5e2..5e9b899fc 100644 --- a/src/components/Chat/Chat.tsx +++ b/src/components/Chat/Chat.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, memo } from 'react'; +import React, { useEffect, memo, useState } from 'react'; import cx from 'classnames'; import { DialogState, @@ -78,9 +78,11 @@ export interface Props { user?: User; experts?: ExpertReference[]; useMathFormatting?: boolean; + showFunctionCache?: boolean; + mode?: 'chat' | 'log'; } -const Chat: React.FC = ({ +const OptimizedChat: React.FC = ({ memori, tenant, sessionID, @@ -124,20 +126,34 @@ const Chat: React.FC = ({ userAvatar, showUpload = false, experts, + showFunctionCache = false, useMathFormatting = false, + mode = 'chat', }) => { + // Determine if the chat has a lot of messages (for optimization) + const hasLargeHistory = history.length > 50; + + // Track if we're currently in a loading state (for large messages) + const [isLoadingMessage, setIsLoadingMessage] = useState(false); + + // Scroll to the bottom of the chat const scrollToBottom = () => { - setTimeout(() => { - let userMsgs = document.querySelectorAll( - '.memori-chat--bubble-container.memori-chat--bubble-from-user' - ); - userMsgs[userMsgs.length - 1]?.scrollIntoView?.(); - }, 200); + // Only attempt to scroll if we're not in a loading state + if (!isLoadingMessage) { + setTimeout(() => { + let userMsgs = document.querySelectorAll( + '.memori-chat--bubble-container.memori-chat--bubble-from-user' + ); + userMsgs[userMsgs.length - 1]?.scrollIntoView?.(); + }, 200); + } }; + useEffect(() => { !preview && scrollToBottom(); - }, [history, preview]); - + }, [history, preview, isLoadingMessage]); + + // Text input focus/blur handlers const onTextareaFocus = () => { stopListening(); const hasTouch = hasTouchscreen(); @@ -155,6 +171,7 @@ const Chat: React.FC = ({ }, 300); } }; + const onTextareaBlur = () => { if ( document @@ -169,6 +186,11 @@ const Chat: React.FC = ({ } }; + // Callback when a message starts/finishes formatting + const handleMessageLoadingState = (isLoading: boolean) => { + setIsLoadingMessage(isLoading); + }; + return (
= ({ 'memori-chat--content-touch': hasTouchscreen(), })} > + {/* Cover image */}
= ({ }} /> + {/* Message history */} {history.map((message, index) => ( + {/* Use optimized chat bubble */} = ({ experts={experts} showCopyButton={showCopyButton} useMathFormatting={useMathFormatting} + showFunctionCache={showFunctionCache} + mode={mode} /> + {/* Timestamps */} {showDates && !!message.timestamp && ( = ({ )} + {/* Context variables */} {showContextPerLine && !!Object.keys(message.contextVars ?? {}).length && (
@@ -282,6 +311,7 @@ const Chat: React.FC = ({
)} + {/* Media attachments */} = ({
))} + {/* Hints display */} {dialogState?.hints && dialogState.hints.length > 0 && !memoriTyping && ( @@ -313,6 +344,7 @@ const Chat: React.FC = ({ /> )} + {/* Typing indicator */} {!!memoriTyping && ( = ({ key={typingText} /> )} + + {/* Reference element for scrolling */}
+ {/* Chat input area */} {showInputs && ( = ({ ); }; -export default memo(Chat); +export default memo(OptimizedChat); \ No newline at end of file diff --git a/src/components/Chat/__snapshots__/Chat.test.tsx.snap b/src/components/Chat/__snapshots__/Chat.test.tsx.snap index 14a5d1a4b..ec171b966 100644 --- a/src/components/Chat/__snapshots__/Chat.test.tsx.snap +++ b/src/components/Chat/__snapshots__/Chat.test.tsx.snap @@ -92,21 +92,12 @@ exports[`renders Chat unchanged 1`] = ` class="memori-chat--bubble memori-chat--user-bubble transition ease-in-out duration-300 opacity-0 scale-09 translate-x-30" >
-
-
-

- Prova -

-
-
+

+ Prova +

-
-
-

- Come faccio a fare delle cose con questa cosa? -

-
-
+

+ Come faccio a fare delle cose con questa cosa? +

-
-
-

- Ah, grazie! Ciao! -

-
-
+

+ Ah, grazie! Ciao! +

-
-
-

- Prova -

-
-
+

+ Prova +

-
-
-

- Come faccio a fare delle cose con questa cosa? -

-
-
+

+ Come faccio a fare delle cose con questa cosa? +

-
-
-

- Ah, grazie! Ciao! -

-
-
+

+ Ah, grazie! Ciao! +

-
-
-

- Prova -

-
-
+

+ Prova +

-
-
-

- Come faccio a fare delle cose con questa cosa? -

-
-
+

+ Come faccio a fare delle cose con questa cosa? +

-
-
-

- Ah, grazie! Ciao! -

-
-
+

+ Ah, grazie! Ciao! +

-
-
-

- Prova -

-
-
+

+ Prova +

-
-
-

- Come faccio a fare delle cose con questa cosa? -

-
-
+

+ Come faccio a fare delle cose con questa cosa? +

-
-
-

- Ah, grazie! Ciao! -

-
-
+

+ Ah, grazie! Ciao! +

-
-
-

- Prova -

-
-
+

+ Prova +

-
-
-

- Come faccio a fare delle cose con questa cosa? -

-
-
+

+ Come faccio a fare delle cose con questa cosa? +

-
-
-

- Ah, grazie! Ciao! -

-
-
+

+ Ah, grazie! Ciao! +

-
-
-

- Prova -

-
-
+

+ Prova +

-
-
-

- Come faccio a fare delle cose con questa cosa? -

-
-
+

+ Come faccio a fare delle cose con questa cosa? +

-
-
-

- Ah, grazie! Ciao! -

-
-
+

+ Ah, grazie! Ciao! +

-
-
-

- Prova -

-
-
+

+ Prova +

-
-
-

- Come faccio a fare delle cose con questa cosa? -

-
-
+

+ Come faccio a fare delle cose con questa cosa? +

-
-
-

- Ah, grazie! Ciao! -

-
-
+

+ Ah, grazie! Ciao! +

-
-
-

- Prova -

-
-
+

+ Prova +

-
-
-

- Come faccio a fare delle cose con questa cosa? -

-
-
+

+ Come faccio a fare delle cose con questa cosa? +

-
-
-

- Ah, grazie! Ciao! -

-
-
+

+ Ah, grazie! Ciao! +

-
-
-

- Prova -

-
-
+

+ Prova +

-
-
-

- Come faccio a fare delle cose con questa cosa? -

-
-
+

+ Come faccio a fare delle cose con questa cosa? +

-
-
-

- Ah, grazie! Ciao! -

-
-
+

+ Ah, grazie! Ciao! +

button { color: var(--memori-primary-text); } + +.memori-chat--bubble-loading { + display: flex; + min-height: 100px; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 1rem; + border-radius: 12px; + background-color: rgba(0, 0, 0, 0.03); +} + +/* Make the antd spinner match the theme better */ +.memori-chat--bubble-loading .ant-spin { + color: inherit; +} + +.memori-chat--bubble-loading .ant-spin-text { + margin-top: 0.5rem; + font-size: 0.85rem; + opacity: 0.8; +} + +/* Add a subtle pulse animation to the loading spinner */ +@keyframes pulse { + 0% { + opacity: 0.6; + } + 50% { + opacity: 1; + } + 100% { + opacity: 0.6; + } +} + +.memori-chat--bubble-loading .ant-spin-dot { + animation: pulse 2s infinite ease-in-out; +} + +/* Make user messages with loading state visually distinct */ +.memori-chat--user-bubble .memori-chat--bubble-loading { + background-color: rgba(0, 100, 255, 0.03); +} + +/* Styles for large message handling */ +.memori-chat--large-message { + max-width: 95% !important; /* Allow large messages to use more space */ +} + +/* Virtualized content styles */ +.memori-chat--virtualized-content { + height: auto; + max-height: 500px; + padding: 5px 10px; + margin: 0; + overflow-y: auto; +} + +.virtualized-content-container { + overflow: hidden; + width: 100%; + border-radius: 8px; + scrollbar-color: rgba(0, 0, 0, 0.2) rgba(0, 0, 0, 0.05); + scrollbar-width: thin; +} + +.virtualized-content-line { + width: 100%; + padding: 2px 0; + overflow-wrap: break-word; + word-break: break-word; +} + +.virtualized-content-loading { + display: flex; + width: 100%; + height: 100px; + align-items: center; + justify-content: center; + color: rgba(0, 0, 0, 0.45); + font-size: 14px; +} + +/* Add scrollbar styling for better UX */ +.virtualized-content-container::-webkit-scrollbar { + width: 6px; +} + +.virtualized-content-container::-webkit-scrollbar-track { + border-radius: 10px; + background: rgba(0, 0, 0, 0.05); +} + +.virtualized-content-container::-webkit-scrollbar-thumb { + border-radius: 10px; + background: rgba(0, 0, 0, 0.2); +} + +.virtualized-content-container::-webkit-scrollbar-thumb:hover { + background: rgba(0, 0, 0, 0.3); +} + +/* Optimized performance for large messages */ +.memori-chat--large-content { + contain: content; /* CSS containment for performance */ + content-visibility: auto; /* Automatically manage content rendering */ + will-change: transform; /* Optimize for animations */ +} diff --git a/src/components/ChatBubble/ChatBubble.stories.tsx b/src/components/ChatBubble/ChatBubble.stories.tsx index 123222aa1..a9df9d685 100644 --- a/src/components/ChatBubble/ChatBubble.stories.tsx +++ b/src/components/ChatBubble/ChatBubble.stories.tsx @@ -617,3 +617,124 @@ WithHTML.args = { `, }, }; + + +// 9. Complex HTML Table +export const ComplexHTMLTable = Template.bind({}); +ComplexHTMLTable.args = { + memori, + tenant, + message: { + fromUser: false, + text: ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Project Timeline
PhaseStart DateEnd Date
PlanningJan 15, 2025Feb 28, 2025
DevelopmentMar 1, 2025Jun 30, 2025
TestingJul 1, 2025Aug 15, 2025
DeploymentAug 16, 2025Sep 30, 2025
Total Duration: 8.5 months
`, + initial: false, + }, +}; +ComplexHTMLTable.parameters = { + docs: { + description: { + story: 'A message containing a complex HTML table that should trigger the loading spinner before rendering.', + }, + }, +}; + +//10. Long text message +export const MediumLongTextMessage = Template.bind({}); +MediumLongTextMessage.args = { + memori, + tenant, + message: { + fromUser: false, + text: Array(100).fill('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non risus. Suspendisse lectus tortor, dignissim sit amet, adipiscing nec, ultricies sed, dolor.').join('\n\n'), + initial: false, + }, +}; + +export const LongTextMessage = Template.bind({}); +LongTextMessage.args = { + memori, + tenant, + mode: 'log', + message: { + fromUser: false, + text: Array(200).fill('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non risus. Suspendisse lectus tortor, dignissim sit amet, adipiscing nec, ultricies sed, dolor. Cras elementum ultrices diam. Maecenas ligula massa, varius a, semper congue, euismod non, mi.').join('\n\n'), + initial: false, + }, +}; + +export const LongTextMessageWithHTML = Template.bind({}); +LongTextMessageWithHTML.args = { + memori, + tenant, + mode: 'log', + message: { + fromUser: false, + text: Array(200).fill('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non risus. Suspendisse lectus tortor, dignissim sit amet, adipiscing nec, ultricies sed, dolor. Cras elementum ultrices diam. Maecenas ligula massa, varius a, semper congue, euismod non, mi. Proin porttitor, orci nec nonummy molestie, enim est eleifend mi, non fermentum diam nisl sit amet erat.').join('\n\n') + ` +
+
+ +

Lenovo IdeaPad 3 Chromebook

+

Intel Celeron, 15", 4GB RAM, 64GB

+

Prezzo: €194,88 (sconto 12% da €221,59)

+
`, + }, +}; + + +// 10. Message with an embedded helper function to show requiresFormatting detection +export const FormattingDetectionDemo = Template.bind({}); +FormattingDetectionDemo.args = { + memori, + tenant, + mode: 'log', + message: { + fromUser: false, + text: 'This message demonstrates how the formatting detection works.\n\nBelow are some examples of content that will be detected as requiring formatting:\n\n1. HTML tags:
Example
\n2. Markdown tables: | Header | Content |\n3. Code blocks: ```javascript\nconst x = 1;\n```\n4. Math formulas: $E = mc^2$\n5. Tabbed data: Name\tAge\tLocation\n\nThe requiresFormatting function will detect these patterns and show a loading spinner.', + initial: false, + }, +}; +FormattingDetectionDemo.parameters = { + docs: { + description: { + story: 'A demonstration of different content patterns that trigger the loading spinner.', + }, + }, +}; diff --git a/src/components/ChatBubble/ChatBubble.tsx b/src/components/ChatBubble/ChatBubble.tsx index 36ee07e04..e8c480907 100644 --- a/src/components/ChatBubble/ChatBubble.tsx +++ b/src/components/ChatBubble/ChatBubble.tsx @@ -1,12 +1,6 @@ -import React, { useEffect, useLayoutEffect, useState } from 'react'; +import React, { useEffect, useState, useRef, lazy, Suspense, useLayoutEffect } from 'react'; import cx from 'classnames'; -import { - ExpertReference, - Memori, - Message, - Tenant, - User, -} from '@memori.ai/memori-api-client/dist/types'; +import { Message, Memori, Tenant, User, ExpertReference } from '@memori.ai/memori-api-client/dist/types'; import { Props as MemoriProps } from '../MemoriWidget/MemoriWidget'; import { Transition } from '@headlessui/react'; import { getResourceUrl } from '../../helpers/media'; @@ -20,11 +14,13 @@ import Button from '../ui/Button'; import QuestionHelp from '../icons/QuestionHelp'; import Copy from '../icons/Copy'; import Code from '../icons/Code'; +import Bug from '../icons/Bug' import WhyThisAnswer from '../WhyThisAnswer/WhyThisAnswer'; import { stripHTML, stripOutputTags } from '../../helpers/utils'; import FilePreview from '../FilePreview/FilePreview'; import { renderMsg, truncateMessage } from '../../helpers/message'; -import Expandable from '../ui/Expandable'; +import Spin from '../ui/Spin'; +import Modal from '../ui/Modal'; // Always import and load MathJax import { installMathJax } from '../../helpers/utils'; @@ -38,8 +34,94 @@ declare global { } } + +// Custom Expandable component optimized for chat bubble content +const ChatBubbleExpandable = ({ + children, + className, + content, + isLarge, + onExpand, + mode = 'chat' +}: { + children: React.ReactNode; + className?: string; + content: string; + isLarge: boolean; + onExpand: () => void; + mode?: 'chat' | 'log'; +}) => { + const { i18n } = useTranslation(); + const lang = i18n.language; + const [expanded, setExpanded] = useState(false); + const [needsExpanding, setNeedsExpanding] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (ref.current && mode === 'log') { + const textContent = ref.current.textContent || ''; + // Determine if content needs to be expandable + if (textContent.length > 300 || isLarge) { + setNeedsExpanding(true); + } + } + }, [isLarge, mode]); + + // If mode is log, always render full content + if (mode === 'chat') { + return ( +
+
+ {children} +
+
+ ); + } + + return ( +
+
+ {expanded ? children : ( +
+ {truncateMessage(typeof content === 'string' ? content : '')} +
+ )} +
+ {needsExpanding && !expanded && ( + + )} + {needsExpanding && expanded && ( + + )} +
+ ); +}; + +// Lazy load the virtualized content component +const VirtualizedContent = lazy(() => import('./VirtualizedContent/VirtualizedContent')); + export interface Props { - message: Message; + message: Message | undefined; memori: Memori; sessionID: string; tenant?: Tenant; @@ -56,8 +138,15 @@ export interface Props { userAvatar?: MemoriProps['userAvatar']; user?: User; experts?: ExpertReference[]; + showFunctionCache?: boolean; + onLoadingStateChange?: (isLoading: boolean) => void; + mode?: 'chat' | 'log'; } +// Size thresholds for different rendering strategies +const LARGE_MESSAGE_THRESHOLD = 5000; // characters +const VERY_LARGE_MESSAGE_THRESHOLD = 15000; // characters for virtualized rendering + const ChatBubble: React.FC = ({ message, memori, @@ -76,10 +165,36 @@ const ChatBubble: React.FC = ({ user, userAvatar, experts, + showFunctionCache = false, + onLoadingStateChange, + mode = 'chat', }) => { const { t, i18n } = useTranslation(); const lang = i18n.language || 'en'; const [showingWhyThisAnswer, setShowingWhyThisAnswer] = useState(false); + const [openFunctionCache, setOpenFunctionCache] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [contentExpanded, setContentExpanded] = useState(false); + const [formattedContent, setFormattedContent] = useState(''); + const contentRef = useRef(null); + + // Determine message size categories + const messageLength = message?.text?.length || 0; + const isLargeMessage = messageLength > LARGE_MESSAGE_THRESHOLD; + const isVeryLargeMessage = messageLength > VERY_LARGE_MESSAGE_THRESHOLD; + + // Get original and plaintext content + const originalText = message?.translatedText || message?.text || ''; + const plainText = message?.fromUser + ? (mode === 'chat' ? truncateMessage(originalText) : originalText) + : stripHTML(stripOutputTags(formattedContent || originalText)); + + // Handle loading state changes + useEffect(() => { + if (onLoadingStateChange) { + onLoadingStateChange(isLoading); + } + }, [isLoading, onLoadingStateChange]); // Initialize MathJax on component mount useEffect(() => { @@ -88,29 +203,62 @@ const ChatBubble: React.FC = ({ } }, []); - const text = message.translatedText || message.text; - const { text: renderedText } = renderMsg(text, useMathFormatting); - const plainText = message.fromUser - ? truncateMessage(text) - : stripHTML(stripOutputTags(renderedText)); + // Format function cache content + const functionCacheData = message?.media?.find( + m => m.properties?.functionCache === "true" + ); - // Render MathJax whenever message content changes + // Initial formatting - format full content if mode is chat + useEffect(() => { + if (mode === 'chat') { + // Always format full content when in chat mode + const { text } = renderMsg(originalText, useMathFormatting); + setFormattedContent(text); + } else { + // Log mode behavior + if (!isLargeMessage) { + const { text } = renderMsg(originalText, useMathFormatting); + setFormattedContent(text); + } else { + // For large messages, only format what's visible initially (truncated content) + const truncated = truncateMessage(originalText); + const { text } = renderMsg(truncated, useMathFormatting); + setFormattedContent(text); + } + } + }, [originalText, useMathFormatting, isLargeMessage, mode]); + + // Handle expansion - format full content when expanding + const handleExpand = () => { + if (isLargeMessage && mode === 'log') { + setIsLoading(true); + setContentExpanded(true); + + // Use timeout to avoid blocking the UI + setTimeout(() => { + try { + const { text } = renderMsg(originalText, useMathFormatting); + setFormattedContent(text); + } catch (error) { + console.error('Error formatting message:', error); + // Fallback to plain unformatted text in case of error + setFormattedContent(`

${originalText}

`); + } finally { + setIsLoading(false); + } + }, 50); + } + }; + + // Render MathJax whenever the formatted content changes useLayoutEffect(() => { - if (typeof window !== 'undefined' && !message.fromUser) { + if (!message?.fromUser && formattedContent && contentRef.current && useMathFormatting) { // Allow a short delay for the DOM to update const timer = setTimeout(() => { if (window.MathJax && window.MathJax.typesetPromise) { try { - const elements = document.querySelectorAll( - '.memori-chat--bubble-content' - ); - if (elements.length > 0) { - window.MathJax.typesetPromise([ - '.memori-chat--bubble-content', - ]).catch(err => - console.error('MathJax typesetting failed:', err) - ); - } + window.MathJax.typesetPromise([contentRef.current?.innerHTML ?? '']) + .catch(err => console.error('MathJax typesetting failed:', err)); } catch (error) { console.error('Error during MathJax typesetting:', error); } @@ -119,11 +267,110 @@ const ChatBubble: React.FC = ({ return () => clearTimeout(timer); } - }, [message.text, message.fromUser, renderedText]); + }, [formattedContent, message?.fromUser, useMathFormatting]); + + // Render the appropriate content based on size and expanded state + const renderMessageContent = () => { + if (isLoading) { + return ( +
+ +
+ ); + } + + // If mode is chat, render content directly without expandable wrapper + if (mode === 'chat') { + if (isVeryLargeMessage) { + // For very large content without expand functionality, use virtualization + return ( + }> + + + ); + } + + // Default rendering for normal-sized messages without expand functionality + return ( +
+ ); + } + + // Log mode expandable behavior + if (contentExpanded && isVeryLargeMessage) { + // For very large expanded content, use virtualization + return ( + }> + + + ); + } + + if (message?.fromUser) { + // User messages use Expandable with simple truncation + return ( + +
+ + ); + } + + // AI/system messages - use expandable for large content + if (isLargeMessage) { + return ( + +
+ + ); + } + + // Default rendering for normal-sized messages + return ( +
+ ); + }; + // Full component render return ( <> - {(message.initial || isFirst) && ( + {(message?.initial || isFirst) && (
)} = ({ appear as="div" className={cx('memori-chat--bubble-container', { - 'memori-chat--bubble-from-user': !!message.fromUser, + 'memori-chat--bubble-from-user': !!message?.fromUser, 'memori-chat--with-addon': - (message.generatedByAI && showAIicon) || + (message?.generatedByAI && showAIicon) || (showFeedback && simulateUserPrompt), + 'memori-chat--large-content': isLargeMessage && mode === 'log', })} > - {!message.fromUser && ( + {!message?.fromUser && ( { e.name === message.emitter) + experts?.find((e) => e.name === message?.emitter) ? `${ new URL(apiUrl ?? '/').origin }/api/v1/memoriai/memori/avatar/${ - experts.find(e => e.name === message.emitter) + experts?.find((e) => e.name === message?.emitter) ?.expertMemoriID }` : memori.avatarURL && memori.avatarURL.length > 0 @@ -189,8 +437,7 @@ const ChatBubble: React.FC = ({ apiURL: apiUrl, }) } - onError={e => { - // Fallback image handling if primary source fails + onError={(e) => { e.currentTarget.src = memori.avatarURL && memori.avatarURL.length > 0 ? getResourceUrl({ @@ -214,49 +461,33 @@ const ChatBubble: React.FC = ({ - {message.fromUser ? ( - -
- - ) : ( -
- )} + {renderMessageContent()} - {((!message.fromUser && showCopyButton) || - (message.generatedByAI && showAIicon) || + {((!message?.fromUser && showCopyButton) || + (message?.generatedByAI && showAIicon) || (showFeedback && simulateUserPrompt)) && (
- {!message.fromUser && showCopyButton && ( + {!message?.fromUser && showCopyButton && (
)} - {message.fromUser && - message.media && - message.media?.length > 0 && - message.media[0].properties?.isAttachedFile && ( + {message?.fromUser && + message?.media && + message?.media?.length > 0 && + message?.media[0]?.properties?.isAttachedFile && ( ({ + previewFiles={message?.media?.map((m) => ({ name: m.title ?? '', id: m.mediumID, content: m.content ?? '', @@ -373,7 +617,7 @@ const ChatBubble: React.FC = ({ )} - {message.fromUser && ( + {message?.fromUser && ( <> {(!!userAvatar && typeof userAvatar === 'string') || (!userAvatar && !!user?.avatarURL?.length) ? ( @@ -382,13 +626,13 @@ const ChatBubble: React.FC = ({ className="memori-chat--bubble-avatar" enter="transition ease-in-out duration-300" enterFrom={`opacity-0 scale-075 ${ - message.fromUser ? 'translate-x-15' : 'translate-x--15' + message?.fromUser ? 'translate-x-15' : 'translate-x--15' }`} enterTo="opacity-1 scale-1 translate-x-0" leave="transition ease-in-out duration-300" leaveFrom="opacity-1 scale-1 translate-x-0" leaveTo={`opacity-0 scale-075 ${ - message.fromUser ? 'translate-x-15' : 'translate-x--15' + message?.fromUser ? 'translate-x-15' : 'translate-x--15' }`} > = ({ className="memori-chat--bubble-avatar" enter="transition ease-in-out duration-300" enterFrom={`opacity-0 scale-075 ${ - message.fromUser ? 'translate-x-15' : 'translate-x--15' + message?.fromUser ? 'translate-x-15' : 'translate-x--15' }`} enterTo="opacity-1 scale-1 translate-x-0" leave="transition ease-in-out duration-300" leaveFrom="opacity-1 scale-1 translate-x-0" leaveTo={`opacity-0 scale-075 ${ - message.fromUser ? 'translate-x-15' : 'translate-x--15' + message?.fromUser ? 'translate-x-15' : 'translate-x--15' }`} > {userAvatar} @@ -420,13 +664,13 @@ const ChatBubble: React.FC = ({ className="memori-chat--bubble-avatar" enter="transition ease-in-out duration-300" enterFrom={`opacity-0 scale-075 ${ - message.fromUser ? 'translate-x-15' : 'translate-x--15' + message?.fromUser ? 'translate-x-15' : 'translate-x--15' }`} enterTo="opacity-1 scale-1 translate-x-0" leave="transition ease-in-out duration-300" leaveFrom="opacity-1 scale-1 translate-x-0" leaveTo={`opacity-0 scale-075 ${ - message.fromUser ? 'translate-x-15' : 'translate-x--15' + message?.fromUser ? 'translate-x-15' : 'translate-x--15' }`} > @@ -436,7 +680,7 @@ const ChatBubble: React.FC = ({ )} - {showingWhyThisAnswer && apiUrl && ( + {showingWhyThisAnswer && apiUrl && message && ( = ({ sessionID={sessionID} /> )} + + {/* Function cache modal */} + setOpenFunctionCache(false)} + className="memori-chat--function-cache-modal" + > +
+          {functionCacheData?.content}
+        
+
); }; -export default ChatBubble; +export default ChatBubble; \ No newline at end of file diff --git a/src/components/ChatBubble/VirtualizedContent/VirtualizedContent.tsx b/src/components/ChatBubble/VirtualizedContent/VirtualizedContent.tsx new file mode 100644 index 000000000..e17e8f6a2 --- /dev/null +++ b/src/components/ChatBubble/VirtualizedContent/VirtualizedContent.tsx @@ -0,0 +1,113 @@ +import React, { useEffect, useState, useRef } from 'react'; +import { Virtuoso } from 'react-virtuoso'; + +interface Props { + content: string; + className?: string; +} + +/** + * VirtualizedContent component that renders large HTML content efficiently + * using virtualization to only render visible elements + */ +const VirtualizedContent: React.FC = ({ content, className = '' }) => { + const [processedContent, setProcessedContent] = useState(''); + const containerRef = useRef(null); + const [height, setHeight] = useState(500); // default height + const [loading, setLoading] = useState(true); + const [mounted, setMounted] = useState(false); + + // Set mounted state on component mount + useEffect(() => { + setMounted(true); + return () => setMounted(false); + }, []); + + // Process content for rendering + useEffect(() => { + if (!content) { + setProcessedContent(''); + setLoading(false); + return; + } + + setLoading(true); + + // Process content to ensure proper HTML rendering + const processContent = () => { + try { + // If content contains HTML, wrap it in a container div + if (content.includes('<')) { + return `
${content}
`; + } + // For plain text, convert newlines to paragraphs + return content + .split(/\n{2,}/) + .filter(para => para.trim().length > 0) + .map(para => `

${para.trim()}

`) + .join(''); + } catch (error) { + console.error('Error processing content:', error); + // Fallback to simple paragraph wrapping + return `

${content}

`; + } + }; + + // Use setTimeout to avoid blocking the UI + setTimeout(() => { + if (mounted) { + const processed = processContent(); + setProcessedContent(processed); + setLoading(false); + } + }, 0); + + }, [content, mounted]); + + // Ensure proper height + useEffect(() => { + // Set a reasonable fixed height to avoid measuring issues + setHeight(Math.min(500, window.innerHeight * 0.6)); + + // Create a resize observer to update height if container changes + if (containerRef.current) { + const resizeObserver = new ResizeObserver(() => { + // Fixed height avoids zero-size issues + setHeight(Math.min(500, window.innerHeight * 0.6)); + }); + + resizeObserver.observe(containerRef.current); + return () => { + if (containerRef.current) { + resizeObserver.unobserve(containerRef.current); + } + }; + } + }, []); + + // Show loading state while processing content + if (loading) { + return
Processing content...
; + } + + // Handle empty content case + if (!processedContent) { + return
No content to display
; + } + + return ( +
+
+
+ ); +}; + +export default VirtualizedContent; diff --git a/src/components/ChatBubble/__snapshots__/ChatBubble.test.tsx.snap b/src/components/ChatBubble/__snapshots__/ChatBubble.test.tsx.snap index 1275f4ee0..ddb5f5974 100644 --- a/src/components/ChatBubble/__snapshots__/ChatBubble.test.tsx.snap +++ b/src/components/ChatBubble/__snapshots__/ChatBubble.test.tsx.snap @@ -9,21 +9,12 @@ exports[`renders ChatBubble from user with avatar as react element unchanged 1`] class="memori-chat--bubble memori-chat--user-bubble transition ease-in-out duration-300 opacity-0 scale-09 translate-x-30" >
-
-
-

- Proin libero ante, dignissim sit amet turpis a, pretium condimentum dolor. -

-
-
+

+ Proin libero ante, dignissim sit amet turpis a, pretium condimentum dolor. +

-
-
-

- Proin libero ante, dignissim sit amet turpis a, pretium condimentum dolor. -

-
-
+

+ Proin libero ante, dignissim sit amet turpis a, pretium condimentum dolor. +

-
-
-

- Proin libero ante, dignissim sit amet turpis a, pretium condimentum dolor. -

-
-
+

+ Proin libero ante, dignissim sit amet turpis a, pretium condimentum dolor. +

-
-
-

- Proin libero ante, dignissim sit amet turpis a, pretium condimentum dolor. -

-
-
+

+ Proin libero ante, dignissim sit amet turpis a, pretium condimentum dolor. +

-
-
-

- Proin libero ante, dignissim sit amet turpis a, pretium condimentum dolor. -

-
-
+

+ Proin libero ante, dignissim sit amet turpis a, pretium condimentum dolor. +

-
-
-

- Proin libero ante, dignissim sit amet turpis a, pretium condimentum dolor. -

-
-
+

+ Proin libero ante, dignissim sit amet turpis a, pretium condimentum dolor. +

( + + + + + + + + + + + + + +); + +export default Bug; \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 32e276941..41f5d8c86 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4171,6 +4171,7 @@ __metadata: react-i18next: 12.0.0 react-is: 18.2.0 react-leaflet: ^4.2.1 + react-virtuoso: ^4.12.7 release-it: 15.5.0 rimraf: 3.0.2 size-limit: 8.1.0 @@ -18722,6 +18723,16 @@ __metadata: languageName: node linkType: hard +"react-virtuoso@npm:^4.12.7": + version: 4.12.7 + resolution: "react-virtuoso@npm:4.12.7" + peerDependencies: + react: ">=16 || >=17 || >= 18 || >= 19" + react-dom: ">=16 || >=17 || >= 18 || >=19" + checksum: 8803f94dc0e4fa8781a7a3605b0629b64cb0fb1da82443d1346e0a6c6527326d28cb588d7bf98e4730da8f68e8b633e7eb330514a35f4a56da7924e775f65bea + languageName: node + linkType: hard + "react@npm:18.2.0": version: 18.2.0 resolution: "react@npm:18.2.0"