mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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:
parent
40cb39fb3b
commit
65f6a8c6b2
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user