//! 生命周期事件域 — handleLifecycleEvent + cleanupTerminatedConversation //! //! 处理:AiCompleted / AiError / AiHelpRequired / AiTaskParked + 上下文生命周期事件(AiCompressing/ //! AiManualCompressed/AiAutoCompressed/AiContextCleared)。 //! 依赖方向:本模块 → aiShared / useAiPendingState(叶子)/ useAiStream / streamingGuard / //! useAiConversations,不导入任何同级子 composable。 import { emit } from '@tauri-apps/api/event' import { useAppSettingsStore } from '@/stores/appSettings' import { state } from '@/stores/ai' import { t } from '@/i18n/i18n-helpers' import { aiApi } from '@/api/ai' import { nextMsgId, convStates, clearApprovalTimer, flushCurrentText, clearAllToolSlowTimers, friendlyError, notifyConversationChanged, clearLastDelta, discardConvPendingApprovals, notifyQueueCleared, clearConvRound } from './aiShared' import { clearTextIdleTimer, pendingMaxRounds, pendingHelp, pendingTaskParked, pendingTaskProgress } from './useAiPendingState' import { clearStreamWatchdog, clearResumeAttempts } from './useAiStream' import { setStreaming } from './streamingGuard' import type { AiChatEvent, AiMessage, MessageId } from '@/api/types' const appSettings = useAppSettingsStore() // 上下文管理生命周期事件回调(useAiContext.initContextListener 注入,本域统一分发) export interface ContextCallbacks { /** 压缩开始(AiCompressing):置 loading=true */ onCompressing?: () => void /** 手动压缩成功(AiManualCompressed):置 loading=false + toast + 回填摘要 */ onManualCompressed?: (conversationId: string, summary: string) => void /** 自动压缩成功(AiAutoCompressed):静默置 loading=false(不弹 toast) */ onAutoCompressed?: () => void /** 上下文已清空(AiContextCleared):toast + 刷新消息列表 */ onCleared?: (conversationId: string) => void /** 错误(AiError,含压缩失败):仅在 loading 时置 false + toast */ onError?: (conversationId: string, message: string) => void /** E2 压缩失败降级(AiContextDegraded):loop 仍在跑,仅提示上下文已降级不中断流 */ onContextDegraded?: (conversationId: string, reason: string) => void } let _contextCallbacks: ContextCallbacks = {} /** 注入上下文生命周期回调(useAiContext.initContextListener 调用) */ export function setContextCallbacks(callbacks: ContextCallbacks): void { _contextCallbacks = callbacks } /** token 用量展示开关(读 appSettings,与 Settings.vue 共享 key `df-show-token-usage`) */ function isShowTokenUsage(): boolean { return appSettings.get('df-show-token-usage', false) } /** * 公共会话终止收尾(AiCompleted/AiError/AiHelpRequired/AiTaskParked 共用):清看门狗/计时器/流式态/挂起。 * * 返回:boolean=true 正常收尾(调用方继续执行下游逻辑);false=迟到收尾被仲裁拦截 * (调用方必须跳过下游:drain 队列/错误气泡/停靠卡等都是旧任务事件伪影,不应执行)。 * * 语义差异(调用方自行处理): * - AiCompleted: 调用后追加 incomplete 气泡 / token 用量 / 队列 drain * - AiError: 调用后追加错误气泡 + 清 queue * - AiHelpRequired: 调用后翻 pendingHelp 驱动求助卡 * - AiTaskParked: 调用后登记 pendingTaskParked 驱动停靠卡(摘旧卡;不清 pendingHelp, * 熔断并存场景见下方 pendingHelp 分支注释) */ async function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | 'AiError' | 'AiHelpRequired' | 'AiTaskParked'): Promise { // ── 迟到收尾仲裁(BUG-2026-10-07 根修)── // // 症状:上条处理中→发新消息入队→点「立即发送」→新气泡丢失+红按钮状态丢失。 // // 链路:sendQueuedNow 先 stopChat(streaming=false)→sendMessage(force) push 新气泡+ // setStreaming(true)→force_send 原子打断旧任务→旧任务的 AiCompleted 迟到到达→ // 本函数无差别 setStreaming(false) 误杀新任务的 streaming→红按钮消失+新 delta 无 // 流式渲染载体(气泡空壳)。 // // 仲裁:收尾前 IPC 查后端该会话真实 generating。仍在生成=这是旧任务迟到收尾(新任务 // 已在跑),跳过全部运行态收敛与下游事件伪影;后端确实已终止=正常收尾,原语义全量执行。 // 查态失败按正常收尾兜底(不因仲裁 IPC 挂而卡终态)。 // // 为什么不用前端 epoch:迟到事件本身不携带归属标记,前端纪元在「新任务收尾 vs 旧任务 // 迟到收尾」两种场景下数值无法区分;后端 generating 是唯一可区分两场景的真值源。 let backendGenerating = false try { backendGenerating = await aiApi.isGenerating(convId || null) } catch { backendGenerating = false // 查态失败:按正常收尾兜底,防终态被仲裁 IPC 永久挂起 } if (backendGenerating && state.streaming) { console.warn(`[AI-Lifecycle] 迟到收尾仲裁命中(conv=${convId.slice(0, 8)}, reason=${reason}):后端仍在生成,跳过收敛与下游伪影`) return false } // ── 以下为正常收尾路径(后端已终止或查态失败兜底)── clearStreamWatchdog(convId || undefined) // 正常收尾路径清该会话的 delta 去重记录:flushCurrentText 只清活跃会话的,这里按 convId // 显式清一遍兜住 convId 与 active 不一致或事件缺 convId 的时序,防残留记录误丢下轮同内容 delta clearLastDelta(convId || undefined) // 正常收尾清自动续跑计数:计数只服务于单轮内连续超时的风暴防护,终态后必须归零, // 否则陈旧计数会让下一轮的正常超时提前触发强制收敛(清零只放收尾路径,不在发消息时清) clearResumeAttempts(convId || undefined) clearAllToolSlowTimers() // 整轮/错误/求助结束清全部工具慢执行计时器与已提示集合 clearTextIdleTimer() // 清文本空闲定时器 + 置 textIdle=true(整轮结束不该有活跃信号残留) flushCurrentText() state.currentText = '' // 清「重试中」过渡气泡(成功后残留根治;AiStreamRetry 推的 retry 气泡是过渡态,终态不清会永久残留)。 // 按 isRetry 标记匹配,替代旧中文文案子串匹配(en locale 文案同样生效,文案改版不再漏清) for (let i = state.messages.length - 1; i >= 0; i--) { const m = state.messages[i] if (m.isRetry === true) { state.messages.splice(i, 1) } } setStreaming(false, { convId: convId || null, reason }) state.agentRound = 0 // 结束/中断/求助,复位轮次(隐藏进度条) // #14 桌面任务卡:终态即清 per-conv 轮次(前台会话终态收敛,与后台分支 clearConvRound 对齐) clearConvRound(convId) // convStates 收敛:AiError 保留 error 项(停止按钮显「重试」态),其余删项回 idle if (reason === 'AiError') { if (convId) convStates.set(convId, 'error') } else { convStates.delete(convId) } // per-conv 挂起仅清本 conv(避免清错其他会话的挂起) if (pendingMaxRounds.value && pendingMaxRounds.value === convId) { pendingMaxRounds.value = null } if (pendingHelp.value && pendingHelp.value.conversationId === convId) { // 熔断场景后端并存发求助卡+停靠卡(agentic/mod.rs 熔断分支先 emit 求助卡再「补发」 // AiTaskParked,注释自称并存意图):停靠收尾不清求助卡,help options 三选项不丢, // 两卡并存供用户任选入口。其余终态(AiCompleted/AiError/AiHelpRequired)照旧清。 if (reason !== 'AiTaskParked') pendingHelp.value = null } // 无人值守停靠卡:会话有任何终态(含停靠本身)即摘旧卡;停靠 case 在收尾后重新登记, // 其余终态(AiCompleted/AiError,点继续续跑后的正常收敛/出错)摘卡即消失,语义对齐 pendingHelp pendingTaskParked.delete(convId) // 无人值守进度快照:终态即清(事件即逝态,终止后不留陈旧「进行中」;下次 loop 每 5 轮重建) pendingTaskProgress.delete(convId) // 审批收尾仅清目标 conv 的待审批项 + 对称清其超时计时器(其他会话的审批卡/计时器不受影响; // conversationId 缺失的旧项无法归属,保守保留防误清正在审批的卡) const removedApprovals = state.pendingApprovals.filter(p => p.conversationId === convId) state.pendingApprovals = state.pendingApprovals.filter(p => !p.conversationId || p.conversationId !== convId) for (const p of removedApprovals) clearApprovalTimer(p.id) // 本会话已终止,其 per-conv 挂起集合作废(仅删集合项,视图保留上面无法归属的旧项; // 切回该会话时由 pendingToolCalls 从后端审计表按需重建) discardConvPendingApprovals(convId || undefined) // 清分离窗口生成态快照(per-conv key,清本会话;兼容旧单 key) if (convId) { localStorage.removeItem(`df-ai-gen-${convId}`) localStorage.removeItem(`df-ai-text-${convId}`) } localStorage.removeItem('df-ai-gen') localStorage.removeItem('df-ai-text') return true // 正常收尾完成,调用方继续执行下游逻辑 } /** * 登记无人值守停靠卡(幂等,convId 键控覆盖)。lifecycle 主路径与 useAiEvents 后台会话 * 分支共用唯一写入口,防止两处字段口径漂移。convId 缺失兜底当前活跃会话(优于丢卡)。 * * #906 打扰治理:同会话短时内重复停靠节流。网络抖动期「重发→中断→再弹」会连续触发停靠, * 卡片反复弹/消造成打扰观感。策略:同一 conv 距上次停靠 < PARKED_REPARK_THROTTLE_MS 时, * 若当前已有停靠卡(未被用户操作摘除)→ 保留原卡不刷新(避免重渲染/时间戳跳动);若原卡已被 * 用户「继续/停止」摘除(用户已主动处置)→ 仍放行新卡(不能吞掉用户需决策的新终局)。 * 节流仅压「未处置的重复弹」,不吞「用户已处置后的真实新停靠」,安全边界清晰。 */ const PARKED_REPARK_THROTTLE_MS = 20000 // 上次停靠时刻(per-conv,跨摘卡保留):用于判定「用户已处置后短时再停靠」是否属抖动回环。 const _lastParkedAt = new Map() export function recordTaskParked(convId: string | null | undefined, reason: string, stopKind?: string): void { const id = convId || state.activeConversationId if (!id) return const now = Date.now() const existing = pendingTaskParked.get(id) const lastAt = _lastParkedAt.get(id) ?? 0 // 节流:卡仍在(未处置)+ 距上次停靠极近 → 保留原卡,不刷新(压抖动期重复弹)。 if (existing && now - lastAt < PARKED_REPARK_THROTTLE_MS) { return } _lastParkedAt.set(id, now) pendingTaskParked.set(id, { reason, stopKind: stopKind || undefined, conversationId: id, parkedAt: now, }) } /** lifecycle 域:整轮收尾(AiCompleted)与异常中断(AiError)。返回 true=已处理。 * 异步说明:cleanup 内含仲裁 IPC(ai_is_generating),故本函数 async;handleEvent 以 void 调用, * 每个终态 case 必须先 await cleanup 拿到仲裁结果再决定下游执行。 */ export async function handleLifecycleEvent(event: AiChatEvent): Promise { switch (event.type) { case 'AiCompleted': { console.warn('[FE-AICOMPLETED] 收到 conv=', event.conversation_id, 'streaming=', state.streaming) const completed = await cleanupTerminatedConversation(event.conversation_id || '', 'AiCompleted') console.warn('[FE-AICOMPLETED] cleanup 后 streaming=', state.streaming, '仲裁通过=', completed) if (!completed) return true // 迟到收尾:事件伪影(incomplete/token/drain)全跳过,新任务的终态再统一收 // 流中途失败保文:后端 emit AiCompleted(incomplete=true),前端追加系统提示气泡。 // incomplete_reason 细分文案:"max_rounds"=达上限自动收尾(非网络中断,语义不同), // 其他/缺省=网络中断等异常走原提示。 if (event.incomplete) { state.messages.push({ id: `incomplete-${nextMsgId()}` as MessageId, role: 'assistant', content: event.incomplete_reason === 'max_rounds' ? t('ai.responseIncompleteMaxRounds') : t('ai.responseIncomplete'), timestamp: Date.now(), }) } // 看板 953:max_tokens 截断标记(主对话气泡截断提示条的数据源)。后端正常收敛出口且 // 最终回复被 provider 上限腰斩时为 true;旧事件无字段(undefined)不标,向后兼容。 // 只标末条 assistant(倒序找,跳过错误气泡):用户再发消息/继续输出后新气泡顶掉末条 // 位置,提示条随 isLastAi 判定自然消失,无需清除逻辑。 if (event.truncated_by_max_tokens) { for (let i = state.messages.length - 1; i >= 0; i--) { const m = state.messages[i] if (m.role === 'assistant' && !m.isError) { m.truncatedByMaxTokens = true break } } } // 直接从事件更新 pinned_goals,不等 loadConversations 异步刷新 if (event.pinned_goals && state.activeConversationId) { const conv = state.conversations.find(c => c.id === state.activeConversationId) if (conv) conv.pinned_goals = event.pinned_goals } // FE1(AC-EFF-F1-1/2/3):去掉显式 loadConversations(曾与下方 notify 自触发双拉), // 列表刷新统一收敛到 ai-conversation-changed listener 的防抖调度,一次收尾只拉一次。 // token 用量记录(开关开时):lastTokenUsage 供当前回复展示,convTokenTotal 累加对话总量 if (isShowTokenUsage()) { state.lastTokenUsage = { prompt: event.prompt_tokens, completion: event.completion_tokens, total: event.total_tokens, cache_hit: event.prompt_cache_hit_tokens, cache_miss: event.prompt_cache_miss_tokens, reasoning: event.reasoning_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 } } // 每轮 token 写入最后一条 AI 消息,供 MessageList 逐条显示 // 口径说明:后端 AiCompleted 每 loop 只发一次,携带本 loop 累计用量,故 live 态 // 聚合到「最后一条 assistant」;reload(switchConversation)从 DB 逐条回显各轮本轮量 // (消息级 token)。两者展示层级不同(live=loop 聚合,reload=单轮),属已接受口径。 for (let i = state.messages.length - 1; i >= 0; i--) { const m = state.messages[i] if (m.role === 'assistant' && !m.isError) { m.tokenUsage = { prompt: event.prompt_tokens, completion: event.completion_tokens, cache_hit: event.prompt_cache_hit_tokens, cache_miss: event.prompt_cache_miss_tokens, reasoning: event.reasoning_tokens, is_estimated: event.is_estimated, } m.prompt_cache_hit_tokens = event.prompt_cache_hit_tokens m.prompt_cache_miss_tokens = event.prompt_cache_miss_tokens m.reasoning_tokens = event.reasoning_tokens break } } } notifyConversationChanged() // 队列续发:当前完成后自动发下一条(经事件总线桥接,避免 import useAiSend 循环依赖) emit('ai-drain-queue', { conversationId: event.conversation_id }) return true } case 'AiError': { const errored = await cleanupTerminatedConversation(event.conversation_id || '', 'AiError') if (!errored) return true // 迟到收尾:旧任务的错误事件伪影,跳过清队/错误气泡/回调 // 只清除出错会话的队列项,不误伤其他会话的排队消息; // UX-P2-A2:清队从静默改 toast 提示条数(用户排队的消息无声消失原无任何反馈) if (event.conversation_id) { const dropped = state.queue.filter(q => q.conversationId === event.conversation_id).length state.queue = state.queue.filter(q => q.conversationId !== event.conversation_id) notifyQueueCleared(dropped) } else { // 9-28#5 收口:无 convId 的 AiError 属异常防御路径(后端 emit 必带),清全部队列 // 会误伤其他会话排队消息,改保留 + console.warn 留痕(不再弹「已清 N 条」误导用户) console.warn('[ai] AiError 缺 conversation_id,跳过清队列(防误伤其他会话排队)') } state.messages.push({ id: `err-${nextMsgId()}` as MessageId, role: 'assistant', content: friendlyError(event.error), isError: true, errorType: event.error_type, timestamp: Date.now(), } as AiMessage) // AiError 也用于压缩 IPC 失败:通知上下文回调复位 loading + 弹错误 toast(内部判 isCompressing 守卫) _contextCallbacks.onError?.(event.conversation_id || '', event.error) return true } case 'AiHelpRequired': { // 求助协议:后端断路器熔断已 guard.reset(loop 终止)。按终止态收尾但不创建错误气泡, // 改为翻 pendingHelp 驱动求助卡(reason + options 按钮供用户选)。 const helped = await cleanupTerminatedConversation(event.conversation_id || '', 'AiHelpRequired') if (!helped) return true // 迟到收尾:旧任务的求助卡伪影,跳过(新任务无求助语境) // 用 convId 兜底(后端必带,无时默认当前活跃会话,优于丢卡片) pendingHelp.value = { reason: event.reason, context: event.context, options: event.options, conversationId: event.conversation_id || state.activeConversationId || null, } // FE1:去掉显式 loadConversations(与下行 notify 双拉),由 listener 防抖调度统一收口。 notifyConversationChanged() return true } case 'AiTaskParked': { // 无人值守安全停靠(设计 §四):五路非正常终局统一出口,后端已保存现场并复位 // generating(guard 复位非死等)。对齐 AiHelpRequired 家族:终止态收尾不建错误气泡, // 改登记 pendingTaskParked 驱动停靠卡(reason + stop_kind 标签 + 继续/停止归档)。 // 队列不动:停靠等用户拍板,排队消息不自动续发不静默清(继续/停止由用户决定)。 const parked = await cleanupTerminatedConversation(event.conversation_id || '', 'AiTaskParked') if (!parked) return true // 迟到收尾:旧任务的停靠卡伪影,跳过登记 recordTaskParked(event.conversation_id, event.reason, event.stop_kind ?? undefined) // FE1:停靠刷新侧栏(更新活跃时间排序),由 listener 防抖调度统一收口。 notifyConversationChanged() return true } case 'AiCompressing': _contextCallbacks.onCompressing?.() return true case 'AiManualCompressed': { // 手动 IPC 压缩:复位 loading + 弹 toast + 刷新视图回填摘要 _contextCallbacks.onManualCompressed?.(event.conversation_id || '', event.summary || '') return true } case 'AiAutoCompressed': { // loop 自动压缩对桌面静默——仅复位 loading(不弹 toast、不打断 LLM 流) _contextCallbacks.onAutoCompressed?.() return true } case 'AiContextCleared': _contextCallbacks.onCleared?.(event.conversation_id || '') return true case 'AiContextDegraded': // E2:压缩失败已走关键词摘要兜底,loop 继续——透出降级提示而非静默失忆 _contextCallbacks.onContextDegraded?.(event.conversation_id || '', event.reason || '') return true default: return false } }