567 lines
14 KiB
Markdown
567 lines
14 KiB
Markdown
# 消息实时更新和自动滚动修复报告
|
|
|
|
## 问题描述
|
|
|
|
### 问题1: 发送消息后界面不更新
|
|
用户发送消息后,对话框中看不到新消息,需要刷新或切换对话才能看到。
|
|
|
|
### 问题2: scrollbarEl.querySelector 错误
|
|
控制台报错:
|
|
```
|
|
Unhandled Promise Rejection: TypeError: scrollbarEl.querySelector is not a function
|
|
```
|
|
|
|
### 问题3: 消息窗口不自动滚动
|
|
发送消息后,消息窗口没有自动滚动到底部,用户需要手动滚动才能看到新消息。
|
|
|
|
## 根本原因分析
|
|
|
|
### 原因1: Vue 响应式系统未触发更新
|
|
|
|
**位置**: `/web/src/stores/chatStore.ts`
|
|
|
|
**问题**:
|
|
```typescript
|
|
// 原代码
|
|
const loadMessages = (topicId: string) => {
|
|
state.messages = chatService.getMessages(topicId)
|
|
}
|
|
```
|
|
|
|
`chatService.getMessages()` 返回的是 `conversation.messages` 的直接引用:
|
|
```typescript
|
|
// 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),由于引用未变,组件不会重新渲染
|
|
|
|
**例子**:
|
|
```typescript
|
|
// 第一次加载
|
|
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行
|
|
|
|
**错误代码**:
|
|
```typescript
|
|
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` 方法
|
|
```typescript
|
|
// 修复前
|
|
const loadMessages = (topicId: string) => {
|
|
state.messages = chatService.getMessages(topicId)
|
|
}
|
|
|
|
// 修复后
|
|
const loadMessages = (topicId: string) => {
|
|
// 创建新数组以确保触发响应式更新
|
|
state.messages = [...chatService.getMessages(topicId)]
|
|
}
|
|
```
|
|
|
|
**效果**:
|
|
- ✅ 使用扩展运算符 `[...]` 创建新数组
|
|
- ✅ 每次调用 `loadMessages` 都会改变数组引用
|
|
- ✅ Vue 检测到引用变化,触发组件重新渲染
|
|
|
|
#### 1.2 修改 `sendMessageStream` 方法
|
|
```typescript
|
|
// 修复前
|
|
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`
|
|
|
|
```typescript
|
|
// 修复前
|
|
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`
|
|
|
|
```typescript
|
|
// 修复前
|
|
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 响应式系统
|
|
|
|
```typescript
|
|
// Vue 3 使用 Proxy 实现响应式
|
|
const state = reactive({
|
|
messages: []
|
|
})
|
|
|
|
// 情况1: 引用相同 - 不触发更新 ❌
|
|
const oldRef = state.messages
|
|
state.messages = oldRef // 引用没变,不更新
|
|
|
|
// 情况2: 引用不同 - 触发更新 ✅
|
|
state.messages = [...oldRef] // 新数组,触发更新
|
|
```
|
|
|
|
### 数组扩展运算符
|
|
|
|
```typescript
|
|
const original = [1, 2, 3]
|
|
const copied = [...original]
|
|
|
|
console.log(original === copied) // false (不同引用)
|
|
console.log(original[0] === copied[0]) // true (元素相同)
|
|
```
|
|
|
|
**特点**:
|
|
- 浅拷贝数组
|
|
- 创建新引用
|
|
- 元素本身仍是原引用(对象类型)
|
|
- 性能好(只复制引用,不深拷贝对象)
|
|
|
|
### Naive UI NScrollbar 组件结构
|
|
|
|
```html
|
|
<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. **预期**:
|
|
- ✅ 消息持续滚动跟随
|
|
- ✅ 始终显示最新内容
|
|
- ✅ 滚动流畅不卡顿
|
|
|
|
## 性能考虑
|
|
|
|
### 数组复制开销
|
|
|
|
```typescript
|
|
// 每次复制整个消息数组
|
|
state.messages = [...chatService.getMessages(currentTopicId)]
|
|
```
|
|
|
|
**分析**:
|
|
- 扩展运算符只复制数组引用,不复制消息对象
|
|
- 复杂度: O(n),n 为消息数量
|
|
- 典型对话: 20-100条消息
|
|
- 开销: 可忽略(< 1ms)
|
|
|
|
**优化建议**(如果消息数>1000):
|
|
```typescript
|
|
// 使用虚拟滚动
|
|
import { VirtualList } from 'naive-ui'
|
|
|
|
// 或只更新变化的消息
|
|
const lastMessageCount = state.messages.length
|
|
const newMessages = chatService.getMessages(topicId)
|
|
if (newMessages.length !== lastMessageCount) {
|
|
state.messages = [...newMessages]
|
|
}
|
|
```
|
|
|
|
### 滚动频率
|
|
|
|
```typescript
|
|
// 每次 onChunk 都滚动
|
|
onChunk: () => {
|
|
scrollToBottom() // 可能每100ms触发一次
|
|
}
|
|
```
|
|
|
|
**优化**(如果卡顿):
|
|
```typescript
|
|
// 节流滚动
|
|
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. 虚拟滚动(长对话)
|
|
```typescript
|
|
// 使用 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. 消息缓存(减少复制)
|
|
```typescript
|
|
// 缓存消息数组,只在真正变化时更新
|
|
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. 增量更新(仅更新变化的消息)
|
|
```typescript
|
|
// 只更新新增或变化的消息
|
|
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. 智能滚动(用户可控)
|
|
```typescript
|
|
// 检测用户是否在查看历史消息
|
|
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等)。
|