|
|
@@ -5,7 +5,7 @@
|
|
|
ref="bubbleListRef"
|
|
|
class="item-list"
|
|
|
:list="bubbleListItems"
|
|
|
- auto-scroll
|
|
|
+ :auto-scroll="autoScrollEnabled"
|
|
|
max-height="100%"
|
|
|
show-back-button
|
|
|
:btn-loading="loading"
|
|
|
@@ -38,13 +38,7 @@
|
|
|
color="var(--text-primary)"
|
|
|
>
|
|
|
<template #content="{ content }">
|
|
|
- <SMarkdown
|
|
|
- :markdown="content"
|
|
|
- :customAttrs="markdownCustomAttrs"
|
|
|
- allow-html
|
|
|
- enableLatex
|
|
|
- enableBreaks
|
|
|
- />
|
|
|
+ <SMarkdown :markdown="content" allow-html enableLatex enableBreaks />
|
|
|
</template>
|
|
|
</Thinking>
|
|
|
|
|
|
@@ -138,23 +132,23 @@
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
- <div v-if="getInlineErrors(item).length" class="inline-errors">
|
|
|
- <div v-for="(err, idx) in getInlineErrors(item)" :key="idx" class="inline-error-item">
|
|
|
- <el-tag type="danger" size="small" effect="plain" class="inline-error-tag">
|
|
|
- {{ t('pages.chat.failedTag') }}
|
|
|
- </el-tag>
|
|
|
- <span class="inline-error-text">{{ err }}</span>
|
|
|
- </div>
|
|
|
+ <div v-if="getInlineErrors(item).length" class="inline-errors">
|
|
|
+ <div v-for="(err, idx) in getInlineErrors(item)" :key="idx" class="inline-error-item">
|
|
|
+ <el-tag type="danger" size="small" effect="plain" class="inline-error-tag">
|
|
|
+ {{ t('pages.chat.failedTag') }}
|
|
|
+ </el-tag>
|
|
|
+ <span class="inline-error-text">{{ err }}</span>
|
|
|
</div>
|
|
|
+ </div>
|
|
|
<SMarkdown
|
|
|
v-if="getDisplayText(item)"
|
|
|
:class="['msg-content-text', { 'msg-content-text--error': isErrorMessage(item) }]"
|
|
|
:markdown="getDisplayText(item)"
|
|
|
- :customAttrs="markdownCustomAttrs"
|
|
|
allow-html
|
|
|
enableLatex
|
|
|
enableBreaks
|
|
|
need-view-code-btn
|
|
|
+ @card-submit="(val) => emit('card-submit', val)"
|
|
|
/>
|
|
|
<div v-if="item?.stopped" class="msg-stop-indicator">(已停止)</div>
|
|
|
</div>
|
|
|
@@ -223,7 +217,7 @@
|
|
|
</template>
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
-import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
|
|
+import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
|
import { BubbleList, Thinking } from 'vue-element-plus-x'
|
|
|
import { useI18n } from '@/composables/useI18n'
|
|
|
import { ElMessage } from 'element-plus'
|
|
|
@@ -250,16 +244,9 @@ const props = withDefaults(
|
|
|
const emit = defineEmits<{
|
|
|
retry: [message: BubbleMessage]
|
|
|
addToKb: [text: string]
|
|
|
+ 'card-submit': [text: string]
|
|
|
}>()
|
|
|
|
|
|
-const markdownCustomAttrs = {
|
|
|
- kb: (_node: any, attrs: Record<string, any>) => ({
|
|
|
- class: 'kb-reference-node',
|
|
|
- 'data-doc': attrs.doc || '',
|
|
|
- 'data-chunk-id': attrs.chunk_id || ''
|
|
|
- })
|
|
|
-}
|
|
|
-
|
|
|
const getMessageImages = (message: BubbleMessage) => {
|
|
|
const files = message.message_files
|
|
|
const ids = Array.isArray(files) ? files : files ? [files] : []
|
|
|
@@ -272,9 +259,16 @@ const bubbleListRef = ref<{
|
|
|
scrollToBubble: (index: number) => void
|
|
|
} | null>(null)
|
|
|
const chatContentRef = ref<HTMLElement | null>(null)
|
|
|
+const autoScrollEnabled = ref(true)
|
|
|
let scrollFrameId: number | null = null
|
|
|
let scrollResizeObserver: ResizeObserver | null = null
|
|
|
+let scrollMutationObserver: MutationObserver | null = null
|
|
|
+let scrollStabilityTimerId: number | null = null
|
|
|
+let boundScrollContainer: HTMLElement | null = null
|
|
|
+let isProgrammaticScroll = false
|
|
|
const scrollTimeoutIds = new Set<number>()
|
|
|
+const AUTO_SCROLL_BOTTOM_THRESHOLD = 120
|
|
|
+const SCROLL_STABILITY_MS = 3000
|
|
|
|
|
|
const bubbleListItems = computed(() =>
|
|
|
props.messages.map((item) => ({
|
|
|
@@ -290,43 +284,128 @@ const clearScheduledScroll = () => {
|
|
|
}
|
|
|
scrollResizeObserver?.disconnect()
|
|
|
scrollResizeObserver = null
|
|
|
+ scrollMutationObserver?.disconnect()
|
|
|
+ scrollMutationObserver = null
|
|
|
+ if (scrollStabilityTimerId !== null) {
|
|
|
+ window.clearTimeout(scrollStabilityTimerId)
|
|
|
+ scrollStabilityTimerId = null
|
|
|
+ }
|
|
|
scrollTimeoutIds.forEach((id) => window.clearTimeout(id))
|
|
|
scrollTimeoutIds.clear()
|
|
|
}
|
|
|
|
|
|
+const unbindScrollContainer = () => {
|
|
|
+ boundScrollContainer?.removeEventListener('scroll', handleScroll)
|
|
|
+ boundScrollContainer = null
|
|
|
+}
|
|
|
+
|
|
|
const getScrollContainer = () => {
|
|
|
const root = chatContentRef.value
|
|
|
if (!root) return null
|
|
|
return root.querySelector<HTMLElement>('.el-bubble-list')
|
|
|
}
|
|
|
|
|
|
+const getDistanceToBottom = (scrollContainer: HTMLElement) =>
|
|
|
+ scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight
|
|
|
+
|
|
|
+const updateAutoScrollState = () => {
|
|
|
+ const scrollContainer = getScrollContainer()
|
|
|
+ if (!scrollContainer) {
|
|
|
+ autoScrollEnabled.value = true
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ autoScrollEnabled.value = getDistanceToBottom(scrollContainer) <= AUTO_SCROLL_BOTTOM_THRESHOLD
|
|
|
+}
|
|
|
+
|
|
|
+function handleScroll() {
|
|
|
+ // 程序化滚动触发的事件不更新自动滚动状态
|
|
|
+ if (isProgrammaticScroll) return
|
|
|
+ updateAutoScrollState()
|
|
|
+ if (!autoScrollEnabled.value) {
|
|
|
+ clearScheduledScroll()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const bindScrollContainer = async () => {
|
|
|
+ await nextTick()
|
|
|
+ const scrollContainer = getScrollContainer()
|
|
|
+ if (scrollContainer === boundScrollContainer) {
|
|
|
+ updateAutoScrollState()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ unbindScrollContainer()
|
|
|
+ boundScrollContainer = scrollContainer
|
|
|
+ boundScrollContainer?.addEventListener('scroll', handleScroll, { passive: true })
|
|
|
+ updateAutoScrollState()
|
|
|
+}
|
|
|
+
|
|
|
const scrollToBottomOnce = () => {
|
|
|
+ isProgrammaticScroll = true
|
|
|
bubbleListRef.value?.scrollToBottom()
|
|
|
|
|
|
const scrollContainer = getScrollContainer()
|
|
|
if (scrollContainer) {
|
|
|
scrollContainer.scrollTop = scrollContainer.scrollHeight
|
|
|
}
|
|
|
+ autoScrollEnabled.value = true
|
|
|
+ // 延迟释放标志,确保 scroll 事件在当前帧内不会误判
|
|
|
+ requestAnimationFrame(() => {
|
|
|
+ isProgrammaticScroll = false
|
|
|
+ })
|
|
|
}
|
|
|
|
|
|
const keepBottomDuringLayout = () => {
|
|
|
const scrollContainer = getScrollContainer()
|
|
|
- if (!scrollContainer || typeof ResizeObserver === 'undefined') return
|
|
|
+ if (!scrollContainer) return
|
|
|
|
|
|
+ // ResizeObserver: 观察滚动容器本身和直接子元素的尺寸变化
|
|
|
scrollResizeObserver?.disconnect()
|
|
|
scrollResizeObserver = new ResizeObserver(() => {
|
|
|
- scrollToBottomOnce()
|
|
|
+ if (autoScrollEnabled.value) {
|
|
|
+ scrollToBottomOnce()
|
|
|
+ resetStabilityTimer()
|
|
|
+ }
|
|
|
})
|
|
|
+ scrollResizeObserver.observe(scrollContainer)
|
|
|
Array.from(scrollContainer.children).forEach((child) => {
|
|
|
scrollResizeObserver?.observe(child)
|
|
|
})
|
|
|
|
|
|
- const timeoutId = window.setTimeout(() => {
|
|
|
- scrollTimeoutIds.delete(timeoutId)
|
|
|
+ // MutationObserver: 观察整个子树的 DOM 变化(Markdown 渲染、图片加载、虚拟滚动新增项等)
|
|
|
+ scrollMutationObserver?.disconnect()
|
|
|
+ scrollMutationObserver = new MutationObserver(() => {
|
|
|
+ if (autoScrollEnabled.value) {
|
|
|
+ scrollToBottomOnce()
|
|
|
+ resetStabilityTimer()
|
|
|
+ }
|
|
|
+ // 动态观察新增的子元素(虚拟滚动可能动态添加)
|
|
|
+ Array.from(scrollContainer.children).forEach((child) => {
|
|
|
+ scrollResizeObserver?.observe(child)
|
|
|
+ })
|
|
|
+ })
|
|
|
+ scrollMutationObserver.observe(scrollContainer, {
|
|
|
+ characterData: true,
|
|
|
+ childList: true,
|
|
|
+ subtree: true
|
|
|
+ })
|
|
|
+
|
|
|
+ // 内容稳定后自动断开(无 DOM 变化 3s 后停止观察,避免长期性能开销)
|
|
|
+ resetStabilityTimer()
|
|
|
+}
|
|
|
+
|
|
|
+const resetStabilityTimer = () => {
|
|
|
+ if (scrollStabilityTimerId !== null) {
|
|
|
+ window.clearTimeout(scrollStabilityTimerId)
|
|
|
+ }
|
|
|
+ scrollStabilityTimerId = window.setTimeout(() => {
|
|
|
+ scrollStabilityTimerId = null
|
|
|
scrollResizeObserver?.disconnect()
|
|
|
scrollResizeObserver = null
|
|
|
- }, 1200)
|
|
|
- scrollTimeoutIds.add(timeoutId)
|
|
|
+ scrollMutationObserver?.disconnect()
|
|
|
+ scrollMutationObserver = null
|
|
|
+ }, SCROLL_STABILITY_MS)
|
|
|
}
|
|
|
|
|
|
const scrollToBottom = async () => {
|
|
|
@@ -338,14 +417,18 @@ const scrollToBottom = async () => {
|
|
|
|
|
|
scrollFrameId = window.requestAnimationFrame(() => {
|
|
|
scrollFrameId = null
|
|
|
- scrollToBottomOnce()
|
|
|
- keepBottomDuringLayout()
|
|
|
+ if (autoScrollEnabled.value) {
|
|
|
+ scrollToBottomOnce()
|
|
|
+ keepBottomDuringLayout()
|
|
|
+ }
|
|
|
})
|
|
|
|
|
|
for (const delay of [60, 180, 360]) {
|
|
|
const timeoutId = window.setTimeout(() => {
|
|
|
scrollTimeoutIds.delete(timeoutId)
|
|
|
- scrollToBottomOnce()
|
|
|
+ if (autoScrollEnabled.value) {
|
|
|
+ scrollToBottomOnce()
|
|
|
+ }
|
|
|
}, delay)
|
|
|
scrollTimeoutIds.add(timeoutId)
|
|
|
}
|
|
|
@@ -354,15 +437,23 @@ const scrollToBottom = async () => {
|
|
|
watch(
|
|
|
() => props.messages.length,
|
|
|
(length, oldLength) => {
|
|
|
+ bindScrollContainer()
|
|
|
if (length > 0 && length !== oldLength) {
|
|
|
- scrollToBottom()
|
|
|
+ if (autoScrollEnabled.value) {
|
|
|
+ scrollToBottom()
|
|
|
+ }
|
|
|
}
|
|
|
},
|
|
|
{ flush: 'post' }
|
|
|
)
|
|
|
|
|
|
+onMounted(() => {
|
|
|
+ bindScrollContainer()
|
|
|
+})
|
|
|
+
|
|
|
onBeforeUnmount(() => {
|
|
|
clearScheduledScroll()
|
|
|
+ unbindScrollContainer()
|
|
|
})
|
|
|
|
|
|
const THINK_TAG_RE = /<think\b[^>]*>([\s\S]*?)(?:<\/think>|$)/gi
|
|
|
@@ -474,6 +565,7 @@ const getInlineErrors = (message: BubbleMessage) => message.inlineErrors || []
|
|
|
defineExpose({
|
|
|
scrollToTop: () => bubbleListRef.value?.scrollToTop(),
|
|
|
scrollToBottom,
|
|
|
+ isNearBottom: () => autoScrollEnabled.value,
|
|
|
scrollToBubble: (index: number) => bubbleListRef.value?.scrollToBubble(index)
|
|
|
})
|
|
|
|
|
|
@@ -815,4 +907,5 @@ const handleAddToKb = (message: BubbleMessage) => {
|
|
|
color: var(--el-color-danger);
|
|
|
line-height: 1.5;
|
|
|
word-break: break-all;
|
|
|
-}</style>
|
|
|
+}
|
|
|
+</style>
|