mirror of
https://github.com/csunny/DB-GPT.git
synced 2025-07-24 12:45:45 +00:00
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:
parent
5d13cf911d
commit
66377e87ba
@ -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]);
|
||||
|
@ -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({
|
||||
|
@ -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 (
|
||||
|
Loading…
Reference in New Issue
Block a user