Browse Source

Merge pull request #2555 from Yidadaa/bugfix-0804

feat: close #2545 improve lazy load message list
Yifei Zhang 1 year ago
parent
commit
44e43729bf
3 changed files with 118 additions and 115 deletions
  1. 110 63
      app/components/chat.tsx
  2. 5 52
      app/components/markdown.tsx
  3. 3 0
      app/constant.ts

+ 110 - 63
app/components/chat.tsx

@@ -74,7 +74,13 @@ import {
   showToast,
 } from "./ui-lib";
 import { useLocation, useNavigate } from "react-router-dom";
-import { LAST_INPUT_KEY, Path, REQUEST_TIMEOUT_MS } from "../constant";
+import {
+  CHAT_PAGE_SIZE,
+  LAST_INPUT_KEY,
+  MAX_RENDER_MSG_COUNT,
+  Path,
+  REQUEST_TIMEOUT_MS,
+} from "../constant";
 import { Avatar } from "./emoji";
 import { ContextPrompts, MaskAvatar, MaskConfig } from "./mask";
 import { useMaskStore } from "../store/mask";
@@ -370,33 +376,31 @@ function ChatAction(props: {
 function useScrollToBottom() {
   // for auto-scroll
   const scrollRef = useRef<HTMLDivElement>(null);
-  const autoScroll = useRef(true);
-  const scrollToBottom = useCallback(() => {
+  const [autoScroll, setAutoScroll] = useState(true);
+
+  function scrollDomToBottom() {
     const dom = scrollRef.current;
     if (dom) {
-      requestAnimationFrame(() => dom.scrollTo(0, dom.scrollHeight));
+      requestAnimationFrame(() => {
+        setAutoScroll(true);
+        dom.scrollTo(0, dom.scrollHeight);
+      });
     }
-  }, []);
-  const setAutoScroll = (enable: boolean) => {
-    autoScroll.current = enable;
-  };
+  }
 
   // auto scroll
   useEffect(() => {
-    const intervalId = setInterval(() => {
-      if (autoScroll.current) {
-        scrollToBottom();
-      }
-    }, 30);
-    return () => clearInterval(intervalId);
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, []);
+    console.log("auto scroll", autoScroll);
+    if (autoScroll) {
+      scrollDomToBottom();
+    }
+  });
 
   return {
     scrollRef,
     autoScroll,
     setAutoScroll,
-    scrollToBottom,
+    scrollDomToBottom,
   };
 }
 
@@ -595,7 +599,7 @@ export function EditMessageModal(props: { onClose: () => void }) {
   );
 }
 
