@@ -1,4 +1,4 @@ -import React, { memo, useEffect, useState } from 'react' +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import ReactMarkdown from 'react-markdown' import dynamic from 'next/dynamic' import remarkGfm from 'remark-gfm' @@ -16,53 +16,78 @@ interface IProps { export const Markdown = memo(({ content, id = 'markdown' }: IProps) => { const { theme } = useThemeAndDevice() - const LazyCode = dynamic(() => import('#/widgets/chat-gpt-field/ui/code')) - - const [bufferedContent, setBufferedContent] = useState(content) + + const [displayContent, setDisplayContent] = useState(content) + const currentIdRef = useRef(id) + const isMountedRef = useRef(true) useEffect(() => { - setBufferedContent(content) + setDisplayContent(content) }, [content]) useEffect(() => { - eventBus.subscribe(`bot-message-update-${id}`, (data) => { - requestAnimationFrame(() => { - setBufferedContent(data) - }) - }) - }, [id]) + currentIdRef.current = id + isMountedRef.current = true + + const eventName = `bot-message-update-${id}` + + const handleUpdate = (data: string) => { + // Проверяем, что компонент все еще смонтирован и id не изменился + if (!isMountedRef.current || currentIdRef.current !== id) { + return + } + + setDisplayContent((prev) => { + if (prev !== data) { + return data + } + return prev + }) + } + + eventBus.subscribe(eventName, handleUpdate) + + return () => { + isMountedRef.current = false + } + }, [id]) + + const codeComponent = useCallback(({ node, inline, className, children, ...props }: any) => { + const match = /language-(\w+)/.exec(className || '') + + return !inline && match ? ( + <> + + {children} + + + ) : ( + <> + + {children} + + + ) + }, [theme, LazyCode]) + + const components = useMemo(() => ({ + code: codeComponent + }), [codeComponent]) return ( - - {children} - - - ) : ( - <> - - {children} - - - ) - }, - }} + components={components} > - {bufferedContent} + {displayContent} ) }) @@ -3,9 +3,11 @@ import { createPortal } from 'react-dom' import { Box, CircularProgress } from '@mui/material' import { useSession } from 'next-auth/react' +import { useAppSelector } from '#/app/store/store' import { BotMessage } from '#/entities/message' import { ImageModal } from '#/features/image-modal' import { ClientOnly } from '#/shared' +import { eventBus } from '#/shared/classes' import { AIModel } from '#/shared/api/models/models' import { ArrowDownScroll } from '#/shared/ui/icon-components/scroll-down-arrow' import { PreviewView } from '#/widgets/messages' @@ -43,19 +45,39 @@ export const ChatMessagesList: React.FC = memo( }) => { // Refs const paginationScroll = useRef(null) - const isUserAtBottomRef = useRef(true) + const previousScrollTop = useRef(0) + const userScrolledUpRef = useRef(false) + const previousLastMessageUid = useRef(null) // State const [scrollBottom, setScrollBottom] = useState(0) + const [userScrolledUp, setUserScrolledUp] = useState(false) const [modal, setModal] = useState(false) const [isNewMessage, setIsNewMessage] = useState(false) const [currentSrc, setCurrentSrc] = useState(null) + const hasInitializedRef = useRef(false) // Computed values const desktop = device === 'desktop' const { status } = useSession() + + const isChatBotStreaming = useAppSelector((state) => state.streaming.isChatBotStreaming) + + // Определяем, происходит ли стриминг сейчас + const isStreaming = useMemo(() => { + const hasActiveStreaming = Object.values(isChatBotStreaming).some(Boolean) + if (hasActiveStreaming) return true + + if (!messageResponse || messageResponse.length === 0) return false + + const lastMessage = messageResponse[messageResponse.length - 1] + if (!lastMessage || !lastMessage.from_model) return false + + // Если последнее сообщение от модели имеет placeholder текст или пустое - идет стриминг + const placeholderText = 'Ваш вопрос получен. Ожидание ответа от модели...' + return lastMessage.content === placeholderText || lastMessage.content === '' + }, [messageResponse, isChatBotStreaming]) - // Memoized values const onlyImageMessage = useMemo(() => { if (!messageResponse) return [] return messageResponse @@ -82,11 +104,21 @@ export const ChatMessagesList: React.FC = memo( [messageResponse?.length, isAuthenticated] ) + + const shouldShowScrollButton = useMemo(() => scrollBottom > 500, [scrollBottom] ) + const isAttachedToBottom = useMemo(() => { + // Если пользователь скроллил вверх, не считаем его прикрепленным к низу + if (userScrolledUp) { + return false + } + return scrollBottom <= 100 + }, [scrollBottom, userScrolledUp]) + // Memoized handlers const handleScrollToBottom = useCallback(() => { const block = paginationScroll.current @@ -104,51 +136,89 @@ export const ChatMessagesList: React.FC = memo( } }, [getMessagesPagination]) - // Дополнительный эффект для гарантированного скролла к низу при загрузке сообщений - React.useEffect(() => { - const block = paginationScroll.current - if (!block || !messageResponse || messageResponse.length === 0) return - - // Дополнительный плавный скролл к низу с большей задержкой - const ensureScrollToBottom = () => { - if (block) { - block.scrollTo({ - top: block.scrollHeight, - behavior: 'smooth' - }) - } + // Вызываем handleScrollToBottom при первом обновлении messageResponse + useEffect(() => { + if (!hasInitializedRef.current && messageResponse && messageResponse.length > 0) { + hasInitializedRef.current = true + setTimeout(() => { + handleScrollToBottom() + }, 0) } + }, [messageResponse, handleScrollToBottom]) - // Используем несколько попыток скролла - const timer1 = setTimeout(ensureScrollToBottom, 200) - const timer2 = setTimeout(ensureScrollToBottom, 500) - return () => { - clearTimeout(timer1) - clearTimeout(timer2) + useEffect(() => { + if (!messageResponse || messageResponse.length === 0) { + previousLastMessageUid.current = null + return } - }, [messageResponse?.length, messageResponse]) // Срабатывает при изменении количества сообщений - useEffect(() => { + const lastMessage = messageResponse[messageResponse.length - 1] + const currentLastMessageUid = lastMessage?.uid || null + + // Сравниваем последний элемент с предыдущим + if (previousLastMessageUid.current !== null && currentLastMessageUid !== previousLastMessageUid.current) { setIsNewMessage(true) - }, [messageResponse]) + handleScrollToBottom() + } + + // Обновляем ref для следующего сравнения + previousLastMessageUid.current = currentLastMessageUid + }, [messageResponse, handleScrollToBottom]) + + // Отслеживание обновлений последнего сообщения во время стриминга + useEffect(() => { + if (!isStreaming || !messageResponse || messageResponse.length === 0) return + + const lastMessage = messageResponse[messageResponse.length - 1] + if (!lastMessage?.from_model || !lastMessage.uid) return + + const eventName = `bot-message-update-${lastMessage.uid}` + + // Подписываемся на обновления последнего сообщения + eventBus.subscribe(eventName, (updatedContent: string) => { + const block = paginationScroll.current + if (!block) return + + // Проверяем актуальное состояние скролла + const currentScrollTop = block.scrollTop + const currentDistanceFromBottom = block.scrollHeight - currentScrollTop - block.clientHeight + + // Скроллим только если пользователь не скроллил вверх И находится близко к низу + if (!userScrolledUpRef.current && currentDistanceFromBottom <= 100) { + handleScrollToBottom() + } + }) + }, [isStreaming, messageResponse, isAttachedToBottom, handleScrollToBottom]) const handleScroll = useCallback(() => { const block = paginationScroll.current if (!block) return - const distanceFromBottom = block.scrollHeight - block.scrollTop - block.clientHeight + const currentScrollTop = block.scrollTop + const distanceFromBottom = block.scrollHeight - currentScrollTop - block.clientHeight + + // Определяем направление скролла + if (previousScrollTop.current > 0) { + if (currentScrollTop < previousScrollTop.current) { + setUserScrolledUp(true) + userScrolledUpRef.current = true + } + else if (currentScrollTop > previousScrollTop.current && distanceFromBottom <= 100) { + setUserScrolledUp(false) + userScrolledUpRef.current = false + } + } + + previousScrollTop.current = currentScrollTop setScrollBottom(distanceFromBottom) - // Обновляем флаг: пользователь считается "внизу", если находится в пределах 50px от низа - isUserAtBottomRef.current = distanceFromBottom < 50 - if (messageResponse?.length !== 0) { - const { scrollTop } = block - if (scrollTop === 0 && getMessagesPagination) { + if (currentScrollTop === 0 && getMessagesPagination) { getMessagesPagination() } } + }, [messageResponse?.length, getMessagesPagination]) return (