|
|
@@ -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(),
|
|
|
);
|
|
|
|