From 46e1857489ab00e294c88ad7566c1b2c0f9237d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20L=C3=89VEIL?= Date: Mon, 27 Mar 2023 09:22:38 +0200 Subject: [PATCH] Fix rendering performances issues related to scrolling events (#174) * memoize chat related components * Avoid re-rendering ChatInput on every message udpate * change the way the horizontal scrollbar is hidden * make the scroll event listener passive * perf(Chat): fix performances issues related to autoscroll Uses the intersection API to determine autoscroll mode instead of listening for scroll events * tuning detection of autoscroll --- components/Chat/Chat.tsx | 364 +++++++++--------- components/Chat/ChatInput.tsx | 12 +- components/Chat/ChatMessage.tsx | 361 ++++++++--------- components/Markdown/CodeBlock.tsx | 7 +- components/Markdown/MemoizedReactMarkdown.tsx | 4 + utils/index.ts | 19 + 6 files changed, 401 insertions(+), 366 deletions(-) create mode 100644 components/Markdown/MemoizedReactMarkdown.tsx create mode 100644 utils/index.ts diff --git a/components/Chat/Chat.tsx b/components/Chat/Chat.tsx index 0541b8c..7ebd174 100644 --- a/components/Chat/Chat.tsx +++ b/components/Chat/Chat.tsx @@ -7,6 +7,7 @@ import { } from '@/types'; import { FC, + memo, MutableRefObject, useCallback, useEffect, @@ -21,6 +22,7 @@ import { ErrorMessageDiv } from './ErrorMessageDiv'; import { ModelSelect } from './ModelSelect'; import { SystemPrompt } from './SystemPrompt'; import { IconSettings } from '@tabler/icons-react'; +import { throttle } from '@/utils'; interface Props { conversation: Conversation; @@ -40,197 +42,203 @@ interface Props { stopConversationRef: MutableRefObject; } -export const Chat: FC = ({ - conversation, - models, - apiKey, - serverSideApiKeyIsSet, - messageIsStreaming, - modelError, - messageError, - loading, - onSend, - onUpdateConversation, - onEditMessage, - stopConversationRef, -}) => { - const { t } = useTranslation('chat'); - const [currentMessage, setCurrentMessage] = useState(); - const [autoScrollEnabled, setAutoScrollEnabled] = useState(true); - const [showSettings, setShowSettings] = useState(false); +export const Chat: FC = memo( + ({ + conversation, + models, + apiKey, + serverSideApiKeyIsSet, + messageIsStreaming, + modelError, + messageError, + loading, + onSend, + onUpdateConversation, + onEditMessage, + stopConversationRef, + }) => { + const { t } = useTranslation('chat'); + const [currentMessage, setCurrentMessage] = useState(); + const [autoScrollEnabled, setAutoScrollEnabled] = useState(true); + const [showSettings, setShowSettings] = useState(false); - const messagesEndRef = useRef(null); - const chatContainerRef = useRef(null); - const textareaRef = useRef(null); + const messagesEndRef = useRef(null); + const chatContainerRef = useRef(null); + const textareaRef = useRef(null); - const scrollToBottom = useCallback(() => { - if (autoScrollEnabled) { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - textareaRef.current?.focus(); - } - }, [autoScrollEnabled]); + const handleSettings = () => { + setShowSettings(!showSettings); + }; - const handleScroll = () => { - if (chatContainerRef.current) { - const { scrollTop, scrollHeight, clientHeight } = - chatContainerRef.current; - const bottomTolerance = 5; - - if (scrollTop + clientHeight < scrollHeight - bottomTolerance) { - setAutoScrollEnabled(false); - } else { - setAutoScrollEnabled(true); + const scrollDown = () => { + if (autoScrollEnabled) { + messagesEndRef.current?.scrollIntoView(true); } - } - }; + }; + const throttledScrollDown = throttle(scrollDown, 250); - const handleSettings = () => { - setShowSettings(!showSettings); - }; - - useEffect(() => { - scrollToBottom(); - setCurrentMessage(conversation.messages[conversation.messages.length - 2]); - }, [conversation.messages, scrollToBottom]); - - useEffect(() => { - const chatContainer = chatContainerRef.current; - - if (chatContainer) { - chatContainer.addEventListener('scroll', handleScroll); + useEffect(() => { + throttledScrollDown(); + setCurrentMessage( + conversation.messages[conversation.messages.length - 2], + ); + }, [conversation.messages, throttledScrollDown]); + useEffect(() => { + const observer = new IntersectionObserver( + ([entry]) => { + setAutoScrollEnabled(entry.isIntersecting); + if (entry.isIntersecting) { + textareaRef.current?.focus(); + } + }, + { + root: null, + threshold: 0.5, + }, + ); + const messagesEndElement = messagesEndRef.current; + if (messagesEndElement) { + observer.observe(messagesEndElement); + } return () => { - chatContainer.removeEventListener('scroll', handleScroll); + if (messagesEndElement) { + observer.unobserve(messagesEndElement); + } }; - } - }, []); + }, [messagesEndRef]); - return ( -
- {!(apiKey || serverSideApiKeyIsSet) ? ( -
-
- {t('OpenAI API Key Required')} + return ( +
+ {!(apiKey || serverSideApiKeyIsSet) ? ( +
+
+ {t('OpenAI API Key Required')} +
+
+ {t( + 'Please set your OpenAI API key in the bottom left of the sidebar.', + )} +
+
+ {t("If you don't have an OpenAI API key, you can get one here: ")} + + openai.com + +
-
- {t( - 'Please set your OpenAI API key in the bottom left of the sidebar.', - )} -
-
- {t("If you don't have an OpenAI API key, you can get one here: ")} - + ) : ( + <> + -
- ) : modelError ? ( - - ) : ( - <> -
- {conversation.messages.length === 0 ? ( - <> -
-
- {models.length === 0 ? t('Loading...') : 'Chatbot UI'} + {conversation.messages.length === 0 ? ( + <> +
+
+ {models.length === 0 ? t('Loading...') : 'Chatbot UI'} +
+ + {models.length > 0 && ( +
+ + onUpdateConversation(conversation, { + key: 'model', + value: model, + }) + } + /> + + + onUpdateConversation(conversation, { + key: 'prompt', + value: prompt, + }) + } + /> +
+ )}
- - {models.length > 0 && ( -
- - onUpdateConversation(conversation, { - key: 'model', - value: model, - }) - } - /> - - - onUpdateConversation(conversation, { - key: 'prompt', - value: prompt, - }) - } - /> + + ) : ( + <> +
+ {t('Model')}: {conversation.model.name} + +
+ {showSettings && ( +
+
+ + onUpdateConversation(conversation, { + key: 'model', + value: model, + }) + } + /> +
)} -
- - ) : ( - <> -
- {t('Model')}: {conversation.model.name} - ( + + ))} + + {loading && } + +
-
- {showSettings && ( -
-
- - onUpdateConversation(conversation, { - key: 'model', - value: model, - }) - } - /> -
-
- )} + + )} +
- {conversation.messages.map((message, index) => ( - - ))} - - {loading && } - -
- - )} -
- - { - setCurrentMessage(message); - onSend(message); - }} - onRegenerate={() => { - if (currentMessage) { - onSend(currentMessage, 2); - } - }} - /> - - )} -
- ); -}; + 0} + model={conversation.model} + onSend={(message) => { + setCurrentMessage(message); + onSend(message); + }} + onRegenerate={() => { + if (currentMessage) { + onSend(currentMessage, 2); + } + }} + /> + + )} +
+ ); + }, +); +Chat.displayName = 'Chat'; diff --git a/components/Chat/ChatInput.tsx b/components/Chat/ChatInput.tsx index d9bb300..0e91b2b 100644 --- a/components/Chat/ChatInput.tsx +++ b/components/Chat/ChatInput.tsx @@ -12,7 +12,7 @@ import { useTranslation } from 'next-i18next'; interface Props { messageIsStreaming: boolean; model: OpenAIModel; - messages: Message[]; + conversationIsEmpty: boolean; onSend: (message: Message) => void; onRegenerate: () => void; stopConversationRef: MutableRefObject; @@ -22,7 +22,7 @@ interface Props { export const ChatInput: FC = ({ messageIsStreaming, model, - messages, + conversationIsEmpty, onSend, onRegenerate, stopConversationRef, @@ -102,11 +102,11 @@ export const ChatInput: FC = ({ } return ( -
+
{messageIsStreaming && ( )} - {!messageIsStreaming && messages.length > 0 && ( + {!messageIsStreaming && !conversationIsEmpty && (