Files
map-client-vue/MESSAGE_UPDATE_FIX.md
2025-10-14 21:52:11 +08:00

14 KiB

消息实时更新和自动滚动修复报告

问题描述

问题1: 发送消息后界面不更新

用户发送消息后,对话框中看不到新消息,需要刷新或切换对话才能看到。

问题2: scrollbarEl.querySelector 错误

控制台报错:

Unhandled Promise Rejection: TypeError: scrollbarEl.querySelector is not a function

问题3: 消息窗口不自动滚动

发送消息后,消息窗口没有自动滚动到底部,用户需要手动滚动才能看到新消息。

根本原因分析

原因1: Vue 响应式系统未触发更新

位置: /web/src/stores/chatStore.ts

问题:

// 原代码
const loadMessages = (topicId: string) => {
  state.messages = chatService.getMessages(topicId)
}

chatService.getMessages() 返回的是 conversation.messages 的直接引用:

// chatService.ts
getMessages(topicId: string): Message[] {
  for (const conv of this.conversations.values()) {
    if (conv.topicId === topicId) {
      return conv.messages  // ❌ 直接返回引用
    }
  }
  return []
}

问题分析:

  • Vue 3 的响应式系统基于 Proxy
  • state.messages 被赋值为同一个数组引用时,Vue 无法检测到变化
  • 即使数组内部元素被修改(push, splice),由于引用未变,组件不会重新渲染

例子:

// 第一次加载
state.messages = conversation.messages  // messages 引用: 0x1234

// 发送消息后
conversation.messages.push(newMessage)  // 数组内容变了
state.messages = conversation.messages  // 但引用还是: 0x1234 ❌ Vue 不更新!

原因2: NScrollbar 使用方式错误

位置: /web/src/components/Chat/ChatLayout.vue 第615行

错误代码:

const scrollToBottom = () => {
  nextTick(() => {
    if (messagesScrollRef.value) {
      messagesScrollRef.value.scrollTo({ 
        top: messagesScrollRef.value.$el.scrollHeight,  // ❌ 错误
        behavior: 'smooth' 
      })
    }
  })
}

问题:

  1. messagesScrollRef.value.$el.scrollHeight 获取的是组件根元素的高度,不是滚动容器的高度
  2. Naive UI 的 NScrollbar 组件需要特殊的访问方式
  3. 代码尝试调用 querySelector 但该方法在组件实例上不存在

原因3: 滚动时机不对

问题:

  • 发送消息后没有立即滚动
  • 只在 onChunk 回调中滚动,但用户消息已经添加了
  • 用户看到自己的消息在上方,需要手动滚动

解决方案

修复1: 强制创建新数组触发响应式更新

文件: /web/src/stores/chatStore.ts

1.1 修改 loadMessages 方法

// 修复前
const loadMessages = (topicId: string) => {
  state.messages = chatService.getMessages(topicId)
}

// 修复后
const loadMessages = (topicId: string) => {
  // 创建新数组以确保触发响应式更新
  state.messages = [...chatService.getMessages(topicId)]
}

效果:

  • 使用扩展运算符 [...] 创建新数组
  • 每次调用 loadMessages 都会改变数组引用
  • Vue 检测到引用变化,触发组件重新渲染

1.2 修改 sendMessageStream 方法

// 修复前
const sendMessageStream = async (
  content: string,
  model?: string,
  mcpServerId?: string,
  onChunk?: (chunk: string) => void
) => {
  if (!state.currentTopicId || !content.trim()) return

  state.isSending = true
  const currentTopicId = state.currentTopicId
  
  try {
    await chatService.sendMessageStream(
      {...},
      (event) => {
        // 实时更新消息列表
        if (state.currentTopicId === currentTopicId) {
          loadMessages(currentTopicId)  // ❌ 可能不触发更新
        }
        ...
      },
      mcpServerId
    )
    ...
  } finally {
    state.isSending = false
  }
}

// 修复后
const sendMessageStream = async (
  content: string,
  model?: string,
  mcpServerId?: string,
  onChunk?: (chunk: string) => void
) => {
  if (!state.currentTopicId || !content.trim()) return

  state.isSending = true
  const currentTopicId = state.currentTopicId
  
  // ✅ 立即加载一次消息,显示用户消息
  loadMessages(currentTopicId)
  
  try {
    await chatService.sendMessageStream(
      {...},
      (event) => {
        // 实时更新消息列表
        if (state.currentTopicId === currentTopicId) {
          // ✅ 强制创建新数组以触发响应式更新
          state.messages = [...chatService.getMessages(currentTopicId)]
        }
        
        if (event.type === 'delta' && event.content && onChunk) {
          onChunk(event.content)
        }
      },
      mcpServerId
    )
    
    // ✅ 最终更新
    if (state.currentTopicId === currentTopicId) {
      state.messages = [...chatService.getMessages(currentTopicId)]
    }
    loadTopics()
  } finally {
    state.isSending = false
  }
}

