mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +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'
|
return isLastAssistant && msg.status === 'generating'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听消息变化,自动滚动
|
// 监听消息变化,自动滚动。流式生成时 token 逐字变更会高频触发本 watcher,
|
||||||
|
// 用一个 rAF 合并同一帧内的多次变更为一次即时滚动,避免每 token 动画抖动;
|
||||||
|
// 用户上滚脱离贴底后(escapedFromLock)完全不滚动,读历史不被打断。
|
||||||
|
let scrollRaf = 0
|
||||||
watch(
|
watch(
|
||||||
() => props.messages,
|
() => props.messages,
|
||||||
async () => {
|
() => {
|
||||||
await nextTick()
|
|
||||||
// Don't auto-scroll while the user has scrolled up to read history.
|
|
||||||
if (escapedFromLock.value) return
|
if (escapedFromLock.value) return
|
||||||
scrollToBottom()
|
if (scrollRaf) return
|
||||||
|
scrollRaf = requestAnimationFrame(async () => {
|
||||||
|
scrollRaf = 0
|
||||||
|
await nextTick()
|
||||||
|
if (escapedFromLock.value) return
|
||||||
|
scrollToBottom({ smooth: false })
|
||||||
|
})
|
||||||
},
|
},
|
||||||
{ deep: true }
|
{ deep: true }
|
||||||
)
|
)
|
||||||
@ -226,7 +233,8 @@ watch(
|
|||||||
// Explicit "jump to bottom" request (button click / End key): force past the
|
// 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.
|
// escape lock and clear it so sticky auto-scroll resumes following new content.
|
||||||
function goToBottom() {
|
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 })
|
scrollToBottom({ force: true, smooth: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* 智能滚动 Composable
|
* 智能滚动 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'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
@ -16,11 +24,11 @@ export interface StickToBottomOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface StickToBottomReturn {
|
export interface StickToBottomReturn {
|
||||||
/** 是否在底部 */
|
/** 是否贴底(自动跟随新内容) */
|
||||||
isAtBottom: import('vue').Ref<boolean>
|
isAtBottom: import('vue').Ref<boolean>
|
||||||
/** 是否在底部附近 */
|
/** 是否在底部附近 */
|
||||||
isNearBottom: import('vue').ComputedRef<boolean>
|
isNearBottom: import('vue').ComputedRef<boolean>
|
||||||
/** 是否被用户滚动中断 */
|
/** 是否被用户滚动中断(= 未贴底) */
|
||||||
escapedFromLock: import('vue').Ref<boolean>
|
escapedFromLock: import('vue').Ref<boolean>
|
||||||
/** 滚动元素引用 */
|
/** 滚动元素引用 */
|
||||||
scrollRef: import('vue').Ref<HTMLElement | null>
|
scrollRef: import('vue').Ref<HTMLElement | null>
|
||||||
@ -51,150 +59,153 @@ export function useStickToBottom(
|
|||||||
const scrollRef = ref<HTMLElement | null>(null)
|
const scrollRef = ref<HTMLElement | null>(null)
|
||||||
const contentRef = 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 isAtBottom = ref(true)
|
||||||
const escapedFromLock = ref(false)
|
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 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(() => {
|
const isNearBottom = computed(() => {
|
||||||
if (!scrollRef.value) return false
|
if (!scrollRef.value) return false
|
||||||
const { scrollHeight, scrollTop, clientHeight } = scrollRef.value
|
return atBottom(scrollRef.value)
|
||||||
return scrollHeight - scrollTop - clientHeight <= opts.offset
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 检查是否在底部
|
// 检查是否在底部
|
||||||
const checkIsAtBottom = () => {
|
const checkIsAtBottom = () => {
|
||||||
if (!scrollRef.value) return false
|
if (!scrollRef.value) return false
|
||||||
const { scrollHeight, scrollTop, clientHeight } = scrollRef.value
|
return atBottom(scrollRef.value)
|
||||||
return scrollHeight - scrollTop - clientHeight <= opts.offset
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 滚动到底部
|
// 滚动到底部
|
||||||
const scrollToBottom = async (scrollOptions?: { force?: boolean; smooth?: boolean }) => {
|
const scrollToBottom = async (scrollOptions?: { force?: boolean; smooth?: boolean }) => {
|
||||||
const { force = false, smooth = opts.smooth } = scrollOptions || {}
|
const { force = false, smooth = opts.smooth } = scrollOptions || {}
|
||||||
|
|
||||||
if (!scrollRef.value) return
|
const element = scrollRef.value
|
||||||
|
if (!element) return
|
||||||
|
|
||||||
// 如果用户已经向上滚动,且不强制滚动,则跳过
|
// 用户已上滚脱离贴底,且非强制:不打扰
|
||||||
if (!force && escapedFromLock.value) return
|
if (!force && escapedFromLock.value) return
|
||||||
|
// 正在选择文本:不滚动
|
||||||
// 如果正在选择文本,不滚动
|
|
||||||
if (isSelecting) return
|
if (isSelecting) return
|
||||||
|
|
||||||
const element = scrollRef.value
|
|
||||||
const targetScrollTop = element.scrollHeight - element.clientHeight
|
const targetScrollTop = element.scrollHeight - element.clientHeight
|
||||||
|
|
||||||
// 已经在底部,无需滚动
|
// 已在底部
|
||||||
if (element.scrollTop >= targetScrollTop - 1) return
|
if (element.scrollTop >= targetScrollTop - 1) {
|
||||||
|
setPinned(true)
|
||||||
isScrolling = true
|
return
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = () => {
|
const stopScroll = () => {
|
||||||
escapedFromLock.value = true
|
programmatic = false
|
||||||
isAtBottom.value = false
|
setPinned(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理滚动事件
|
// 处理滚动事件 —— 纯绝对位置判定,无方向增量
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
if (!scrollRef.value) return
|
const element = scrollRef.value
|
||||||
if (isScrolling) {
|
if (!element) return
|
||||||
// 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 (programmatic) {
|
||||||
if (currentScrollTop < lastScrollTop) {
|
lastScrollTop = element.scrollTop
|
||||||
isScrolling = false
|
|
||||||
escapedFromLock.value = true
|
|
||||||
isAtBottom.value = false
|
|
||||||
}
|
|
||||||
lastScrollTop = scrollRef.value.scrollTop
|
|
||||||
return
|
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 element = scrollRef.value
|
||||||
const currentScrollTop = element.scrollTop
|
if (element && !atBottom(element)) setPinned(false)
|
||||||
|
|
||||||
// 检测滚动方向
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理鼠标滚轮
|
|
||||||
const handleWheel = (e: WheelEvent) => {
|
const handleWheel = (e: WheelEvent) => {
|
||||||
if (!scrollRef.value) return
|
if (e.deltaY < 0) handleUserGestureUp()
|
||||||
|
|
||||||
// 如果用户向上滚动,确保我们记录这个行为
|
|
||||||
if (e.deltaY < 0) {
|
|
||||||
isScrolling = false
|
|
||||||
escapedFromLock.value = true
|
|
||||||
isAtBottom.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理鼠标/触摸开始(选择文本)
|
// 处理鼠标/触摸开始(拖动滚动条 / 选择文本)
|
||||||
const handlePointerDown = () => {
|
const handlePointerDown = () => {
|
||||||
isSelecting = true
|
isSelecting = true
|
||||||
|
// 抓住滚动条即视为用户接管,取消程序化滚动
|
||||||
|
programmatic = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePointerUp = () => {
|
const handlePointerUp = () => {
|
||||||
isSelecting = false
|
isSelecting = false
|
||||||
// 选择结束后检查是否在底部
|
// 结束后按绝对位置复核贴底状态
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (checkIsAtBottom()) {
|
const element = scrollRef.value
|
||||||
escapedFromLock.value = false
|
if (element) setPinned(atBottom(element))
|
||||||
isAtBottom.value = true
|
|
||||||
}
|
|
||||||
}, 100)
|
}, 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetLock = () => {
|
const resetLock = () => {
|
||||||
escapedFromLock.value = false
|
programmatic = false
|
||||||
isAtBottom.value = true
|
setPinned(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResizeObserver 监听内容变化
|
// ResizeObserver 监听内容变化
|
||||||
@ -205,18 +216,16 @@ export function useStickToBottom(
|
|||||||
|
|
||||||
const element = scrollRef.value
|
const element = scrollRef.value
|
||||||
|
|
||||||
// 添加事件监听
|
|
||||||
element.addEventListener('scroll', handleScroll, { passive: true })
|
element.addEventListener('scroll', handleScroll, { passive: true })
|
||||||
element.addEventListener('wheel', handleWheel, { passive: true })
|
element.addEventListener('wheel', handleWheel, { passive: true })
|
||||||
element.addEventListener('mousedown', handlePointerDown)
|
element.addEventListener('mousedown', handlePointerDown)
|
||||||
element.addEventListener('touchstart', handlePointerDown)
|
element.addEventListener('touchstart', handlePointerDown, { passive: true })
|
||||||
document.addEventListener('mouseup', handlePointerUp)
|
document.addEventListener('mouseup', handlePointerUp)
|
||||||
document.addEventListener('touchend', handlePointerUp)
|
document.addEventListener('touchend', handlePointerUp)
|
||||||
|
|
||||||
// 监听内容变化
|
// 内容变化时,仅在贴底状态下跟随(用即时滚动,避免每 token 动画抖动)
|
||||||
if (contentRef.value && window.ResizeObserver) {
|
if (contentRef.value && window.ResizeObserver) {
|
||||||
resizeObserver = new ResizeObserver(() => {
|
resizeObserver = new ResizeObserver(() => {
|
||||||
// 内容变化时,如果在底部则保持滚动到底部
|
|
||||||
if (opts.enabled && isAtBottom.value && !escapedFromLock.value) {
|
if (opts.enabled && isAtBottom.value && !escapedFromLock.value) {
|
||||||
scrollToBottom({ smooth: false })
|
scrollToBottom({ smooth: false })
|
||||||
}
|
}
|
||||||
@ -231,19 +240,16 @@ export function useStickToBottom(
|
|||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (!scrollRef.value) return
|
|
||||||
|
|
||||||
const element = scrollRef.value
|
const element = scrollRef.value
|
||||||
|
if (element) {
|
||||||
// 移除事件监听
|
element.removeEventListener('scroll', handleScroll)
|
||||||
element.removeEventListener('scroll', handleScroll)
|
element.removeEventListener('wheel', handleWheel)
|
||||||
element.removeEventListener('wheel', handleWheel)
|
element.removeEventListener('mousedown', handlePointerDown)
|
||||||
element.removeEventListener('mousedown', handlePointerDown)
|
element.removeEventListener('touchstart', handlePointerDown)
|
||||||
element.removeEventListener('touchstart', handlePointerDown)
|
}
|
||||||
document.removeEventListener('mouseup', handlePointerUp)
|
document.removeEventListener('mouseup', handlePointerUp)
|
||||||
document.removeEventListener('touchend', handlePointerUp)
|
document.removeEventListener('touchend', handlePointerUp)
|
||||||
|
|
||||||
// 断开 ResizeObserver
|
|
||||||
if (resizeObserver) {
|
if (resizeObserver) {
|
||||||
resizeObserver.disconnect()
|
resizeObserver.disconnect()
|
||||||
resizeObserver = null
|
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