-export function Chat() {
+function _Chat() {
   type RenderMessage = ChatMessage & { preview?: boolean };
 
   const chatStore = useChatStore();
@@ -609,21 +613,11 @@ export function Chat() {
   const [userInput, setUserInput] = useState("");
   const [isLoading, setIsLoading] = useState(false);
   const { submitKey, shouldSubmit } = useSubmitHandler();
-  const { scrollRef, setAutoScroll, scrollToBottom } = useScrollToBottom();
+  const { scrollRef, setAutoScroll, scrollDomToBottom } = useScrollToBottom();
   const [hitBottom, setHitBottom] = useState(true);
   const isMobileScreen = useMobileScreen();
   const navigate = useNavigate();
 
-  const lastBodyScroolTop = useRef(0);
-  const onChatBodyScroll = (e: HTMLElement) => {
-    const isTouchBottom = e.scrollTop + e.clientHeight >= e.scrollHeight - 10;
-    setHitBottom(isTouchBottom);
-
-    // only enable auto scroll when scroll down and touched bottom
-    setAutoScroll(e.scrollTop >= lastBodyScroolTop.current && isTouchBottom);
-    lastBodyScroolTop.current = e.scrollTop;
-  };
-
   // prompt hints
   const promptStore = usePromptStore();
   const [promptHints, setPromptHints] = useState<RenderPompt[]>([]);
@@ -865,10 +859,9 @@ export function Chat() {
     });
   };
 
-  const context: RenderMessage[] = session.mask.hideContext
-    ? []
-    : session.mask.context.slice();
-
+  const context: RenderMessage[] = useMemo(() => {
+    return session.mask.hideContext ? [] : session.mask.context.slice();
+  }, [session.mask.context, session.mask.hideContext]);
   const accessStore = useAccessStore();
 
   if (
@@ -889,34 +882,80 @@ export function Chat() {
       : -1;
 
   // preview messages
-  const messages = context
-    .concat(session.messages as RenderMessage[])
-    .concat(
-      isLoading
-        ? [
-            {
-              ...createMessage({
-                role: "assistant",
-                content: "……",
-              }),
-              preview: true,
-            },
-          ]
-        : [],
-    )
-    .concat(
-      userInput.length > 0 && config.sendPreviewBubble
-        ? [
-            {
-              ...createMessage({
-                role: "user",
-                content: userInput,
-              }),
-              preview: true,
-            },
-          ]
-        : [],
+  const renderMessages = useMemo(() => {
+    return context
+      .concat(session.messages as RenderMessage[])
+      .concat(
+        isLoading
+          ? [
+              {
+                ...createMessage({
+                  role: "assistant",
+                  content: "……",
+                }),
+                preview: true,
+              },
+            ]
+          : [],
+      )
+      .concat(
+        userInput.length > 0 && config.sendPreviewBubble
+          ? [
+              {
+                ...createMessage({
+                  role: "user",
+                  content: userInput,
+                }),
+                preview: true,
+              },
+            ]
+          : [],
+      );
+  }, [
+    config.sendPreviewBubble,
+    context,
+    isLoading,
+    session.messages,
+    userInput,
+  ]);
+
+  const [msgRenderIndex, setMsgRenderIndex] = useState(
+    renderMessages.length - CHAT_PAGE_SIZE,
+  );
+  const messages = useMemo(() => {
+    const endRenderIndex = Math.min(
+      msgRenderIndex + 3 * CHAT_PAGE_SIZE,
+      renderMessages.length,
     );
+    return renderMessages.slice(msgRenderIndex, endRenderIndex);
+  }, [msgRenderIndex, renderMessages]);
+
+  const onChatBodyScroll = (e: HTMLElement) => {
+    const EDGE_THRESHOLD = 100;
+    const bottomHeight = e.scrollTop + e.clientHeight;
+    const isTouchTopEdge = e.scrollTop <= EDGE_THRESHOLD;
+    const isTouchBottomEdge = bottomHeight >= e.scrollHeight - EDGE_THRESHOLD;
+    const isHitBottom = bottomHeight >= e.scrollHeight - 10;
+
+    if (isTouchTopEdge) {
+      setMsgRenderIndex(Math.max(0, msgRenderIndex - CHAT_PAGE_SIZE));
+    } else if (isTouchBottomEdge) {
+      setMsgRenderIndex(
+        Math.min(
+          msgRenderIndex + CHAT_PAGE_SIZE,
+          renderMessages.length - CHAT_PAGE_SIZE,
+        ),
+      );
+    }
+
+    setHitBottom(isHitBottom);
+    setAutoScroll(isHitBottom);
+  };
+
+  function scrollToBottom() {
+    setMsgRenderIndex(renderMessages.length - CHAT_PAGE_SIZE);
+    scrollDomToBottom();
+  }
 
   const [showPromptModal, setShowPromptModal] = useState(false);
 
@@ -1064,7 +1103,7 @@ export function Chat() {
           const shouldShowClearContextDivider = i === clearContextIndex - 1;
 
           return (
-            <Fragment key={i}>
+            <Fragment key={message.id}>
               <div
                 className={
                   isUser ? styles["chat-message-user"] : styles["chat-message"]
@@ -1148,7 +1187,8 @@ export function Chat() {
                     <Markdown
                       content={message.content}
                       loading={
-                        (message.preview || message.content.length === 0) &&
+                        (message.preview || message.streaming) &&
+                        message.content.length === 0 &&
                         !isUser
                       }
                       onContextMenu={(e) => onRightClick(e, message)}
@@ -1202,7 +1242,8 @@ export function Chat() {
             onInput={(e) => onInput(e.currentTarget.value)}
             value={userInput}
             onKeyDown={onInputKeyDown}
-            onFocus={() => setAutoScroll(true)}
+            onFocus={scrollToBottom}
+            onClick={scrollToBottom}
             rows={inputRows}
             autoFocus={autoFocus}
             style={{
@@ -1233,3 +1274,9 @@ export function Chat() {
     </div>
   );
 }
+
+export function Chat() {
+  const chatStore = useChatStore();
+  const sessionIndex = chatStore.currentSessionIndex;
+  return <_Chat key={sessionIndex}></_Chat>;
+}

+ 5 - 52
app/components/markdown.tsx

@@ -146,70 +146,23 @@ export function Markdown(
   } & React.DOMAttributes<HTMLDivElement>,
 ) {
   const mdRef = useRef<HTMLDivElement>(null);
-  const renderedHeight = useRef(0);
-  const renderedWidth = useRef(0);
-  const inView = useRef(!!props.defaultShow);
-  const [_, triggerRender] = useState(0);
-  const checkInView = useThrottledCallback(
-    () => {
-      const parent = props.parentRef?.current;
-      const md = mdRef.current;
-      if (parent && md && !props.defaultShow) {
-        const parentBounds = parent.getBoundingClientRect();
-        const twoScreenHeight = Math.max(500, parentBounds.height * 2);
-        const mdBounds = md.getBoundingClientRect();
-        const parentTop = parentBounds.top - twoScreenHeight;
-        const parentBottom = parentBounds.bottom + twoScreenHeight;
-        const isOverlap =
-          Math.max(parentTop, mdBounds.top) <=
-          Math.min(parentBottom, mdBounds.bottom);
-        inView.current = isOverlap;
-        triggerRender(Date.now());
-      }
-
-      if (inView.current && md) {
-        const rect = md.getBoundingClientRect();
-        renderedHeight.current = Math.max(renderedHeight.current, rect.height);
-        renderedWidth.current = Math.max(renderedWidth.current, rect.width);
-      }
-      // eslint-disable-next-line react-hooks/exhaustive-deps
-    },
-    300,
-    {
-      leading: true,
-      trailing: true,
-    },
-  );
-
-  useEffect(() => {
-    props.parentRef?.current?.addEventListener("scroll", checkInView);
-    checkInView();
-    return () =>
-      props.parentRef?.current?.removeEventListener("scroll", checkInView);
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, []);
-
-  const getSize = (x: number) => (!inView.current && x > 0 ? x : "auto");
 
   return (
     <div
       className="markdown-body"
       style={{
         fontSize: `${props.fontSize ?? 14}px`,
-        height: getSize(renderedHeight.current),
-        width: getSize(renderedWidth.current),
         direction: /[\u0600-\u06FF]/.test(props.content) ? "rtl" : "ltr",
       }}
       ref={mdRef}
       onContextMenu={props.onContextMenu}
       onDoubleClickCapture={props.onDoubleClickCapture}
     >
-      {inView.current &&
-        (props.loading ? (
-          <LoadingIcon />
-        ) : (
-          <MarkdownContent content={props.content} />
-        ))}
+      {props.loading ? (
+        <LoadingIcon />
+      ) : (
+        <MarkdownContent content={props.content} />
+      )}
     </div>
   );
 }

+ 3 - 0
app/constant.ts

@@ -109,3 +109,6 @@ export const DEFAULT_MODELS = [
     available: true,
   },
 ] as const;
+
+export const CHAT_PAGE_SIZE = 10;
+export const MAX_RENDER_MSG_COUNT = 20;