mirror of
https://github.com/HKUDS/LightRAG.git
synced 2025-06-26 22:00:19 +00:00
Simplified scroll to bottom logic
This commit is contained in:
parent
5bfa2e7dc6
commit
6f064925eb
@ -1,6 +1,7 @@
|
|||||||
import Input from '@/components/ui/Input'
|
import Input from '@/components/ui/Input'
|
||||||
import Button from '@/components/ui/Button'
|
import Button from '@/components/ui/Button'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { throttle } from '@/lib/utils'
|
||||||
import { queryText, queryTextStream, Message } from '@/api/lightrag'
|
import { queryText, queryTextStream, Message } from '@/api/lightrag'
|
||||||
import { errorMessage } from '@/lib/utils'
|
import { errorMessage } from '@/lib/utils'
|
||||||
import { useSettingsStore } from '@/stores/settings'
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
@ -19,31 +20,28 @@ export default function RetrievalTesting() {
|
|||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
// Reference to track if we should follow scroll during streaming (using ref for synchronous updates)
|
// Reference to track if we should follow scroll during streaming (using ref for synchronous updates)
|
||||||
const shouldFollowScrollRef = useRef(true)
|
const shouldFollowScrollRef = useRef(true)
|
||||||
// Reference to track if this is the first chunk of a streaming response
|
// Reference to track if user interaction is from the form area
|
||||||
const isFirstChunkRef = useRef(true)
|
const isFormInteractionRef = useRef(false)
|
||||||
|
// Reference to track if scroll was triggered programmatically
|
||||||
|
const programmaticScrollRef = useRef(false)
|
||||||
|
// Reference to track if we're currently receiving a streaming response
|
||||||
|
const isReceivingResponseRef = useRef(false)
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
const messagesContainerRef = useRef<HTMLDivElement>(null)
|
const messagesContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// Check if the container is near the bottom
|
// Scroll to bottom function - restored smooth scrolling with better handling
|
||||||
const isNearBottom = useCallback(() => {
|
const scrollToBottom = useCallback(() => {
|
||||||
const container = messagesContainerRef.current
|
// Set flag to indicate this is a programmatic scroll
|
||||||
if (!container) return true // Default to true if no container reference
|
programmaticScrollRef.current = true
|
||||||
|
// Use requestAnimationFrame for better performance
|
||||||
// Calculate distance to bottom
|
requestAnimationFrame(() => {
|
||||||
const { scrollTop, scrollHeight, clientHeight } = container
|
if (messagesEndRef.current) {
|
||||||
const distanceToBottom = scrollHeight - scrollTop - clientHeight
|
// Use smooth scrolling for better user experience
|
||||||
|
messagesEndRef.current.scrollIntoView({ behavior: 'auto' })
|
||||||
// Consider near bottom if less than 100px from bottom
|
}
|
||||||
return distanceToBottom < 100
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const scrollToBottom = useCallback((force = false) => {
|
|
||||||
// Only scroll if forced or user is already near bottom
|
|
||||||
if (force || isNearBottom()) {
|
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
||||||
}
|
|
||||||
}, [isNearBottom])
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
async (e: React.FormEvent) => {
|
async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@ -64,15 +62,15 @@ export default function RetrievalTesting() {
|
|||||||
|
|
||||||
// Add messages to chatbox
|
// Add messages to chatbox
|
||||||
setMessages([...prevMessages, userMessage, assistantMessage])
|
setMessages([...prevMessages, userMessage, assistantMessage])
|
||||||
|
|
||||||
// Reset first chunk flag for new streaming response
|
// Reset scroll following state for new query
|
||||||
isFirstChunkRef.current = true
|
|
||||||
// Enable follow scroll for new query
|
|
||||||
shouldFollowScrollRef.current = true
|
shouldFollowScrollRef.current = true
|
||||||
|
// Set flag to indicate we're receiving a response
|
||||||
|
isReceivingResponseRef.current = true
|
||||||
|
|
||||||
// Force scroll to bottom after messages are rendered
|
// Force scroll to bottom after messages are rendered
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
scrollToBottom(true)
|
scrollToBottom()
|
||||||
}, 0)
|
}, 0)
|
||||||
|
|
||||||
// Clear input and set loading
|
// Clear input and set loading
|
||||||
@ -81,17 +79,6 @@ export default function RetrievalTesting() {
|
|||||||
|
|
||||||
// Create a function to update the assistant's message
|
// Create a function to update the assistant's message
|
||||||
const updateAssistantMessage = (chunk: string, isError?: boolean) => {
|
const updateAssistantMessage = (chunk: string, isError?: boolean) => {
|
||||||
// Check if this is the first chunk of the streaming response
|
|
||||||
if (isFirstChunkRef.current) {
|
|
||||||
// Determine scroll behavior based on initial position
|
|
||||||
shouldFollowScrollRef.current = isNearBottom();
|
|
||||||
isFirstChunkRef.current = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save current scroll position before updating content
|
|
||||||
const container = messagesContainerRef.current;
|
|
||||||
const currentScrollPosition = container ? container.scrollTop : 0;
|
|
||||||
|
|
||||||
assistantMessage.content += chunk
|
assistantMessage.content += chunk
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
const newMessages = [...prev]
|
const newMessages = [...prev]
|
||||||
@ -102,19 +89,13 @@ export default function RetrievalTesting() {
|
|||||||
}
|
}
|
||||||
return newMessages
|
return newMessages
|
||||||
})
|
})
|
||||||
|
|
||||||
// After updating content, check if we should scroll
|
// After updating content, scroll to bottom if auto-scroll is enabled
|
||||||
// Use consistent scrolling behavior throughout the streaming response
|
// Use a longer delay to ensure DOM has updated
|
||||||
if (shouldFollowScrollRef.current) {
|
if (shouldFollowScrollRef.current) {
|
||||||
scrollToBottom(true);
|
|
||||||
} else if (container) {
|
|
||||||
// If user was not near bottom, restore their scroll position
|
|
||||||
// This needs to be in a setTimeout to work after React updates the DOM
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (container) {
|
scrollToBottom()
|
||||||
container.scrollTop = currentScrollPosition;
|
}, 30)
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -152,6 +133,7 @@ export default function RetrievalTesting() {
|
|||||||
} finally {
|
} finally {
|
||||||
// Clear loading and add messages to state
|
// Clear loading and add messages to state
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
|
isReceivingResponseRef.current = false
|
||||||
useSettingsStore
|
useSettingsStore
|
||||||
.getState()
|
.getState()
|
||||||
.setRetrievalHistory([...prevMessages, userMessage, assistantMessage])
|
.setRetrievalHistory([...prevMessages, userMessage, assistantMessage])
|
||||||
@ -160,30 +142,76 @@ export default function RetrievalTesting() {
|
|||||||
[inputValue, isLoading, messages, setMessages, t, scrollToBottom]
|
[inputValue, isLoading, messages, setMessages, t, scrollToBottom]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Add scroll event listener to detect when user manually scrolls
|
// Add event listeners to detect when user manually interacts with the container
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = messagesContainerRef.current;
|
const container = messagesContainerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const handleScroll = () => {
|
// Handle significant mouse wheel events - only disable auto-scroll for deliberate scrolling
|
||||||
const isNearBottomNow = isNearBottom();
|
const handleWheel = (e: WheelEvent) => {
|
||||||
|
// Only consider significant wheel movements (more than 10px)
|
||||||
// If user scrolls away from bottom while in auto-scroll mode, disable it
|
if (Math.abs(e.deltaY) > 10 && !isFormInteractionRef.current) {
|
||||||
if (shouldFollowScrollRef.current && !isNearBottomNow) {
|
|
||||||
shouldFollowScrollRef.current = false;
|
shouldFollowScrollRef.current = false;
|
||||||
}
|
}
|
||||||
// If user scrolls back to bottom while not in auto-scroll mode, re-enable it
|
|
||||||
else if (!shouldFollowScrollRef.current && isNearBottomNow) {
|
|
||||||
shouldFollowScrollRef.current = true;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
container.addEventListener('scroll', handleScroll);
|
|
||||||
return () => container.removeEventListener('scroll', handleScroll);
|
|
||||||
}, [isNearBottom]); // Remove shouldFollowScroll from dependencies since we're using ref now
|
|
||||||
|
|
||||||
const debouncedMessages = useDebounce(messages, 100)
|
// Handle scroll events - only disable auto-scroll if not programmatically triggered
|
||||||
useEffect(() => scrollToBottom(false), [debouncedMessages, scrollToBottom])
|
// and if it's a significant scroll
|
||||||
|
const handleScroll = throttle(() => {
|
||||||
|
// If this is a programmatic scroll, don't disable auto-scroll
|
||||||
|
if (programmaticScrollRef.current) {
|
||||||
|
programmaticScrollRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're receiving a response, be more conservative about disabling auto-scroll
|
||||||
|
if (!isFormInteractionRef.current && !isReceivingResponseRef.current) {
|
||||||
|
shouldFollowScrollRef.current = false;
|
||||||
|
}
|
||||||
|
}, 30);
|
||||||
|
|
||||||
|
// Add event listeners - only listen for wheel and scroll events
|
||||||
|
container.addEventListener('wheel', handleWheel as EventListener);
|
||||||
|
container.addEventListener('scroll', handleScroll as EventListener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
container.removeEventListener('wheel', handleWheel as EventListener);
|
||||||
|
container.removeEventListener('scroll', handleScroll as EventListener);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Add event listeners to the form area to prevent disabling auto-scroll when interacting with form
|
||||||
|
useEffect(() => {
|
||||||
|
const form = document.querySelector('form');
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
const handleFormMouseDown = () => {
|
||||||
|
// Set flag to indicate form interaction
|
||||||
|
isFormInteractionRef.current = true;
|
||||||
|
|
||||||
|
// Reset the flag after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
isFormInteractionRef.current = false;
|
||||||
|
}, 500); // Give enough time for the form interaction to complete
|
||||||
|
};
|
||||||
|
|
||||||
|
form.addEventListener('mousedown', handleFormMouseDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
form.removeEventListener('mousedown', handleFormMouseDown);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Use a longer debounce time for better performance with large message updates
|
||||||
|
const debouncedMessages = useDebounce(messages, 150)
|
||||||
|
useEffect(() => {
|
||||||
|
// Only auto-scroll if enabled
|
||||||
|
if (shouldFollowScrollRef.current) {
|
||||||
|
// Force scroll to bottom when messages change
|
||||||
|
scrollToBottom()
|
||||||
|
}
|
||||||
|
}, [debouncedMessages, scrollToBottom])
|
||||||
|
|
||||||
|
|
||||||
const clearMessages = useCallback(() => {
|
const clearMessages = useCallback(() => {
|
||||||
setMessages([])
|
setMessages([])
|
||||||
@ -194,7 +222,15 @@ export default function RetrievalTesting() {
|
|||||||
<div className="flex size-full gap-2 px-2 pb-12 overflow-hidden">
|
<div className="flex size-full gap-2 px-2 pb-12 overflow-hidden">
|
||||||
<div className="flex grow flex-col gap-4">
|
<div className="flex grow flex-col gap-4">
|
||||||
<div className="relative grow">
|
<div className="relative grow">
|
||||||
<div ref={messagesContainerRef} className="bg-primary-foreground/60 absolute inset-0 flex flex-col overflow-auto rounded-lg border p-2">
|
<div
|
||||||
|
ref={messagesContainerRef}
|
||||||
|
className="bg-primary-foreground/60 absolute inset-0 flex flex-col overflow-auto rounded-lg border p-2"
|
||||||
|
onClick={() => {
|
||||||
|
if (shouldFollowScrollRef.current) {
|
||||||
|
shouldFollowScrollRef.current = false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
||||||
{messages.length === 0 ? (
|
{messages.length === 0 ? (
|
||||||
<div className="text-muted-foreground flex h-full items-center justify-center text-lg">
|
<div className="text-muted-foreground flex h-full items-center justify-center text-lg">
|
||||||
|
@ -19,6 +19,39 @@ export function errorMessage(error: any) {
|
|||||||
return error instanceof Error ? error.message : `${error}`
|
return error instanceof Error ? error.message : `${error}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a throttled function that limits how often the original function can be called
|
||||||
|
* @param fn The function to throttle
|
||||||
|
* @param delay The delay in milliseconds
|
||||||
|
* @returns A throttled version of the function
|
||||||
|
*/
|
||||||
|
export function throttle<T extends (...args: any[]) => any>(fn: T, delay: number): (...args: Parameters<T>) => void {
|
||||||
|
let lastCall = 0
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
return function(this: any, ...args: Parameters<T>) {
|
||||||
|
const now = Date.now()
|
||||||
|
const remaining = delay - (now - lastCall)
|
||||||
|
|
||||||
|
if (remaining <= 0) {
|
||||||
|
// If enough time has passed, execute the function immediately
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
timeoutId = null
|
||||||
|
}
|
||||||
|
lastCall = now
|
||||||
|
fn.apply(this, args)
|
||||||
|
} else if (!timeoutId) {
|
||||||
|
// If not enough time has passed, set a timeout to execute after the remaining time
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
lastCall = Date.now()
|
||||||
|
timeoutId = null
|
||||||
|
fn.apply(this, args)
|
||||||
|
}, remaining)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type WithSelectors<S> = S extends { getState: () => infer T }
|
type WithSelectors<S> = S extends { getState: () => infer T }
|
||||||
? S & { use: { [K in keyof T]: () => T[K] } }
|
? S & { use: { [K in keyof T]: () => T[K] } }
|
||||||
: never
|
: never
|
||||||
|
Loading…
x
Reference in New Issue
Block a user