mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(chat): stop history view snapping to bottom during streaming (#498)
This commit is contained in:
parent
87a5f8a844
commit
5db8fb14a4
@ -200,14 +200,21 @@ const showCursorForMessage = (msg: Message) => {
|
||||
return isLastAssistant && msg.status === 'generating'
|
||||
}
|
||||
|
||||
// 监听消息变化,自动滚动
|
||||
// 监听消息变化,自动滚动。流式生成时 token 逐字变更会高频触发本 watcher,
|
||||
// 用一个 rAF 合并同一帧内的多次变更为一次即时滚动,避免每 token 动画抖动;
|
||||
// 用户上滚脱离贴底后(escapedFromLock)完全不滚动,读历史不被打断。
|
||||
let scrollRaf = 0
|
||||
watch(
|
||||
() => props.messages,
|
||||
async () => {
|
||||
await nextTick()
|
||||
// Don't auto-scroll while the user has scrolled up to read history.
|
||||
() => {
|
||||
if (escapedFromLock.value) return
|
||||
scrollToBottom()
|
||||
if (scrollRaf) return
|
||||
scrollRaf = requestAnimationFrame(async () => {
|
||||
scrollRaf = 0
|
||||
await nextTick()
|
||||
if (escapedFromLock.value) return
|
||||
scrollToBottom({ smooth: false })
|
||||
})
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
@ -226,7 +233,8 @@ watch(
|
||||
// Explicit "jump to bottom" request (button click / End key): force past the
|
||||
// escape lock and clear it so sticky auto-scroll resumes following new content.
|
||||
function goToBottom() {
|
||||
escapedFromLock.value = false
|
||||
// force ignores the escape lock; the composable re-pins (isAtBottom /
|
||||
// escapedFromLock stay in sync) once the scroll settles.
|
||||
scrollToBottom({ force: true, smooth: true })
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,14 @@
|
||||
/**
|
||||
* 智能滚动 Composable
|
||||
* 提供"贴底/脱离锁定"的智能自动滚动体验:内容增长时自动贴底,用户上滚后释放锁定
|
||||
* 提供"贴底/脱离锁定"的智能自动滚动体验:内容增长时自动贴底,用户上滚后释放锁定。
|
||||
*
|
||||
* Pin model: a single "pinned" state derived purely from the absolute distance
|
||||
* from the bottom (scrollHeight - scrollTop - clientHeight). The view is pinned
|
||||
* (auto-follows new content) only while the user is within `offset` of the
|
||||
* bottom; scrolling up past `offset` unpins, and scrolling back within `offset`
|
||||
* re-pins. No scroll-direction deltas are used — those flicker during scrollbar
|
||||
* drags and caused the view to snap back to the newest message while reading
|
||||
* history (issue #498).
|
||||
*/
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
@ -16,11 +24,11 @@ export interface StickToBottomOptions {
|
||||
}
|
||||
|
||||
export interface StickToBottomReturn {
|
||||
/** 是否在底部 */
|
||||
/** 是否贴底(自动跟随新内容) */
|
||||
isAtBottom: import('vue').Ref<boolean>
|
||||
/** 是否在底部附近 */
|
||||
isNearBottom: import('vue').ComputedRef<boolean>
|
||||
/** 是否被用户滚动中断 */
|
||||
/** 是否被用户滚动中断(= 未贴底) */
|
||||
escapedFromLock: import('vue').Ref<boolean>
|
||||
/** 滚动元素引用 */
|
||||
scrollRef: import('vue').Ref<HTMLElement | null>
|
||||
@ -47,154 +55,157 @@ export function useStickToBottom(
|
||||
options: StickToBottomOptions = {}
|
||||
): StickToBottomReturn {
|
||||
const opts = { ...DEFAULT_OPTIONS, ...options }
|
||||
|
||||
|
||||
const scrollRef = ref<HTMLElement | null>(null)
|
||||
const contentRef = ref<HTMLElement | null>(null)
|
||||
|
||||
|
||||
// Single source of truth. `isAtBottom` = pinned (auto-follow on);
|
||||
// `escapedFromLock` is its inverse, kept in sync via setPinned() so the two
|
||||
// exported refs can never disagree.
|
||||
const isAtBottom = ref(true)
|
||||
const escapedFromLock = ref(false)
|
||||
let isScrolling = false
|
||||
let lastScrollTop = 0
|
||||
|
||||
// True only while we drive a programmatic (smooth) scroll toward the bottom.
|
||||
// During that window the scroll events we ourselves generate must not be
|
||||
// interpreted as user intent. A user gesture (wheel-up / pointer down) clears
|
||||
// it to hand control back immediately.
|
||||
let programmatic = false
|
||||
let isSelecting = false
|
||||
let lastScrollTop = 0
|
||||
|
||||
const supportsSmooth =
|
||||
typeof document !== 'undefined' && 'scrollBehavior' in document.documentElement.style
|
||||
|
||||
const distanceFromBottom = (el: HTMLElement) =>
|
||||
el.scrollHeight - el.scrollTop - el.clientHeight
|
||||
|
||||
const atBottom = (el: HTMLElement) => distanceFromBottom(el) <= opts.offset
|
||||
|
||||
const setPinned = (pinned: boolean) => {
|
||||
isAtBottom.value = pinned
|
||||
escapedFromLock.value = !pinned
|
||||
}
|
||||
|
||||
// 是否在底部附近
|
||||
const isNearBottom = computed(() => {
|
||||
if (!scrollRef.value) return false
|
||||
const { scrollHeight, scrollTop, clientHeight } = scrollRef.value
|
||||
return scrollHeight - scrollTop - clientHeight <= opts.offset
|
||||
return atBottom(scrollRef.value)
|
||||
})
|
||||
|
||||
// 检查是否在底部
|
||||
const checkIsAtBottom = () => {
|
||||
if (!scrollRef.value) return false
|
||||
const { scrollHeight, scrollTop, clientHeight } = scrollRef.value
|
||||
return scrollHeight - scrollTop - clientHeight <= opts.offset
|
||||
return atBottom(scrollRef.value)
|
||||
}
|
||||
|
||||
// 滚动到底部
|
||||
const scrollToBottom = async (scrollOptions?: { force?: boolean; smooth?: boolean }) => {
|
||||
const { force = false, smooth = opts.smooth } = scrollOptions || {}
|
||||
|
||||
if (!scrollRef.value) return
|
||||
|
||||
// 如果用户已经向上滚动,且不强制滚动,则跳过
|
||||
if (!force && escapedFromLock.value) return
|
||||
|
||||
// 如果正在选择文本,不滚动
|
||||
if (isSelecting) return
|
||||
|
||||
const element = scrollRef.value
|
||||
if (!element) return
|
||||
|
||||
// 用户已上滚脱离贴底,且非强制:不打扰
|
||||
if (!force && escapedFromLock.value) return
|
||||
// 正在选择文本:不滚动
|
||||
if (isSelecting) return
|
||||
|
||||
const targetScrollTop = element.scrollHeight - element.clientHeight
|
||||
|
||||
// 已经在底部,无需滚动
|
||||
if (element.scrollTop >= targetScrollTop - 1) return
|
||||
|
||||
isScrolling = true
|
||||
|
||||
if (smooth && 'scrollBehavior' in document.documentElement.style) {
|
||||
// 使用原生平滑滚动
|
||||
element.scrollTo({ top: targetScrollTop, behavior: 'smooth' })
|
||||
|
||||
// 等待滚动完成
|
||||
await new Promise<void>((resolve) => {
|
||||
const startedAt = performance.now()
|
||||
const checkScrollEnd = () => {
|
||||
if (!isScrolling || Math.abs(element.scrollTop - targetScrollTop) < 1
|
||||
|| performance.now() - startedAt > opts.duration * 2) {
|
||||
isScrolling = false
|
||||
lastScrollTop = element.scrollTop
|
||||
resolve()
|
||||
} else {
|
||||
requestAnimationFrame(checkScrollEnd)
|
||||
}
|
||||
}
|
||||
setTimeout(checkScrollEnd, opts.duration)
|
||||
})
|
||||
} else {
|
||||
// 直接滚动
|
||||
element.scrollTop = targetScrollTop
|
||||
isScrolling = false
|
||||
lastScrollTop = element.scrollTop
|
||||
// 已在底部
|
||||
if (element.scrollTop >= targetScrollTop - 1) {
|
||||
setPinned(true)
|
||||
return
|
||||
}
|
||||
|
||||
isAtBottom.value = true
|
||||
if (smooth && supportsSmooth) {
|
||||
programmatic = true
|
||||
element.scrollTo({ top: targetScrollTop, behavior: 'smooth' })
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const startedAt = performance.now()
|
||||
const step = () => {
|
||||
// 用户中断(programmatic 被手势清除)—— 让位
|
||||
if (!programmatic) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
const settled = Math.abs(element.scrollTop - (element.scrollHeight - element.clientHeight)) < 1
|
||||
const timedOut = performance.now() - startedAt > opts.duration * 2
|
||||
if (settled || timedOut) {
|
||||
programmatic = false
|
||||
lastScrollTop = element.scrollTop
|
||||
setPinned(true)
|
||||
resolve()
|
||||
} else {
|
||||
requestAnimationFrame(step)
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(step)
|
||||
})
|
||||
} else {
|
||||
element.scrollTop = targetScrollTop
|
||||
lastScrollTop = element.scrollTop
|
||||
setPinned(true)
|
||||
}
|
||||
}
|
||||
|
||||
// 停止自动滚动
|
||||
const stopScroll = () => {
|
||||
escapedFromLock.value = true
|
||||
isAtBottom.value = false
|
||||
programmatic = false
|
||||
setPinned(false)
|
||||
}
|
||||
|
||||
// 处理滚动事件
|
||||
// 处理滚动事件 —— 纯绝对位置判定,无方向增量
|
||||
const handleScroll = () => {
|
||||
if (!scrollRef.value) return
|
||||
if (isScrolling) {
|
||||
// User scrolled up during a programmatic scroll — release the lock so the
|
||||
// view stops snapping back to the bottom.
|
||||
const currentScrollTop = scrollRef.value.scrollTop
|
||||
if (currentScrollTop < lastScrollTop) {
|
||||
isScrolling = false
|
||||
escapedFromLock.value = true
|
||||
isAtBottom.value = false
|
||||
}
|
||||
lastScrollTop = scrollRef.value.scrollTop
|
||||
const element = scrollRef.value
|
||||
if (!element) return
|
||||
|
||||
// 忽略我们自己的程序化滚动产生的事件
|
||||
if (programmatic) {
|
||||
lastScrollTop = element.scrollTop
|
||||
return
|
||||
}
|
||||
|
||||
// 绝对判定:距底 <= offset 即贴底,否则脱离
|
||||
if (atBottom(element)) {
|
||||
if (escapedFromLock.value) setPinned(true)
|
||||
} else if (!escapedFromLock.value) {
|
||||
setPinned(false)
|
||||
}
|
||||
lastScrollTop = element.scrollTop
|
||||
}
|
||||
|
||||
// 用户手势:立即接管(取消进行中的程序化滚动)
|
||||
const handleUserGestureUp = () => {
|
||||
programmatic = false
|
||||
const element = scrollRef.value
|
||||
const currentScrollTop = element.scrollTop
|
||||
|
||||
// 检测滚动方向
|
||||
const isScrollingUp = currentScrollTop < lastScrollTop
|
||||
const isScrollingDown = currentScrollTop > lastScrollTop
|
||||
|
||||
lastScrollTop = currentScrollTop
|
||||
|
||||
// 向上滚动,用户想要查看历史内容,中断自动滚动
|
||||
if (isScrollingUp) {
|
||||
escapedFromLock.value = true
|
||||
isAtBottom.value = false
|
||||
}
|
||||
|
||||
// 向下滚动到底部,恢复自动滚动
|
||||
if ((isScrollingDown || checkIsAtBottom()) && isNearBottom.value) {
|
||||
escapedFromLock.value = false
|
||||
isAtBottom.value = true
|
||||
}
|
||||
if (element && !atBottom(element)) setPinned(false)
|
||||
}
|
||||
|
||||
// 处理鼠标滚轮
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if (!scrollRef.value) return
|
||||
|
||||
// 如果用户向上滚动,确保我们记录这个行为
|
||||
if (e.deltaY < 0) {
|
||||
isScrolling = false
|
||||
escapedFromLock.value = true
|
||||
isAtBottom.value = false
|
||||
}
|
||||
if (e.deltaY < 0) handleUserGestureUp()
|
||||
}
|
||||
|
||||
// 处理鼠标/触摸开始(选择文本)
|
||||
// 处理鼠标/触摸开始(拖动滚动条 / 选择文本)
|
||||
const handlePointerDown = () => {
|
||||
isSelecting = true
|
||||
// 抓住滚动条即视为用户接管,取消程序化滚动
|
||||
programmatic = false
|
||||
}
|
||||
|
||||
const handlePointerUp = () => {
|
||||
isSelecting = false
|
||||
// 选择结束后检查是否在底部
|
||||
// 结束后按绝对位置复核贴底状态
|
||||
setTimeout(() => {
|
||||
if (checkIsAtBottom()) {
|
||||
escapedFromLock.value = false
|
||||
isAtBottom.value = true
|
||||
}
|
||||
const element = scrollRef.value
|
||||
if (element) setPinned(atBottom(element))
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const resetLock = () => {
|
||||
escapedFromLock.value = false
|
||||
isAtBottom.value = true
|
||||
programmatic = false
|
||||
setPinned(true)
|
||||
}
|
||||
|
||||
// ResizeObserver 监听内容变化
|
||||
@ -205,18 +216,16 @@ export function useStickToBottom(
|
||||
|
||||
const element = scrollRef.value
|
||||
|
||||
// 添加事件监听
|
||||
element.addEventListener('scroll', handleScroll, { passive: true })
|
||||
element.addEventListener('wheel', handleWheel, { passive: true })
|
||||
element.addEventListener('mousedown', handlePointerDown)
|
||||
element.addEventListener('touchstart', handlePointerDown)
|
||||
element.addEventListener('touchstart', handlePointerDown, { passive: true })
|
||||
document.addEventListener('mouseup', handlePointerUp)
|
||||
document.addEventListener('touchend', handlePointerUp)
|
||||
|
||||
// 监听内容变化
|
||||
// 内容变化时,仅在贴底状态下跟随(用即时滚动,避免每 token 动画抖动)
|
||||
if (contentRef.value && window.ResizeObserver) {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
// 内容变化时,如果在底部则保持滚动到底部
|
||||
if (opts.enabled && isAtBottom.value && !escapedFromLock.value) {
|
||||
scrollToBottom({ smooth: false })
|
||||
}
|
||||
@ -231,19 +240,16 @@ export function useStickToBottom(
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (!scrollRef.value) return
|
||||
|
||||
const element = scrollRef.value
|
||||
|
||||
// 移除事件监听
|
||||
element.removeEventListener('scroll', handleScroll)
|
||||
element.removeEventListener('wheel', handleWheel)
|
||||
element.removeEventListener('mousedown', handlePointerDown)
|
||||
element.removeEventListener('touchstart', handlePointerDown)
|
||||
if (element) {
|
||||
element.removeEventListener('scroll', handleScroll)
|
||||
element.removeEventListener('wheel', handleWheel)
|
||||
element.removeEventListener('mousedown', handlePointerDown)
|
||||
element.removeEventListener('touchstart', handlePointerDown)
|
||||
}
|
||||
document.removeEventListener('mouseup', handlePointerUp)
|
||||
document.removeEventListener('touchend', handlePointerUp)
|
||||
|
||||
// 断开 ResizeObserver
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
resizeObserver = null
|
||||
|
||||
22
mateclaw-ui/src/types/components.d.ts
vendored
Normal file
22
mateclaw-ui/src/types/components.d.ts
vendored
Normal file
@ -0,0 +1,22 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// biome-ignore lint: disable
|
||||
// oxlint-disable
|
||||
// ------
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ElConfigProvider: typeof import('element-plus/es/components/config-provider/index')['ElConfigProvider']
|
||||
ElDialog: typeof import('element-plus/es/components/dialog/index')['ElDialog']
|
||||
ElIcon: typeof import('element-plus/es/components/icon/index')['ElIcon']
|
||||
ElPopover: typeof import('element-plus/es/components/popover/index')['ElPopover']
|
||||
ElTooltip: typeof import('element-plus/es/components/tooltip/index')['ElTooltip']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user