From 66377e87ba0a4cdf7e27f09d4dd91abd4cb8c3f0 Mon Sep 17 00:00:00 2001 From: WangzJi Date: Mon, 30 Jun 2025 20:11:03 +0800 Subject: [PATCH] fix: resolve streaming output scroll interruption in chat interface - Implement MutationObserver for real-time DOM change detection - Add triple-layer scroll guarantee: height detection + force check + backup timer - Optimize architecture: separate scroll logic between container and content components - Add intelligent user experience: respect manual scrolling with 100px threshold - Fix second message scroll failure and streaming mode timeout issues - Support both new chat mode and dashboard mode scenarios --- web/components/chat/completion.tsx | 200 ++++--- .../chat/ChatContentContainer.tsx | 516 +++++++++++------- .../chat/content/ChatCompletion.tsx | 157 +----- 3 files changed, 434 insertions(+), 439 deletions(-) diff --git a/web/components/chat/completion.tsx b/web/components/chat/completion.tsx index 494dce490..194121f54 100644 --- a/web/components/chat/completion.tsx +++ b/web/components/chat/completion.tsx @@ -181,154 +181,186 @@ const Completion = ({ messages, onSubmit, onFormatContent }: Props) => { }, []); // Track user scrolling behavior - const lastScrollTimeRef = useRef(0); const isUserScrollingRef = useRef(false); - const prevMessageCountRef = useRef(showMessages.length); + const prevMessageCountRef = useRef(0); const lastContentHeightRef = useRef(0); - const scrollTimeoutRef = useRef(null); - const isAutoScrollingRef = useRef(false); // Track if we're auto-scrolling + const isStreamingRef = useRef(false); // Track if we're in streaming mode + const streamingTimeoutRef = useRef(null); - // Initialize refs + // Initialize refs only once useEffect(() => { - console.log('Completion initializing with showMessages length:', showMessages.length); - // Set initial scroll time to allow streaming from the beginning - if (lastScrollTimeRef.current === 0) { - lastScrollTimeRef.current = Date.now() - 3000; // Set to 3 seconds ago - } - }, []); - - // Update message count tracking when showMessages changes - useEffect(() => { - console.log('Completion updating prevMessageCountRef:', { - currentLength: showMessages.length, - prevCount: prevMessageCountRef.current, - }); - - // Only update if this is the first time (count is 0) + console.log('Completion initializing refs'); + // Initialize message count tracking if (prevMessageCountRef.current === 0) { - prevMessageCountRef.current = showMessages.length; + prevMessageCountRef.current = 0; // Will be set when first message arrives } - }, [showMessages.length]); + }, []); // No dependencies - only run once // Handle scroll events to detect user interaction const handleScrollEvent = useCallback(() => { - // Ignore scroll events caused by auto-scrolling - if (isAutoScrollingRef.current) { + // Completely ignore scroll events during streaming to prevent interference + if (isStreamingRef.current) { + console.log('Ignoring scroll event during streaming'); return; } - lastScrollTimeRef.current = Date.now(); - if (scrollableRef.current) { const scrollElement = scrollableRef.current; const { scrollTop, scrollHeight, clientHeight } = scrollElement; - const isAtBottom = scrollTop + clientHeight >= scrollHeight - 20; + const isAtBottom = scrollTop + clientHeight >= scrollHeight - 5; + + const wasUserScrolling = isUserScrollingRef.current; isUserScrollingRef.current = !isAtBottom; + + if (wasUserScrolling !== isUserScrollingRef.current) { + console.log('User scroll state changed:', { + isAtBottom, + isUserScrolling: isUserScrollingRef.current, + scrollTop, + scrollHeight, + clientHeight, + }); + } } }, []); - // Auto-scroll to bottom for new messages or content updates + // Simple and reliable scroll function + const scrollToBottom = useCallback(() => { + if (!scrollableRef.current) return; + + const scrollElement = scrollableRef.current; + console.log('Scrolling to bottom'); + + // Use instant scroll to avoid animation-related event conflicts + scrollElement.scrollTo({ + top: scrollElement.scrollHeight, + behavior: 'instant', + }); + }, []); + useEffect(() => { if (!scrollableRef.current) return; const scrollElement = scrollableRef.current; const currentMessageCount = showMessages.length; const isNewMessage = currentMessageCount > prevMessageCountRef.current; - const now = Date.now(); - const userRecentlyScrolled = now - lastScrollTimeRef.current < 2000; console.log('Completion scroll check:', { currentMessageCount, prevCount: prevMessageCountRef.current, isNewMessage, - showMessagesLength: showMessages.length, - userRecentlyScrolled, + isStreaming: isStreamingRef.current, isUserScrolling: isUserScrollingRef.current, - isAutoScrolling: isAutoScrollingRef.current, + lastContentHeight: lastContentHeightRef.current, + currentScrollHeight: scrollElement.scrollHeight, }); - // Always handle new messages first - this is the highest priority + // Handle new messages - always scroll to bottom and start streaming mode if (isNewMessage) { - console.log('Completion: New message detected, forcing scroll to bottom'); - // New message - always scroll to bottom regardless of user position - isAutoScrollingRef.current = true; + console.log('Completion: New message detected, forcing restart of streaming mode'); - // Reset states IMMEDIATELY to ensure streaming can work - lastScrollTimeRef.current = Date.now() - 3000; // Allow streaming immediately + // Force exit any existing streaming mode + if (streamingTimeoutRef.current) { + clearTimeout(streamingTimeoutRef.current); + console.log('Cleared existing streaming timeout'); + } + + // Enter streaming mode - disable user scroll detection + isStreamingRef.current = true; isUserScrollingRef.current = false; - lastContentHeightRef.current = scrollElement.scrollHeight; // Set current height as baseline - scrollElement.scrollTo({ - top: scrollElement.scrollHeight, - behavior: 'smooth', - }); + // Scroll to bottom immediately + scrollToBottom(); - // Reset auto scroll flag after animation - setTimeout(() => { - isAutoScrollingRef.current = false; - }, 100); + // Update message count but reset height tracking to let streaming content set it + prevMessageCountRef.current = currentMessageCount; + lastContentHeightRef.current = 0; // Reset to allow streaming to establish new baseline - prevMessageCountRef.current = currentMessageCount; // Update count immediately - return; // Exit early for new messages + // Exit streaming mode after no content updates for 3 seconds (increased timeout) + streamingTimeoutRef.current = setTimeout(() => { + console.log('Exiting streaming mode after timeout'); + isStreamingRef.current = false; + }, 3000); // Increased from 2000 to 3000 + + return; } - // Handle streaming content updates (only if user hasn't manually scrolled recently) - if (!userRecentlyScrolled && !isUserScrollingRef.current && !isAutoScrollingRef.current) { - // Streaming content - scroll based on content height change + // Handle streaming content updates - always scroll during streaming + if (isStreamingRef.current) { const currentHeight = scrollElement.scrollHeight; - // Initialize lastContentHeightRef if not set + console.log('Completion streaming update:', { + currentHeight, + lastHeight: lastContentHeightRef.current, + isFirstCheck: lastContentHeightRef.current === 0, + }); + + // Initialize baseline height or check for changes if (lastContentHeightRef.current === 0) { + console.log('Setting initial baseline height:', currentHeight); lastContentHeightRef.current = currentHeight; + // Don't return here - continue to check for immediate height differences } const heightDiff = currentHeight - lastContentHeightRef.current; - console.log('Completion streaming check:', { + console.log('Completion streaming height check:', { currentHeight, lastHeight: lastContentHeightRef.current, heightDiff, - threshold: 12, + willScroll: heightDiff > 0, }); - // Only scroll if content height increased by at least ~0.5 lines (12px) for smoother experience - if (heightDiff >= 12) { - // Clear any pending scroll timeout - if (scrollTimeoutRef.current) { - clearTimeout(scrollTimeoutRef.current); + // Any height increase triggers scroll during streaming + if (heightDiff > 0) { + console.log('Completion: Content height increased by', heightDiff, 'px, scrolling'); + + // Scroll immediately + scrollToBottom(); + + // Update height tracking + lastContentHeightRef.current = currentHeight; + + // Reset streaming timeout - keep streaming mode active + if (streamingTimeoutRef.current) { + clearTimeout(streamingTimeoutRef.current); } - - console.log('Completion: Triggering streaming scroll, heightDiff:', heightDiff); - - // Debounce scroll calls to avoid conflicts - scrollTimeoutRef.current = setTimeout(() => { - isAutoScrollingRef.current = true; - scrollElement.scrollTo({ - top: scrollElement.scrollHeight, - behavior: 'smooth', - }); - setTimeout(() => { - isAutoScrollingRef.current = false; - }, 200); // Longer timeout for streaming scroll - lastContentHeightRef.current = currentHeight; - }, 30); // 30ms debounce for smooth experience + streamingTimeoutRef.current = setTimeout(() => { + console.log('Exiting streaming mode after content timeout'); + isStreamingRef.current = false; + }, 3000); + } else if (heightDiff === 0) { + console.log('Completion: No height change during streaming, content may not be updating'); } } else { - console.log('Completion streaming blocked:', { - userRecentlyScrolled, - isUserScrolling: isUserScrollingRef.current, - isAutoScrolling: isAutoScrollingRef.current, - }); - } - }, [showMessages, scene]); + // Not streaming - check if user wants auto-scroll + if (!isUserScrollingRef.current) { + const currentHeight = scrollElement.scrollHeight; + const heightDiff = currentHeight - lastContentHeightRef.current; - // Add scroll event listener + if (heightDiff > 0) { + console.log('Completion: Non-streaming height increase, scrolling'); + scrollToBottom(); + lastContentHeightRef.current = currentHeight; + } + } else { + console.log('Completion: User has scrolled away, not auto-scrolling'); + } + } + }, [showMessages, scene, scrollToBottom]); + + // Add scroll event listener and cleanup useEffect(() => { const scrollElement = scrollableRef.current; if (scrollElement) { scrollElement.addEventListener('scroll', handleScrollEvent); return () => { scrollElement.removeEventListener('scroll', handleScrollEvent); + // Cleanup streaming timeout + if (streamingTimeoutRef.current) { + clearTimeout(streamingTimeoutRef.current); + streamingTimeoutRef.current = null; + } }; } }, [handleScrollEvent]); diff --git a/web/new-components/chat/ChatContentContainer.tsx b/web/new-components/chat/ChatContentContainer.tsx index 3f4b7b8c5..62254a504 100644 --- a/web/new-components/chat/ChatContentContainer.tsx +++ b/web/new-components/chat/ChatContentContainer.tsx @@ -20,242 +20,358 @@ const ChatContentContainer = ({}, ref: React.ForwardedRef) => { return scrollRef.current; }); + // Initial UI state setup useEffect(() => { if (scrollRef.current) { - scrollRef.current.addEventListener('scroll', handleScroll); - // Check initially if content is scrollable const isScrollable = scrollRef.current.scrollHeight > scrollRef.current.clientHeight; setShowScrollButtons(isScrollable); } - - return () => { - // eslint-disable-next-line react-hooks/exhaustive-deps - scrollRef.current && scrollRef.current.removeEventListener('scroll', handleScroll); - }; }, []); - const scrollToBottomSmooth = useCallback((force = false, isStreaming = false) => { - if (!scrollRef.current) return; - - const container = scrollRef.current; - - if (force) { - // Force scroll for new messages - always scroll regardless of position - container.scrollTo({ - top: container.scrollHeight, - behavior: 'smooth', - }); - return; - } - - // For streaming updates, check if user is near bottom - const { scrollTop, scrollHeight, clientHeight } = container; - const buffer = Math.max(100, clientHeight * 0.2); - const isNearBottom = scrollTop + clientHeight >= scrollHeight - buffer; - - if (!isNearBottom) { - return; - } - - // Use smooth scroll for streaming updates - container.scrollTo({ - top: container.scrollHeight, - behavior: 'smooth', - }); - }, []); - - // Track message count to detect new messages - const prevMessageCountRef = useRef(history.length); - const lastScrollTimeRef = useRef(0); + // Track message count and user scrolling behavior + const prevMessageCountRef = useRef(0); // Always start from 0 to detect first message correctly const isUserScrollingRef = useRef(false); const lastContentHeightRef = useRef(0); - const scrollTimeoutRef = useRef(null); - const isAutoScrollingRef = useRef(false); // Track if we're auto-scrolling + const isStreamingRef = useRef(false); // Track if we're in streaming mode + const streamingTimeoutRef = useRef(null); + const mutationObserverRef = useRef(null); + const backupIntervalRef = useRef(null); - // Initialize refs + // Initialize refs with current history length useEffect(() => { console.log('ChatContentContainer initializing with history length:', history.length); - // Set initial scroll time to allow streaming from the beginning - if (lastScrollTimeRef.current === 0) { - lastScrollTimeRef.current = Date.now() - 3000; // Set to 3 seconds ago - } - }, []); + // Set initial message count to current history length (to avoid triggering new message on first render) + prevMessageCountRef.current = history.length; + }, []); // No dependencies - only run once - // Update message count tracking when history changes - useEffect(() => { - console.log('ChatContentContainer updating prevMessageCountRef:', { - currentLength: history.length, - prevCount: prevMessageCountRef.current, - }); - - // Only update if this is the first time (count is 0) - if (prevMessageCountRef.current === 0) { - prevMessageCountRef.current = history.length; - } - }, [history.length]); - - // Handle scroll events to detect user interaction + // Combined scroll event handler for both streaming logic and UI state const handleScrollEvent = useCallback(() => { - // Ignore scroll events caused by auto-scrolling - if (isAutoScrollingRef.current) { - return; - } - - lastScrollTimeRef.current = Date.now(); - - if (scrollRef.current) { - const scrollElement = scrollRef.current; - const { scrollTop, scrollHeight, clientHeight } = scrollElement; - const isAtBottom = scrollTop + clientHeight >= scrollHeight - 20; - isUserScrollingRef.current = !isAtBottom; - } - }, []); - - useEffect(() => { if (!scrollRef.current) return; const scrollElement = scrollRef.current; - const currentMessageCount = history.length; - const isNewMessage = currentMessageCount > prevMessageCountRef.current; - const now = Date.now(); - const userRecentlyScrolled = now - lastScrollTimeRef.current < 2000; - - console.log('ChatContentContainer scroll check:', { - currentMessageCount, - prevCount: prevMessageCountRef.current, - isNewMessage, - historyLength: history.length, - userRecentlyScrolled, - isUserScrolling: isUserScrollingRef.current, - isAutoScrolling: isAutoScrollingRef.current, - }); - - // Always handle new messages first - this is the highest priority - if (isNewMessage) { - console.log('ChatContentContainer: New message detected, forcing scroll to bottom'); - // New message - always scroll to bottom regardless of user position - isAutoScrollingRef.current = true; - - // Reset states IMMEDIATELY to ensure streaming can work - lastScrollTimeRef.current = Date.now() - 3000; // Allow streaming immediately - isUserScrollingRef.current = false; - lastContentHeightRef.current = scrollElement.scrollHeight; // Set current height as baseline - - scrollElement.scrollTo({ - top: scrollElement.scrollHeight, - behavior: 'smooth', - }); - - // Reset auto scroll flag after animation - setTimeout(() => { - isAutoScrollingRef.current = false; - }, 100); - - prevMessageCountRef.current = currentMessageCount; // Update count immediately - return; // Exit early for new messages - } - - // Handle streaming content updates (only if user hasn't manually scrolled recently) - if (!userRecentlyScrolled && !isUserScrollingRef.current && !isAutoScrollingRef.current) { - // Streaming content - scroll based on content height change - const currentHeight = scrollElement.scrollHeight; - - // Initialize lastContentHeightRef if not set - if (lastContentHeightRef.current === 0) { - lastContentHeightRef.current = currentHeight; - } - - const heightDiff = currentHeight - lastContentHeightRef.current; - - console.log('ChatContentContainer streaming check:', { - currentHeight, - lastHeight: lastContentHeightRef.current, - heightDiff, - threshold: 12, - }); - - // Only scroll if content height increased by at least ~0.5 lines (12px) for smoother experience - if (heightDiff >= 12) { - // Clear any pending scroll timeout - if (scrollTimeoutRef.current) { - clearTimeout(scrollTimeoutRef.current); - } - - console.log('ChatContentContainer: Triggering streaming scroll, heightDiff:', heightDiff); - - // Debounce scroll calls to avoid conflicts - scrollTimeoutRef.current = setTimeout(() => { - isAutoScrollingRef.current = true; - scrollElement.scrollTo({ - top: scrollElement.scrollHeight, - behavior: 'smooth', - }); - setTimeout(() => { - isAutoScrollingRef.current = false; - }, 200); // Longer timeout for streaming scroll - lastContentHeightRef.current = currentHeight; - }, 30); // 30ms debounce for smooth experience - } - } else { - console.log('ChatContentContainer streaming blocked:', { - userRecentlyScrolled, - isUserScrolling: isUserScrollingRef.current, - isAutoScrolling: isAutoScrollingRef.current, - }); - } - }, [history]); - - // Add scroll event listener - useEffect(() => { - const scrollElement = scrollRef.current; - if (scrollElement) { - scrollElement.addEventListener('scroll', handleScrollEvent); - return () => { - scrollElement.removeEventListener('scroll', handleScrollEvent); - }; - } - }, [handleScrollEvent]); - - // Enhanced scroll handler to track user scrolling behavior - const handleScroll = useCallback(() => { - if (!scrollRef.current) return; - - const container = scrollRef.current; - const scrollTop = container.scrollTop; - const scrollHeight = container.scrollHeight; - const clientHeight = container.clientHeight; + const { scrollTop, scrollHeight, clientHeight } = scrollElement; const buffer = 20; - // Record user scroll time - lastScrollTimeRef.current = Date.now(); - - // Determine if user is actively scrolling up - const atBottom = scrollTop + clientHeight >= scrollHeight - buffer; - isUserScrollingRef.current = !atBottom; - - // Update wasAtBottomRef + // UI state updates (for scroll buttons and header visibility) const atBottomPrecise = scrollTop + clientHeight >= scrollHeight - 5; wasAtBottomRef.current = atBottomPrecise; - // Check if we're at the top setIsAtTop(scrollTop <= buffer); - - // Check if we're at the bottom setIsAtBottom(scrollTop + clientHeight >= scrollHeight - buffer); - // Header visibility if (scrollTop >= 42 + 32) { setIsScrollToTop(true); } else { setIsScrollToTop(false); } - // Show scroll buttons when content is scrollable const isScrollable = scrollHeight > clientHeight; setShowScrollButtons(isScrollable); + + // Streaming logic - only update user scroll state when not in streaming mode + if (!isStreamingRef.current) { + const isAtBottom = scrollTop + clientHeight >= scrollHeight - 5; + const wasUserScrolling = isUserScrollingRef.current; + isUserScrollingRef.current = !isAtBottom; + + if (wasUserScrolling !== isUserScrollingRef.current) { + console.log('ChatContentContainer: User scroll state changed:', { + isAtBottom, + isUserScrolling: isUserScrollingRef.current, + scrollTop, + scrollHeight, + clientHeight, + }); + } + } else { + console.log('ChatContentContainer: Ignoring scroll event during streaming'); + } }, []); + // Simple and reliable scroll function + const scrollToBottomInstant = useCallback(() => { + if (!scrollRef.current) return; + + const scrollElement = scrollRef.current; + console.log('ChatContentContainer: Scrolling to bottom'); + + // Use instant scroll to avoid animation-related event conflicts + scrollElement.scrollTo({ + top: scrollElement.scrollHeight, + behavior: 'instant', + }); + }, []); + + // Check for height changes and handle streaming scroll + const checkContentHeight = useCallback(() => { + if (!scrollRef.current || !isStreamingRef.current) return; + + try { + const scrollElement = scrollRef.current; + const currentHeight = scrollElement.scrollHeight; + + console.log('ChatContentContainer streaming height check:', { + currentHeight, + lastHeight: lastContentHeightRef.current, + heightDiff: currentHeight - lastContentHeightRef.current, + isStreaming: isStreamingRef.current, + }); + + // Initialize baseline height or check for changes + if (lastContentHeightRef.current === 0) { + console.log('ChatContentContainer: Setting initial baseline height:', currentHeight); + lastContentHeightRef.current = currentHeight; + return; + } + + const heightDiff = currentHeight - lastContentHeightRef.current; + + // Lower threshold: any height increase triggers scroll during streaming + if (heightDiff > 0) { + console.log('ChatContentContainer: Content height increased by', heightDiff, 'px, scrolling'); + + // Check if user has manually scrolled up significantly during streaming + const { scrollTop, clientHeight } = scrollElement; + const distanceFromBottom = currentHeight - (scrollTop + clientHeight); + + // If user scrolled up more than 100px, don't force scroll but show a notification + if (distanceFromBottom > 100) { + console.log('ChatContentContainer: User scrolled away during streaming, not forcing scroll'); + // Could add a "scroll to bottom" button here + return; + } + + // Scroll immediately + scrollToBottomInstant(); + + // Update height tracking + lastContentHeightRef.current = currentHeight; + + // Reset streaming timeout - keep streaming mode active + if (streamingTimeoutRef.current) { + clearTimeout(streamingTimeoutRef.current); + streamingTimeoutRef.current = null; + } + + streamingTimeoutRef.current = setTimeout(() => { + console.log('ChatContentContainer: Exiting streaming mode after content timeout'); + isStreamingRef.current = false; + + // Clean up MutationObserver when exiting streaming mode + if (mutationObserverRef.current) { + mutationObserverRef.current.disconnect(); + mutationObserverRef.current = null; + console.log('ChatContentContainer: MutationObserver stopped on timeout'); + } + }, 10000); + } + } catch (error) { + console.error('ChatContentContainer: Error in checkContentHeight:', error); + } + }, [scrollToBottomInstant]); + + // Force scroll check regardless of height changes + const forceScrollCheck = useCallback(() => { + if (!scrollRef.current || !isStreamingRef.current) return; + + try { + const scrollElement = scrollRef.current; + const { scrollTop, scrollHeight, clientHeight } = scrollElement; + const isAtBottom = scrollTop + clientHeight >= scrollHeight - 5; + + console.log('ChatContentContainer force scroll check:', { + scrollTop, + scrollHeight, + clientHeight, + isAtBottom, + distanceFromBottom: scrollHeight - (scrollTop + clientHeight), + }); + + // If not at bottom during streaming, scroll to bottom + if (!isAtBottom) { + const distanceFromBottom = scrollHeight - (scrollTop + clientHeight); + + // Only respect user scrolling if they scrolled significantly up + if (distanceFromBottom <= 100) { + console.log('ChatContentContainer: Forcing scroll to bottom during streaming'); + scrollToBottomInstant(); + } + } + + // Update height tracking + lastContentHeightRef.current = scrollElement.scrollHeight; + + // Reset streaming timeout + if (streamingTimeoutRef.current) { + clearTimeout(streamingTimeoutRef.current); + streamingTimeoutRef.current = null; + } + + streamingTimeoutRef.current = setTimeout(() => { + console.log('ChatContentContainer: Exiting streaming mode after content timeout'); + isStreamingRef.current = false; + + // Clean up MutationObserver when exiting streaming mode + if (mutationObserverRef.current) { + mutationObserverRef.current.disconnect(); + mutationObserverRef.current = null; + console.log('ChatContentContainer: MutationObserver stopped on timeout'); + } + }, 10000); + } catch (error) { + console.error('ChatContentContainer: Error in forceScrollCheck:', error); + } + }, [scrollToBottomInstant]); + + // Clean up streaming mode + const cleanupStreamingMode = useCallback(() => { + console.log('ChatContentContainer: Cleaning up streaming mode'); + + isStreamingRef.current = false; + + if (streamingTimeoutRef.current) { + clearTimeout(streamingTimeoutRef.current); + streamingTimeoutRef.current = null; + } + + if (backupIntervalRef.current) { + clearInterval(backupIntervalRef.current); + backupIntervalRef.current = null; + console.log('ChatContentContainer: Backup interval cleared'); + } + + if (mutationObserverRef.current) { + try { + mutationObserverRef.current.disconnect(); + mutationObserverRef.current = null; + console.log('ChatContentContainer: MutationObserver cleaned up'); + } catch (error) { + console.error('ChatContentContainer: Error disconnecting MutationObserver:', error); + } + } + }, []); + + // Start streaming mode for new messages + const startStreamingMode = useCallback(() => { + console.log('ChatContentContainer: Starting streaming mode'); + + // Clean up any existing streaming mode first + cleanupStreamingMode(); + + // Enter streaming mode - disable user scroll detection + isStreamingRef.current = true; + isUserScrollingRef.current = false; + + // Scroll to bottom immediately + scrollToBottomInstant(); + + // Reset height tracking + lastContentHeightRef.current = 0; + + // Set up MutationObserver to watch for DOM changes + if (scrollRef.current) { + try { + mutationObserverRef.current = new MutationObserver(mutations => { + // Only process if we're still in streaming mode + if (!isStreamingRef.current) return; + + console.log('ChatContentContainer: MutationObserver detected changes:', mutations.length); + + // Always trigger scroll check for any mutation during streaming + // Use both height-based and force-based checks + setTimeout(() => { + checkContentHeight(); + forceScrollCheck(); + }, 5); // Very short delay to let DOM settle + }); + + // Monitor all possible DOM changes that could affect content + mutationObserverRef.current.observe(scrollRef.current, { + childList: true, // Child elements added/removed + subtree: true, // Monitor entire subtree + characterData: true, // Text content changes + attributes: true, // All attribute changes + attributeOldValue: true, // Track attribute value changes + characterDataOldValue: true, // Track text changes + }); + + console.log('ChatContentContainer: Enhanced MutationObserver started'); + } catch (error) { + console.error('ChatContentContainer: Error creating MutationObserver:', error); + } + } + + // Also set up a backup interval to ensure scrolling continues + backupIntervalRef.current = setInterval(() => { + if (!isStreamingRef.current) { + if (backupIntervalRef.current) { + clearInterval(backupIntervalRef.current); + backupIntervalRef.current = null; + } + return; + } + + console.log('ChatContentContainer: Backup scroll check'); + forceScrollCheck(); + }, 500); // Check every 500ms as backup + + // Exit streaming mode after no content updates for 10 seconds + streamingTimeoutRef.current = setTimeout(() => { + console.log('ChatContentContainer: Exiting streaming mode after timeout'); + cleanupStreamingMode(); + }, 10000); + }, [scrollToBottomInstant, checkContentHeight, forceScrollCheck, cleanupStreamingMode]); + + // Monitor history changes for new messages + useEffect(() => { + if (!scrollRef.current) return; + + const currentMessageCount = history.length; + const isNewMessage = currentMessageCount > prevMessageCountRef.current; + + console.log('ChatContentContainer message count check:', { + currentMessageCount, + prevCount: prevMessageCountRef.current, + isNewMessage, + isStreaming: isStreamingRef.current, + }); + + // Handle new messages - always scroll to bottom and start streaming mode + if (isNewMessage) { + console.log('ChatContentContainer: New message detected, starting streaming mode'); + + prevMessageCountRef.current = currentMessageCount; + startStreamingMode(); + } else if (!isStreamingRef.current) { + // Handle content updates when not in streaming mode (e.g., static content changes) + const scrollElement = scrollRef.current; + if (scrollElement && !isUserScrollingRef.current) { + const currentHeight = scrollElement.scrollHeight; + const heightDiff = currentHeight - lastContentHeightRef.current; + + if (heightDiff > 0) { + console.log('ChatContentContainer: Non-streaming content change, scrolling'); + scrollToBottomInstant(); + lastContentHeightRef.current = currentHeight; + } + } + } + }, [history.length, startStreamingMode, scrollToBottomInstant]); + + // Add scroll event listener and cleanup + useEffect(() => { + const scrollElement = scrollRef.current; + if (scrollElement) { + scrollElement.addEventListener('scroll', handleScrollEvent); + return () => { + scrollElement.removeEventListener('scroll', handleScrollEvent); + // Use the centralized cleanup function + cleanupStreamingMode(); + }; + } + }, [handleScrollEvent, cleanupStreamingMode]); + const scrollToTop = () => { if (scrollRef.current) { scrollRef.current.scrollTo({ diff --git a/web/new-components/chat/content/ChatCompletion.tsx b/web/new-components/chat/content/ChatCompletion.tsx index c63ce9a42..7b04bd567 100644 --- a/web/new-components/chat/content/ChatCompletion.tsx +++ b/web/new-components/chat/content/ChatCompletion.tsx @@ -10,11 +10,10 @@ import { useAsyncEffect } from 'ahooks'; import { Modal } from 'antd'; import { cloneDeep } from 'lodash'; import { useSearchParams } from 'next/navigation'; -import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useContext, useMemo, useState } from 'react'; import { v4 as uuid } from 'uuid'; const ChatCompletion: React.FC = () => { - const scrollRef = useRef(null); const searchParams = useSearchParams(); const chatId = searchParams?.get('id') ?? ''; @@ -79,160 +78,8 @@ const ChatCompletion: React.FC = () => { } }, [chatId, currentDialogInfo]); - // Track message count and user scrolling behavior - const prevMessageCountRef = useRef(history.length); - const lastScrollTimeRef = useRef(0); - const isUserScrollingRef = useRef(false); - const lastContentHeightRef = useRef(0); - const scrollTimeoutRef = useRef(null); - const isAutoScrollingRef = useRef(false); // Track if we're auto-scrolling - - // Initialize refs - useEffect(() => { - console.log('ChatCompletion initializing with history length:', history.length); - // Set initial scroll time to allow streaming from the beginning - if (lastScrollTimeRef.current === 0) { - lastScrollTimeRef.current = Date.now() - 3000; // Set to 3 seconds ago - } - }, []); - - // Update message count tracking when history changes - useEffect(() => { - console.log('ChatCompletion updating prevMessageCountRef:', { - currentLength: history.length, - prevCount: prevMessageCountRef.current, - }); - - // Only update if this is the first time (count is 0) - if (prevMessageCountRef.current === 0) { - prevMessageCountRef.current = history.length; - } - }, [history.length]); - - // Handle scroll events to detect user interaction - const handleScrollEvent = useCallback(() => { - // Ignore scroll events caused by auto-scrolling - if (isAutoScrollingRef.current) { - return; - } - - lastScrollTimeRef.current = Date.now(); - - if (scrollRef.current) { - const scrollElement = scrollRef.current; - const { scrollTop, scrollHeight, clientHeight } = scrollElement; - const isAtBottom = scrollTop + clientHeight >= scrollHeight - 20; - isUserScrollingRef.current = !isAtBottom; - } - }, []); - - useEffect(() => { - if (!scrollRef.current) return; - - const scrollElement = scrollRef.current; - const currentMessageCount = history.length; - const isNewMessage = currentMessageCount > prevMessageCountRef.current; - const now = Date.now(); - const userRecentlyScrolled = now - lastScrollTimeRef.current < 2000; - - console.log('ChatCompletion scroll check:', { - currentMessageCount, - prevCount: prevMessageCountRef.current, - isNewMessage, - historyLength: history.length, - userRecentlyScrolled, - isUserScrolling: isUserScrollingRef.current, - isAutoScrolling: isAutoScrollingRef.current, - }); - - // Always handle new messages first - this is the highest priority - if (isNewMessage) { - console.log('ChatCompletion: New message detected, forcing scroll to bottom'); - // New message - always scroll to bottom regardless of user position - isAutoScrollingRef.current = true; - - // Reset states IMMEDIATELY to ensure streaming can work - lastScrollTimeRef.current = Date.now() - 3000; // Allow streaming immediately - isUserScrollingRef.current = false; - lastContentHeightRef.current = scrollElement.scrollHeight; // Set current height as baseline - - scrollElement.scrollTo({ - top: scrollElement.scrollHeight, - behavior: 'smooth', - }); - - // Reset auto scroll flag after animation - setTimeout(() => { - isAutoScrollingRef.current = false; - }, 100); - - prevMessageCountRef.current = currentMessageCount; // Update count immediately - return; // Exit early for new messages - } - - // Handle streaming content updates (only if user hasn't manually scrolled recently) - if (!userRecentlyScrolled && !isUserScrollingRef.current && !isAutoScrollingRef.current) { - // Streaming content - scroll based on content height change - const currentHeight = scrollElement.scrollHeight; - - // Initialize lastContentHeightRef if not set - if (lastContentHeightRef.current === 0) { - lastContentHeightRef.current = currentHeight; - } - - const heightDiff = currentHeight - lastContentHeightRef.current; - - console.log('ChatCompletion streaming check:', { - currentHeight, - lastHeight: lastContentHeightRef.current, - heightDiff, - threshold: 12, - }); - - // Only scroll if content height increased by at least ~0.5 lines (12px) for smoother experience - if (heightDiff >= 12) { - // Clear any pending scroll timeout - if (scrollTimeoutRef.current) { - clearTimeout(scrollTimeoutRef.current); - } - - console.log('ChatCompletion: Triggering streaming scroll, heightDiff:', heightDiff); - - // Debounce scroll calls to avoid conflicts - scrollTimeoutRef.current = setTimeout(() => { - isAutoScrollingRef.current = true; - scrollElement.scrollTo({ - top: scrollElement.scrollHeight, - behavior: 'smooth', - }); - setTimeout(() => { - isAutoScrollingRef.current = false; - }, 200); // Longer timeout for streaming scroll - lastContentHeightRef.current = currentHeight; - }, 30); // 30ms debounce for smooth experience - } - } else { - console.log('ChatCompletion streaming blocked:', { - userRecentlyScrolled, - isUserScrolling: isUserScrollingRef.current, - isAutoScrolling: isAutoScrollingRef.current, - }); - } - }, [history]); - - // Add scroll event listener - useEffect(() => { - const scrollElement = scrollRef.current; - if (scrollElement) { - scrollElement.addEventListener('scroll', handleScrollEvent); - return () => { - scrollElement.removeEventListener('scroll', handleScrollEvent); - }; - } - }, [handleScrollEvent]); - return ( -
+
{!!showMessages.length && showMessages.map((content, index) => { return (