mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 20:08:18 +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,
|
enabled: props.autoScroll,
|
||||||
offset: 70,
|
offset: 70,
|
||||||
smooth: true,
|
smooth: true,
|
||||||
@ -275,6 +275,8 @@ function handleGlobalKeydown(e: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => document.addEventListener('keydown', handleGlobalKeydown))
|
onMounted(() => document.addEventListener('keydown', handleGlobalKeydown))
|
||||||
|
defineExpose({ resetScrollLock: resetLock })
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
clearDockTimer()
|
clearDockTimer()
|
||||||
document.removeEventListener('keydown', handleGlobalKeydown)
|
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
|
// Reconnect to a stream that is already running on the backend
|
||||||
const reconnectStream = async (conversationId: string) => {
|
const reconnectStream = async (conversationId: string) => {
|
||||||
if (isGenerating.value) return
|
if (isGenerating.value && streamConversationId === conversationId) return
|
||||||
|
|
||||||
// Clear any leftover stop fallback timer
|
// Clear any leftover stop fallback timer
|
||||||
if (stopFallbackTimer) { clearTimeout(stopFallbackTimer); stopFallbackTimer = null }
|
if (stopFallbackTimer) { clearTimeout(stopFallbackTimer); stopFallbackTimer = null }
|
||||||
@ -2054,9 +2054,28 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const assistantMessage = createAssistantMessage('', conversationId)
|
const existingAsst = [...messages.value].reverse().find(
|
||||||
;(assistantMessage as any)._turnId = activeTurnId
|
m => m.role === 'assistant'
|
||||||
currentAssistantId.value = assistantMessage.id as string
|
&& 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 {
|
try {
|
||||||
// reconnectStream always rebuilds from an EMPTY placeholder (above), so it
|
// reconnectStream always rebuilds from an EMPTY placeholder (above), so it
|
||||||
|
|||||||
@ -32,6 +32,7 @@ export interface StickToBottomReturn {
|
|||||||
stopScroll: () => void
|
stopScroll: () => void
|
||||||
/** 检查是否在底部 */
|
/** 检查是否在底部 */
|
||||||
checkIsAtBottom: () => boolean
|
checkIsAtBottom: () => boolean
|
||||||
|
resetLock: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// 默认配置
|
// 默认配置
|
||||||
@ -126,12 +127,18 @@ export function useStickToBottom(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 处理滚动事件
|
// 处理滚动事件
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
if (!scrollRef.value) return
|
if (!scrollRef.value) return
|
||||||
if (isScrolling) {
|
if (isScrolling) {
|
||||||
|
const currentScrollTop = scrollRef.value.scrollTop
|
||||||
|
if (currentScrollTop < lastScrollTop) {
|
||||||
|
isScrolling = false
|
||||||
|
escapedFromLock.value = true
|
||||||
|
isAtBottom.value = false
|
||||||
|
}
|
||||||
lastScrollTop = scrollRef.value.scrollTop
|
lastScrollTop = scrollRef.value.scrollTop
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const element = scrollRef.value
|
const element = scrollRef.value
|
||||||
const currentScrollTop = element.scrollTop
|
const currentScrollTop = element.scrollTop
|
||||||
@ -183,6 +190,11 @@ export function useStickToBottom(
|
|||||||
}, 100)
|
}, 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resetLock = () => {
|
||||||
|
escapedFromLock.value = false
|
||||||
|
isAtBottom.value = true
|
||||||
|
}
|
||||||
|
|
||||||
// ResizeObserver 监听内容变化
|
// ResizeObserver 监听内容变化
|
||||||
let resizeObserver: ResizeObserver | null = null
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
@ -245,6 +257,7 @@ export function useStickToBottom(
|
|||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
stopScroll,
|
stopScroll,
|
||||||
checkIsAtBottom,
|
checkIsAtBottom,
|
||||||
|
resetLock,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1483,7 +1483,7 @@ async function hydrateStateFromRoute() {
|
|||||||
try {
|
try {
|
||||||
const res: any = await conversationApi.listMessages(conversationId)
|
const res: any = await conversationApi.listMessages(conversationId)
|
||||||
if (currentConversationId.value !== conversationId) return
|
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 {
|
} catch {
|
||||||
// 消息加载失败,保持空
|
// 消息加载失败,保持空
|
||||||
}
|
}
|
||||||
@ -1522,6 +1522,7 @@ async function selectConversation(conv: Conversation) {
|
|||||||
const switchingAway = currentConversationId.value !== conv.conversationId
|
const switchingAway = currentConversationId.value !== conv.conversationId
|
||||||
if (switchingAway) {
|
if (switchingAway) {
|
||||||
resetForNewConversation()
|
resetForNewConversation()
|
||||||
|
messageListRef.value?.resetScrollLock()
|
||||||
}
|
}
|
||||||
currentConversationId.value = conv.conversationId
|
currentConversationId.value = conv.conversationId
|
||||||
selectedAgentId.value = conv.agentId || selectedAgentId.value
|
selectedAgentId.value = conv.agentId || selectedAgentId.value
|
||||||
@ -1544,7 +1545,8 @@ async function selectConversation(conv: Conversation) {
|
|||||||
if (currentConversationId.value !== requestedConvId) return
|
if (currentConversationId.value !== requestedConvId) return
|
||||||
// 点同一个会话时,若已有 SSE 在跑就不要覆盖本地消息状态
|
// 点同一个会话时,若已有 SSE 在跑就不要覆盖本地消息状态
|
||||||
if (switchingAway || !isGenerating.value) {
|
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)
|
// Hydrate pending approvals:恢复刷新后丢失的审批卡片(RFC-067 §4.9)
|
||||||
@ -1943,7 +1945,6 @@ async function handleApproveAlways(
|
|||||||
|
|
||||||
// 重连到运行中的流
|
// 重连到运行中的流
|
||||||
async function reconnectStream(conversationId: string) {
|
async function reconnectStream(conversationId: string) {
|
||||||
if (isGenerating.value) return
|
|
||||||
try {
|
try {
|
||||||
await reconnectChatStream(conversationId)
|
await reconnectChatStream(conversationId)
|
||||||
} catch (e) {
|
} 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] : [] }
|
const msg: Message = { ...raw, contentParts: raw.contentParts ? [...raw.contentParts] : [] }
|
||||||
|
|
||||||
// 统一解析 metadata:确保是对象而非 JSON 字符串
|
// 统一解析 metadata:确保是对象而非 JSON 字符串
|
||||||
@ -2107,7 +2108,7 @@ function normalizeMessage(raw: Message): Message {
|
|||||||
msg.metadata = { ...msg.metadata, toolCalls: cleaned }
|
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
|
// interrupted 是合法的历史状态(interrupt-with-followup),不映射为 stopped
|
||||||
if (!msg.status) msg.status = 'completed'
|
if (!msg.status) msg.status = 'completed'
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user