yangdx
commited on
Commit
·
5068375
1
Parent(s):
f6989e6
Simplified scroll to bottom logic
Browse files
lightrag_webui/src/features/RetrievalTesting.tsx
CHANGED
@@ -1,6 +1,7 @@
|
|
1 |
import Input from '@/components/ui/Input'
|
2 |
import Button from '@/components/ui/Button'
|
3 |
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
|
4 |
import { queryText, queryTextStream, Message } from '@/api/lightrag'
|
5 |
import { errorMessage } from '@/lib/utils'
|
6 |
import { useSettingsStore } from '@/stores/settings'
|
@@ -19,31 +20,28 @@ export default function RetrievalTesting() {
|
|
19 |
const [isLoading, setIsLoading] = useState(false)
|
20 |
// Reference to track if we should follow scroll during streaming (using ref for synchronous updates)
|
21 |
const shouldFollowScrollRef = useRef(true)
|
22 |
-
// Reference to track if
|
23 |
-
const
|
|
|
|
|
|
|
|
|
24 |
const messagesEndRef = useRef<HTMLDivElement>(null)
|
25 |
const messagesContainerRef = useRef<HTMLDivElement>(null)
|
26 |
|
27 |
-
//
|
28 |
-
const
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
}, [])
|
39 |
|
40 |
-
const scrollToBottom = useCallback((force = false) => {
|
41 |
-
// Only scroll if forced or user is already near bottom
|
42 |
-
if (force || isNearBottom()) {
|
43 |
-
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
44 |
-
}
|
45 |
-
}, [isNearBottom])
|
46 |
-
|
47 |
const handleSubmit = useCallback(
|
48 |
async (e: React.FormEvent) => {
|
49 |
e.preventDefault()
|
@@ -64,15 +62,15 @@ export default function RetrievalTesting() {
|
|
64 |
|
65 |
// Add messages to chatbox
|
66 |
setMessages([...prevMessages, userMessage, assistantMessage])
|
67 |
-
|
68 |
-
// Reset
|
69 |
-
isFirstChunkRef.current = true
|
70 |
-
// Enable follow scroll for new query
|
71 |
shouldFollowScrollRef.current = true
|
72 |
-
|
|
|
|
|
73 |
// Force scroll to bottom after messages are rendered
|
74 |
setTimeout(() => {
|
75 |
-
scrollToBottom(
|
76 |
}, 0)
|
77 |
|
78 |
// Clear input and set loading
|
@@ -81,17 +79,6 @@ export default function RetrievalTesting() {
|
|
81 |
|
82 |
// Create a function to update the assistant's message
|
83 |
const updateAssistantMessage = (chunk: string, isError?: boolean) => {
|
84 |
-
// Check if this is the first chunk of the streaming response
|
85 |
-
if (isFirstChunkRef.current) {
|
86 |
-
// Determine scroll behavior based on initial position
|
87 |
-
shouldFollowScrollRef.current = isNearBottom();
|
88 |
-
isFirstChunkRef.current = false;
|
89 |
-
}
|
90 |
-
|
91 |
-
// Save current scroll position before updating content
|
92 |
-
const container = messagesContainerRef.current;
|
93 |
-
const currentScrollPosition = container ? container.scrollTop : 0;
|
94 |
-
|
95 |
assistantMessage.content += chunk
|
96 |
setMessages((prev) => {
|
97 |
const newMessages = [...prev]
|
@@ -102,19 +89,13 @@ export default function RetrievalTesting() {
|
|
102 |
}
|
103 |
return newMessages
|
104 |
})
|
105 |
-
|
106 |
-
// After updating content,
|
107 |
-
// Use
|
108 |
if (shouldFollowScrollRef.current) {
|
109 |
-
scrollToBottom(true);
|
110 |
-
} else if (container) {
|
111 |
-
// If user was not near bottom, restore their scroll position
|
112 |
-
// This needs to be in a setTimeout to work after React updates the DOM
|
113 |
setTimeout(() => {
|
114 |
-
|
115 |
-
|
116 |
-
}
|
117 |
-
}, 0);
|
118 |
}
|
119 |
}
|
120 |
|
@@ -152,6 +133,7 @@ export default function RetrievalTesting() {
|
|
152 |
} finally {
|
153 |
// Clear loading and add messages to state
|
154 |
setIsLoading(false)
|
|
|
155 |
useSettingsStore
|
156 |
.getState()
|
157 |
.setRetrievalHistory([...prevMessages, userMessage, assistantMessage])
|
@@ -160,30 +142,76 @@ export default function RetrievalTesting() {
|
|
160 |
[inputValue, isLoading, messages, setMessages, t, scrollToBottom]
|
161 |
)
|
162 |
|
163 |
-
// Add
|
164 |
useEffect(() => {
|
165 |
const container = messagesContainerRef.current;
|
166 |
if (!container) return;
|
167 |
-
|
168 |
-
|
169 |
-
|
170 |
-
|
171 |
-
|
172 |
-
if (shouldFollowScrollRef.current && !isNearBottomNow) {
|
173 |
shouldFollowScrollRef.current = false;
|
174 |
}
|
175 |
-
|
176 |
-
|
177 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
178 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
179 |
};
|
180 |
-
|
181 |
-
container.addEventListener('scroll', handleScroll);
|
182 |
-
return () => container.removeEventListener('scroll', handleScroll);
|
183 |
-
}, [isNearBottom]); // Remove shouldFollowScroll from dependencies since we're using ref now
|
184 |
|
185 |
-
|
186 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
187 |
|
188 |
const clearMessages = useCallback(() => {
|
189 |
setMessages([])
|
@@ -194,7 +222,15 @@ export default function RetrievalTesting() {
|
|
194 |
<div className="flex size-full gap-2 px-2 pb-12 overflow-hidden">
|
195 |
<div className="flex grow flex-col gap-4">
|
196 |
<div className="relative grow">
|
197 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
198 |
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
199 |
{messages.length === 0 ? (
|
200 |
<div className="text-muted-foreground flex h-full items-center justify-center text-lg">
|
|
|
1 |
import Input from '@/components/ui/Input'
|
2 |
import Button from '@/components/ui/Button'
|
3 |
import { useCallback, useEffect, useRef, useState } from 'react'
|
4 |
+
import { throttle } from '@/lib/utils'
|
5 |
import { queryText, queryTextStream, Message } from '@/api/lightrag'
|
6 |
import { errorMessage } from '@/lib/utils'
|
7 |
import { useSettingsStore } from '@/stores/settings'
|
|
|
20 |
const [isLoading, setIsLoading] = useState(false)
|
21 |
// Reference to track if we should follow scroll during streaming (using ref for synchronous updates)
|
22 |
const shouldFollowScrollRef = useRef(true)
|
23 |
+
// Reference to track if user interaction is from the form area
|
24 |
+
const isFormInteractionRef = useRef(false)
|
25 |
+
// Reference to track if scroll was triggered programmatically
|
26 |
+
const programmaticScrollRef = useRef(false)
|
27 |
+
// Reference to track if we're currently receiving a streaming response
|
28 |
+
const isReceivingResponseRef = useRef(false)
|
29 |
const messagesEndRef = useRef<HTMLDivElement>(null)
|
30 |
const messagesContainerRef = useRef<HTMLDivElement>(null)
|
31 |
|
32 |
+
// Scroll to bottom function - restored smooth scrolling with better handling
|
33 |
+
const scrollToBottom = useCallback(() => {
|
34 |
+
// Set flag to indicate this is a programmatic scroll
|
35 |
+
programmaticScrollRef.current = true
|
36 |
+
// Use requestAnimationFrame for better performance
|
37 |
+
requestAnimationFrame(() => {
|
38 |
+
if (messagesEndRef.current) {
|
39 |
+
// Use smooth scrolling for better user experience
|
40 |
+
messagesEndRef.current.scrollIntoView({ behavior: 'auto' })
|
41 |
+
}
|
42 |
+
})
|
43 |
}, [])
|
44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
45 |
const handleSubmit = useCallback(
|
46 |
async (e: React.FormEvent) => {
|
47 |
e.preventDefault()
|
|
|
62 |
|
63 |
// Add messages to chatbox
|
64 |
setMessages([...prevMessages, userMessage, assistantMessage])
|
65 |
+
|
66 |
+
// Reset scroll following state for new query
|
|
|
|
|
67 |
shouldFollowScrollRef.current = true
|
68 |
+
// Set flag to indicate we're receiving a response
|
69 |
+
isReceivingResponseRef.current = true
|
70 |
+
|
71 |
// Force scroll to bottom after messages are rendered
|
72 |
setTimeout(() => {
|
73 |
+
scrollToBottom()
|
74 |
}, 0)
|
75 |
|
76 |
// Clear input and set loading
|
|
|
79 |
|
80 |
// Create a function to update the assistant's message
|
81 |
const updateAssistantMessage = (chunk: string, isError?: boolean) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
82 |
assistantMessage.content += chunk
|
83 |
setMessages((prev) => {
|
84 |
const newMessages = [...prev]
|
|
|
89 |
}
|
90 |
return newMessages
|
91 |
})
|
92 |
+
|
93 |
+
// After updating content, scroll to bottom if auto-scroll is enabled
|
94 |
+
// Use a longer delay to ensure DOM has updated
|
95 |
if (shouldFollowScrollRef.current) {
|
|
|
|
|
|
|
|
|
96 |
setTimeout(() => {
|
97 |
+
scrollToBottom()
|
98 |
+
}, 30)
|
|
|
|
|
99 |
}
|
100 |
}
|
101 |
|
|
|
133 |
} finally {
|
134 |
// Clear loading and add messages to state
|
135 |
setIsLoading(false)
|
136 |
+
isReceivingResponseRef.current = false
|
137 |
useSettingsStore
|
138 |
.getState()
|
139 |
.setRetrievalHistory([...prevMessages, userMessage, assistantMessage])
|
|
|
142 |
[inputValue, isLoading, messages, setMessages, t, scrollToBottom]
|
143 |
)
|
144 |
|
145 |
+
// Add event listeners to detect when user manually interacts with the container
|
146 |
useEffect(() => {
|
147 |
const container = messagesContainerRef.current;
|
148 |
if (!container) return;
|
149 |
+
|
150 |
+
// Handle significant mouse wheel events - only disable auto-scroll for deliberate scrolling
|
151 |
+
const handleWheel = (e: WheelEvent) => {
|
152 |
+
// Only consider significant wheel movements (more than 10px)
|
153 |
+
if (Math.abs(e.deltaY) > 10 && !isFormInteractionRef.current) {
|
|
|
154 |
shouldFollowScrollRef.current = false;
|
155 |
}
|
156 |
+
};
|
157 |
+
|
158 |
+
// Handle scroll events - only disable auto-scroll if not programmatically triggered
|
159 |
+
// and if it's a significant scroll
|
160 |
+
const handleScroll = throttle(() => {
|
161 |
+
// If this is a programmatic scroll, don't disable auto-scroll
|
162 |
+
if (programmaticScrollRef.current) {
|
163 |
+
programmaticScrollRef.current = false;
|
164 |
+
return;
|
165 |
+
}
|
166 |
+
|
167 |
+
// If we're receiving a response, be more conservative about disabling auto-scroll
|
168 |
+
if (!isFormInteractionRef.current && !isReceivingResponseRef.current) {
|
169 |
+
shouldFollowScrollRef.current = false;
|
170 |
}
|
171 |
+
}, 30);
|
172 |
+
|
173 |
+
// Add event listeners - only listen for wheel and scroll events
|
174 |
+
container.addEventListener('wheel', handleWheel as EventListener);
|
175 |
+
container.addEventListener('scroll', handleScroll as EventListener);
|
176 |
+
|
177 |
+
return () => {
|
178 |
+
container.removeEventListener('wheel', handleWheel as EventListener);
|
179 |
+
container.removeEventListener('scroll', handleScroll as EventListener);
|
180 |
+
};
|
181 |
+
}, []);
|
182 |
+
|
183 |
+
// Add event listeners to the form area to prevent disabling auto-scroll when interacting with form
|
184 |
+
useEffect(() => {
|
185 |
+
const form = document.querySelector('form');
|
186 |
+
if (!form) return;
|
187 |
+
|
188 |
+
const handleFormMouseDown = () => {
|
189 |
+
// Set flag to indicate form interaction
|
190 |
+
isFormInteractionRef.current = true;
|
191 |
+
|
192 |
+
// Reset the flag after a short delay
|
193 |
+
setTimeout(() => {
|
194 |
+
isFormInteractionRef.current = false;
|
195 |
+
}, 500); // Give enough time for the form interaction to complete
|
196 |
};
|
|
|
|
|
|
|
|
|
197 |
|
198 |
+
form.addEventListener('mousedown', handleFormMouseDown);
|
199 |
+
|
200 |
+
return () => {
|
201 |
+
form.removeEventListener('mousedown', handleFormMouseDown);
|
202 |
+
};
|
203 |
+
}, []);
|
204 |
+
|
205 |
+
// Use a longer debounce time for better performance with large message updates
|
206 |
+
const debouncedMessages = useDebounce(messages, 150)
|
207 |
+
useEffect(() => {
|
208 |
+
// Only auto-scroll if enabled
|
209 |
+
if (shouldFollowScrollRef.current) {
|
210 |
+
// Force scroll to bottom when messages change
|
211 |
+
scrollToBottom()
|
212 |
+
}
|
213 |
+
}, [debouncedMessages, scrollToBottom])
|
214 |
+
|
215 |
|
216 |
const clearMessages = useCallback(() => {
|
217 |
setMessages([])
|
|
|
222 |
<div className="flex size-full gap-2 px-2 pb-12 overflow-hidden">
|
223 |
<div className="flex grow flex-col gap-4">
|
224 |
<div className="relative grow">
|
225 |
+
<div
|
226 |
+
ref={messagesContainerRef}
|
227 |
+
className="bg-primary-foreground/60 absolute inset-0 flex flex-col overflow-auto rounded-lg border p-2"
|
228 |
+
onClick={() => {
|
229 |
+
if (shouldFollowScrollRef.current) {
|
230 |
+
shouldFollowScrollRef.current = false;
|
231 |
+
}
|
232 |
+
}}
|
233 |
+
>
|
234 |
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
235 |
{messages.length === 0 ? (
|
236 |
<div className="text-muted-foreground flex h-full items-center justify-center text-lg">
|
lightrag_webui/src/lib/utils.ts
CHANGED
@@ -19,6 +19,39 @@ export function errorMessage(error: any) {
|
|
19 |
return error instanceof Error ? error.message : `${error}`
|
20 |
}
|
21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
22 |
type WithSelectors<S> = S extends { getState: () => infer T }
|
23 |
? S & { use: { [K in keyof T]: () => T[K] } }
|
24 |
: never
|
|
|
19 |
return error instanceof Error ? error.message : `${error}`
|
20 |
}
|
21 |
|
22 |
+
/**
|
23 |
+
* Creates a throttled function that limits how often the original function can be called
|
24 |
+
* @param fn The function to throttle
|
25 |
+
* @param delay The delay in milliseconds
|
26 |
+
* @returns A throttled version of the function
|
27 |
+
*/
|
28 |
+
export function throttle<T extends (...args: any[]) => any>(fn: T, delay: number): (...args: Parameters<T>) => void {
|
29 |
+
let lastCall = 0
|
30 |
+
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
31 |
+
|
32 |
+
return function(this: any, ...args: Parameters<T>) {
|
33 |
+
const now = Date.now()
|
34 |
+
const remaining = delay - (now - lastCall)
|
35 |
+
|
36 |
+
if (remaining <= 0) {
|
37 |
+
// If enough time has passed, execute the function immediately
|
38 |
+
if (timeoutId) {
|
39 |
+
clearTimeout(timeoutId)
|
40 |
+
timeoutId = null
|
41 |
+
}
|
42 |
+
lastCall = now
|
43 |
+
fn.apply(this, args)
|
44 |
+
} else if (!timeoutId) {
|
45 |
+
// If not enough time has passed, set a timeout to execute after the remaining time
|
46 |
+
timeoutId = setTimeout(() => {
|
47 |
+
lastCall = Date.now()
|
48 |
+
timeoutId = null
|
49 |
+
fn.apply(this, args)
|
50 |
+
}, remaining)
|
51 |
+
}
|
52 |
+
}
|
53 |
+
}
|
54 |
+
|
55 |
type WithSelectors<S> = S extends { getState: () => infer T }
|
56 |
? S & { use: { [K in keyof T]: () => T[K] } }
|
57 |
: never
|