修复: 消息重叠与审批卡死

This commit is contained in:
2026-06-24 01:44:46 +08:00
parent 61bad4fa28
commit 1ffa023f3d
5 changed files with 110 additions and 3 deletions

View File

@@ -943,3 +943,25 @@
**与主走查重复(用户项 = 已登记,跳过)**:用户 P1-5(配置硬编码)= P1-D / P1-6(MaxRounds UI)= P1-E / P2-11(deviceOnline 不复位)= P2-B / P2-9(R1 并发防护)= `remote_bridge.rs:27-33` `check_generating_reject` **已实现**(send_message 路由前置 generating 检查)。 **与主走查重复(用户项 = 已登记,跳过)**:用户 P1-5(配置硬编码)= P1-D / P1-6(MaxRounds UI)= P1-E / P2-11(deviceOnline 不复位)= P2-B / P2-9(R1 并发防护)= `remote_bridge.rs:27-33` `check_generating_reject` **已实现**(send_message 路由前置 generating 检查)。
---
### ✅ 2026-06-24 aichat 可靠性修复(消息重叠×5 + 授权弹窗卡死 · 诊断workflow→论证workflow)
> 用户「从历史消息找需求逐一比对」→ 捞出反复报告未根治的可靠性 bug。**诊断 workflow(4 agent 只读核验)** 定根因 → **论证 workflow(4 角度:正确性/回归/完整性/边界)** 验正确性+完整性 → 补 2 处(完整性高优+回归中优)。vue-tsc 0。
>
> **BUG-260624-01 消息重叠/堆叠根治**(用户 2d89921a×3/d056b5ad×2 共 5 次报告):
> - 根因:currentText 全局单例 + flushCurrentText 末尾盲写 + MessageList streamingBlocks(:69 实例级)跨轮/跨会话残留 + 渲染 isLastAi&&currentText 非 streaming 态也驱动。
> - 修复(4 处协同,治本非补丁):① useAiEvents `flushCurrentText` 回填后自清 currentText(消除调用方清空顺序依赖)② MessageList currentText watch 归零清 streamingBlocks+lastStreamText(新轮)③ 1011 v-if 加 streaming(堵非 streaming 残留)④ activeConversationId watch 切会话清 streamingBlocks+lastStreamText+cancelAnimationFrame(治 switchConversation/restoreGeneratingState 残留根因,论证完整性高优)。
>
> **BUG-260624-02 授权弹窗卡死根治**(用户 8e4944b7/b1f644a7「没审批直接死了」):
> - 根因:统一审批开关 `df-ai-unified-approval` 默认开 → path 审批走 ToolCard 内联,但同名工具≥2 分组默认折叠(group-hidden display:none)→ 审批卡不可见 → 5min 超时(APPROVAL_TIMEOUT_MS)静默 deny → 误显「用户拒绝」卡死。ToolCard 根 :5 动态拼 `ai-tool-card--'+tc.status` 已挂 pending_approval class(scrollToFirstPending 可命中)。
> - 修复:useAiEvents AiDirAuthRequired/AiApprovalRequired case 内 `emit('ai-pending-arrived')`;ToolCardList onMounted listen → scrollToFirstPending(自动展开折叠组+scroll,原仅手动徽标触发)+ 卸载竞态 disposed 标志(论证回归中优)。
>
> **诊断推翻的误判**(anti-contamination 印证):压缩「未降prompt」= 指标口径(accumulate_tokens 跨轮累计非单轮;压缩真省,is_active 白名单剔除 compressed)✅ / generating 状态机 = 已落地(RAII guard guard.rs + enum 视图 session_state + per_conv,原创建对话硬拦已删)✅。task #120 关闭。
>
> **剩余低优 [ ](非阻断)**:
> - [ ] BUG-260624-01 残留:scrollToFirstPending 去抖(同 toolCallId 不重复 scroll,防多 pending 连发滚动打断)+ 全实例广播过滤(props.toolCalls.some 跳过无 pending 实例,大列表性能)
> - [ ] BUG-260624-02 残留:审批超时倒计时 UI(5min 无倒计时,ToolCard pending 态显 mm:ss 到期前 60s 变红)+ 核 detached 窗口 emit 作用域(分离窗口是否独立 startListener,否则 ai-pending-arrived 收不到)
> - [ ] MessageList:503 badge scrollToFirstPending return 过早(既有 bug,遍历实例应找含卡的那个,不首个 return)
> - [ ] F-09 根因:streaming/currentText 全局单例改 per-conv(根治多会话串扰,见 docs/待决策.md)

