Leo Qin

← 文章

· 约 4 分钟读完

AI 聊天界面自动滚动:如何判断用户意图

工具 · 开发 · 反思 · AI

做 AI 聊天产品有一个细节最容易翻车,那就是"自动滚到底部"。AI 在流式输出,新字一个一个往外蹦,理想状态下窗口跟着滚到最新的位置。但如果用户中途想往上看一眼之前的内容,它又得识相地停下来。这个判断"用户现在到底想不想跟"的小逻辑,看着两行代码就能写完,实际上踩坑能踩半年。这篇聊聊我做这一类功能时的几次反复。

最简单的版本

第一版我和大多数人一样,用阈值。距离底部多少像素之内算"用户想跟",超出就停。

function shouldAutoScroll(container: HTMLElement, threshold = 100) {
  const { scrollTop, scrollHeight, clientHeight } = container
  const distance = scrollHeight - scrollTop - clientHeight
  return distance < threshold
}

跑起来很丝滑。但只要用户想往上回看一段比较长的回答,就会发现一个尴尬的现象。AI 还在生成,每来一段新内容,scrollHeight 变大,distance 跟着变大,于是 shouldAutoScroll 翻成 false,看起来像"它停了"。但只要 distance 再次掉到阈值以内(比如用户继续往下滚),它又会自动跳到底部,把用户当前看的内容一下顶飞。

第二版,加状态机

意识到这是状态问题之后,我把它改成显式的"跟随模式"。

type ScrollMode = 'following' | 'free'

let mode: ScrollMode = 'following'

function onUserScroll(container: HTMLElement) {
  const distance = container.scrollHeight - container.scrollTop - container.clientHeight
  if (distance > 100) {
    mode = 'free'  // 用户主动离开底部
  } else if (distance < 10) {
    mode = 'following'  // 用户回到底部
  }
}

function onContentUpdate(container: HTMLElement) {
  if (mode === 'following') {
    container.scrollTop = container.scrollHeight
  }
}

关键是把"用户主动滚动"和"内容增长导致的位置变化"分开来。前者影响模式,后者只在 following 模式下才触发自动滚。

但还有个细节。怎么区分"用户主动滚"和"我自己滚的"?因为每次 scrollTop = scrollHeight 也会触发一次 scroll 事件。我通常用一个短暂的 flag 来处理:

let isProgrammaticScroll = false

function onContentUpdate(container: HTMLElement) {
  if (mode === 'following') {
    isProgrammaticScroll = true
    container.scrollTop = container.scrollHeight
    requestAnimationFrame(() => {
      isProgrammaticScroll = false
    })
  }
}

container.addEventListener('scroll', () => {
  if (isProgrammaticScroll) return
  // 真正的用户滚动逻辑
})

长对话的性能问题

问题不止在状态。当一段对话长到几千行,DOM 节点全部留着,光是滚动本身就开始卡。这时候得上虚拟滚动,只渲染视窗附近的内容。

class VirtualScroller {
  itemHeight: number
  visibleCount: number
  totalCount: number
  buffer: number

  getVisibleRange(scrollTop: number): [number, number] {
    const start = Math.floor(scrollTop / this.itemHeight)
    const end = Math.min(start + this.visibleCount + this.buffer, this.totalCount)
    return [Math.max(0, start - this.buffer), end]
  }
}

但虚拟滚动给"自动跟到底部"这件事增加了难度。scrollHeight 是按"假装所有 item 都渲染了"算的虚拟高度,所以 scrollTop = scrollHeight 不再总是等于"滚到最后一条"。通常需要专门写一个 scrollToIndex(lastIndex)。

那些更隐蔽的边界情况

写这种自动滚的时候,能掉进的坑远不止上面几个。

  • 窗口缩放。 浏览器窗口大小变化时,clientHeight 会变化,distance 计算也需要重新跑一遍
  • 字号变化。 如果用户缩放页面,每个 item 的高度都会变化,假定固定 itemHeight 的虚拟滚动可能失效
  • 图片加载。 如果 AI 输出里嵌了图,图加载完之前它的高度可能是 0,加载完后会大很多,导致滚动位置跳动
  • 键盘事件。 用户按 Page Up 翻页,应该被识别为"想自由浏览",进入 free 模式
  • 触摸滑动。 在手机端,wheel 事件不够用,还得监听触摸移动(touchmove)

每一个都不难,但加起来足够把一个小功能变成一个礼拜的工作。

写在最后

最后我学到一件事,跟用户意图打交道的功能,不要去预测用户想要什么,而是老老实实记录用户做了什么。所谓"智能跟随",本质上是把用户的滚动行为分类:主动离开、主动回归,以及内容变化导致的被动跳变。机器学习不是这件事的好答案,清晰的状态机才是。

参考资料

评论

更多文章

萤火之森 · CMJ