改进点:

  1. 在发送前立即加载一次消息(显示用户刚输入的消息)
  2. 在每个事件回调中强制创建新数组
  3. 消息完成后最终更新一次

修复2: 正确使用 NScrollbar 组件

文件: /web/src/components/Chat/ChatLayout.vue

// 修复前
const scrollToBottom = () => {
  nextTick(() => {
    if (messagesScrollRef.value) {
      messagesScrollRef.value.scrollTo({ 
        top: messagesScrollRef.value.$el.scrollHeight,  // ❌ 错误
        behavior: 'smooth' 
      })
    }
  })
}

// 修复后
const scrollToBottom = () => {
  nextTick(() => {
    if (messagesScrollRef.value) {
      const scrollbarEl = messagesScrollRef.value
      // Naive UI NScrollbar 的正确用法
      if (scrollbarEl.scrollTo) {
        // 方法1: 使用组件的 scrollTo 方法
        scrollbarEl.scrollTo({ top: 999999, behavior: 'smooth' })
      } else if (scrollbarEl.$el) {
        // 方法2: 降级方案 - 直接操作 DOM
        const container = scrollbarEl.$el.querySelector('.n-scrollbar-container')
        if (container) {
          container.scrollTop = container.scrollHeight
        }
      }
    }
  })
}

技术细节:

  1. 方法1: 使用 scrollTo({ top: 999999 }) - 足够大的数字确保滚动到底部
  2. 方法2: 降级方案 - 如果组件方法不可用,直接操作 DOM
  3. querySelector: 查找 .n-scrollbar-container 类(Naive UI 内部结构)
  4. scrollTop: 设置为 scrollHeight 确保到达底部

修复3: 优化滚动时机

文件: /web/src/components/Chat/ChatLayout.vue

// 修复前
const handleSendMessage = async () => {
  if (!inputText.value.trim() || store.state.isSending) return

  const content = inputText.value.trim()
  inputText.value = ''

  try {
    const mcpId = selectedMCP.value === 'none' ? undefined : selectedMCP.value
    await store.sendMessageStream(
      content,
      selectedModel.value,
      mcpId,
      () => {
        scrollToBottom()  // ❌ 只在收到AI回复时滚动
      }
    )
    scrollToBottom()
  } catch (error) {
    message.error(error instanceof Error ? error.message : '发送失败')
  }
}

// 修复后
const handleSendMessage = async () => {
  if (!inputText.value.trim() || store.state.isSending) return

  const content = inputText.value.trim()
  inputText.value = ''

  // ✅ 发送消息后立即滚动到底部(显示用户消息)
  nextTick(() => {
    scrollToBottom()
  })

  try {
    const mcpId = selectedMCP.value === 'none' ? undefined : selectedMCP.value
    await store.sendMessageStream(
      content,
      selectedModel.value,
      mcpId,
      () => {
        // ✅ 每次收到消息块时滚动
        scrollToBottom()
      }
    )
    // ✅ 消息完成后再滚动一次
    scrollToBottom()
  } catch (error) {
    message.error(error instanceof Error ? error.message : '发送失败')
  }
}

滚动时机:

  1. 发送前: nextTick(() => scrollToBottom()) - 显示用户消息
  2. 流式接收: 每次 onChunk 回调时滚动 - 跟随AI回复
  3. 完成后: 最终滚动一次 - 确保到达底部

技术原理

Vue 3 响应式系统

// Vue 3 使用 Proxy 实现响应式
const state = reactive({
  messages: []
})

// 情况1: 引用相同 - 不触发更新 ❌
const oldRef = state.messages
state.messages = oldRef  // 引用没变,不更新

// 情况2: 引用不同 - 触发更新 ✅
state.messages = [...oldRef]  // 新数组,触发更新

数组扩展运算符

const original = [1, 2, 3]
const copied = [...original]

console.log(original === copied)  // false (不同引用)
console.log(original[0] === copied[0])  // true (元素相同)

特点:

  • 浅拷贝数组
  • 创建新引用
  • 元素本身仍是原引用(对象类型)
  • 性能好(只复制引用,不深拷贝对象)

Naive UI NScrollbar 组件结构

<n-scrollbar ref="scrollbarRef">
  <!-- 内部 DOM 结构 -->
  <div class="n-scrollbar-container">
    <div class="n-scrollbar-content">
      <!-- 实际内容 -->
    </div>
  </div>
</n-scrollbar>

API:

  • scrollTo(options): 滚动到指定位置
    • options.top: 目标滚动位置
    • options.behavior: 'smooth' | 'auto'
  • $el: 组件根元素 DOM

测试场景

场景1: 发送新消息

  1. 在输入框输入消息
  2. 点击发送
  3. 预期:
    • 立即看到用户消息
    • 自动滚动到底部
    • 看到AI回复逐字出现
    • 滚动跟随AI回复

场景2: 快速连续发送

  1. 连续发送多条消息
  2. 预期:
    • 每条消息都立即显示
    • 每次都自动滚动
    • 不会出现消息堆积

