//! AI 事件监听与分发 — startListener/stopListener/handleEvent 及其辅助函数 //! //! 模块级私有状态(不进 reactive): //! - _unlistenAiEvent / _unlistenConvChanged: 已注册的 unlistener //! - _startPromise: startListener 并发去重(防 onMounted 与 sendMessage 首发竞态重复注册) //! //! 耦合: //! - handleEvent 调 useAiConversations.loadConversations、useAiSend.drainQueue、 //! useAiStream.{resetStreamWatchdog,clearStreamWatchdog}、本模块 flushCurrentText/findToolCall/notifyConversationChanged //! - nextMsgId 已下沉到 aiShared(原为本模块导出,useAiStream 亦依赖之构成循环依赖,故抽出) import { listen, emit } from '@tauri-apps/api/event' import { aiApi } from '../../api' import { useAppSettingsStore } from '../../stores/appSettings' import { state } from '../../stores/ai' import i18n from '../../i18n' import { nextMsgId } from './aiShared' import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream' import { drainQueue } from './useAiSend' import { loadConversations } from './useAiConversations' import type { AiChatEvent, AiToolCallInfo } from '../../api/types' // composable 内非组件上下文(无 setup),用 vue-i18n 全局实例的 t 而非 useI18n()。 // 通过 any 中转规避 vue-i18n 深度 message schema 泛型导致的 TS2589(类型实例化过深)。 const t = ((i18n as any).global.t as (key: string) => string).bind((i18n as any).global) let _unlistenAiEvent: (() => void) | null = null let _unlistenConvChanged: (() => void) | null = null // startListener 并发去重:防 onMounted 与 sendMessage 首发竞态下重复注册 listener // (两回调写同一 state.currentText → 流式文字双倍) let _startPromise: Promise | null = null const appSettings = useAppSettingsStore() /** 通知会话列表发生变化(供 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 收尾共用) */ export function flushCurrentText() { if (!state.currentText) return const last = state.messages[state.messages.length - 1] if (last && last.role === 'assistant') last.content = state.currentText } /** * 在全部消息中查找指定 id 的工具调用卡片(用于状态流转) * * 扫描策略:从尾部反向遍历并提前退出。tool_result/approval/completed * 通常对应最近触发的 tool_use(agent 顺序执行工具并尽快回填结果), * 反向扫描使其命中落在尾部 → 平均 O(1)、最坏 O(n)。(原正向全量扫描为 O(n²), * 每条 tool_result 都从头扫整条历史。) * * 注:未引入 Map 索引。state.messages 在 newConversation / * switchConversation / deleteConversation 等处被整体替换(且这些操作散落在 * useAiEvents 之外),独立索引极易与响应式数组失配形成"陈旧引用"——状态卡 * 片读到已不存在的对象。反向扫描零额外状态、改动最小且行为完全一致。 */ export function findToolCall(id: string): AiToolCallInfo | undefined { for (let i = state.messages.length - 1; i >= 0; i--) { const toolCalls = state.messages[i].toolCalls if (!toolCalls) continue // 同一消息内工具调用通常很少,沿用正向扫描;命中即返回 for (let j = 0; j < toolCalls.length; j++) { if (toolCalls[j].id === id) return toolCalls[j] } } return undefined } /** token 用量展示开关(读 appSettings,与 Settings.vue 共享 key `df-show-token-usage`) */ function isShowTokenUsage(): boolean { return appSettings.get('df-show-token-usage', false) } /** 后端事件分发:按 conversation_id 路由,流式累积文本,工具状态流转,看门狗联动 */ export function handleEvent(event: AiChatEvent) { const convId = event.conversation_id // 首次收到事件时同步当前对话 id(后端自动建对话的场景) // 同步写 appSettings(SQLite):刷新页面后 loadConversations 据此恢复上次会话 if (convId && !state.activeConversationId) { state.activeConversationId = convId void appSettings.set('df-ai-active-conv', convId) } // 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误时刷新侧边栏 const isCurrent = !convId || convId === state.activeConversationId if (!isCurrent) { if (event.type === 'AiCompleted' || event.type === 'AiError') { state.generatingConvId = null void loadConversations() } return } // 标记正在生成的对话(完成/错误事件除外) if (convId && event.type !== 'AiCompleted' && event.type !== 'AiError') { state.generatingConvId = convId } // 流式看门狗:活跃事件(delta/工具/新轮/审批结果)重置;审批等待/完成/错误在 case 内 clear if (!['AiApprovalRequired', 'AiCompleted', 'AiError'].includes(event.type)) { resetStreamWatchdog() } switch (event.type) { case 'AiTextDelta': state.currentText += event.delta break case 'AiAgentRound': { // Agent 循环新一轮:保存当前文本到上一条 assistant 消息,新建空 assistant 消息 flushCurrentText() state.currentText = '' state.messages.push({ id: `ai-${nextMsgId()}`, role: 'assistant', content: '', timestamp: Date.now(), }) break } case 'AiHeartbeat': // 心跳事件:仅维持看门狗(已在上方 reset),无需额外处理;显式 case 防 switch 穿透 break case 'AiToolCallStarted': { const info: AiToolCallInfo = { id: event.id, name: event.name, args: event.args, status: 'running', } const lastMsg = state.messages[state.messages.length - 1] if (lastMsg && lastMsg.role === 'assistant') { lastMsg.toolCalls = lastMsg.toolCalls || [] lastMsg.toolCalls.push(info) } break } 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) break } case 'AiApprovalRequired': { clearStreamWatchdog() // 等用户审批,不计超时 const info: AiToolCallInfo = { id: event.id, name: event.name, args: event.args, status: 'pending_approval', } state.pendingApprovals.push(info) const tc = findToolCall(event.id) if (tc) { tc.status = 'pending_approval' tc.reason = event.reason } break } case 'AiApprovalResult': { if (!event.approved) { const tc = findToolCall(event.id) if (tc) { tc.status = 'rejected' // 与后端落库一致:拒绝结果回填卡片,供 UI 展示拒绝原因 tc.result = '用户拒绝了此操作' } state.pendingApprovals = state.pendingApprovals.filter(p => p.id !== event.id) } break } case 'AiCompleted': { clearStreamWatchdog() flushCurrentText() state.currentText = '' state.streaming = false state.generatingConvId = null // 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 } } } // 清理分离窗口生成态快照 localStorage.removeItem('df-ai-gen') localStorage.removeItem('df-ai-text') void loadConversations() notifyConversationChanged() // 队列续发:当前完成后自动发下一条(后端 generating 已复位,不会被"正在生成中"拒绝) drainQueue() break } case 'AiError': { clearStreamWatchdog() state.streaming = false state.generatingConvId = null state.currentText = '' state.queue = [] // B-32:错误收尾清队列,防生成中入队的消息被静默丢失(drainQueue 仅 AiCompleted 触发) localStorage.removeItem('df-ai-gen') localStorage.removeItem('df-ai-text') state.messages.push({ id: `err-${nextMsgId()}`, role: 'assistant', content: friendlyError(event.error), isError: true, timestamp: Date.now(), }) break } } } /** 启动事件监听(幂等 + 并发去重,onMounted 与 sendMessage 首发竞态不会重复注册) */ export async function startListener() { // 已注册 → 直接复用(幂等;sendMessage 每次调用不重复注册) if (_unlistenAiEvent && _unlistenConvChanged) return // 并发去重:防 onMounted 与 sendMessage 首发竞态重复注册 listener → 文字双倍 if (_startPromise) return _startPromise _startPromise = (async () => { _unlistenAiEvent = await aiApi.onEvent(handleEvent) _unlistenConvChanged = await listen('ai-conversation-changed', () => { void loadConversations() }) })() try { await _startPromise } finally { _startPromise = null } } /** 停止事件监听(卸载时调用,释放后端 listener) */ function stopListener() { _unlistenAiEvent?.() _unlistenConvChanged?.() _unlistenAiEvent = null _unlistenConvChanged = null } export function useAiEvents() { return { startListener, stopListener, handleEvent, flushCurrentText, findToolCall, friendlyError, notifyConversationChanged, } }