fix(chat): 修复滚动回弹和切会话消息异常两处核心bug,附加三项优化 (#425)

Bug 1 — 滚动条/触控板上滚后自动弹回底部
- useStickToBottom.ts: handleScroll 在 isScrolling 期间检测用户上滚方向,
  上滚时立即取消程序化滚动并设 escapedFromLock

Bug 2 — 切回生成中的会话显示"失败"且出现重复空气泡
- ChatConsole.vue: normalizeMessage 加 preserveGeneratingStatus 参数
- ChatConsole.vue: selectConversation 根据 conv.streamStatus 决定是否保留 generating
- ChatConsole.vue: 本地 reconnectStream 移除 isGenerating guard
- useChat.ts: reconnectStream guard 收窄为同会话+正在生成才跳过
- useChat.ts: reconnectStream 复用现有 generating/awaiting_approval 消息

优化1 — hydrateStateFromRoute 路径传 preserveGeneratingStatus=true
优化2 — useStickToBottom 新增 resetLock,MessageList defineExpose,
       selectConversation 切走时调用,避免上滚锁跨会话泄漏
优化3 — reconnect 复用 existingAsst 时清空 contentParts/segments,
       补充 _turnId 确保 flushSegmentsToMessage 正常写入
This commit is contained in:
MIST 2026-06-26 16:55:17 +08:00 committed by GitHub
parent 40cb39fb3b
commit 65f6a8c6b2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 49 additions and 14 deletions

View File

@ -186,7 +186,7 @@ const isCronHeader = (msg: Message) => {
}
//
const { scrollRef, contentRef, isAtBottom, escapedFromLock, scrollToBottom } = useStickToBottom({
const { scrollRef, contentRef, isAtBottom, escapedFromLock, scrollToBottom, resetLock } = useStickToBottom({
enabled: props.autoScroll,
offset: 70,
smooth: true,
@ -275,6 +275,8 @@ function handleGlobalKeydown(e: KeyboardEvent) {
}
onMounted(() => document.addEventListener('keydown', handleGlobalKeydown))
defineExpose({ resetScrollLock: resetLock })
onUnmounted(() => {
clearDockTimer()
document.removeEventListener('keydown', handleGlobalKeydown)

View File

@ -2028,7 +2028,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
// Reconnect to a stream that is already running on the backend
const reconnectStream = async (conversationId: string) => {
if (isGenerating.value) return
if (isGenerating.value && streamConversationId === conversationId) return
// Clear any leftover stop fallback timer
if (stopFallbackTimer) { clearTimeout(stopFallbackTimer); stopFallbackTimer = null }
@ -2054,9 +2054,28 @@ export function useChat(options: UseChatOptions): UseChatReturn {
}
}
const assistantMessage = createAssistantMessage('', conversationId)
;(assistantMessage as any)._turnId = activeTurnId
currentAssistantId.value = assistantMessage.id as string
const existingAsst = [...messages.value].reverse().find(
m => m.role === 'assistant'
&& m.conversationId === conversationId
&& (m.status === 'generating' || m.status === 'awaiting_approval')
)
if (existingAsst) {
updateMessage(existingAsst.id, {
...existingAsst,
content: '',
contentParts: [],
_turnId: activeTurnId,
metadata: {
...((existingAsst as any).metadata || {}),
segments: [],
},
} as any)
currentAssistantId.value = existingAsst.id as string
} else {
const assistantMessage = createAssistantMessage('', conversationId)
;(assistantMessage as any)._turnId = activeTurnId
currentAssistantId.value = assistantMessage.id as string
}
try {
// reconnectStream always rebuilds from an EMPTY placeholder (above), so it

View File

@ -32,6 +32,7 @@ export interface StickToBottomReturn {
stopScroll: () => void
/** 检查是否在底部 */
checkIsAtBottom: () => boolean
resetLock: () => void
}
// 默认配置
@ -126,12 +127,18 @@ export function useStickToBottom(
}
// 处理滚动事件
const handleScroll = () => {
if (!scrollRef.value) return
if (isScrolling) {
const handleScroll = () => {
if (!scrollRef.value) return
if (isScrolling) {
const currentScrollTop = scrollRef.value.scrollTop
if (currentScrollTop < lastScrollTop) {
isScrolling = false
escapedFromLock.value = true
isAtBottom.value = false
}
lastScrollTop = scrollRef.value.scrollTop
return
}
}
const element = scrollRef.value
const currentScrollTop = element.scrollTop
@ -183,6 +190,11 @@ export function useStickToBottom(
}, 100)
}
const resetLock = () => {
escapedFromLock.value = false
isAtBottom.value = true
}
// ResizeObserver 监听内容变化
let resizeObserver: ResizeObserver | null = null
@ -245,6 +257,7 @@ export function useStickToBottom(
scrollToBottom,
stopScroll,
checkIsAtBottom,
resetLock,
}
}

View File

@ -1483,7 +1483,7 @@ async function hydrateStateFromRoute() {
try {
const res: any = await conversationApi.listMessages(conversationId)
if (currentConversationId.value !== conversationId) return
messages.value = extractMessages(res).messages.map((msg: Message) => normalizeMessage(msg))
messages.value = extractMessages(res).messages.map((msg: Message) => normalizeMessage(msg, true))
} catch {
//
}
@ -1522,6 +1522,7 @@ async function selectConversation(conv: Conversation) {
const switchingAway = currentConversationId.value !== conv.conversationId
if (switchingAway) {
resetForNewConversation()
messageListRef.value?.resetScrollLock()
}
currentConversationId.value = conv.conversationId
selectedAgentId.value = conv.agentId || selectedAgentId.value
@ -1544,7 +1545,8 @@ async function selectConversation(conv: Conversation) {
if (currentConversationId.value !== requestedConvId) return
// SSE
if (switchingAway || !isGenerating.value) {
messages.value = extractMessages(res).messages.map((msg: Message) => normalizeMessage(msg))
const convRunning = conv.streamStatus === 'running'
messages.value = extractMessages(res).messages.map((msg: Message) => normalizeMessage(msg, convRunning))
}
// Hydrate pending approvalsRFC-067 §4.9
@ -1943,7 +1945,6 @@ async function handleApproveAlways(
//
async function reconnectStream(conversationId: string) {
if (isGenerating.value) return
try {
await reconnectChatStream(conversationId)
} catch (e) {
@ -2037,7 +2038,7 @@ function buildOutgoingParts(text: string, attachments: ChatAttachment[]): Messag
}
// ============ ============
function normalizeMessage(raw: Message): Message {
function normalizeMessage(raw: Message, preserveGeneratingStatus?: boolean): Message {
const msg: Message = { ...raw, contentParts: raw.contentParts ? [...raw.contentParts] : [] }
// metadata JSON
@ -2107,7 +2108,7 @@ function normalizeMessage(raw: Message): Message {
msg.metadata = { ...msg.metadata, toolCalls: cleaned }
}
if (msg.status === 'generating') msg.status = 'failed'
if (!preserveGeneratingStatus && msg.status === 'generating') msg.status = 'failed'
// interrupted interrupt-with-followup stopped
if (!msg.status) msg.status = 'completed'