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'