View File

@@ -320,6 +320,25 @@
--- ---
## F-09 streaming/currentText 改 per-conv(消息重叠/多会话串扰根治)
**背景**:BUG-260624-01 消息重叠核心修复后,论证 workflow 边界角度指出残留根因——`streaming`/`currentText` 是 store 全局单例(非 per-conv),F-09 多会话并发下 A 后台生成(streaming=true)+ 切 B 会话,B 末条 AI 气泡命中 `isLastAi&&streaming&&currentText` 会渲 A 残留 currentText。当前修复(flushCurrentText 自清 + activeConversationId watch 清 streamingBlocks)已大幅收窄残留窗口,但单例根因未除。
**决策点**:是否立项 per-conv 化(streaming/currentText/streamingBlocks 改 Map<convId,...>)?
- **a 立项根治**:store 改 per-conv Map,所有读写点改。大改(涉 ai.ts store + useAiEvents/useAiStream/useAiConversations/MessageList 多处),对齐 [[aichat-arch-extensibility]] AiSession 单例未动 + [[fe-arch-tech-debt]] status 无 union。
- **b 暂缓(当前缓解够用)**:自清 + activeConv watch 已覆盖主路径(新轮/切会话/结束清),残留仅极端并发场景(A 后台 streaming + 切 B)。等 B 路线多会话并发([[aichat-b-route-parallel-multiround]])正式推进时一并 per-conv。
**多角度论证(自主决策依据)**:
- 根治性:a 一次性消除单例串扰族 bug;b 缓解主路径,极端并发残留。
- 改动面:a 大(store + 5 composable + MessageList),回归风险高,需全链路测;b 零额外改动。
- 时机:b 与 B 路线多会话并发同批做更合理(那时 per-conv 是前置),现在单独做与 B 路线重复拆改。
- 当前用户场景:自用单会话为主,多会话并发(A 后台 + 切 B)非高频。
**倾向**:**⏸️ b 暂缓**(当前修复缓解有效 + per-conv 是 B 路线大改 + 与多会话并发同批更合理)。
**状态**:🟡 待拍板(若用户多会话并发场景频繁 → a 立项;当前单会话为主 → b 暂缓随 B 路线)。
---
## 已决归档 ## 已决归档
按月归档(随时间增长追加月份文件,防主文件膨胀): 按月归档(随时间增长追加月份文件,防主文件膨胀):

View File

@@ -46,9 +46,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue' import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import ToolCard from './ToolCard.vue' import ToolCard from './ToolCard.vue'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import type { AiToolCallInfo } from '@/api/types' import type { AiToolCallInfo } from '@/api/types'
// useI18n needed for $t() in template (collapseAll / expandAll) + groupDisplayName(i18n key) // useI18n needed for $t() in template (collapseAll / expandAll) + groupDisplayName(i18n key)
@@ -299,6 +300,34 @@ function scrollToFirstPending(): void {
card.scrollIntoView({ behavior: 'smooth', block: 'center' }) card.scrollIntoView({ behavior: 'smooth', block: 'center' })
} }
// BUG-260624-02(授权弹窗卡死根治·诊断 workflow high 置信):监听 pending 审批卡到达事件,
// 自动展开折叠分组 + scroll 到 pending 卡。根因:统一审批开关 df-ai-unified-approval 默认开 →
// path 审批走 ToolCard 内联,但同名工具≥2 分组默认折叠(initDefaultCollapse)+ group-hidden
// (display:none)→ 审批按钮对用户不可见 → 5min 超时(aiShared APPROVAL_TIMEOUT_MS)静默
// authorizeDir/approve(deny)→ 误显"用户拒绝" → 用户全程未见审批入口即"卡死/被拒"。
// scrollToFirstPending(:285)已能自动展开折叠组(offsetParent===null → collapsedGroups 清空),
// 原仅 AiChat 头部徽标手动触发(:230);此处 pending 到达即自动调用,让审批卡必可见可操作。
let _unlistenPending: UnlistenFn | null = null
// 论证 workflow 回归中优:防卸载竞态——await listen 解析前组件已卸载(HMR/快速切会话)时,
// onBeforeUnmount 跑到 _unlistenPending 仍 null 跳过 unlisten → 孤儿监听器泄漏。disposed 标志兜底。
let _disposed = false
onMounted(async () => {
const fn = await listen('ai-pending-arrived', () => {
// nextTick 等 pending_approval 类挂到 DOM 后再查(scrollToFirstPending 查 .ai-tool-card--pending_approval)。
// 多个 ToolCardList 实例(每条 AI 消息一个)各自 listen,querySelector 只命中自身 root 内的
// pending 卡,非含 pending 卡的实例查 null no-op,天然过滤不串扰。
nextTick(() => scrollToFirstPending())
})
// 解析时若已卸载(HMR/极速 mount/unmount),立即 unlisten 刚拿到的句柄,防孤儿监听器。
if (_disposed) { fn(); return }
_unlistenPending = fn
})
onBeforeUnmount(() => {
_disposed = true
_unlistenPending?.()
_unlistenPending = null
})
defineExpose({ collapseInactive, scrollToFirstPending }) defineExpose({ collapseInactive, scrollToFirstPending })
/* -- 分组图标(复用 ToolCard 的 toolCategory 逻辑) -- */ /* -- 分组图标(复用 ToolCard 的 toolCategory 逻辑) -- */

