//! AI 事件监听与分发(分派器)— startListener(租约)/ ensureListenerStarted(幂等确保)/ handleEvent //! //! 拆分(批次 A-B1):14 个事件 case 按域拆到 3 个叶子 handler: //! - useAiStreamingEvents.handleStreamingEvent(流式) //! - useAiToolEvents.handleToolEvent(工具) //! - useAiLifecycleEvents.handleLifecycleEvent(生命周期) //! 跨域共享的 helper/状态(textIdle/pendingMaxRounds/pendingHelp、工具慢执行 //! 计时器、delta 去重 Map、flushCurrentText/friendlyError/notifyConversationChanged)已下沉到 //! aiShared 或 useAiPendingState(叶子)。依赖方向:分派器 → 各叶子 handler → aiShared / 叶子(无环)。 //! //! handleEvent 保留外围逻辑(convId 同步/路由/看门狗)与 L2 状态机、跨端用户消息两个域内 handler。 //! useAiEvents() 返回 shape 保持不变(stores/ai.ts spread 形状不变 → 组件零改动)。 import { listen, emit } from '@tauri-apps/api/event' import { t } from '@/i18n/i18n-helpers' import { aiApi } from '@/api' import { useAppSettingsStore } from '@/stores/appSettings' import { state } from '@/stores/ai' import { nextMsgId, setConvState, convStates, switchingConvs, getConvStreamState, setConvCurrentText, setConvStreaming, clearConvStreamState, clearAllApprovalTimers, clearAllToolSlowTimers, flushCurrentText, friendlyError, notifyConversationChanged, clearLastDelta, discardConvPendingApprovals, addConvAgentFinished, clearConvAgentFinished, setConvRound, clearConvRound, type AgentFinishedStatus } from './aiShared' import { resetStreamWatchdog, clearAllStreamWatchdogs, clearResumeAttempts } from './useAiStream' import { setStreaming } from './streamingGuard' import { scheduleConversationsRefresh, activeConvKvKey } from './useAiConversations' import { pendingTaskParked, pendingTaskProgress } from './useAiPendingState' import { clearConvBoundaryCache } from './useTaskBoundary' import { handleStreamingEvent } from './useAiStreamingEvents' import { handleToolEvent } from './useAiToolEvents' import { handleLifecycleEvent, recordTaskParked } from './useAiLifecycleEvents' import type { AiChatEvent, MessageId } from '@/api/types' // 跨域共享 helper re-export(原为 useAiEvents 导出,拆分后从 aiShared/叶子透传,消费方 import 路径不变) export { getConvState } from './aiShared' export { flushCurrentText, friendlyError, notifyConversationChanged } export { setContextCallbacks } from './useAiLifecycleEvents' export type { ContextCallbacks } from './useAiLifecycleEvents' let _unlistenAiEvent: (() => void) | null = null let _unlistenConvChanged: (() => void) | null = null // onStreamTimeout 广播的审批计时器清理事件 unlistener let _unlistenApprovalClear: (() => void) | null = null // startListener 并发去重:防 onMounted 与 sendMessage 首发竞态下重复注册 listener // (两回调写同一 state.currentText → 流式文字双倍) let _startPromise: Promise | null = null // 租约计数(所有权模型,2026-10-05 根本修):仅组件挂载级 startListener 借/还, // send 等运行时路径走 ensureListenerStarted 不计数——计数只由对称生命周期驱动,不漂移。 let _leaseCount = 0 const appSettings = useAppSettingsStore() // 看门狗不重置的事件集合(审批等待/完成/错误/达 max/求助/停靠由各自 case 内 clear)。 // 模块级 Set 复用,避免每事件(delta/token 高频)新建数组字面量做 includes。 const NO_RESET_WATCHDOG = new Set(['AiApprovalRequired', 'AiCompleted', 'AiError', 'AiMaxRoundsReached', 'AiHelpRequired', 'AiTaskParked']) // 后台会话完成 toast 的摘要截长(轻提醒只够判断「做完了/值不值得切回去看」) const AGENT_FINISHED_TOAST_SUMMARY_MAX = 40 /** 全局审批类事件:跨会话放行,不因非当前会话被过滤。审批挂起是等用户决策的跨会话资产, * 丢弃后用户在切回该会话前没有任何感知与处理入口;归口仍按 conversation_id 写 per-conv * 待审批集合(aiShared.addPendingApproval),不污染当前视图。 */ function isGlobalApprovalEvent(event: AiChatEvent): boolean { return event.type === 'AiApprovalRequired' } /** L2 状态机域:消费 AiConvStateChanged,写 convStates(per-conv conv_state 真相源)。 * 返回 true=已处理;无 convId 无法路由时忽略(防误写全局态)。 */ function handleConvStateEvent(event: AiChatEvent): boolean { if (event.type !== 'AiConvStateChanged') return false const convId = event.conversation_id || state.activeConversationId if (!convId) return true setConvState(convId, event.conv_state) return true } /** S1 子代理完成域:消费 AiAgentFinished,写 per-conv 完成卡集合(aiShared.convAgentFinished)。 * AiChatEvent 联合类型(types.ts 归口)暂未收编该事件,经局部 shape 窄化取字段,不改 types。 * 归集挂在 isCurrent 守卫之前(同 AiConvStateChanged 范式):后台会话的子代理结束也要落卡, * 切回仍可见;「不串当前视图」由消费侧只读当前会话数组天然保证,无需在此按活跃会话过滤。 */ function handleAgentFinishedEvent(event: AiChatEvent): boolean { if ((event as { type?: string }).type !== 'AiAgentFinished') return false const ev = event as unknown as { agent_id: string summary: string status: AgentFinishedStatus rounds: number tokens: number rejected_tools?: string[] | null stop_kind?: string | null } const convId = event.conversation_id if (!convId || !ev.agent_id) return true addConvAgentFinished(convId, { agentId: ev.agent_id, summary: ev.summary, status: ev.status, rounds: ev.rounds, tokens: ev.tokens, // 后端 Option→null/缺省,卡片语义统一为缺省=零拒绝 rejected_tools: ev.rejected_tools ?? undefined, // 结构化终止原因(可选,旧后端不发):存卡供卡面状态词优先消费,替代 summary 启发式 stop_kind: ev.stop_kind ?? undefined, }) // P3-6:后台会话完成轻提醒(当前会话卡已在消息流可见,不弹)。 // composable 无组件上下文,复用 ai-toast 事件总线先例(ai-tool-slow-toast),AiChat listen 弹本地 toast; // 多个子代理连发时 toast 单槽自然合并,无风暴面。 if (convId !== state.activeConversationId) { // 未知 status 兜底按 completed 语气(防 t 回显键名);failed 用 warning 色与完成区分 const statusKey = ev.status === 'failed' ? 'failed' : ev.status === 'truncated' ? 'truncated' : 'completed' const collapsed = ev.summary.replace(/\s+/g, ' ').trim() const summary = collapsed.length > AGENT_FINISHED_TOAST_SUMMARY_MAX ? collapsed.slice(0, AGENT_FINISHED_TOAST_SUMMARY_MAX) + '…' : collapsed void emit('ai-toast', { msg: t('ai.agentFinishedToast', { status: t(`ai.agentFinished.${statusKey}`), summary }), type: ev.status === 'failed' ? 'warning' : 'info', }) } // 子代理执行是该会话的真实活跃信号(父 loop 在 spawn_agent 内等待):此前该事件穿透到 // 活跃事件分支会重置看门狗,现提前归集后显式补上,保持既有不误判停滞的语义。 resetStreamWatchdog(convId) return true } /** 无人值守停靠域:消费 AiTaskParked(per-conv 落卡 + 后台会话收敛,挂起家族终局)。 * 返回 true=已处理。仅拦截后台会话:当前会话放行到 lifecycle 域统一收尾(看门狗/流式复位 * 在 cleanupTerminatedConversation,后台分支此处的收敛集合与其对齐但不重复触发两次)。 * 停靠卡必须 per-conv 落卡:多会话并发各自可停靠,后台会话的卡丢了用户切回前无从得知。 */ function handleTaskParkedEvent(event: AiChatEvent): boolean { if (event.type !== 'AiTaskParked') return false const convId = event.conversation_id || state.activeConversationId if (convId && convId !== state.activeConversationId) { recordTaskParked(convId, event.reason, event.stop_kind ?? undefined) // 对齐 AiCompleted 家族后台分支的收敛:删状态项回不在生成、清 per-conv 流式态/去重记录/ // 挂起审批集合/自动续跑计数/进度快照,再刷新侧栏(防抖调度) convStates.delete(convId) clearConvStreamState(convId) clearLastDelta(convId) discardConvPendingApprovals(convId) clearResumeAttempts(convId || undefined) pendingTaskProgress.delete(convId) scheduleConversationsRefresh() return true } return false } /** 无人值守进度域:消费 AiTaskProgress(per-conv 快照,静默期「在干活」可见)。 * 返回 true=已处理。isCurrent 守卫之前归集(同停靠卡范式):后台会话的进度也要落 Map, * 切回仍可见。进度事件即活体信号,顺带 resetStreamWatchdog(子代理长跑/重入退避期间 * 后端心跳之外的第二重存活证明,防看门狗误判停滞)。 */ function handleTaskProgressEvent(event: AiChatEvent): boolean { if (event.type !== 'AiTaskProgress') return false const convId = event.conversation_id || state.activeConversationId if (!convId) return true pendingTaskProgress.set(convId, { round: event.round, maxRounds: event.max_rounds, tokensUsed: event.tokens_used, updatedAt: Date.now(), }) resetStreamWatchdog(convId) return true } /** 跨端用户消息域:miniapp→桌面 user 气泡同步(仅处理 AiUserMessage)。 * 去重:末条已是同 content 的 user 则跳过(防本地乐观 push + 事件回灌双气泡)。 */ function handleUserMessageEvent(event: AiChatEvent): boolean { if (event.type !== 'AiUserMessage') return false // 多会话并行隔离:AiUserMessage 必带 conversation_id;非当前会话不污染当前视图,仅刷新列表 const uc = event.conversation_id ?? null if (uc && state.activeConversationId && uc !== state.activeConversationId) { // FE1:非当前会话的用户消息只刷新列表(走防抖调度,与收尾 notify 合并为一次)。 scheduleConversationsRefresh() return true } const last = state.messages[state.messages.length - 1] if (last && last.role === 'user' && last.content === event.message) { return true } state.messages.push({ id: `user-${nextMsgId()}` as MessageId, role: 'user', content: event.message, timestamp: Date.now(), }) return true } /** 后端事件分发:按 conversation_id 路由,流式累积文本,工具状态流转,看门狗联动 */ export function handleEvent(event: AiChatEvent) { const convId = event.conversation_id // 首次收到事件时同步当前对话 id(后端自动建对话的场景)并持久化 if (convId && !state.activeConversationId) { state.activeConversationId = convId void appSettings.set(activeConvKvKey(), convId) } // L2 状态机:AiConvStateChanged 是 per-conv 状态信号,在 isCurrent 守卫之前处理 // (切走会话时也须更新 conv_state,防停止按钮/MaxRoundsCard 错态) if (handleConvStateEvent(event)) return // 跨端用户消息同步:在 isCurrent 守卫之前处理(补 user 气泡信号,非流式活跃事件) if (handleUserMessageEvent(event)) return // S1 子代理完成卡归集:在 isCurrent 守卫之前处理(per-conv 落卡,后台会话不丢卡) if (handleAgentFinishedEvent(event)) return // 无人值守停靠卡归集:在 isCurrent 守卫之前处理(per-conv 落卡,后台会话不丢卡) if (handleTaskParkedEvent(event)) return // 无人值守进度快照归集:在 isCurrent 守卫之前处理(per-conv 落 Map,后台会话不丢进度) if (handleTaskProgressEvent(event)) return // 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误/求助时刷新侧边栏; // 全局审批类事件例外(isGlobalApprovalEvent),放行到下方按域分派 const isCurrent = !convId || convId === state.activeConversationId if (!isCurrent && !isGlobalApprovalEvent(event)) { if (event.type === 'AiCompleted' || event.type === 'AiError' || event.type === 'AiHelpRequired') { // 非当前会话终止:收敛会话状态(删 Map 项回不在生成) convStates.delete(convId || '') // 同步清该会话 per-conv 流式态。残留的 streaming/currentText 是死数据:切回时会被 // switchConversation 恢复进 currentText,下轮发送的防御 flush 又把它写进上一轮气泡, // 把已完成回复覆盖为残片;回复正文以切换时 DB 加载为准,流式态无需保留。 clearConvStreamState(convId || '') // 对齐前台 cleanupTerminatedConversation 的 per-conv 收敛:清 delta 去重记录(残留记录 // 会在切回后把下一轮首个与上轮末个相同内容的 delta 误判重复丢弃)、待审批集合(终止即作废, // 切回时由 pendingToolCalls 从后端审计表按需重建)、自动续跑计数(终止即清,不带入新轮) clearLastDelta(convId || '') discardConvPendingApprovals(convId || '') clearResumeAttempts(convId || undefined) // 终态即清进度快照(事件即逝态,会话已终止不留陈旧「进行中」) pendingTaskProgress.delete(convId || '') // #14 桌面任务卡:终态即清轮次(任务卡据 convRounds 渲染在途项,终止不留陈旧项) clearConvRound(convId || '') // FE1:后台会话终止只刷新列表(防抖调度,避免与 AiCompleted 收尾的 notify 双拉)。 scheduleConversationsRefresh() // 后台会话终止也触发队列续发/清队(完成续发;错误清该会话队列) if (event.type === 'AiCompleted') { emit('ai-drain-queue', { conversationId: event.conversation_id }) } else if (event.type === 'AiError') { // 9-28#5 收口:无 convId 的 AiError 属异常防御路径(后端 emit 必带),清全部队列 // 会误伤其他会话排队消息,改保留 + console.warn 留痕 if (event.conversation_id) { state.queue = state.queue.filter(q => q.conversationId !== event.conversation_id) } else { console.warn('[ai] AiError 缺 conversation_id,跳过清队列(防误伤其他会话排队)') } } } else if (event.type === 'AiTextDelta' && convId && switchingConvs.has(convId)) { // 切换中缓冲:目标会话正被 switchConversation 拉取,其 delta 累积到 per-conv 流式态, // 切换完成后由渲染层续显,避免切换窗口丢可见内容 setConvStreaming(convId, true) setConvCurrentText(convId, (getConvStreamState(convId)?.currentText ?? '') + (event.delta ?? '')) } else if (event.type === 'AiAgentRound' && convId && event.round > 0) { // #14 桌面任务卡:后台会话轮次追踪(isCurrent=false 原本直接丢弃本事件, // 任务卡需要后台轮次显示「进行中 N 轮」;取 max 防事件乱序回退) setConvRound(convId, event.round) } return } // 无目标会话(事件缺 convId 且当前无活跃会话):流式 delta 无处累积,跳过写防 null 键无意义写入 if (!convId && !state.activeConversationId && event.type === 'AiTextDelta') return // 活跃事件(delta/工具/新轮/审批结果)到达即标记该会话生成中(enum 兜底写入口; // 后端 AiConvStateChanged{generating} 已先到时同值 set 幂等覆盖,无副作用)。 // 终态家族(AiCompleted/AiError/AiHelpRequired/AiTaskParked)除外:标记生成中会与 // 收尾的删项收敛打架(停靠后端已复位 generating,不能被兜底写回) if (convId && event.type !== 'AiCompleted' && event.type !== 'AiError' && event.type !== 'AiHelpRequired' && event.type !== 'AiTaskParked') { setConvState(convId, 'generating') } // 流式看门狗:活跃事件重置;审批等待/完成/错误在 case 内 clear(per-conv,各会话独立计时) if (!NO_RESET_WATCHDOG.has(event.type)) { resetStreamWatchdog(convId || undefined) } // 按域分派(streaming → tool → lifecycle);未命中任一域 → 无操作 if (handleStreamingEvent(event)) return if (handleToolEvent(event)) return void handleLifecycleEvent(event) } /** 启动事件监听(幂等确保注册,无所有权——send 等运行时路径调用,不计入租约)。 * 所有权模型(2026-10-05 根本修): * - ensureListenerStarted:仅保证 listener 存在,谁都可调、任意次,不持租约; * - startListener:组件挂载用,返回 release 租约句柄,unmount 时调用归还; * 最后一份租约归还才真拆 listener + 清全局计时器。 * 两者分离根治「send 裸调 start 无配对 stop → 裸计数只涨不跌 → 永不真拆」的结构性漂移: * 计数只由组件挂载/卸载这一对对称生命周期驱动,不变量可静态审计。 */ export async function startListener(): Promise<() => void> { await ensureListenerStarted() _leaseCount++ let released = false return () => { if (released) { // 双重归还是调用方 bug(组件卸载钩子重复执行等),dev 下即刻暴露防静默漂移 if (import.meta.env.DEV) console.error('[useAiEvents] listener 租约重复归还(调用方 bug)') return } released = true releaseLease() } } /** 幂等确保 listener 已注册(并发去重;不持租约,不触发拆卸)。 */ export async function ensureListenerStarted(): Promise { if (_unlistenAiEvent && _unlistenConvChanged && _unlistenApprovalClear) { return } if (_startPromise) return _startPromise _startPromise = (async () => { try { _unlistenAiEvent = await aiApi.onEvent(handleEvent) _unlistenConvChanged = await listen('ai-conversation-changed', (e) => { // FE1:notify 自触发的刷新走防抖调度,同窗口内多次 notify 合并为一次列表拉取。 scheduleConversationsRefresh() // 主窗口删除了本窗口当前展示的会话:清空视图防陈旧幽灵(分离窗口独立 JS context,经事件同步)。 const deleted = (e.payload as { deletedConvId?: string } | undefined)?.deletedConvId if (deleted && state.activeConversationId === deleted) { state.activeConversationId = null state.messages = [] clearConvStreamState(deleted) setStreaming(false, { convId: deleted, reason: 'conv-deleted' }) convStates.delete(deleted) } // S1:会话删除连带清其完成卡(Map 不持已删会话条目;存在即清,不限当前视图) if (deleted) { clearConvAgentFinished(deleted) // 无人值守三件随会话同亡:停靠卡挂起 + 进度快照 + 边界镜像(边界生命周期=会话存续,设计 §二) pendingTaskParked.delete(deleted) pendingTaskProgress.delete(deleted) clearConvBoundaryCache(deleted) } }) _unlistenApprovalClear = await listen('ai-approval-clear-timers', () => { clearAllApprovalTimers() }) emit('ai-client-ready', { lastConvId: state.activeConversationId }) } catch (err) { console.error('[useAiEvents] startListener 注册失败,事件监听未生效:', err) throw err } })() try { await _startPromise } finally { _startPromise = null } } /** 归还一份租约:计数归零才真拆(多实例挂载时先退出的实例不误拆他人在用的 listener)。 */ function releaseLease() { if (_leaseCount === 0) { if (import.meta.env.DEV) console.error('[useAiEvents] 租约下溢(归还多于借出,调用方 bug)') return } _leaseCount-- if (_leaseCount > 0) return _unlistenAiEvent?.() _unlistenConvChanged?.() _unlistenApprovalClear?.() _unlistenAiEvent = null _unlistenConvChanged = null _unlistenApprovalClear = null // stop 清零去重 promise:极速 mount/unmount/mount(HMR)时,首 mount 的 _startPromise 可能仍 pending, // 再 mount 若命中并发去重分支会返回这个已代表「旧注册」的过期 promise,新 listener 实际未注册 _startPromise = null clearAllStreamWatchdogs() clearAllToolSlowTimers() clearAllApprovalTimers() } export function useAiEvents() { return { startListener, ensureListenerStarted, handleEvent, flushCurrentText, friendlyError, notifyConversationChanged, } }