@@ -91,7 +91,9 @@ const chatsSlice = createSlice({ .addCase(fetchChatsByModel.fulfilled, (state, action) => { state.loading = false state.chats = action.payload.chats - state.currentChat = action.payload.currentChat + const hasCurrentChat = + state.currentChat != null && action.payload.chats.some((chat) => chat.uid === state.currentChat) + state.currentChat = hasCurrentChat ? state.currentChat : action.payload.currentChat }) .addCase(fetchChatsByModel.rejected, (state, action) => { state.loading = false @@ -158,6 +158,19 @@ type StreamRuntime = { receivedStart: boolean streamCompleted: boolean streamFailed: boolean + streamErrored: boolean +} + +function getSseErrorDetail(data: unknown): string { + if (typeof data === 'string' && data.trim()) { + return data + } + + if (data && typeof data === 'object' && 'detail' in data && typeof (data as { detail?: unknown }).detail === 'string') { + return (data as { detail: string }).detail + } + + return 'Ошибка генерации' } async function consumeSseStream( @@ -194,9 +207,8 @@ async function consumeSseStream( break } case 'error': { - const data = sseEvent.data as { detail?: string } - handlers.onError(data?.detail ?? 'Ошибка генерации') - break + handlers.onError(getSseErrorDetail(sseEvent.data)) + return } } } @@ -212,6 +224,7 @@ export function useChatModel( const dispatch = useAppDispatch() const [messages, setMessages] = useState(null) const [loading, setLoading] = useState(false) + const [paginationLoading, setPaginationLoading] = useState(false) const [offset, setOffset] = useState(0) const abortRef = useRef(null) @@ -225,6 +238,7 @@ export function useChatModel( receivedStart: false, streamCompleted: false, streamFailed: false, + streamErrored: false, }) const persistStreamSession = useCallback( @@ -247,6 +261,7 @@ export function useChatModel( receivedStart: false, streamCompleted: false, streamFailed: false, + streamErrored: false, } }, []) @@ -364,26 +379,36 @@ export function useChatModel( return prev.map((message) => (message.uid === modelUid ? { ...message, content } : message)) }) }, - onError: () => { + onError: (detail) => { runtime.streamFailed = true + runtime.streamErrored = true + const inputUuid = inputMessageUuidRef.current - if (inputUuid) { - persistStreamSession(currentChat, inputUuid) + clearChatStreamSession(currentChat) + setMessages((prev) => removeStreamingModelMessage(prev, inputUuid)) + + if (!runtime.receivedStart) { + markOptimisticUserMessageFailed(inputUuid) } + + inputMessageUuidRef.current = null + showMessage(detail) }, }) const hasActiveSession = readChatStreamSession(currentChat) !== null return { - shouldReconnect: runtime.streamFailed && (runtime.hasStreamEvents || hasActiveSession), + shouldReconnect: + runtime.streamFailed && !runtime.streamErrored && (runtime.hasStreamEvents || hasActiveSession), streamFailed: runtime.streamFailed, - missingStart: !runtime.receivedStart && !options.isReconnect && !runtime.streamCompleted, + streamErrored: runtime.streamErrored, + missingStart: !runtime.receivedStart && !options.isReconnect && !runtime.streamCompleted && !runtime.streamErrored, receivedStart: runtime.receivedStart, streamCompleted: runtime.streamCompleted, } }, - [currentChat, modelType, persistStreamSession] + [currentChat, markOptimisticUserMessageFailed, modelType, persistStreamSession, showMessage] ) const reconnectToStream = useCallback( @@ -450,6 +475,11 @@ export function useChatModel( const result = await processMessageStream(response, { isReconnect: true }) + if (result.streamErrored) { + resetStreamRuntime() + return false + } + if (result.shouldReconnect) { persistStreamSession(chatUid, inputMessageUuid) return reconnectToStream( @@ -462,7 +492,7 @@ export function useChatModel( ) } - if (result.streamFailed && !options.silent) { + if (result.streamFailed && !result.streamErrored && !options.silent) { showMessage('Ошибка генерации') } @@ -509,6 +539,14 @@ export function useChatModel( [reconnectToStream] ) + const streamingRef = useRef(streaming) + streamingRef.current = streaming + const tryReconnectOnLoadRef = useRef(tryReconnectOnLoad) + tryReconnectOnLoadRef.current = tryReconnectOnLoad + const messagesRef = useRef(messages) + messagesRef.current = messages + const reconnectAttemptedForChatRef = useRef(null) + useEffect(() => { return () => { abortRef.current?.abort() @@ -523,61 +561,106 @@ export function useChatModel( resetStreamRuntime() if (!currentChat) { - setMessages(null) + setMessages([]) setOffset(0) + reconnectAttemptedForChatRef.current = null return } - setMessages([]) + setMessages(null) setOffset(0) + reconnectAttemptedForChatRef.current = null let cancelled = false ;(async () => { setLoading(true) - const answer = await chatMessagesApi.getMessages(currentChat, 0, data?.access) - if (cancelled) { - return - } - setLoading(false) - if (!Array.isArray(answer)) { - showMessage('Ошибка загрузки чата') - return - } + try { + const answer = await chatMessagesApi.getMessages(currentChat, 0, data?.access) + if (cancelled) { + return + } - const loadedMessages = answer.reverse() - setMessages(loadedMessages) - setOffset(answer.length) + if (!Array.isArray(answer)) { + showMessage('Ошибка загрузки чата') + setMessages([]) + return + } + + const loadedMessages = answer.reverse() + setMessages(loadedMessages) + setOffset(answer.length) - if (streaming && data?.access) { - await tryReconnectOnLoad(currentChat, loadedMessages) + if (streamingRef.current && data?.access) { + reconnectAttemptedForChatRef.current = currentChat + await tryReconnectOnLoadRef.current(currentChat, loadedMessages) + } + } finally { + if (!cancelled) { + setLoading(false) + } } })() return () => { cancelled = true } - }, [currentChat, data?.access, resetStreamRuntime, showMessage, streaming, tryReconnectOnLoad]) + }, [currentChat, data?.access, showMessage]) + + useEffect(() => { + if (!currentChat || !streaming || !data?.access) { + return + } + + const loadedMessages = messagesRef.current + if (loadedMessages === null) { + return + } + + if (reconnectAttemptedForChatRef.current === currentChat) { + return + } + + reconnectAttemptedForChatRef.current = currentChat + void tryReconnectOnLoad(currentChat, loadedMessages) + }, [currentChat, streaming, data?.access, tryReconnectOnLoad]) const getMessagesPagination = useCallback(async () => { - if (!currentChat || isSendingRef.current) { + if (!currentChat || isSendingRef.current || paginationLoading) { return } - setLoading(true) - const answer = await chatMessagesApi.getMessages(currentChat, offset, data?.access) - setLoading(false) + setPaginationLoading(true) + + try { + const answer = await chatMessagesApi.getMessages(currentChat, offset, data?.access) + + if (!Array.isArray(answer) || answer.length === 0) { + return + } - if (Array.isArray(answer)) { const newMessages = answer.reverse() - setMessages((prev) => (prev != null ? [...newMessages, ...prev] : newMessages)) + + setMessages((prev) => { + const existingUids = new Set(prev?.map((message) => message.uid) ?? []) + const toPrepend = newMessages.filter((message) => !existingUids.has(message.uid)) + + if (toPrepend.length === 0) { + return prev ?? null + } + + return [...toPrepend, ...(prev ?? [])] + }) + setOffset((prev) => prev + answer.length) - return + } catch (error) { + console.error('[useChatModel] getMessagesPagination: request failed', { chatUid: currentChat, error }) + showMessage('Ошибка загрузки сообщений') + } finally { + setPaginationLoading(false) } - - showMessage('Ошибка загрузки сообщений') - }, [currentChat, data?.access, offset, showMessage]) + }, [currentChat, data?.access, offset, paginationLoading, showMessage]) const sendMessage = useCallback( async (dataForSend: MessageSend) => { @@ -691,6 +774,11 @@ export function useChatModel( const result = await processMessageStream(response) + if (result.streamErrored) { + resetStreamRuntime() + return + } + if (result.shouldReconnect) { const inputUuid = inputMessageUuidRef.current ?? readChatStreamSession(currentChat)?.inputMessageUuid ?? STREAMING_PENDING_UID @@ -777,5 +865,5 @@ export function useChatModel( [currentChat, data?.access] ) - return { messages, sendMessage, loading, getMessagesPagination, deleteMessage } + return { messages, sendMessage, loading, paginationLoading, getMessagesPagination, deleteMessage } } @@ -1,4 +1,4 @@ -import React, { memo, useEffect, useMemo, useRef, useState } from 'react' +import React, { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { Box, CircularProgress } from '@mui/material' import { useSession } from 'next-auth/react' @@ -25,14 +25,130 @@ interface IMessagesList { setResendValue: (value: string) => void onLoadImage?: (event: React.ChangeEvent | null, file?: File) => void loading: boolean + paginationLoading?: boolean } export const ChatMessagesList: React.FC = memo( - ({ messageResponse, onLoadImage, setResendValue, modelTitle, device, modelType, botParams, getMessagesPagination, deleteMessage, loading }) => { - const paginationScroll = React.useRef(null) - const [isPaginating, setIsPaginating] = React.useState(false) - const [chatScrollHeight, setChatScrollHeight] = React.useState(0) + ({ messageResponse, onLoadImage, setResendValue, modelTitle, device, modelType, botParams, getMessagesPagination, deleteMessage, loading, paginationLoading = false }) => { + const paginationScroll = React.useRef(null) const [scrollBottom, setScrollBottom] = React.useState(0) + const prevMessagesSnapshotRef = useRef<{ + length: number + firstUid?: string + lastUid?: string + lastContentLength: number + } | null>(null) + const scrollAnchorRef = useRef<{ + uid: string + topOffset: number + scrollTop: number + scrollHeight: number + } | null>(null) + const prependRestoreObserverRef = useRef(null) + const prependRestoreTimeoutRef = useRef | null>(null) + const initialScrollObserverRef = useRef(null) + const initialScrollTimeoutRef = useRef | null>(null) + const isRestoringScrollRef = useRef(false) + const scrollBottomRef = useRef(0) + const lastSeenLastUidRef = useRef(null) + + const getMessageTopInViewport = (block: HTMLDivElement, el: HTMLElement) => + el.getBoundingClientRect().top - block.getBoundingClientRect().top + + const captureScrollAnchor = (block: HTMLDivElement) => { + const anchorUid = messageResponse?.[0]?.uid + if (!anchorUid) { + return + } + + const el = block.querySelector(`[data-message-uid="${CSS.escape(anchorUid)}"]`) as HTMLElement | null + + scrollAnchorRef.current = { + uid: anchorUid, + topOffset: el ? getMessageTopInViewport(block, el) : 0, + scrollTop: block.scrollTop, + scrollHeight: block.scrollHeight, + } + } + + const scrollToBottom = (block: HTMLDivElement, persist = false) => { + const apply = () => { + block.scrollTop = block.scrollHeight + } + + apply() + + if (!persist) { + return + } + + initialScrollObserverRef.current?.disconnect() + if (initialScrollTimeoutRef.current) { + clearTimeout(initialScrollTimeoutRef.current) + } + + const observer = new ResizeObserver(apply) + initialScrollObserverRef.current = observer + observer.observe(block) + + requestAnimationFrame(() => { + apply() + requestAnimationFrame(apply) + }) + + initialScrollTimeoutRef.current = setTimeout(() => { + observer.disconnect() + initialScrollObserverRef.current = null + }, 3000) + } + + const restorePrependScroll = (block: HTMLDivElement) => { + const anchor = scrollAnchorRef.current + if (!anchor) { + return + } + + const apply = () => { + block.scrollTop = anchor.scrollTop + (block.scrollHeight - anchor.scrollHeight) + + const el = block.querySelector(`[data-message-uid="${CSS.escape(anchor.uid)}"]`) as HTMLElement | null + if (el) { + const currentTop = getMessageTopInViewport(block, el) + block.scrollTop = block.scrollTop + currentTop - anchor.topOffset + } + + return true + } + + prependRestoreObserverRef.current?.disconnect() + if (prependRestoreTimeoutRef.current) { + clearTimeout(prependRestoreTimeoutRef.current) + } + + isRestoringScrollRef.current = true + apply() + + const observer = new ResizeObserver(() => { + apply() + }) + prependRestoreObserverRef.current = observer + observer.observe(block) + + requestAnimationFrame(() => { + apply() + requestAnimationFrame(() => { + apply() + isRestoringScrollRef.current = false + }) + }) + + prependRestoreTimeoutRef.current = setTimeout(() => { + observer.disconnect() + prependRestoreObserverRef.current = null + scrollAnchorRef.current = null + isRestoringScrollRef.current = false + }, 3000) + } const desktop = device === 'desktop' const { status } = useSession() @@ -54,47 +170,111 @@ export const ChatMessagesList: React.FC = memo( .filter((el) => el !== null) as Message[] }, [messageResponse]) - React.useEffect(() => { + useEffect(() => { + return () => { + prependRestoreObserverRef.current?.disconnect() + initialScrollObserverRef.current?.disconnect() + if (prependRestoreTimeoutRef.current) { + clearTimeout(prependRestoreTimeoutRef.current) + } + if (initialScrollTimeoutRef.current) { + clearTimeout(initialScrollTimeoutRef.current) + } + } + }, []) + + useEffect(() => { + if (!messageResponse?.length) { + prevMessagesSnapshotRef.current = null + lastSeenLastUidRef.current = null + scrollAnchorRef.current = null + initialScrollObserverRef.current?.disconnect() + if (initialScrollTimeoutRef.current) { + clearTimeout(initialScrollTimeoutRef.current) + } + } + }, [messageResponse]) + + useLayoutEffect(() => { + if (!messageResponse?.length) { + return + } + const block = paginationScroll.current + if (!block) { + return + } - if (messageResponse != undefined && !isPaginating) { - setChatScrollHeight(paginationScroll.current.scrollHeight) + const snapshot = { + length: messageResponse.length, + firstUid: messageResponse[0]?.uid, + lastUid: messageResponse[messageResponse.length - 1]?.uid, + lastContentLength: messageResponse[messageResponse.length - 1]?.content?.length ?? 0, + } + const prev = prevMessagesSnapshotRef.current - const time = setTimeout(() => { - if (block) { - //@ts-ignore - block.scrollTo({ - top: block.scrollHeight, - behavior: 'smooth', // добавляем плавную прокрутку - }) - } - }, 250) - return () => clearTimeout(time) - } else if (messageResponse != undefined && isPaginating) { - if (block) { - //@ts-ignore - block.scrollTop = block.scrollHeight - chatScrollHeight - setChatScrollHeight(paginationScroll.current.scrollHeight) - } + if (!prev) { + scrollToBottom(block, true) + prevMessagesSnapshotRef.current = snapshot + return + } + + const prepended = + snapshot.length > prev.length && snapshot.lastUid === prev.lastUid + + const appended = + snapshot.lastUid !== prev.lastUid && snapshot.length >= prev.length && !prepended + + const structureSame = + snapshot.length === prev.length && + snapshot.firstUid === prev.firstUid && + snapshot.lastUid === prev.lastUid + + const streamingGrowth = + structureSame && + snapshot.lastContentLength > prev.lastContentLength && + Boolean(snapshot.lastUid?.startsWith('streaming:')) + + if (prepended && scrollAnchorRef.current) { + restorePrependScroll(block) + } else if (appended) { + block.scrollTo({ + top: block.scrollHeight, + behavior: 'smooth', + }) + } else if (streamingGrowth && scrollBottomRef.current < 200) { + block.scrollTop = block.scrollHeight } - setIsPaginating(false) + + prevMessagesSnapshotRef.current = snapshot }, [messageResponse]) useEffect(() => { - setIsNewMessage(true) + const lastUid = messageResponse?.[messageResponse.length - 1]?.uid + if (!lastUid) { + return + } + + if (lastSeenLastUidRef.current && lastSeenLastUidRef.current !== lastUid) { + setIsNewMessage(true) + } + + lastSeenLastUidRef.current = lastUid }, [messageResponse]) const handleScroll = () => { - setScrollBottom(paginationScroll.current?.scrollHeight - paginationScroll.current?.scrollTop - paginationScroll.current?.clientHeight) - - if (paginationScroll.current && messageResponse?.length !== 0) { - const { scrollTop, scrollHeight, clientHeight } = paginationScroll.current - if (scrollTop === 0) { - if (getMessagesPagination) { - setIsPaginating(true) - getMessagesPagination() - } - } + const block = paginationScroll.current + if (!block) { + return + } + + const distanceFromBottom = block.scrollHeight - block.scrollTop - block.clientHeight + scrollBottomRef.current = distanceFromBottom + setScrollBottom(distanceFromBottom) + + if (messageResponse?.length && block.scrollTop === 0 && getMessagesPagination && !paginationLoading && !isRestoringScrollRef.current) { + captureScrollAnchor(block) + void getMessagesPagination() } } @@ -154,7 +334,7 @@ export const ChatMessagesList: React.FC = memo( )} - {loading && ( + {paginationLoading && ( = memo( messageResponse?.map((message, idx) => { const isStreaming = loading && + !paginationLoading && message.from_model && message.uid.startsWith('streaming:') && idx === messageResponse.length - 1 @@ -190,7 +371,7 @@ export const ChatMessagesList: React.FC = memo( - + {!props.message.from_model ? ( = () => { const currentChat = useAppSelector(selectCurrentChat) - const { messages, sendMessage, loading, getMessagesPagination, deleteMessage } = useChatModel( + const { messages, sendMessage, loading, paginationLoading, getMessagesPagination, deleteMessage } = useChatModel( currentChat, showMessage, modelType, @@ -66,6 +66,12 @@ const Page: NextPageWithLayout = () => { const deleteMessageMemo = useCallback(deleteMessage, [currentChat, messages]) + const chatWindowHeight = desktop + ? '75vh' + : `calc(100dvh - ${(botParams?.tags ?? []).length === 0 ? '200px' : '285px'})` + + const isMessagesLoading = messages === null + React.useEffect(() => { if (data?.access) { model_api.getBotParams(router.asPath.split('/')[2], data.access).then((res) => { @@ -263,29 +269,52 @@ const Page: NextPageWithLayout = () => { - + {isMessagesLoading ? ( + + + + ) : ( + + )} { openMobileFilters: () => void setting?: T loading: boolean + paginationLoading?: boolean file?: any setFile: React.Dispatch> isCalculating: boolean @@ -49,6 +50,7 @@ export interface ChatProps { function Chat({ device, loading, + paginationLoading, messages, modelType, sendMessage, @@ -126,6 +128,7 @@ function Chat({ deleteMessage={deleteMessage} modelTitle={modelTitle} loading={loading} + paginationLoading={paginationLoading} />