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
This commit is contained in:
WangzJi 2025-06-30 20:11:03 +08:00
parent 5d13cf911d
commit 66377e87ba
No known key found for this signature in database
GPG Key ID: C237805F3F8E1CB6
3 changed files with 434 additions and 439 deletions

View File

@ -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<NodeJS.Timeout | null>(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<NodeJS.Timeout | null>(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]);

View File

@ -20,242 +20,358 @@ const ChatContentContainer = ({}, ref: React.ForwardedRef<any>) => {
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<NodeJS.Timeout | null>(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<NodeJS.Timeout | null>(null);
const mutationObserverRef = useRef<MutationObserver | null>(null);
const backupIntervalRef = useRef<NodeJS.Timeout | null>(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({

View File

@ -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<HTMLDivElement>(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<NodeJS.Timeout | null>(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 (
<div className='flex flex-col w-5/6 mx-auto' ref={scrollRef}>
<div className='flex flex-col w-5/6 mx-auto'>
{!!showMessages.length &&
showMessages.map((content, index) => {
return (