View File

@@ -425,7 +425,19 @@ watch(() => store.state.currentText, (text) => {
// 不再用 onContentChange 的 nextTick(微任务先于 rAF,滚到旧 scrollHeight 致错位抖动)。 // 不再用 onContentChange 的 nextTick(微任务先于 rAF,滚到旧 scrollHeight 致错位抖动)。
// 仅流式渲染触发;非流式 currentText 变化(罕见)走 onContentChange 兜底。 // 仅流式渲染触发;非流式 currentText 变化(罕见)走 onContentChange 兜底。
if (store.state.streaming && text) scheduleStreamParse(text) if (store.state.streaming && text) scheduleStreamParse(text)
else onContentChange() else {
// BUG-260624-01(消息重叠根治·诊断 workflow 确认):currentText 归零(新轮 AiAgentRound
// 清空 / 生成结束)时,同步清上一轮流式分块缓存 streamingBlocks + lastStreamText。
// 原:streaming watch(:435)仅在 streaming 翻 false 时清;但 agent 多轮连续生成时
// streaming 保持 true,新轮 AiAgentRound push 新空气泡 + currentText='',streamingBlocks
// 残留上一轮 → 新气泡 isLastAi 命中(:1017)渲染残留分块 → 与上一条回复重叠堆叠。
// 在 currentText 归零处清,精确覆盖"新轮切换"与"生成结束"两个时机。
if (!text) {
streamingBlocks.value = []
lastStreamText = ''
}
onContentChange()
}
}) })
watch(mdReady, (ready) => { watch(mdReady, (ready) => {
if (ready && store.state.streaming && store.state.currentText) { if (ready && store.state.streaming && store.state.currentText) {
@@ -844,6 +856,17 @@ watch(() => store.state.activeConversationId, () => {
wasNearBottom = true wasNearBottom = true
// B-260618-24: 切会话重置跟随意图(新会话默认跟随底部,防 A 上滑残留 isFollowingBottom=false) // B-260618-24: 切会话重置跟随意图(新会话默认跟随底部,防 A 上滑残留 isFollowingBottom=false)
isFollowingBottom = true isFollowingBottom = true
// BUG-260624-01(消息重叠根治·论证 workflow 完整性高优):MessageList 是持久单实例(AiChat.vue
// 无 :key/v-if 卸载),实例级 streamingBlocks(:69)跨会话/跨轮残留是消息重叠主根因之一。
// 钉在「会话切换」这一确切时机清,根治两条残留路径:① switchConversation(useAiConversations)
// 清 currentText 但实例级 streamingBlocks 未同步;② restoreGeneratingState(useAiWindow)
// 切到正在生成的 conv / 分离窗口挂载。比靠 currentText 副作用间接触发更精确,不依赖
// switchConversation 内部语句顺序(对齐 no-patch-groundwork:状态变更点收敛,非渲染侧补丁)。
if (streamingBlocks.value.length) {
streamingBlocks.value = []
lastStreamText = ''
}
if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null }
}) })
// 流式 rAF 清理:组件卸载时若仍有 pending rAF(streaming 中途切走/关窗),取消防泄漏。 // 流式 rAF 清理:组件卸载时若仍有 pending rAF(streaming 中途切走/关窗),取消防泄漏。
@@ -1008,7 +1031,7 @@ defineExpose({
<!-- 文本内容流式或固定Markdown 渲染 --> <!-- 文本内容流式或固定Markdown 渲染 -->
<!-- UX-2025-01:流式分块v-for,已完成块DOM稳定不重建选文字保持 --> <!-- UX-2025-01:流式分块v-for,已完成块DOM稳定不重建选文字保持 -->
<div <div
v-if="item.msg.content || (isLastAi(item.msg) && store.state.currentText)" v-if="item.msg.content || (isLastAi(item.msg) && store.state.streaming && store.state.currentText)"
class="ai-msg-bubble ai-msg-bubble--ai ai-md" class="ai-msg-bubble ai-msg-bubble--ai ai-md"
:class="{ 'ai-msg-bubble--error': item.msg.isError }" :class="{ 'ai-msg-bubble--error': item.msg.isError }"
:key="'md-' + item.msg.id + '-' + (isLastAi(item.msg) ? _mdRenderKey : 0)" :key="'md-' + item.msg.id + '-' + (isLastAi(item.msg) ? _mdRenderKey : 0)"

View File

@@ -195,6 +195,13 @@ export function flushCurrentText() {
break break
} }
} }
// BUG-260624-01(消息重叠根治·诊断 workflow 确认):回填后立即自清 currentText。
// 原:清空依赖调用方(AiAgentRound/AiCompleted/AiError/AiHelpRequired 各跟一行 currentText='')。
// 竞态:任一新增 flush 调用点漏清,或事件乱序致渲染先于调用方清空,全局单例 currentText
// 残留 → MessageList 渲染侧 isLastAi(msg)&&currentText 把残留文本渲到新气泡 → 重叠堆叠。
// 自清把 flush 语义收敛为"回填并归零",消除对调用方清空顺序的依赖。各调用方后续的
// currentText='' 对已清空值幂等,无副作用。
state.currentText = ''
} }
/** token 用量展示开关(读 appSettings,与 Settings.vue 共享 key `df-show-token-usage`) */ /** token 用量展示开关(读 appSettings,与 Settings.vue 共享 key `df-show-token-usage`) */
@@ -378,6 +385,11 @@ function handleStreamingEvent(event: AiChatEvent): boolean {
// 传 kind='path' → 到点回调调 authorizeDir(id,'deny')(非 ai_approve,避免后端 kind==Risk 校验拒卡死)。 // 传 kind='path' → 到点回调调 authorizeDir(id,'deny')(非 ai_approve,避免后端 kind==Risk 校验拒卡死)。
startApprovalTimer(event.id, event.tool, 'path') startApprovalTimer(event.id, event.tool, 'path')
} }
// BUG-260624-02(授权弹窗卡死根治·诊断 workflow high 置信):path 类审批卡归一进 ToolCard
// 内联后,可能落入同名工具≥2 的折叠分组(ToolCardList group-hidden display:none)对用户
// 不可见 → 5min 超时(aiShared APPROVAL_TIMEOUT_MS)静默 authorizeDir('deny')→ 误显
// "用户拒绝" → 用户全程未见审批入口即卡死。通知 ToolCardList 自动展开折叠组 + scroll。
void emit('ai-pending-arrived', { toolCallId: event.id })
return true return true
} }
// 开关关(兜底回退):push 到 pendingDirAuths 驱动 DirAuthDialog 弹窗。 // 开关关(兜底回退):push 到 pendingDirAuths 驱动 DirAuthDialog 弹窗。
@@ -470,6 +482,8 @@ function handleToolEvent(event: AiChatEvent): boolean {
// AE-2025-06: 审批等待开始→启动审批超时计时器(5min 不处理自动拒绝) // AE-2025-06: 审批等待开始→启动审批超时计时器(5min 不处理自动拒绝)
// 传 kind='risk' → 到点回调调 aiApi.approve(id,false)(后端 ai_approve kind==Risk 链路)。 // 传 kind='risk' → 到点回调调 aiApi.approve(id,false)(后端 ai_approve kind==Risk 链路)。
startApprovalTimer(event.id, event.name, 'risk') startApprovalTimer(event.id, event.name, 'risk')
// BUG-260624-02:同 AiDirAuthRequired,risk 类 pending 卡落折叠分组不可见时自动展开 + scroll。
void emit('ai-pending-arrived', { toolCallId: event.id })
return true return true
} }