会话意图识别层(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
521 lines
24 KiB
TypeScript
521 lines
24 KiB
TypeScript
//! 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,
|
||
}
|
||
}
|