From 65f6a8c6b21f7dd3e5f37708fe424c1c2c1eb5d2 Mon Sep 17 00:00:00 2001 From: MIST <52695829+MISTLXC@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:55:17 +0800 Subject: [PATCH] =?UTF-8?q?fix(chat):=20=E4=BF=AE=E5=A4=8D=E6=BB=9A?= =?UTF-8?q?=E5=8A=A8=E5=9B=9E=E5=BC=B9=E5=92=8C=E5=88=87=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E5=BC=82=E5=B8=B8=E4=B8=A4=E5=A4=84=E6=A0=B8?= =?UTF-8?q?=E5=BF=83bug=EF=BC=8C=E9=99=84=E5=8A=A0=E4=B8=89=E9=A1=B9?= =?UTF-8?q?=E4=BC=98=E5=8C=96=20(#425)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 正常写入 --- .../src/components/chat/MessageList.vue | 4 ++- mateclaw-ui/src/composables/chat/useChat.ts | 27 ++++++++++++++++--- .../src/composables/chat/useStickToBottom.ts | 21 ++++++++++++--- mateclaw-ui/src/views/ChatConsole.vue | 11 ++++---- 4 files changed, 49 insertions(+), 14 deletions(-) diff --git a/mateclaw-ui/src/components/chat/MessageList.vue b/mateclaw-ui/src/components/chat/MessageList.vue index b030b61a..a7ef3061 100644 --- a/mateclaw-ui/src/components/chat/MessageList.vue +++ b/mateclaw-ui/src/components/chat/MessageList.vue @@ -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) diff --git a/mateclaw-ui/src/composables/chat/useChat.ts b/mateclaw-ui/src/composables/chat/useChat.ts index 507ba630..9da575e3 100644 --- a/mateclaw-ui/src/composables/chat/useChat.ts +++ b/mateclaw-ui/src/composables/chat/useChat.ts @@ -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 diff --git a/mateclaw-ui/src/composables/chat/useStickToBottom.ts b/mateclaw-ui/src/composables/chat/useStickToBottom.ts index 9c3339db..278bc9fe 100644 --- a/mateclaw-ui/src/composables/chat/useStickToBottom.ts +++ b/mateclaw-ui/src/composables/chat/useStickToBottom.ts @@ -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, } } diff --git a/mateclaw-ui/src/views/ChatConsole.vue b/mateclaw-ui/src/views/ChatConsole.vue index eac2c6b3..da438fc5 100644 --- a/mateclaw-ui/src/views/ChatConsole.vue +++ b/mateclaw-ui/src/views/ChatConsole.vue @@ -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 approvals:恢复刷新后丢失的审批卡片(RFC-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'