场景3: 切换对话

  1. 在对话A发送消息
  2. 立即切换到对话B
  3. 预期:
    • 对话A的消息继续更新(后台)
    • 对话B显示正确的消息列表
    • 切回对话A看到完整对话

场景4: 长消息回复

  1. 发送一个会产生长回复的消息
  2. 预期:
    • 消息持续滚动跟随
    • 始终显示最新内容
    • 滚动流畅不卡顿

性能考虑

数组复制开销

// 每次复制整个消息数组
state.messages = [...chatService.getMessages(currentTopicId)]

分析:

  • 扩展运算符只复制数组引用,不复制消息对象
  • 复杂度: O(n),n 为消息数量
  • 典型对话: 20-100条消息
  • 开销: 可忽略(< 1ms)

优化建议(如果消息数>1000):

// 使用虚拟滚动
import { VirtualList } from 'naive-ui'

// 或只更新变化的消息
const lastMessageCount = state.messages.length
const newMessages = chatService.getMessages(topicId)
if (newMessages.length !== lastMessageCount) {
  state.messages = [...newMessages]
}

滚动频率

// 每次 onChunk 都滚动
onChunk: () => {
  scrollToBottom()  // 可能每100ms触发一次
}

优化(如果卡顿):

// 节流滚动
let scrollTimer: NodeJS.Timeout | null = null
const throttledScroll = () => {
  if (scrollTimer) return
  scrollTimer = setTimeout(() => {
    scrollToBottom()
    scrollTimer = null
  }, 100)  // 最多100ms滚动一次
}

相关文件

修改的文件

  1. /web/src/stores/chatStore.ts

    • loadMessages() - 强制创建新数组
    • sendMessageStream() - 添加立即更新和强制刷新
  2. /web/src/components/Chat/ChatLayout.vue

    • scrollToBottom() - 修复 NScrollbar 使用方式
    • handleSendMessage() - 优化滚动时机

依赖关系

ChatLayout.vue
  ↓ 调用
chatStore.sendMessageStream()
  ↓ 调用
chatService.sendMessageStream()
  ↓ 回调
chatStore (event handler)
  ↓ 更新
state.messages = [...新数组]
  ↓ 触发
Vue 响应式系统
  ↓ 重新渲染
ChatLayout.vue (消息列表)
  ↓ DOM更新后
scrollToBottom()

后续优化建议

1. 虚拟滚动(长对话)

// 使用 Naive UI 的虚拟列表
import { VirtualList } from 'naive-ui'

// 只渲染可见区域的消息
<n-virtual-list
  :items="store.state.messages"
  :item-size="80"
  :item-resizable="true"
>
  <template #default="{ item: msg }">
    <MessageItem :message="msg" />
  </template>
</n-virtual-list>

2. 消息缓存(减少复制)

// 缓存消息数组,只在真正变化时更新
let messageCache: Message[] = []
let messageCacheVersion = 0

const updateMessages = (topicId: string) => {
  const newMessages = chatService.getMessages(topicId)
  const newVersion = chatService.getMessagesVersion(topicId)
  
  if (newVersion !== messageCacheVersion) {
    messageCache = [...newMessages]
    messageCacheVersion = newVersion
    state.messages = messageCache
  }
}

3. 增量更新(仅更新变化的消息)

// 只更新新增或变化的消息
const updateMessagesIncremental = (topicId: string) => {
  const newMessages = chatService.getMessages(topicId)
  
  if (newMessages.length > state.messages.length) {
    // 只添加新消息
    state.messages = [...state.messages, ...newMessages.slice(state.messages.length)]
  } else {
    // 更新最后一条消息(AI回复)
    const lastIndex = state.messages.length - 1
    if (lastIndex >= 0) {
      state.messages[lastIndex] = { ...newMessages[lastIndex] }
    }
  }
}

4. 智能滚动(用户可控)

// 检测用户是否在查看历史消息
const isUserScrolling = ref(false)
const shouldAutoScroll = computed(() => {
  return !isUserScrolling.value
})

// 滚动时检测
const handleScroll = (e: Event) => {
  const container = e.target as HTMLElement
  const threshold = 100  // 距离底部100px以内视为"在底部"
  const isAtBottom = 
    container.scrollHeight - container.scrollTop - container.clientHeight < threshold
  isUserScrolling.value = !isAtBottom
}

// 只在用户在底部时自动滚动
const smartScrollToBottom = () => {
  if (shouldAutoScroll.value) {
    scrollToBottom()
  }
}

总结

本次修复解决了三个关键问题:

  1. 响应式更新

    • 使用扩展运算符创建新数组
    • 确保Vue检测到变化并重新渲染
  2. 滚动功能

    • 正确使用 Naive UI NScrollbar API
    • 提供降级方案确保兼容性
  3. 用户体验

    • 消息立即显示
    • 自动滚动到底部
    • 流畅跟随AI回复

修复后,聊天体验接近主流AI聊天应用(ChatGPT, Claude等)。