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 this is the first chunk of a streaming response
23
- const isFirstChunkRef = useRef(true)
 
 
 
 
24
  const messagesEndRef = useRef<HTMLDivElement>(null)
25
  const messagesContainerRef = useRef<HTMLDivElement>(null)
26
 
27
- // Check if the container is near the bottom
28
- const isNearBottom = useCallback(() => {
29
- const container = messagesContainerRef.current
30
- if (!container) return true // Default to true if no container reference
31
-
32
- // Calculate distance to bottom
33
- const { scrollTop, scrollHeight, clientHeight } = container
34
- const distanceToBottom = scrollHeight - scrollTop - clientHeight
35
-
36
- // Consider near bottom if less than 100px from bottom
37
- return distanceToBottom < 100
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 first chunk flag for new streaming response
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(true)
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, check if we should scroll
107
- // Use consistent scrolling behavior throughout the streaming response
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
- if (container) {
115
- container.scrollTop = currentScrollPosition;
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 scroll event listener to detect when user manually scrolls
164
  useEffect(() => {
165
  const container = messagesContainerRef.current;
166
  if (!container) return;
167
-
168
- const handleScroll = () => {
169
- const isNearBottomNow = isNearBottom();
170
-
171
- // If user scrolls away from bottom while in auto-scroll mode, disable it
172
- if (shouldFollowScrollRef.current && !isNearBottomNow) {
173
  shouldFollowScrollRef.current = false;
174
  }
175
- // If user scrolls back to bottom while not in auto-scroll mode, re-enable it
176
- else if (!shouldFollowScrollRef.current && isNearBottomNow) {
177
- shouldFollowScrollRef.current = true;
 
 
 
 
 
 
 
 
 
 
 
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
- const debouncedMessages = useDebounce(messages, 100)
186
- useEffect(() => scrollToBottom(false), [debouncedMessages, scrollToBottom])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ref={messagesContainerRef} className="bg-primary-foreground/60 absolute inset-0 flex flex-col overflow-auto rounded-lg border p-2">
 
 
 
 
 
 
 
 
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