Browse Source

fix: 聊天流式输出自动跟随滚动,用户上翻后暂停自动滚动

- 用 ResizeObserver 监听消息内容容器高度,流式文字/表格/图谱/工具详情变化都会贴底。- 用户鼠标向上滚轮立即暂停跟随;滚动条离开底部也暂停,回到底部后恢复。- 新消息强制回到底部;保留回到底部按钮。- 滚动贴底阈值从 80 收紧到 24,避免用户轻微上翻后仍被自动带回。
cc 11 hours ago
parent
commit
22357b0082
1 changed files with 58 additions and 7 deletions
  1. 58 7
      ai-electron/frontend/src/ai/components/ChatPanel.vue

+ 58 - 7
ai-electron/frontend/src/ai/components/ChatPanel.vue

@@ -30,8 +30,8 @@
 
     <!-- 消息流 -->
     <div v-else class="ai-chat__flow">
-      <div ref="scrollRef" class="ai-scroll" @scroll="onScroll">
-        <div class="ai-column ai-chat__messages">
+      <div ref="scrollRef" class="ai-scroll" @scroll="onScroll" @wheel="onUserWheel">
+        <div ref="contentRef" class="ai-column ai-chat__messages">
           <ChatMessageItem
             v-for="(msg, idx) in messages"
             :key="msg.id"
@@ -86,7 +86,7 @@
    * 面板内的滚动、建议词回填、模型选择都是纯视图行为,自持即可;
    * 消息的增删与发送属于会话状态,一律通过 props / emit 交给调用方。
    */
-  import { computed, nextTick, ref, watch } from 'vue';
+  import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
   import Icon from '@/core/components/Icon/src/Icon.vue';
   import type { AiModel } from '../api/types';
   import type { ChatMessage } from '../store/chatStream';
@@ -130,6 +130,8 @@
   }>();
 
   const scrollRef = ref<HTMLElement>();
+  /** 消息内容容器:高度变化时(流式文字、表格、图谱、工具详情)都触发贴底跟随 */
+  const contentRef = ref<HTMLElement>();
   const composerRef = ref<InstanceType<typeof ChatComposer>>();
 
   /** 分析模板抽屉开关 */
@@ -163,7 +165,10 @@
     await nextTick();
     const el = scrollRef.value;
     if (el) el.scrollTop = el.scrollHeight;
-    if (force) showToBottom.value = false;
+    if (force) {
+      stickToBottom = true;
+      showToBottom.value = false;
+    }
   }
 
   defineExpose({ fill, focus, toBottom });
@@ -174,13 +179,59 @@
     const el = scrollRef.value;
     if (!el) return;
     const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
-    stickToBottom = distance < 80;
+    // 阈值收小:用户一旦上翻就停止自动跟随,避免和输出抢滚动条
+    stickToBottom = distance < 24;
     showToBottom.value = distance > 200;
   }
 
-  // 新消息 / 流式增量时保持贴底
+  /** 鼠标向上滚动立即暂停自动跟随(程序化滚动不会触发 wheel) */
+  function onUserWheel(event: WheelEvent) {
+    if (event.deltaY < 0) {
+      stickToBottom = false;
+      showToBottom.value = true;
+    }
+  }
+
+  let contentObserver: ResizeObserver | null = null;
+
+  function observeContentHeight() {
+    contentObserver?.disconnect();
+    contentObserver = null;
+    const el = contentRef.value;
+    if (!el || typeof ResizeObserver === 'undefined') return;
+    contentObserver = new ResizeObserver(() => {
+      // 用户已手动上翻时保持不动;否则任何内容高度变化都跟随到底部
+      void toBottom();
+    });
+    contentObserver.observe(el);
+  }
+
+  onMounted(() => {
+    observeContentHeight();
+    void toBottom(true);
+  });
+
+  onBeforeUnmount(() => {
+    contentObserver?.disconnect();
+    contentObserver = null;
+  });
+
+  watch(
+    () => contentRef.value,
+    () => observeContentHeight(),
+  );
+
+  // 新消息(用户发送 / 新回答)强制回到底部;流式高度变化交给 ResizeObserver
+  watch(
+    () => props.messages.length,
+    () => {
+      void toBottom(true);
+    },
+  );
+
+  // 兜底:流式文本增长时即使 ResizeObserver 不可用也能跟随
   watch(
-    () => [props.messages.length, props.messages[props.messages.length - 1]?.content?.length],
+    () => props.messages[props.messages.length - 1]?.content?.length,
     () => toBottom(),
   );