//! AI 事件监听与分发 — startListener/stopListener/handleEvent 及其辅助函数 //! //! 模块级私有状态(不进 reactive): //! - _unlistenAiEvent / _unlistenConvChanged: 已注册的 unlistener //! - _startPromise: startListener 并发去重(防 onMounted 与 sendMessage 首发竞态重复注册) //! //! handleEvent 结构(fe-arch P0-2 拆分后): //! - 外围逻辑(convId 同步/路由/看门狗)留在 handleEvent //! - 14 个 case 按域分派到 3 个 handler: //! - handleStreamingEvent:AiTextDelta/AiAgentRound/AiHeartbeat/AiStreamRetry/AiMaxRoundsReached //! - handleToolEvent:AiToolCallStarted/AiToolCallCompleted/AiToolAutoApproved/AiApprovalRequired/AiApprovalResult //! - handleLifecycleEvent:AiCompleted/AiError //! 各 handler 共享模块级 helper(state/aiShared/审批计时器/工具慢执行计时器)。 //! //! 耦合: //! - handleEvent 调 useAiConversations.loadConversations、 //! useAiStream.{resetStreamWatchdog,clearStreamWatchdog}、本模块 flushCurrentText/findToolCall/notifyConversationChanged //! - drainQueue 经 ai-drain-queue 事件总线桥接(原直接 import useAiSend 构成循环依赖,已破除) //! - 审批计时器(startApprovalTimer/clearApprovalTimer/clearAllApprovalTimers)已下沉到 aiShared(同上) //! - nextMsgId 已下沉到 aiShared(原为本模块导出,useAiStream 亦依赖之构成循环依赖,故抽出) import { listen, emit } from '@tauri-apps/api/event' import { ref } from 'vue' import { aiApi } from '@/api' import { useAppSettingsStore } from '@/stores/appSettings' import { state } from '@/stores/ai' import { t } from '@/i18n/i18n-helpers' import { nextMsgId, findToolCall, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers } from './aiShared' import { resetStreamWatchdog, clearStreamWatchdog, clearAllStreamWatchdogs } from './useAiStream' import { setStreaming } from './streamingGuard' import { loadConversations } from './useAiConversations' import type { AiChatEvent, AiMessage, AiToolCallInfo } from '@/api/types' let _unlistenAiEvent: (() => void) | null = null let _unlistenConvChanged: (() => void) | null = null // AE-2025-06: onStreamTimeout 广播的审批计时器清理事件 unlistener let _unlistenApprovalClear: (() => void) | null = null // startListener 并发去重:防 onMounted 与 sendMessage 首发竞态下重复注册 listener // (两回调写同一 state.currentText → 流式文字双倍) let _startPromise: Promise | null = null const appSettings = useAppSettingsStore() // B-260616-17: 看门狗不重置的事件集合(审批等待/完成/错误由各自 case 内 clear)。 // 模块级 Set 复用,避免 handleEvent 每事件(delta/token 高频)新建数组字面量做 includes。 // F-260616-03: AiMaxRoundsReached 加入——达 max 暂停态等用户决定继续/停止,不计整流超时。 // F-260619-03 Phase B: AiDirAuthRequired 加入——路径授权挂起等用户决定,不计整流超时。 // L1 求助协议(§2.3):AiHelpRequired 加入——断路器熔断已 guard.reset 终止 loop,等用户选 option。 const NO_RESET_WATCHDOG = new Set(['AiApprovalRequired', 'AiCompleted', 'AiError', 'AiMaxRoundsReached', 'AiDirAuthRequired', 'AiHelpRequired']) // B-260616-12: 工具执行超时提示(纯前端降级,后端无工具级取消 IPC)。 // 每个 running 工具一个独立 setTimeout;到时若仍未收到 Completed/Approval, // 经 Tauri 事件 ai-tool-slow-toast 通知 AiChat.vue 弹 warning toast(仅提示一次, // 不动 running 态——慢工具如 read_file 大文件/run_workflow 长任务不可误杀)。 // 真正取消需后端开 ai_cancel_tool IPC + 工具执行 select 改造,单独立项。 const TOOL_SLOW_MS = 30000 // callId → timer;记录所有已挂载的工具级计时器 const _toolTimers = new Map>() // 已提示过的 callId(同一工具只弹一次 toast,即便仍 running 到本轮结束) const _toolSlowNotified = new Set() /** 启动工具级慢执行计时器(超时仅弹 toast,不修改 status) */ function startToolSlowTimer(callId: string, toolName: string): void { if (_toolTimers.has(callId)) return // 幂等:同 id 重复 Started 不重建 const timer = setTimeout(() => { _toolTimers.delete(callId) if (_toolSlowNotified.has(callId)) return _toolSlowNotified.add(callId) // 经 Tauri 事件总线广播(composable 无组件上下文,无法直接调 toast)。 // AiChat.vue(主窗口与分离窗口各挂一份)listen 后弹本地 toast。 // toolName 来自后端事件,经 i18n key 查不到翻译时回退原值——这里原样透传, // 由消费方 i18n 插值展示。 void emit('ai-tool-slow-toast', { name: toolName }) }, TOOL_SLOW_MS) _toolTimers.set(callId, timer) } /** 清除单个工具的慢执行计时器(收到 Completed/Approval 时调用) */ function clearToolSlowTimer(callId: string): void { const timer = _toolTimers.get(callId) if (timer) { clearTimeout(timer) _toolTimers.delete(callId) } } /** 清除全部工具慢执行计时器(stopListener/整流超时收尾时调用) */ function clearAllToolSlowTimers(): void { for (const timer of _toolTimers.values()) clearTimeout(timer) _toolTimers.clear() _toolSlowNotified.clear() } // F-260616-03: 达 max_iterations 暂停态(后端仍 generating=true)。 // // 不进 store.state(批次41 领地,且与 pendingApprovals 同性质是"待用户操作"信号, // 独立事件源——AiMaxRoundsReached 与 AiApprovalRequired 不混)。用模块级 ref 导出, // AiChat.vue 直接 import 读;case 内 push,true 表"有挂起询问"。 // 一次只追踪一个挂起询问(后端 AiSession 单例,达 max 后续轮次前用户必先决定), // 故用单值 ref 而非数组,语义更精确。 // // TD-260621-02 per-conv:原 ref(false)(boolean)无 convId 维度,F-09 多会话并发下 A 达 max // 挂起时切到 B 会话,MaxRoundsCard 守卫(仅判 isViewingGenerating)会把 A 的操作卡错显于 B。 // 改 ref(存挂起 convId,null=无挂起)。消费方(MaxRoundsCard)守卫改精确比对 // pendingMaxRounds === activeConversationId。set 按事件 conversation_id 赋值; // clear(AiCompleted/AiError)仅当 pendingMaxRounds===该 convId 才清 null(避免清错会话的挂起)。 export const pendingMaxRounds = ref(null) // F-260619-03 Phase B: 路径授权弹窗挂起态(后端 AiDirAuthRequired 事件置,用户决策后清)。 // // path_auth 审批链阶段1(解卡死):单 ref → 数组。根因——同轮多个文件工具落不同未授权目录时, // 后端连发多条 AiDirAuthRequired,单 ref 后到覆盖先到,先到的弹窗丢失→授权无法到达→loop 卡死。 // 改数组 push 多条并存,各 case(AiApprovalResult/AiCompleted/AiError)按 id filter 移除。 // 开关 df-ai-multi-pending-auth 控制多挂起并存(true,默认)vs 单 ref 覆盖(false,兜底回退)。 // 模块级 ref(不进 store.state),AiChat.vue 的 DirAuthDialog 组件直接 import 读。 export interface PendingDirAuth { id: string tool: string path: string dir: string conversationId?: string } export const pendingDirAuths = ref([]) // L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21): // // agent 断路器熔断时后端 emit AiHelpRequired(已 guard.reset,generating=false,loop 终止)。 // 前端据此翻 pendingHelp 驱动求助卡(HelpRequiredCard),显 reason + options 按钮供用户选。 // 模块级 ref(不进 store.state,与 pendingMaxRounds/pendingDirAuths 同性质是"待用户操作"信号, // 独立事件源——求助卡不与达 max 操作卡/审批卡混)。TD-260621-02 per-conv:存挂起 convId, // 消费方(HelpRequiredCard)守卫按 activeConversationId 精确比对(F-09 多会话并发不串台)。 // 一次只追踪一个挂起求助(后端断路器熔断即 return 终止 loop,用户选 option 前不再发)。 export interface PendingHelp { reason: string context: string options: string[] conversationId: string | null } export const pendingHelp = ref(null) /** 通知会话列表发生变化(供 newConversation/deleteConversation/rename/archive 等触发刷新侧栏) */ export function notifyConversationChanged() { emit('ai-conversation-changed', {}) } /** 后端原始错误转用户友好提示 */ export function friendlyError(raw: string): string { if (/404|not\s*found/i.test(raw)) return t('ai.errorNotFound') if (/401|403|unauthorized|api[_\s-]?key/i.test(raw)) return t('ai.errorAuth') if (/timeout|超时/i.test(raw)) return t('ai.errorTimeout') if (/network|connection|ECONN|网络|连接/i.test(raw)) return t('ai.errorNetwork') return raw } /** 把流式累积的 currentText 回填到最后一条 assistant 消息(AiAgentRound/AiCompleted/AiError 收尾共用) */ export function flushCurrentText() { if (!state.currentText) return // 从末尾向前找最后一个非 isError assistant 气泡写入。跳过 AiStreamRetry 错误气泡 // (isError),写入其前的占位 assistant,保留部分回复(UX-260619-06 MED-1)。 for (let i = state.messages.length - 1; i >= 0; i--) { const m = state.messages[i] if (m.role !== 'assistant') break // 遇 user/tool 停,占位不在其后 if (!m.isError) { m.content = state.currentText break } } } /** token 用量展示开关(读 appSettings,与 Settings.vue 共享 key `df-show-token-usage`) */ function isShowTokenUsage(): boolean { return appSettings.get('df-show-token-usage', false) } /** path_auth 多挂起并行开关(appSettings key `df-ai-multi-pending-auth`)。 * path_auth 审批链阶段1:解卡死核心——同轮多个文件工具落不同未授权目录时,后端会连发多条 * AiDirAuthRequired。单 ref 会被后到的事件覆盖,先到的弹窗丢失→授权无法到达→loop 卡死。 * 开(true,默认):pendingDirAuths 数组,push 多条并存,按 id 定位消费。 * 关(false,兜底回退):退化单 ref 语义——后到覆盖先到(push 前清空数组),行为对齐旧版。 * 回退路径:appSettings.set('df-ai-multi-pending-auth', false) 即恢复单 ref 行为,无需改代码。 */ function isMultiPendingAuth(): boolean { return appSettings.get('df-ai-multi-pending-auth', true) } /** * path_auth 审批链阶段3b(统一审批模型)开关(appSettings key `df-ai-unified-approval`)。 * * 决策(plan 阶段3b):状态层合(单 pendingApprovals + kind 字段)+ 决策层分(path 走 authorizeDir / * risk 走 approve)。开关控制 path 类挂起是否归一进 pendingApprovals 带 kind='path' 字段, * 由 ToolCard 内联审批(once/always/deny);关时回退 DirAuthDialog 老链路(独立弹窗)。 * * 开(true,默认):AiDirAuthRequired case 把 path 挂起转成 pendingApprovals 项(kind='path'), * 不再 push pendingDirAuths;DirAuthDialog 在 AiChat 中按开关判断不挂载。ToolCard 按 kind * 显 once/always/deny 三按钮,调 aiApi.authorizeDir。 * 关(false,兜底回退):退回阶段1老链路——AiDirAuthRequired push pendingDirAuths,DirAuthDialog * 渲染弹窗,与阶段3a 后端合(单 HashMap + kind 字段)兼容(后端语义层已合,前端 UI 不切)。 * 回退路径:appSettings.set('df-ai-unified-approval', false) 即恢复 DirAuthDialog 弹窗,无需改代码。 */ function isUnifiedApproval(): boolean { return appSettings.get('df-ai-unified-approval', true) } /** 向 pendingDirAuths 追加一条(开关控制:多挂起并存 vs 单 ref 覆盖) */ function pushPendingDirAuth(p: PendingDirAuth): void { if (isMultiPendingAuth()) { pendingDirAuths.value.push(p) } else { // 兜底单 ref 语义:后到覆盖先到(行为对齐旧版,数组只留最新一条) pendingDirAuths.value = [p] } } /** 按 id 从 pendingDirAuths 移除一条 */ function removePendingDirAuth(id: string): void { pendingDirAuths.value = pendingDirAuths.value.filter(p => p.id !== id) } // ─── 事件域 handler(从原 handleEvent switch 抽出,按域分组)──────────────────────────── // // 拆分策略:策略 C(抽 case 体为函数)+ 按域分组(streaming/tool/lifecycle)。 // handleEvent 保留外围逻辑(convId 同步/路由/看门狗),switch 改为按 event.type 分派到 // 对应域 handler。各 handler 共享模块级 helper(state/aiShared/审批计时器/工具慢执行计时器)。 // 零行为变更:逻辑等价搬运,case 体一字未改。 // // 域分组: // - streaming 流式:AiTextDelta / AiAgentRound / AiHeartbeat / AiStreamRetry / AiMaxRoundsReached // - tool 工具:AiToolCallStarted / AiToolCallCompleted / AiToolAutoApproved / AiApprovalRequired / AiApprovalResult // - lifecycle 生命周期:AiCompleted / AiError // // 返回值:true=已处理(命中域内 case);false=未命中(供 handleEvent 兜底/未来扩展) // 例外:AiMaxRoundsReached 在 streaming 域(语义属流式中断信号),但 lifecycle 域收尾时 // 需置 pendingMaxRounds=false —— 该 ref 为模块级,跨域共享无碍。 /** streaming 域:流式文本累积/新轮/心跳/重试/max 轮暂停 */ function handleStreamingEvent(event: AiChatEvent): boolean { switch (event.type) { case 'AiTextDelta': state.currentText += event.delta return true case 'AiAgentRound': { // Agent 循环新一轮:保存当前文本到上一条 assistant 消息,新建空 assistant 消息 flushCurrentText() state.currentText = '' state.messages.push({ id: `ai-${nextMsgId()}`, role: 'assistant', content: '', timestamp: Date.now(), }) // AE-2025-07: 记录当前轮次供进度条展示。 // event.round>0 = run_agentic_loop 内 iteration+1(第几轮工具→LLM 循环); // event.round==0 = try_continue_agent_loop 审批通过后"隔开新一轮"的占位事件, // 此时实际轮次尚未推进(run_agentic_loop 入口 iteration=0),不覆盖,避免审批通过瞬间 // 进度条 2→0 闪烁;下一轮真正的 round>0 事件到达时再更新。 if (event.round > 0) state.agentRound = event.round return true } case 'AiHeartbeat': // 心跳事件:仅维持看门狗(已在上方 reset),无需额外处理;显式 case 防 switch 穿透 return true case 'AiStreamRetry': { // UX-260618-15: 流前失败重试中——后端 emit AiStreamRetry 携带 attempt/max_attempts。 // 方案 A 根治 N+1 气泡:后端重试过程不再先 emit AiError(emit 权交 agentic 重试耗尽/Fatal 时 // 统一发最终错误),故首次 AiStreamRetry 到达时末条不是 isError,需创建新错误气泡; // 后续 AiStreamRetry 到达时末条已是 isError,仅更新 content(单气泡聚合)。 // 看门狗已在上方 NO_RESET_WATCHDOG 之外(此事件不在集合内)reset,不卡整流超时。 const lastMsg = state.messages[state.messages.length - 1] const retryText = t('ai.aiStreamRetry', { attempt: event.attempt, max: event.max_attempts }) if (lastMsg && lastMsg.isError) { lastMsg.content = retryText } else { state.messages.push({ id: `err-${nextMsgId()}`, role: 'assistant', content: retryText, isError: true, timestamp: Date.now(), } as AiMessage) } return true } case 'AiMaxRoundsReached': // F-260616-03: 达 max_iterations 暂停态(后端仍 generating=true),前端展示操作卡询问。 // 后端已 save 落库,这里仅翻 pendingMaxRounds 驱动 UI 卡片;看门狗已由 // NO_RESET_WATCHDOG 跳过 reset(达 max 不计整流超时)。 // TD-260621-02 per-conv:存挂起 convId(非 boolean),消费方按 activeConversationId 精确比对。 // 注:无 convId 时**不**设 null(=无挂起,达 max 操作卡丢失)——保留原值兜底。 // 用 activeConversationId 兜底:达 max 事件理论上必带 convId,无时默认属当前活跃会话, // 优于清空挂起导致 MaxRoundsCard 守卫误判无挂起(用户达 max 操作卡凭空消失)。 pendingMaxRounds.value = event.conversation_id || state.activeConversationId || pendingMaxRounds.value return true case 'AiDirAuthRequired': { // F-260619-03 Phase B: 路径授权挂起(后端 generating 保持 true,等用户决策)。 // push 到 pendingDirAuths 驱动 DirAuthDialog 弹窗;看门狗已由 NO_RESET_WATCHDOG 跳过 reset。 // F-260620 根治(错调 ai_approve 致卡死):path_auth 挂起**不**置工具卡 pending_approval—— // 工具卡 pending_approval 态有审批按钮调 ai_approve(为 RiskLevel 审批设计),path_auth 错调 // ai_approve 会被后端拦(path_auth 回滚+Err)→ 前端转圈卡死(authz-debug.log 铁证:同 tool_call_id // ai_approve+ai_authorize_dir 双调)。path_auth 只走 DirAuthDialog(ai_authorize_dir), // 工具卡保持 running(等授权结果,授权后 AiToolCallCompleted 转 completed)。 // // path_auth 审批链阶段3b(统一审批模型):开关 df-ai-unified-approval 开时,path 挂起归一进 // pendingApprovals 带 kind='path',由 ToolCard 内联显 once/always/deny 三按钮(调 authorizeDir), // 消除独立 DirAuthDialog 弹窗(单真相源:状态层合)。开关关时回退阶段1老链路(push pendingDirAuths, // DirAuthDialog 渲染)。两路共存,兜底可随时回退。 clearStreamWatchdog(event.conversation_id || undefined) clearToolSlowTimer(event.id) // 开关开:归一进 pendingApprovals(kind='path'),驱动 ToolCard 内联审批。 // 同 id 幂等:GLM anthropic_compat id 不稳或重发时,已有同 id 不重复 push(防残留重复卡)。 if (isUnifiedApproval()) { if (!state.pendingApprovals.some(p => p.id === event.id)) { state.pendingApprovals.push({ id: event.id, name: event.tool, args: { path: event.path }, status: 'pending_approval', kind: 'path', dir: event.dir, path: event.path, // path 类审批提示复用 aiChat.dirAuthHint(已存在 i18n,tool+path 文案); // 不新增 key 避免 i18n 缺失 prod runtime 报错(memory: i18n-message-compile-blindspot)。 reason: t('aiChat.dirAuthHint', { tool: event.tool, path: event.path }), }) // 同步改对应工具卡的 tc(让 ToolCard 显 path 类审批按钮 once/always/deny + status-dot pending 色)。 // F-260620 注释"工具卡保持 running"是为防 path_auth 错调 ai_approve 卡死——统一模式后 path 类 // 调 authorizeDir 不再错调,故可安全进 pending_approval(与 risk 类 AiApprovalRequired 同款流转)。 // 开关关(兜底)时仍保持老语义(tc 不动,走 DirAuthDialog)。 const tc = findToolCall(event.id) if (tc) { tc.status = 'pending_approval' tc.kind = 'path' tc.dir = event.dir tc.path = event.path tc.reason = t('aiChat.dirAuthHint', { tool: event.tool, path: event.path }) } // path 类挂起同样启动审批超时计时器(5min 不处理自动拒),与 risk 类一致。 // 传 kind='path' → 到点回调调 authorizeDir(id,'deny')(非 ai_approve,避免后端 kind==Risk 校验拒卡死)。 startApprovalTimer(event.id, event.tool, 'path') } return true } // 开关关(兜底回退):push 到 pendingDirAuths 驱动 DirAuthDialog 弹窗。 // pushPendingDirAuth 据开关(df-ai-multi-pending-auth)决定多挂起并存(默认)还是单 ref 覆盖兜底。 if (!pendingDirAuths.value.some(p => p.id === event.id)) { pushPendingDirAuth({ id: event.id, tool: event.tool, path: event.path, dir: event.dir, conversationId: event.conversation_id, }) } return true } default: return false } } /** tool 域:工具卡片状态流转/会话级信任/审批 */ function handleToolEvent(event: AiChatEvent): boolean { switch (event.type) { case 'AiToolCallStarted': { const info: AiToolCallInfo = { id: event.id, name: event.name, args: event.args, status: 'running', } // B-260616-21: 同 tool_call_id 重复 emit Started 时(GLM anthropic_compat id 不稳), // 仅挂计时器不重复 push(对齐 startToolSlowTimer 守卫风格),防残留 running 空卡(0 行·N KB)。 // 详 docs/02-架构设计/已编号方案/B-260616-21排查方案-2026-06-16.md 方案①治标(后端治本待取证)。 if (!findToolCall(event.id)) { const lastMsg = state.messages[state.messages.length - 1] if (lastMsg && lastMsg.role === 'assistant') { lastMsg.toolCalls = lastMsg.toolCalls || [] lastMsg.toolCalls.push(info) } } // B-260616-12: 工具开始执行即挂慢执行计时器(超时仅 toast,不动 running 态) startToolSlowTimer(event.id, event.name) return true } case 'AiToolCallCompleted': { const tc = findToolCall(event.id) if (tc) { tc.status = 'completed' tc.result = event.result } state.pendingApprovals = state.pendingApprovals.filter(p => p.id !== event.id) clearToolSlowTimer(event.id) // AE-2025-06: 工具结束(无论审批通过后执行还是被拒),清审批超时计时器 clearApprovalTimer(event.id) return true } case 'AiToolAutoApproved': { // AE-2025-04 会话级信任:不写消息/不动 pending(Started/Completed 仍独立发), // 仅经事件总线桥接到 AiChat.vue 弹本地 toast(composable 无组件上下文,与 // ai-tool-slow-toast 同款中转模式)。主窗口与分离窗口各挂一份 AiChat,各自消费。 void emit('ai-tool-auto-approved-toast', { tool: event.tool, dir: event.dir }) return true } case 'AiApprovalRequired': { clearStreamWatchdog(event.conversation_id || undefined) // 等用户审批,不计超时 // path_auth 审批链阶段3b:event.kind 缺省默认 'risk'(后端阶段3a wire 未加 kind 字段, // 老后端不传即 risk 类)。event.kind 为 'path' 时理论上 path 挂起也走此事件(后端未来可统一), // 当前阶段3a 后端 path 挂起仍走独立 AiDirAuthRequired 事件,故此处 kind 恒 'risk'。 const info: AiToolCallInfo = { id: event.id, name: event.name, args: event.args, status: 'pending_approval', kind: event.kind ?? 'risk', } state.pendingApprovals.push(info) const tc = findToolCall(event.id) if (tc) { tc.status = 'pending_approval' tc.reason = event.reason // AE-2025-03: write_file 审批注入行级 diff(旧文件 vs 新内容),前端审批卡预览 if (event.diff) tc.diff = event.diff } // B-260616-12: 进入审批等待→取消该工具慢执行计时器(审批耗时由用户主导,非执行慢) clearToolSlowTimer(event.id) // AE-2025-06: 审批等待开始→启动审批超时计时器(5min 不处理自动拒绝) // 传 kind='risk' → 到点回调调 aiApi.approve(id,false)(后端 ai_approve kind==Risk 链路)。 startApprovalTimer(event.id, event.name, 'risk') return true } case 'AiApprovalResult': { if (!event.approved) { const tc = findToolCall(event.id) if (tc) { tc.status = 'rejected' // CR-260615-08(P1-1):走 i18n —— 原硬编码中文使 en locale 拒绝提示恒中文, // 且废掉已存在的 aiTool.rejectedHint(en: 'User rejected this action')翻译 tc.result = t('ai.aiTool.rejectedHint') } state.pendingApprovals = state.pendingApprovals.filter(p => p.id !== event.id) clearToolSlowTimer(event.id) // AE-2025-06: 用户已拒绝(状态离开 pending_approval)→清审批超时计时器 clearApprovalTimer(event.id) } else { // B-260616-12: 审批通过→工具重新进入执行态,重启慢执行计时器 startToolSlowTimer(event.id, findToolCall(event.id)?.name || '') // path_auth 审批链:path 类(once/always 通过)与 risk 类共用此分支。 // 补 clearApprovalTimer 防 timer 到期错调:授权通过但超时计时器未清,5min 后仍会触发拒绝回调 // (path 类调 authorizeDir('deny')/risk 类调 ai_approve(false))与已通过的执行态冲突。risk 类拒绝分支(:422)已清, // 此处通过分支原漏清(回归:统一审批开关开后 path 类挂起也挂 timer,通过分支须对称清)。 clearApprovalTimer(event.id) } // F-260619-03 Phase B + path_auth 审批链阶段1:路径授权挂起的工具收到 ApprovalResult // (once/always 通过 / deny 拒绝)→ 按 id 从 pendingDirAuths 移除该条(后端 ai_authorize_dir // 已 try_continue 续 loop)。数组化后只清这一条,不影响同轮其他未决策的挂起项。 removePendingDirAuth(event.id) return true } default: return false } } /** lifecycle 域:整轮收尾(AiCompleted)与异常中断(AiError) */ function handleLifecycleEvent(event: AiChatEvent): boolean { switch (event.type) { case 'AiCompleted': { clearStreamWatchdog(event.conversation_id || undefined) clearAllToolSlowTimers() // B-260616-12: 整轮结束清全部工具慢执行计时器与已提示集合 flushCurrentText() state.currentText = '' setStreaming(false, { convId: event.conversation_id || null, reason: 'AiCompleted' }) state.generatingConvs.delete(event.conversation_id || '') state.agentRound = 0 // AE-2025-07: agentic 结束,复位轮次(隐藏进度条) // TD-260621-02 per-conv:仅当 pendingMaxRounds===本 conv 才清 null(避免清其他会话的挂起)。 if (pendingMaxRounds.value && pendingMaxRounds.value === (event.conversation_id || '')) { pendingMaxRounds.value = null } // L1 求助协议(§2.3):求助卡挂起仅清本 conv(新一轮发送时旧求助卡应消失)。 if (pendingHelp.value && pendingHelp.value.conversationId === (event.conversation_id || '')) { pendingHelp.value = null } // TD-260621-03 per-conv:仅清本 conv 的 path_auth 挂起(其他会话的弹窗不连累清空)。 // 批2-A 刚把 pendingMaxRounds per-conv,pendingDirAuths 漏跟;此处补齐—— // F-09 多会话并发下全清会让 B 会话的 DirAuthDialog 凭空消失(用户报"弹窗没了我没操作")。 const doneConv2 = event.conversation_id || '' pendingDirAuths.value = pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId === doneConv2) // UX-2025-04 / CR-30-2 / 决策 a1: 流中途失败保文——后端 emit AiCompleted(incomplete=true), // 前端追加系统提示气泡(镜像后端 session.messages 的 system 提示)。 // 注:此系统提示仅前端展示,后端已独立 push 到 session.messages 落库。 if (event.incomplete) { state.messages.push({ id: `incomplete-${nextMsgId()}`, role: 'assistant', content: t('ai.responseIncomplete'), timestamp: Date.now(), } as AiMessage) } // token 用量记录(开关开时):lastTokenUsage 供当前回复展示,convTokenTotal 累加对话总量 if (isShowTokenUsage()) { state.lastTokenUsage = { prompt: event.prompt_tokens, completion: event.completion_tokens, total: event.total_tokens, } if (state.convTokenTotal) { state.convTokenTotal.prompt += event.prompt_tokens state.convTokenTotal.completion += event.completion_tokens state.convTokenTotal.total += event.total_tokens } else { state.convTokenTotal = { prompt: event.prompt_tokens, completion: event.completion_tokens, total: event.total_tokens } } } // 清理分离窗口生成态快照(F-09:per-conv key,清本会话快照;兼容旧单 key) const doneConv = event.conversation_id || '' if (doneConv) { localStorage.removeItem(`df-ai-gen-${doneConv}`) localStorage.removeItem(`df-ai-text-${doneConv}`) } localStorage.removeItem('df-ai-gen') localStorage.removeItem('df-ai-text') void loadConversations() notifyConversationChanged() // 队列续发:当前完成后自动发下一条(经事件总线桥接,避免 import useAiSend 构成循环依赖) emit('ai-drain-queue', {}) return true } case 'AiError': { clearStreamWatchdog(event.conversation_id || undefined) clearAllToolSlowTimers() // B-260616-12: 错误收尾清全部工具慢执行计时器与已提示集合 clearAllApprovalTimers() // UX-260617-10: 错误中断释放所有审批超时计时器,防回调改 state 触发已卸载/已错流程 // UX-260619-06: 失败前先把流式累积的 currentText 回填到占位 assistant 气泡, // 保留"回答到一半"的部分回复(否则清 currentText 后部分内容消失)。 flushCurrentText() setStreaming(false, { convId: event.conversation_id || null, reason: 'AiError' }) state.generatingConvs.delete(event.conversation_id || '') state.currentText = '' state.agentRound = 0 // AE-2025-07: agentic 异常中断,复位轮次 state.queue = [] // B-32:错误收尾清队列,防生成中入队的消息被静默丢失(drainQueue 仅 AiCompleted 触发) // UX-260617-10: 错误收尾清残留待审批项——错误发生时若有工具停在 pending_approval, // 残留可点击审批按钮会让用户误以为还能批(实际后端已终止),残留审批卡误导操作。 state.pendingApprovals = [] // TD-260621-02 per-conv:仅当 pendingMaxRounds===本 conv 才清 null(避免清其他会话的挂起)。 if (pendingMaxRounds.value && pendingMaxRounds.value === (event.conversation_id || '')) { pendingMaxRounds.value = null } // L1 求助协议(§2.3):求助卡挂起仅清本 conv(错误中断后旧求助卡应消失)。 if (pendingHelp.value && pendingHelp.value.conversationId === (event.conversation_id || '')) { pendingHelp.value = null } // TD-260621-03 per-conv:仅清本 conv 的 path_auth 挂起(异常中断只清本会话弹窗,不连累并发会话)。 const errConv2 = event.conversation_id || '' pendingDirAuths.value = pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId === errConv2) // F-09: 清理分离窗口生成态快照(per-conv key,清本会话快照;兼容旧单 key) const errConv = event.conversation_id || '' if (errConv) { localStorage.removeItem(`df-ai-gen-${errConv}`) localStorage.removeItem(`df-ai-text-${errConv}`) } localStorage.removeItem('df-ai-gen') localStorage.removeItem('df-ai-text') // UX-03: 错误消息携带 error_type(供错误气泡差异化显隐「去设置」按钮)。 // AiMessage 类型未含 errorType 字段(不在本批白名单),用对象字面量 + cast 扩展; // 消费方(AiChat.vue canOpenSettings)经同 cast 读取,类型闭环在两端,不污染 types.ts。 state.messages.push({ id: `err-${nextMsgId()}`, role: 'assistant', content: friendlyError(event.error), isError: true, errorType: event.error_type, timestamp: Date.now(), } as AiMessage) return true } case 'AiHelpRequired': { // L1 求助协议(§2.3,2026-06-21):后端断路器熔断已 guard.reset(generating=false,loop 终止)。 // 前端按终止态收尾(对齐 AiError 分支:清看门狗/流式态/快照/队尾文本 flushCurrentText), // 但不创建错误气泡(求助非错误,是 AI 主动求助)——改为翻 pendingHelp 驱动求助卡(HelpRequiredCard) // 显 reason + options 按钮供用户选。 clearStreamWatchdog(event.conversation_id || undefined) clearAllToolSlowTimers() flushCurrentText() setStreaming(false, { convId: event.conversation_id || null, reason: 'AiHelpRequired' }) state.generatingConvs.delete(event.conversation_id || '') state.currentText = '' state.agentRound = 0 // 求助即终止 loop,清残留待审批项(对齐 AiError 分支语义,防残留审批卡误导)。 state.pendingApprovals = [] // TD-260621-02 per-conv:仅当 pendingMaxRounds===本 conv 才清 null(求助终止同理清达 max 挂起)。 if (pendingMaxRounds.value && pendingMaxRounds.value === (event.conversation_id || '')) { pendingMaxRounds.value = null } // per-conv 清本 conv path_auth 挂起(求助终止只清本会话弹窗,不连累并发会话)。 const helpConv2 = event.conversation_id || '' pendingDirAuths.value = pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId === helpConv2) // F-09: 清理分离窗口生成态快照(per-conv key,清本会话快照;兼容旧单 key) const helpConv = event.conversation_id || '' if (helpConv) { localStorage.removeItem(`df-ai-gen-${helpConv}`) localStorage.removeItem(`df-ai-text-${helpConv}`) } localStorage.removeItem('df-ai-gen') localStorage.removeItem('df-ai-text') // 翻 pendingHelp 驱动求助卡:用 convId 兜底(后端必带,无时默认当前活跃会话,优于丢卡片)。 pendingHelp.value = { reason: event.reason, context: event.context, options: event.options, conversationId: event.conversation_id || state.activeConversationId || null, } void loadConversations() notifyConversationChanged() return true } default: return false } } /** 后端事件分发:按 conversation_id 路由,流式累积文本,工具状态流转,看门狗联动 */ export function handleEvent(event: AiChatEvent) { const convId = event.conversation_id // 注:F-260620 path_auth 审批链根治卡死后,原 [AI-DIRAUTH-DIAG] 高频诊断 console.log 已删除 // (使命完成;热路径污染 devtools)。后续如需事件流诊断用 df-ai-event-trace 开关控制。 // 首次收到事件时同步当前对话 id(后端自动建对话的场景) // 同步写 appSettings(SQLite):刷新页面后 loadConversations 据此恢复上次会话 if (convId && !state.activeConversationId) { state.activeConversationId = convId void appSettings.set('df-ai-active-conv', convId) } // 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误/求助时刷新侧边栏 // L1 求助协议(§2.3):AiHelpRequired 同 AiError 后端已 guard.reset 终止 loop,需移出生成集。 const isCurrent = !convId || convId === state.activeConversationId if (!isCurrent) { if (event.type === 'AiCompleted' || event.type === 'AiError' || event.type === 'AiHelpRequired') { // F-09: 多会话并发 — 仅从 Set 移除该 conv(其他会话可能仍在生成),不再置 null 全局清空 state.generatingConvs.delete(convId || '') void loadConversations() } return } // 标记正在生成的对话(完成/错误/求助事件除外——三者后端均已终止 loop) if (convId && event.type !== 'AiCompleted' && event.type !== 'AiError' && event.type !== 'AiHelpRequired') { state.generatingConvs.add(convId) } // 流式看门狗:活跃事件(delta/工具/新轮/审批结果)重置;审批等待/完成/错误在 case 内 clear // TD-260621-01 per-conv:传 convId 走 per-conv Map(各会话独立计时,互不顶替/连累)。 if (!NO_RESET_WATCHDOG.has(event.type)) { resetStreamWatchdog(convId || undefined) } // 按域分派(streaming → tool → lifecycle);未命中任一域 → 无操作(保留原 switch 无 default 的语义) if (handleStreamingEvent(event)) return if (handleToolEvent(event)) return void handleLifecycleEvent(event) } /** 启动事件监听(幂等 + 并发去重,onMounted 与 sendMessage 首发竞态不会重复注册) */ export async function startListener() { // 已注册 → 直接复用(幂等;sendMessage 每次调用不重复注册) if (_unlistenAiEvent && _unlistenConvChanged && _unlistenApprovalClear) return // 并发去重:防 onMounted 与 sendMessage 首发竞态重复注册 listener → 文字双倍 if (_startPromise) return _startPromise // UX-260617-09: 内部任一 await reject 时,onMounted/sendMessage 的 await 在 Vue async // 生命周期/void 调用点处成为未捕获 rejection(无 console 输出)→ listener 未注册且无任何提示, // 表现为「发消息永远不回」。这里 catch + console.error 保留可观测性,再 throw 让外层 // finally 清 _startPromise(并发去重复位),调用方仍可 try/catch 兜底。 _startPromise = (async () => { try { _unlistenAiEvent = await aiApi.onEvent(handleEvent) _unlistenConvChanged = await listen('ai-conversation-changed', () => { void loadConversations() }) // AE-2025-06: onStreamTimeout(流式整流超时收尾)广播的清理事件 → 清全部审批超时计时器。 // useAiStream 不 import useAiSend(避循环依赖),经事件总线中转。 _unlistenApprovalClear = await listen('ai-approval-clear-timers', () => { clearAllApprovalTimers() }) // B-260616-01: L0 握手 — 通知后端前端已就绪,后端据此清除 HMR/刷新导致的残留 generating 状态 emit('ai-client-ready', { lastConvId: state.activeConversationId }) } catch (err) { console.error('[useAiEvents] startListener 注册失败,事件监听未生效:', err) throw err } })() try { await _startPromise } finally { _startPromise = null } } /** 停止事件监听(卸载时调用,释放后端 listener + 清看门狗) * 清看门狗:卸载时若仍在生成,_streamWatchdog 计时器未释放,130s 后 onStreamTimeout * 仍写 state(messages.push/置 streaming)——已卸载组件不应再被触发。故同步清除。 */ function stopListener() { _unlistenAiEvent?.() _unlistenConvChanged?.() _unlistenApprovalClear?.() _unlistenAiEvent = null _unlistenConvChanged = null _unlistenApprovalClear = null // UX-260617-26: stop 清零去重 promise——极速 mount/unmount/mount(HMR)时,首 mount 的 // _startPromise 可能仍在 pending(未 await 完即 unmount),再 mount 若命中并发去重分支会 // 返回这个已代表「旧注册」的过期 promise,新 listener 实际未注册。 _startPromise = null clearAllStreamWatchdogs() // TD-260621-01: 卸载清全部 per-conv + legacy fallback timer,防回调改已卸载组件 state clearAllToolSlowTimers() // B-260616-12: 卸载时释放所有工具慢执行计时器,防回调改 state 触发已卸载组件 clearAllApprovalTimers() // AE-2025-06: 卸载时释放所有审批超时计时器,防回调 push 消息触发已卸载组件 } export function useAiEvents() { return { startListener, stopListener, handleEvent, flushCurrentText, friendlyError, notifyConversationChanged, } }