//! 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, convStates, setConvState } from './aiShared'
// 批4 双轨收口:getConvState 下沉到 aiShared,本模块 re-export 保持消费方
// (ChatInput.vue/MaxRoundsCard.vue 等)import 路径不变,组件零改动透明继承。
export { getConvState } from './aiShared'
import { resetStreamWatchdog, clearStreamWatchdog, clearAllStreamWatchdogs } from './useAiStream'
import { setStreaming } from './streamingGuard'
import { loadConversations } from './useAiConversations'
import type { AiChatEvent, AiMessage, AiToolCallInfo, MessageId } 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<void> | null = null

// 上一次 AiTextDelta 的内容(单窗口内 LLM 重复 delta 防御用,见 handleStreamingEvent)
let _lastDelta = ''

// 文本空闲信号(reactive ref):streaming=true 且 300ms 无新 delta 时=true。
// 按钮据此显「发送」(idle)而非「停止」。不动 streaming(streaming 是渲染态,
// 控制 MessageList 流式 block 显隐,不能在文本中途翻转,否则闪掉)。
export const textIdle = ref(true)
let _textIdleTimer: ReturnType<typeof setTimeout> | null = null
function resetTextIdleTimer(): void {
  if (_textIdleTimer) clearTimeout(_textIdleTimer)
  textIdle.value = false       // 有新 delta → 文本活跃
  _textIdleTimer = setTimeout(() => {
    textIdle.value = true      // 300ms 无新 delta → 文本空闲
    _textIdleTimer = null
  }, 300)
}
function clearTextIdleTimer(): void {
  if (_textIdleTimer) { clearTimeout(_textIdleTimer); _textIdleTimer = null }
  textIdle.value = true        // 整轮结束/新轮开始 → 默认空闲
}

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<AiChatEvent['type']>(['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<string, ReturnType<typeof setTimeout>>()
// 已提示过的 callId(同一工具只弹一次 toast,即便仍 running 到本轮结束)
const _toolSlowNotified = new Set<string>()

/** 启动工具级慢执行计时器(超时仅弹 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<string|null>(存挂起 convId,null=无挂起)。消费方(MaxRoundsCard)守卫改精确比对
// pendingMaxRounds === activeConversationId。set 按事件 conversation_id 赋值;
// clear(AiCompleted/AiError)仅当 pendingMaxRounds===该 convId 才清 null(避免清错会话的挂起)。
export const pendingMaxRounds = ref<string | null>(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<PendingDirAuth[]>([])

// 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<PendingHelp | null>(null)

// F-15 上下文管理生命周期事件回调(原 useAiContext 独立监听器,现合入统一分发器)。
// useAiContext.initContextListener 注入回调,useAiEvents.handleLifecycleEvent 统一分发。
// 设计:一个事件源(ai-chat-event)→ 一个监听器(useAiEvents)→ 一个分发器(handleEvent),
// 架构保证无双重处理风险(原 useAiContext 第二监听器已删除)。
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
}
let _contextCallbacks: ContextCallbacks = {}

/** 注入上下文生命周期回调(useAiContext.initContextListener 调用) */
export function setContextCallbacks(callbacks: ContextCallbacks): void {
  _contextCallbacks = callbacks
}

// L2 统一状态机 convStates/getConvState/setConvState 已下沉到 aiShared.ts(批4 双轨收口破环:
// stores/ai.ts、useAiStream.ts、useAiConversations.ts 等读点 import getConvState 会与
// useAiEvents 现有依赖构成环,故下沉到本模块既有的破环共享层)。本模块从 aiShared re-import。
// 批4 收口后:convStates 是「会话是否生成中」的唯一真相源,旧 generatingConvs bool 轨已退役,
// 不再有双轨同步逻辑(原 handleConvStateEvent 内 idle/error delete、generating/stopping/compressed
// add 的兜底分支已删,enum 单写 setConvState 即收敛)。

/** 通知会话列表发生变化(供 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
    }
  }
  // BUG-260624-01(消息重叠根治·诊断 workflow 确认):回填后立即自清 currentText。
  // 原:清空依赖调用方(AiAgentRound/AiCompleted/AiError/AiHelpRequired 各跟一行 currentText='')。
  // 竞态:任一新增 flush 调用点漏清,或事件乱序致渲染先于调用方清空,全局单例 currentText
  // 残留 → MessageList 渲染侧 isLastAi(msg)&&currentText 把残留文本渲到新气泡 → 重叠堆叠。
  // 自清把 flush 语义收敛为"回填并归零",消除对调用方清空顺序的依赖。各调用方后续的
  // currentText='' 对已清空值幂等,无副作用。
  state.currentText = ''
  _lastDelta = '' // 同步重置 delta 跟踪,避免新轮首个 delta 误命中上一轮末 delta 重复检测
}

/** token 用量展示开关(读 appSettings,与 Settings.vue 共享 key `df-show-token-usage`) */
function isShowTokenUsage(): boolean {
  return appSettings.get<boolean>('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<boolean>('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<boolean>('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': {
      // BUG-260624-01 / UX-260617-12 消息重叠防御:
      // 历史多次报「LLM 回复文字重复」(如「好好，，现在现在我对我对」单字双重叠加)。
      // 已排除的根因:后端 stream_recv.rs emit 增量 chunk.delta(非累积)、
      // startListener 幂等+_startPromise 并发去重、streamingBlocks 块级 memo。
      // 最可能残留路径:① 主窗口 AI 面板与 ai-detached 分离窗口同时打开,两 webview 独立 JS 上下文
      //   各自 listen 全局广播事件 → 各自 currentText += delta(用户感知「双倍」)。
      //   桌面双窗口场景下难以在单窗口内根治,需后端定向 emit_to(label) 重构。
      // ② LLM provider(GLM anthropic_compat 等)在特定情况下返重复 delta。
      // 防御:连续两次 delta 完全相同(且非空)时丢弃第二次 + console.warn,留下诊断证据。
      // 副作用极低:正常 LLM 流不会连发完全相同 delta(空格/单字符除外,已 len>1 守卫)。
      if (event.delta && _lastDelta === event.delta && event.delta.length > 1) {
        // df-ai-trace-delta 开关控制(默认开,生产可关)
        if (appSettings.get('df-ai-trace-delta', true)) {
          console.warn('[AiTextDelta] 重复 delta 已丢弃(疑似双窗口 listener 或 provider 异常):', JSON.stringify(event.delta))
        }
        return true
      }
      _lastDelta = event.delta
      state.currentText += event.delta
      // 重置文本空闲定时器:每次 delta 重置 300ms。无新 delta 到 300ms → textIdle=true → 按钮白。
      resetTextIdleTimer()
      return true
    }

    case 'AiAgentRound': {
      // Agent 循环新一轮：保存当前文本到上一条 assistant 消息，新建空 assistant 消息
      flushCurrentText()
      state.currentText = ''
      // 文本已刷新完毕 → 清空闲定时器(按钮白,等下一轮 deltas 来再红)
      // 不设 streaming=false:streaming 管渲染,多轮间需持续 true 让 MessageList 渲染后续 deltas。
      // 按钮白由 textIdle 独立控制,无需翻转 streaming。
      clearTextIdleTimer()
      state.completedTools = 0 // 新轮重置工具计数器
      state.messages.push({
        id: `ai-${nextMsgId()}` as MessageId,
        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()}` as MessageId,
          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 不计整流超时)。
      // BUG-260623-06: NO_RESET 只跳 reset 不 clear,达 max 前活跃事件(delta/tool)设的 timer 仍走,
      // 130s 后触发 onStreamTimeout → generating=true(后端保持)+ tool completed → 误报
      // "工具已执行完成,后续回复中断"(实测用户报"对话完成后等一会弹出")。
      // 修:对齐 AiApprovalRequired/AiDirAuthRequired(等用户场景均 clear),达 max 也 clear watchdog。
      // 用户点继续(ai_continue_loop→后端续跑→delta reset)/停止(ai_stop_loop→AiCompleted clear)时重计。
      clearStreamWatchdog(event.conversation_id || undefined)
      // 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')
        }
        // 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
      }
      // 开关关(兜底回退):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.completedTools++ // 进度条计数器
      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',
        // BUG-260624-03(根因3):reason 必须写入 info,否则浮窗 ApprovalPopup.vue
        // 的 tc.reason 永远空(后端 build_approval_reason 生成的风险说明丢失)。
        // tc.reason 与 pendingApprovals[].reason 是两条独立赋值路径:tc 走 findToolCall,
        // 浮窗走主窗口推送的 pendingApprovals 快照,后者此前漏写 reason。
        reason: event.reason,
      }
      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')
      // BUG-260624-02:同 AiDirAuthRequired,risk 类 pending 卡落折叠分组不可见时自动展开 + scroll。
      void emit('ai-pending-arrived', { toolCallId: event.id })
      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
  }
}

/** L2 状态机域:消费 AiConvStateChanged,写 convStates(per-conv conv_state 真相源)。
 *
 *  批4 双轨收口后:enum(convStates)是唯一真相源,setConvState 单写即收敛。
 *  原 idle/error 同步 delete generatingConvs、非终止态同步 add 的双轨兜底分支已删
 *  (bool 轨 generatingConvs 已退役)。看门狗由活跃 delta/工具事件重置,这里不干预。
 *  返回值:true=已处理(命中 case);false=未命中。 */
function handleConvStateEvent(event: AiChatEvent): boolean {
  if (event.type !== 'AiConvStateChanged') return false
  const convId = event.conversation_id || state.activeConversationId
  if (!convId) return true // 无 convId 无法路由,忽略(防误写全局态)
  setConvState(convId, event.conv_state)
  return true
}

/** 跨端用户消息域:F-260622-02 miniapp→桌面 user 气泡同步。
 *
 *  仅处理 AiUserMessage 一个事件。语义:远程(miniapp)发来的用户消息经后端 publish_event
 *  广播到 ai_event_bus,桌面收到后补 user 气泡(桌面本地 sendMessage 是乐观渲染不发此事件,
 *  故桌面 store 不会有这条 user 气泡,需要此事件填补,否则桌面只看到孤立 assistant 响应气泡)。
 *
 *  去重(防双气泡):若末条消息已是同 content 的 user(理论上桌面本地已乐观 push 过,
 *  或 miniapp 自身乐观 push 后又被此事件回灌),跳过不重复 push。MVP 按末条 + content 比对
 *  足够覆盖单会话场景;极端情况下两条不同 user 恰巧同 content 紧邻不会丢(末条不同才 push)。
 *
 *  返回值:true=命中 AiUserMessage;false=其他事件。 */
function handleUserMessageEvent(event: AiChatEvent): boolean {
  if (event.type !== 'AiUserMessage') return false
  // 去重:末条已是 user 且 content 相同则跳过(防桌面本地乐观 push + 事件回灌双气泡)。
  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
}

/**
 * 公共会话终止收尾(任务 #6 DRY 抽离):AiCompleted / AiError / AiHelpRequired
 * 三分支共用同一套清场逻辑——看门狗/计时器/流式态/currentText/agentRound/per-conv
 * 挂起(localStorage 快照 + pendingMaxRounds + pendingDirAuths + convStates)。
 *
 * 语义差异(调用方自行处理):
 * - AiCompleted: 调用后追加 incomplete 气泡 / token 用量 / 队列 drain
 * - AiError:    调用后追加错误气泡 + clearAllApprovalTimers + 清 queue/pendingApprovals
 * - AiHelpRequired: 调用后翻 pendingHelp 驱动求助卡
 */
function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | 'AiError' | 'AiHelpRequired') {
  clearStreamWatchdog(convId || undefined)
  clearAllToolSlowTimers() // B-260616-12: 整轮/错误/求助结束清全部工具慢执行计时器与已提示集合
  clearTextIdleTimer() // 清文本空闲定时器 + 置 textIdle=true(整轮结束不该有活跃信号残留)
  flushCurrentText()
  state.currentText = ''
  setStreaming(false, { convId: convId || null, reason })
  state.agentRound = 0 // AE-2025-07: agentic 结束/中断/求助,复位轮次(隐藏进度条)

  // 批4 双轨收口:收尾收敛 convStates(AiCompleted/AiHelpRequired 删项→idle,AiError 置 error)。
  // 后端 CONV_STATE_ENABLED=on 时 AiConvStateChanged 已先到此处幂等 no-op;
  // off 或老后端无 AiConvStateChanged 时此处保证 convStates 不陈旧。
  if (reason === 'AiError') {
    if (convId) convStates.set(convId, 'error') // 保留 error 项供停止按钮显「重试」态
  } else {
    convStates.delete(convId)
  }

  // TD-260621-02 per-conv:仅当 pendingMaxRounds===本 conv 才清 null(避免清其他会话的挂起)。
  if (pendingMaxRounds.value && pendingMaxRounds.value === convId) {
    pendingMaxRounds.value = null
  }
  // L1 求助协议(§2.3):求助卡挂起仅清本 conv(终止后旧求助卡应消失)。
  if (pendingHelp.value && pendingHelp.value.conversationId === convId) {
    pendingHelp.value = null
  }
  // TD-260621-03 per-conv:仅清本 conv 的 path_auth 挂起(终止只清本会话弹窗,不连累并发会话)。
  // F-09 多会话并发下全清会让 B 会话的 DirAuthDialog 凭空消失(用户报"弹窗没了我没操作")。
  pendingDirAuths.value = pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId === convId)

  // F-09: 清理分离窗口生成态快照(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')
}

/** lifecycle 域:整轮收尾(AiCompleted)与异常中断(AiError) */
function handleLifecycleEvent(event: AiChatEvent): boolean {
  switch (event.type) {
    case 'AiCompleted': {
      console.warn('[FE-AICOMPLETED] 收到 conv=', event.conversation_id, 'streaming=', state.streaming)
      cleanupTerminatedConversation(event.conversation_id || '', 'AiCompleted')
      console.warn('[FE-AICOMPLETED] cleanup 后 streaming=', state.streaming)
      // 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()}` as MessageId,
          role: 'assistant',
          content: t('ai.responseIncomplete'),
          timestamp: Date.now(),
        })
      }
      // 对话透明化 L1:直接从事件更新 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
      }
      void loadConversations()
      // 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 }
        }
        // 每轮 token 写入对应 assistant 消息(最后一条 AI 消息),供 MessageList 逐条显示。
        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 }
            break
          }
        }
      }
      notifyConversationChanged()
      // 队列续发：当前完成后自动发下一条（经事件总线桥接，避免 import useAiSend 构成循环依赖）
      emit('ai-drain-queue', { conversationId: event.conversation_id })
      return true
    }

    case 'AiError': {
      cleanupTerminatedConversation(event.conversation_id || '', 'AiError')
      clearAllApprovalTimers() // UX-260617-10: 错误中断释放所有审批超时计时器,防回调改 state 触发已卸载/已错流程
      // 只清除出错会话的队列项,不误伤其他会话的排队消息
      if (event.conversation_id) {
        state.queue = state.queue.filter(q => q.conversationId !== event.conversation_id)
      } else {
        state.queue = []
      }
      // UX-260617-10: 错误收尾清残留待审批项——错误发生时若有工具停在 pending_approval,
      // 残留可点击审批按钮会让用户误以为还能批(实际后端已终止),残留审批卡误导操作。
      state.pendingApprovals = []
      // UX-03: 错误消息携带 error_type(供错误气泡差异化显隐「去设置」按钮)。
      // AiMessage 类型未含 errorType 字段(不在本批白名单),用对象字面量 + cast 扩展;
      // 消费方(AiChat.vue canOpenSettings)经同 cast 读取,类型闭环在两端,不污染 types.ts。
      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)
      // F-15 上下文管理:AiError 也用于压缩 IPC 失败场景。通知上下文回调复位 loading +
      // 弹错误 toast(若处于压缩中)。与上方流式错误处理不冲突——上下文回调内部判 isCompressing 守卫。
      _contextCallbacks.onError?.(event.conversation_id || '', event.error)
      return true
    }

    case 'AiHelpRequired': {
      // L1 求助协议(§2.3,2026-06-21):后端断路器瘝断已 guard.reset(generating=false,loop 终止)。
      // 前端按终止态收尾(对齐 AiError 分支:清看门狗/流式态/快照/队尾文本 flushCurrentText),
      // 但不创建错误气泡(求助非错误,是 AI 主动求助)——改为翻 pendingHelp 驱动求助卡(HelpRequiredCard)
      // 显 reason + options 按钮供用户选。
      cleanupTerminatedConversation(event.conversation_id || '', 'AiHelpRequired')
      // 求助即终止 loop,清残留待审批项(对齐 AiError 分支语义,防残留审批卡误导)。
      state.pendingApprovals = []
      // 翻 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
    }

    // F-15 上下文管理生命周期事件(原 useAiContext 独立监听器,现合入统一分发器)
    case 'AiCompressing':
      _contextCallbacks.onCompressing?.()
      return true

    case 'AiManualCompressed': {
      // 手动 IPC 压缩(用户点压缩按钮):复位 loading + 弹 toast + 刷新视图回填摘要。
      _contextCallbacks.onManualCompressed?.(event.conversation_id || '', event.summary || '')
      return true
    }

    case 'AiAutoCompressed': {
      // loop 自动压缩对桌面静默——仅复位 loading(冗余兜底,Auto 路径前端 loading 本就为 false),
      // 不调 onManualCompressed(防每次发送误弹 toast + 误 switchConversation 打断 LLM 流)。
      _contextCallbacks.onAutoCompressed?.()
      return true
    }

    case 'AiContextCleared':
      _contextCallbacks.onCleared?.(event.conversation_id || '')
      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)
  }
  // L2 状态机:AiConvStateChanged 是 per-conv 状态信号(F-09 多会话并发下须追踪所有会话),
  // 在 isCurrent 守卫**之前**处理,避免切走会话时 conv_state 不更新致停止按钮/MaxRoundsCard 错态。
  // 该事件非流式活跃信号,不重置看门狗、不进活跃事件兜底写(由 handleConvStateEvent 写 enum)。
  if (handleConvStateEvent(event)) return
  // F-260622-02 跨端用户消息同步:在 isCurrent 守卫**之前**处理——AiUserMessage 是补 user 气泡
  // 信号(非流式活跃事件),不应触发 watchdog reset / 活跃事件兜底写(后续 AiAgentRound/
  // AiTextDelta 等活跃事件会正常触发)。也不受"非当前会话"过滤——理论上此事件必带 conversation_id
  // 且属当前会话(后端 route_send_message 在放行时广播),即使因切走会话到达也仅补 user 气泡,
  // 不影响其他态(handleUserMessageEvent 去重幂等)。
  if (handleUserMessageEvent(event)) return
  // 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误/求助时刷新侧边栏
  // 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') {
      // 批4 双轨收口:非当前会话终止事件,收敛 convStates(删 Map 项回 idle/不在生成),
      // 对齐 :592/:660/:711 当前会话分支语义。非当前会话的 error 态无消费方(MaxRoundsCard/
      // ChatInput 均读 activeConversationId),回退 null 与停止按钮 streaming 回退行为等价。
      convStates.delete(convId || '')
      void loadConversations()
    }
    return
  }
  // 批4 双轨收口:活跃事件(delta/工具/新轮/审批结果)到达即标记该会话生成中。
  // 完成后端 CONV_STATE_ENABLED=off 或老后端不发 AiConvStateChanged{generating} 时,此处作为
  // enum 兜底写入口(替代原 generatingConvs.add + 清残留 error 双操作,合并为 setConvState('generating') 一步)。
  // CONV_STATE_ENABLED=on 时后端 AiConvStateChanged{generating} 已先到(handleConvStateEvent 在
  // isCurrent 守卫前处理),此处 setConvState 同值 set 幂等覆盖,无副作用。完成/错误/求助事件除外
  // (三者后端均已终止 loop,在各自 case 内由 convStates.delete/set('error') 收敛)。
  if (convId && event.type !== 'AiCompleted' && event.type !== 'AiError' && event.type !== 'AiHelpRequired') {
    setConvState(convId, 'generating')
  }
  // 流式看门狗:活跃事件(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
  _startPromise = (async () => {
    try {
      _unlistenAiEvent = await aiApi.onEvent(handleEvent)
      _unlistenConvChanged = await listen('ai-conversation-changed', () => { void loadConversations() })
      _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 + 清看门狗)
 *  清看门狗:卸载时若仍在生成,_streamWatchdog 计时器未释放,130s 后 onStreamTimeout
 *  仍写 state(messages.push/置 streaming)——已卸载组件不应再被触发。故同步清除。 */
export 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,
  }
}
