diff --git a/src/components/ai/MessageList.vue b/src/components/ai/MessageList.vue index cbbd74e..3f78cd2 100644 --- a/src/components/ai/MessageList.vue +++ b/src/components/ai/MessageList.vue @@ -309,21 +309,25 @@ function scrollToFirstPending(): void { } } +/** + * 移除 deep:true,改在 watch source 中计算指纹(消息数 + toolCalls 的 id/status), + * 避免 deep 每帧全量深遍历长会话数组引起掉帧。流式 delta 只改 content 不影响指纹, + * 不触发无意义的收起重算。length 变化(新消息/截断)和 toolCall 状态翻转(新卡/完成/拒绝) + * 均能正确触发——与 body 的快照收缩逻辑等价,无需 deep 兜底。 + */ let lastMsgSnapshot = '' watch( - () => store.state.messages, - (msgs) => { - const snap = JSON.stringify({ - n: msgs.length, - tc: msgs.map(m => (m.toolCalls || []).map(t => ({ id: t.id, s: t.status }))), - }) + () => JSON.stringify({ + n: store.state.messages.length, + tc: store.state.messages.map(m => (m.toolCalls || []).map(t => ({ id: t.id, s: t.status }))), + }), + (snap) => { if (snap === lastMsgSnapshot) return const isFirst = lastMsgSnapshot === '' lastMsgSnapshot = snap - if (isFirst) return // 首次加载/切换对话不触发收起 - collapseAllToolLists(buildActiveToolIds(msgs)) + if (isFirst) return + collapseAllToolLists(buildActiveToolIds(store.state.messages)) }, - { deep: true }, ) // UX-260616-03: AiAgentRound 新轮次到达时收起上一轮工具卡(round 递增)。 diff --git a/src/components/task/TaskOutputCard.vue b/src/components/task/TaskOutputCard.vue index 77c1ed0..b786d1f 100644 --- a/src/components/task/TaskOutputCard.vue +++ b/src/components/task/TaskOutputCard.vue @@ -4,7 +4,6 @@
- {{ $t('taskDetail.output') }}
diff --git a/src/composables/ai/useAiSend.ts b/src/composables/ai/useAiSend.ts index 7530f62..e8ae048 100644 --- a/src/composables/ai/useAiSend.ts +++ b/src/composables/ai/useAiSend.ts @@ -267,14 +267,21 @@ async function editMessage(newMessage: string) { * 卡死),也无任何用户反馈。现显式 catch:复位 streaming + 推错误气泡(后台会话不推,避免污染 * 当前视图)+ 回填失败消息到该会话队首并停 drain(不静默丢用户输入,保留剩余队列供手动重试/ * 编辑/取消)。成功路径不受影响——doSend 成功后端会再 emit AiCompleted 续 drain 链路。 + * + * 并发互斥 — 每个会话维护 _draining 标志,防止 AiCompleted 并发触发两次 drainQueue + * 导致 splice 两条消息同时发送,顺序错乱。已在 drain 中则跳过,等待下一次 AiCompleted 续发。 */ +let _drainingConvs = new Set() export function drainQueue(convId?: string | null) { if (state.queue.length === 0) return // 找队首属于目标会话的消息;若穿越(非目标会话的项堵在队首)则跳过整条队列 const targetId = convId ?? state.activeConversationId if (!targetId) return + // 并发互斥:该会话已在 drain 中则跳过,由下一轮 AiCompleted 续发 + if (_drainingConvs.has(targetId)) return const idx = state.queue.findIndex(q => q.conversationId === targetId) if (idx === -1) return + _drainingConvs.add(targetId) const next = state.queue.splice(idx, 1)[0]! void (async () => { try { @@ -301,6 +308,8 @@ export function drainQueue(convId?: string | null) { } as AiMessage) } console.error('[AI] drainQueue 续发失败:', e) + } finally { + _drainingConvs.delete(targetId) } })() } diff --git a/src/styles/components.css b/src/styles/components.css index 73584a9..8201f1c 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -41,8 +41,8 @@ 原 Ideas.vue / IdeaDetail.vue