Files
DevFlow/src/composables/ai/useAiEvents.ts
绝尘 8da6bdcec2 新增: AI 工具意图识别层 + 文件访问权限模型 Phase B+C
会话意图识别层(intent.rs / df-ai crate):
- Intent 枚举 11 态 + recognize 规则识别(关键词+权重+优先级)+ tool_subset_for 工具子集映射
- 模态接口预留 None,独立模块待接入 loop,36 单测

文件访问权限模型 Phase B+C(F-260619-03):
- Phase B 会话临时白名单 + 循环挂起 + DirAuthDialog 弹窗(仅本次/未来都允许/拒绝)
- Phase C 系统目录黑名单(Win System32/Program Files;Unix /etc /usr...)分段匹配 + 写操作约束
- 已审 CR-260619-09  PASS
2026-06-19 18:58:48 +08:00

521 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 } from './useAiStream'
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<void> | 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 加入——路径授权挂起等用户决定,不计整流超时。
const NO_RESET_WATCHDOG = new Set<AiChatEvent['type']>(['AiApprovalRequired', 'AiCompleted', 'AiError', 'AiMaxRoundsReached', 'AiDirAuthRequired'])
// 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 后续轮次前用户必先决定),
// 故用 boolean ref 而非数组,语义更精确。
export const pendingMaxRounds = ref(false)
// F-260619-03 Phase B: 路径授权弹窗挂起态(后端 AiDirAuthRequired 事件置,用户决策后清)。
//
// 同 pendingMaxRounds 模式:模块级 ref(不进 store.state),AiChat.vue 的 DirAuthDialog 组件
// 直接 import 读。一次只追踪一个挂起询问(后端单 tool_call_id 挂起,用户决策后 try_continue),
// 故用对象 ref 而非数组,语义更精确。null = 无挂起。
export interface PendingDirAuth {
id: string
tool: string
path: string
dir: string
conversationId?: string
}
export const pendingDirAuth = ref<PendingDirAuth | null>(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 收尾共用) */
export function flushCurrentText() {
if (!state.currentText) return
const last = state.messages[state.messages.length - 1]
if (last && last.role === 'assistant') last.content = state.currentText
}
/** token 用量展示开关(读 appSettings,与 Settings.vue 共享 key `df-show-token-usage`) */
function isShowTokenUsage(): boolean {
return appSettings.get<boolean>('df-show-token-usage', false)
}
// ─── 事件域 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=true 驱动 UI 卡片;看门狗已由
// NO_RESET_WATCHDOG 跳过 reset(达 max 不计整流超时)。
pendingMaxRounds.value = true
return true
case 'AiDirAuthRequired': {
// F-260619-03 Phase B: 路径授权挂起(后端 generating 保持 true,等用户决策)。
// 置 pendingDirAuth 驱动 DirAuthDialog 弹窗;看门狗已由 NO_RESET_WATCHDOG 跳过 reset。
// 同时把工具卡置 pending_approval 态(与 AiApprovalRequired 一致,复用 ToolCard 渲染)。
clearStreamWatchdog()
const info: AiToolCallInfo = {
id: event.id,
name: event.tool,
args: { path: event.path },
status: 'pending_approval',
}
state.pendingApprovals.push(info)
const tc = findToolCall(event.id)
if (tc) tc.status = 'pending_approval'
clearToolSlowTimer(event.id)
pendingDirAuth.value = {
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() // 等用户审批,不计超时
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
// AE-2025-03: write_file 审批注入行级 diff旧文件 vs 新内容),前端审批卡预览
if (event.diff) tc.diff = event.diff
}
// B-260616-12: 进入审批等待→取消该工具慢执行计时器(审批耗时由用户主导,非执行慢)
clearToolSlowTimer(event.id)
// AE-2025-06: 审批等待开始→启动审批超时计时器(5min 不处理自动拒绝)
startApprovalTimer(event.id, event.name)
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 || '')
}
// F-260619-03 Phase B: 路径授权挂起的工具收到 ApprovalResult(once/always 通过 / deny 拒绝)
// → 清弹窗(后端 ai_authorize_dir 已 try_continue 续 loop)。
if (pendingDirAuth.value && pendingDirAuth.value.id === event.id) {
pendingDirAuth.value = null
}
return true
}
default:
return false
}
}
/** lifecycle 域:整轮收尾(AiCompleted)与异常中断(AiError) */
function handleLifecycleEvent(event: AiChatEvent): boolean {
switch (event.type) {
case 'AiCompleted': {
clearStreamWatchdog()
clearAllToolSlowTimers() // B-260616-12: 整轮结束清全部工具慢执行计时器与已提示集合
flushCurrentText()
state.currentText = ''
state.streaming = false
state.generatingConvId = null
state.agentRound = 0 // AE-2025-07: agentic 结束,复位轮次(隐藏进度条)
pendingMaxRounds.value = false // F-260616-03: 收尾(停止/续跑后新一轮达 max 才会再 set),清操作卡
pendingDirAuth.value = null // F-260619-03 Phase B: 收尾清路径授权弹窗(防残留)
// 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 }
}
}
// 清理分离窗口生成态快照
localStorage.removeItem('df-ai-gen')
localStorage.removeItem('df-ai-text')
void loadConversations()
notifyConversationChanged()
// 队列续发:当前完成后自动发下一条(经事件总线桥接,避免 import useAiSend 构成循环依赖)
emit('ai-drain-queue', {})
return true
}
case 'AiError': {
clearStreamWatchdog()
clearAllToolSlowTimers() // B-260616-12: 错误收尾清全部工具慢执行计时器与已提示集合
clearAllApprovalTimers() // UX-260617-10: 错误中断释放所有审批超时计时器,防回调改 state 触发已卸载/已错流程
state.streaming = false
state.generatingConvId = null
state.currentText = ''
state.agentRound = 0 // AE-2025-07: agentic 异常中断,复位轮次
state.queue = [] // B-32:错误收尾清队列,防生成中入队的消息被静默丢失(drainQueue 仅 AiCompleted 触发)
// UX-260617-10: 错误收尾清残留待审批项——错误发生时若有工具停在 pending_approval,
// 残留可点击审批按钮会让用户误以为还能批(实际后端已终止),残留审批卡误导操作。
state.pendingApprovals = []
pendingMaxRounds.value = false // F-260616-03: 异常中断,清操作卡
pendingDirAuth.value = null // F-260619-03 Phase B: 异常中断清路径授权弹窗
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
}
default:
return 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 (!NO_RESET_WATCHDOG.has(event.type)) {
resetStreamWatchdog()
}
// 按域分派(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
clearStreamWatchdog()
clearAllToolSlowTimers() // B-260616-12: 卸载时释放所有工具慢执行计时器,防回调改 state 触发已卸载组件
clearAllApprovalTimers() // AE-2025-06: 卸载时释放所有审批超时计时器,防回调 push 消息触发已卸载组件
}
export function useAiEvents() {
return {
startListener,
stopListener,
handleEvent,
flushCurrentText,
friendlyError,
notifyConversationChanged,
}
}