From 5bfa2e7dc68e1f7689732c3624a3454cbed9cb90 Mon Sep 17 00:00:00 2001 From: yangdx Date: Tue, 22 Apr 2025 22:15:42 +0800 Subject: [PATCH 1/5] Improve scrolling logic --- .../src/features/RetrievalTesting.tsx | 58 ++++++++++++++++++- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/lightrag_webui/src/features/RetrievalTesting.tsx b/lightrag_webui/src/features/RetrievalTesting.tsx index cec369f0..ff13153b 100644 --- a/lightrag_webui/src/features/RetrievalTesting.tsx +++ b/lightrag_webui/src/features/RetrievalTesting.tsx @@ -17,6 +17,10 @@ export default function RetrievalTesting() { ) const [inputValue, setInputValue] = useState('') const [isLoading, setIsLoading] = useState(false) + // Reference to track if we should follow scroll during streaming (using ref for synchronous updates) + const shouldFollowScrollRef = useRef(true) + // Reference to track if this is the first chunk of a streaming response + const isFirstChunkRef = useRef(true) const messagesEndRef = useRef(null) const messagesContainerRef = useRef(null) @@ -61,6 +65,11 @@ export default function RetrievalTesting() { // Add messages to chatbox setMessages([...prevMessages, userMessage, assistantMessage]) + // Reset first chunk flag for new streaming response + isFirstChunkRef.current = true + // Enable follow scroll for new query + shouldFollowScrollRef.current = true + // Force scroll to bottom after messages are rendered setTimeout(() => { scrollToBottom(true) @@ -72,6 +81,17 @@ export default function RetrievalTesting() { // Create a function to update the assistant's message 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 setMessages((prev) => { const newMessages = [...prev] @@ -82,8 +102,20 @@ export default function RetrievalTesting() { } return newMessages }) - // Don't force scroll when updating with new chunks - scrollToBottom(false) + + // After updating content, check if we should scroll + // Use consistent scrolling behavior throughout the streaming response + 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(() => { + if (container) { + container.scrollTop = currentScrollPosition; + } + }, 0); + } } // Prepare query parameters @@ -128,6 +160,28 @@ export default function RetrievalTesting() { [inputValue, isLoading, messages, setMessages, t, scrollToBottom] ) + // Add scroll event listener to detect when user manually scrolls + useEffect(() => { + const container = messagesContainerRef.current; + if (!container) return; + + const handleScroll = () => { + const isNearBottomNow = isNearBottom(); + + // If user scrolls away from bottom while in auto-scroll mode, disable it + if (shouldFollowScrollRef.current && !isNearBottomNow) { + 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) useEffect(() => scrollToBottom(false), [debouncedMessages, scrollToBottom]) From 6f064925eb44a33d42328c0ce45d20cd3eff6a1a Mon Sep 17 00:00:00 2001 From: yangdx Date: Wed, 23 Apr 2025 00:38:35 +0800 Subject: [PATCH 2/5] Simplified scroll to bottom logic --- .../src/features/RetrievalTesting.tsx | 168 +++++++++++------- lightrag_webui/src/lib/utils.ts | 33 ++++ 2 files changed, 135 insertions(+), 66 deletions(-) diff --git a/lightrag_webui/src/features/RetrievalTesting.tsx b/lightrag_webui/src/features/RetrievalTesting.tsx index ff13153b..b42c9e3c 100644 --- a/lightrag_webui/src/features/RetrievalTesting.tsx +++ b/lightrag_webui/src/features/RetrievalTesting.tsx @@ -1,6 +1,7 @@ import Input from '@/components/ui/Input' import Button from '@/components/ui/Button' import { useCallback, useEffect, useRef, useState } from 'react' +import { throttle } from '@/lib/utils' import { queryText, queryTextStream, Message } from '@/api/lightrag' import { errorMessage } from '@/lib/utils' import { useSettingsStore } from '@/stores/settings' @@ -19,31 +20,28 @@ export default function RetrievalTesting() { const [isLoading, setIsLoading] = useState(false) // Reference to track if we should follow scroll during streaming (using ref for synchronous updates) const shouldFollowScrollRef = useRef(true) - // Reference to track if this is the first chunk of a streaming response - const isFirstChunkRef = useRef(true) + // Reference to track if user interaction is from the form area + 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(null) const messagesContainerRef = useRef(null) - // Check if the container is near the bottom - const isNearBottom = useCallback(() => { - const container = messagesContainerRef.current - if (!container) return true // Default to true if no container reference - - // Calculate distance to bottom - const { scrollTop, scrollHeight, clientHeight } = container - const distanceToBottom = scrollHeight - scrollTop - clientHeight - - // Consider near bottom if less than 100px from bottom - return distanceToBottom < 100 + // Scroll to bottom function - restored smooth scrolling with better handling + const scrollToBottom = useCallback(() => { + // Set flag to indicate this is a programmatic scroll + programmaticScrollRef.current = true + // Use requestAnimationFrame for better performance + requestAnimationFrame(() => { + if (messagesEndRef.current) { + // Use smooth scrolling for better user experience + messagesEndRef.current.scrollIntoView({ behavior: 'auto' }) + } + }) }, []) - 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( async (e: React.FormEvent) => { e.preventDefault() @@ -64,15 +62,15 @@ export default function RetrievalTesting() { // Add messages to chatbox setMessages([...prevMessages, userMessage, assistantMessage]) - - // Reset first chunk flag for new streaming response - isFirstChunkRef.current = true - // Enable follow scroll for new query + + // Reset scroll following state for new query shouldFollowScrollRef.current = true - + // Set flag to indicate we're receiving a response + isReceivingResponseRef.current = true + // Force scroll to bottom after messages are rendered setTimeout(() => { - scrollToBottom(true) + scrollToBottom() }, 0) // Clear input and set loading @@ -81,17 +79,6 @@ export default function RetrievalTesting() { // Create a function to update the assistant's message 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 setMessages((prev) => { const newMessages = [...prev] @@ -102,19 +89,13 @@ export default function RetrievalTesting() { } return newMessages }) - - // After updating content, check if we should scroll - // Use consistent scrolling behavior throughout the streaming response + + // After updating content, scroll to bottom if auto-scroll is enabled + // Use a longer delay to ensure DOM has updated 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(() => { - if (container) { - container.scrollTop = currentScrollPosition; - } - }, 0); + scrollToBottom() + }, 30) } } @@ -152,6 +133,7 @@ export default function RetrievalTesting() { } finally { // Clear loading and add messages to state setIsLoading(false) + isReceivingResponseRef.current = false useSettingsStore .getState() .setRetrievalHistory([...prevMessages, userMessage, assistantMessage]) @@ -160,30 +142,76 @@ export default function RetrievalTesting() { [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(() => { const container = messagesContainerRef.current; if (!container) return; - - const handleScroll = () => { - const isNearBottomNow = isNearBottom(); - - // If user scrolls away from bottom while in auto-scroll mode, disable it - if (shouldFollowScrollRef.current && !isNearBottomNow) { + + // Handle significant mouse wheel events - only disable auto-scroll for deliberate scrolling + const handleWheel = (e: WheelEvent) => { + // Only consider significant wheel movements (more than 10px) + if (Math.abs(e.deltaY) > 10 && !isFormInteractionRef.current) { 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) - useEffect(() => scrollToBottom(false), [debouncedMessages, scrollToBottom]) + // Handle scroll events - only disable auto-scroll if not programmatically triggered + // 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(() => { setMessages([]) @@ -194,7 +222,15 @@ export default function RetrievalTesting() {
-
+
{ + if (shouldFollowScrollRef.current) { + shouldFollowScrollRef.current = false; + } + }} + >
{messages.length === 0 ? (
diff --git a/lightrag_webui/src/lib/utils.ts b/lightrag_webui/src/lib/utils.ts index f5cc52d2..79475255 100644 --- a/lightrag_webui/src/lib/utils.ts +++ b/lightrag_webui/src/lib/utils.ts @@ -19,6 +19,39 @@ export function errorMessage(error: any) { 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 any>(fn: T, delay: number): (...args: Parameters) => void { + let lastCall = 0 + let timeoutId: ReturnType | null = null + + return function(this: any, ...args: Parameters) { + 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 extends { getState: () => infer T } ? S & { use: { [K in keyof T]: () => T[K] } } : never From e97e54b7a5793859145795e0fb7ae578c1f5ddde Mon Sep 17 00:00:00 2001 From: yangdx Date: Wed, 23 Apr 2025 01:26:34 +0800 Subject: [PATCH 3/5] Feat: support query mode prefix in retrieval input --- .../src/features/RetrievalTesting.tsx | 47 +++++++++++++++++-- lightrag_webui/src/locales/ar.json | 6 ++- lightrag_webui/src/locales/en.json | 6 ++- lightrag_webui/src/locales/fr.json | 6 ++- lightrag_webui/src/locales/zh.json | 6 ++- lightrag_webui/src/locales/zh_TW.json | 6 ++- 6 files changed, 64 insertions(+), 13 deletions(-) diff --git a/lightrag_webui/src/features/RetrievalTesting.tsx b/lightrag_webui/src/features/RetrievalTesting.tsx index b42c9e3c..b54103e0 100644 --- a/lightrag_webui/src/features/RetrievalTesting.tsx +++ b/lightrag_webui/src/features/RetrievalTesting.tsx @@ -10,6 +10,7 @@ import QuerySettings from '@/components/retrieval/QuerySettings' import { ChatMessage, MessageWithError } from '@/components/retrieval/ChatMessage' import { EraserIcon, SendIcon } from 'lucide-react' import { useTranslation } from 'react-i18next' +import type { QueryMode } from '@/api/lightrag' export default function RetrievalTesting() { const { t } = useTranslation() @@ -18,6 +19,7 @@ export default function RetrievalTesting() { ) const [inputValue, setInputValue] = useState('') const [isLoading, setIsLoading] = useState(false) + const [inputError, setInputError] = useState('') // Error message for input // Reference to track if we should follow scroll during streaming (using ref for synchronous updates) const shouldFollowScrollRef = useRef(true) // Reference to track if user interaction is from the form area @@ -47,7 +49,38 @@ export default function RetrievalTesting() { e.preventDefault() if (!inputValue.trim() || isLoading) return + // Parse query mode prefix + const allowedModes: QueryMode[] = ['naive', 'local', 'global', 'hybrid', 'mix', 'bypass'] + const prefixMatch = inputValue.match(/^\/(\w+)\s+(.+)/) + let modeOverride: QueryMode | undefined = undefined + let actualQuery = inputValue + + // If input starts with a slash, but does not match the valid prefix pattern, treat as error + if (/^\/\S+/.test(inputValue) && !prefixMatch) { + setInputError(t('retrievePanel.retrieval.queryModePrefixInvalid')) + return + } + + if (prefixMatch) { + const mode = prefixMatch[1] as QueryMode + const query = prefixMatch[2] + if (!allowedModes.includes(mode)) { + setInputError( + t('retrievePanel.retrieval.queryModeError', { + modes: 'naive, local, global, hybrid, mix, bypass', + }) + ) + return + } + modeOverride = mode + actualQuery = query + } + + // Clear error message + setInputError('') + // Create messages + // Save the original input (with prefix if any) in userMessage.content for display const userMessage: Message = { content: inputValue, role: 'user' @@ -103,11 +136,12 @@ export default function RetrievalTesting() { const state = useSettingsStore.getState() const queryParams = { ...state.querySettings, - query: userMessage.content, + query: actualQuery, conversation_history: prevMessages .filter((m) => m.isError !== true) .slice(-(state.querySettings.history_turns || 0) * 2) - .map((m) => ({ role: m.role, content: m.content })) + .map((m) => ({ role: m.role, content: m.content })), + ...(modeOverride ? { mode: modeOverride } : {}) } try { @@ -270,10 +304,17 @@ export default function RetrievalTesting() { id="query-input" className="w-full" value={inputValue} - onChange={(e) => setInputValue(e.target.value)} + onChange={(e) => { + setInputValue(e.target.value) + if (inputError) setInputError('') + }} placeholder={t('retrievePanel.retrieval.placeholder')} disabled={isLoading} /> + {/* Error message below input */} + {inputError && ( +
{inputError}
+ )}