Files
DevFlow/src/composables/ai/useAiEvents.ts
绝尘 d2cada97cd 重构: aichat agent 能力系统化(L1元能力+L2/L3后端+list去重)
L1 agent 元能力层(治痛①死循环零交付):
- env_profile 环境姿势注入 + shell 默认 PowerShell(防引号地狱)
- 断路器:同类工具失败≥3熔断 + guard.reset
- detect_environment 主动探测工具(python/node/shell)
- 求助协议 AiHelpRequired 事件 + 前端求助卡

L2 统一状态机后端(治痛②,前端批2):
- ConvState enum 5态 + 合法转换守卫(conv_state.rs)
- GeneratingGuard 接入视图层(guard.rs)

L3 事件总线后端骨架(治痛③④⑤,接入批2):
- EventBus pub-sub + AiBusEvent 8变体(event_bus.rs)

list 工具调用重复治理第一步:
- build_system_prompt_with_excluded 去重被@实体 + 清单注明语
2026-06-22 00:04:14 +08:00

729 lines
39 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, clearAllStreamWatchdogs } from './useAiStream'
import { setStreaming } from './streamingGuard'
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 加入——路径授权挂起等用户决定,不计整流超时。
// 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)
/** 通知会话列表发生变化(供 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
}
}
}
/** 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':
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 驱动 UI 卡片;看门狗已由
// NO_RESET_WATCHDOG 跳过 reset(达 max 不计整流超时)。
// 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')
}
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.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',
}
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')
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
}
}
/** lifecycle 域:整轮收尾(AiCompleted)与异常中断(AiError) */
function handleLifecycleEvent(event: AiChatEvent): boolean {
switch (event.type) {
case 'AiCompleted': {
clearStreamWatchdog(event.conversation_id || undefined)
clearAllToolSlowTimers() // B-260616-12: 整轮结束清全部工具慢执行计时器与已提示集合
flushCurrentText()
state.currentText = ''
setStreaming(false, { convId: event.conversation_id || null, reason: 'AiCompleted' })
state.generatingConvs.delete(event.conversation_id || '')
state.agentRound = 0 // AE-2025-07: agentic 结束,复位轮次(隐藏进度条)
// TD-260621-02 per-conv:仅当 pendingMaxRounds===本 conv 才清 null(避免清其他会话的挂起)。
if (pendingMaxRounds.value && pendingMaxRounds.value === (event.conversation_id || '')) {
pendingMaxRounds.value = null
}
// L1 求助协议(§2.3):求助卡挂起仅清本 conv(新一轮发送时旧求助卡应消失)。
if (pendingHelp.value && pendingHelp.value.conversationId === (event.conversation_id || '')) {
pendingHelp.value = null
}
// TD-260621-03 per-conv:仅清本 conv 的 path_auth 挂起(其他会话的弹窗不连累清空)。
// 批2-A 刚把 pendingMaxRounds per-conv,pendingDirAuths 漏跟;此处补齐——
// F-09 多会话并发下全清会让 B 会话的 DirAuthDialog 凭空消失(用户报"弹窗没了我没操作")。
const doneConv2 = event.conversation_id || ''
pendingDirAuths.value = pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId === doneConv2)
// 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 }
}
}
// 清理分离窗口生成态快照(F-09:per-conv key,清本会话快照;兼容旧单 key)
const doneConv = event.conversation_id || ''
if (doneConv) {
localStorage.removeItem(`df-ai-gen-${doneConv}`)
localStorage.removeItem(`df-ai-text-${doneConv}`)
}
localStorage.removeItem('df-ai-gen')
localStorage.removeItem('df-ai-text')
void loadConversations()
notifyConversationChanged()
// 队列续发:当前完成后自动发下一条(经事件总线桥接,避免 import useAiSend 构成循环依赖)
emit('ai-drain-queue', {})
return true
}
case 'AiError': {
clearStreamWatchdog(event.conversation_id || undefined)
clearAllToolSlowTimers() // B-260616-12: 错误收尾清全部工具慢执行计时器与已提示集合
clearAllApprovalTimers() // UX-260617-10: 错误中断释放所有审批超时计时器,防回调改 state 触发已卸载/已错流程
// UX-260619-06: 失败前先把流式累积的 currentText 回填到占位 assistant 气泡,
// 保留"回答到一半"的部分回复(否则清 currentText 后部分内容消失)。
flushCurrentText()
setStreaming(false, { convId: event.conversation_id || null, reason: 'AiError' })
state.generatingConvs.delete(event.conversation_id || '')
state.currentText = ''
state.agentRound = 0 // AE-2025-07: agentic 异常中断,复位轮次
state.queue = [] // B-32:错误收尾清队列,防生成中入队的消息被静默丢失(drainQueue 仅 AiCompleted 触发)
// UX-260617-10: 错误收尾清残留待审批项——错误发生时若有工具停在 pending_approval,
// 残留可点击审批按钮会让用户误以为还能批(实际后端已终止),残留审批卡误导操作。
state.pendingApprovals = []
// TD-260621-02 per-conv:仅当 pendingMaxRounds===本 conv 才清 null(避免清其他会话的挂起)。
if (pendingMaxRounds.value && pendingMaxRounds.value === (event.conversation_id || '')) {
pendingMaxRounds.value = null
}
// L1 求助协议(§2.3):求助卡挂起仅清本 conv(错误中断后旧求助卡应消失)。
if (pendingHelp.value && pendingHelp.value.conversationId === (event.conversation_id || '')) {
pendingHelp.value = null
}
// TD-260621-03 per-conv:仅清本 conv 的 path_auth 挂起(异常中断只清本会话弹窗,不连累并发会话)。
const errConv2 = event.conversation_id || ''
pendingDirAuths.value = pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId === errConv2)
// F-09: 清理分离窗口生成态快照(per-conv key,清本会话快照;兼容旧单 key)
const errConv = event.conversation_id || ''
if (errConv) {
localStorage.removeItem(`df-ai-gen-${errConv}`)
localStorage.removeItem(`df-ai-text-${errConv}`)
}
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
}
case 'AiHelpRequired': {
// L1 求助协议(§2.3,2026-06-21):后端断路器熔断已 guard.reset(generating=false,loop 终止)。
// 前端按终止态收尾(对齐 AiError 分支:清看门狗/流式态/快照/队尾文本 flushCurrentText),
// 但不创建错误气泡(求助非错误,是 AI 主动求助)——改为翻 pendingHelp 驱动求助卡(HelpRequiredCard)
// 显 reason + options 按钮供用户选。
clearStreamWatchdog(event.conversation_id || undefined)
clearAllToolSlowTimers()
flushCurrentText()
setStreaming(false, { convId: event.conversation_id || null, reason: 'AiHelpRequired' })
state.generatingConvs.delete(event.conversation_id || '')
state.currentText = ''
state.agentRound = 0
// 求助即终止 loop,清残留待审批项(对齐 AiError 分支语义,防残留审批卡误导)。
state.pendingApprovals = []
// TD-260621-02 per-conv:仅当 pendingMaxRounds===本 conv 才清 null(求助终止同理清达 max 挂起)。
if (pendingMaxRounds.value && pendingMaxRounds.value === (event.conversation_id || '')) {
pendingMaxRounds.value = null
}
// per-conv 清本 conv path_auth 挂起(求助终止只清本会话弹窗,不连累并发会话)。
const helpConv2 = event.conversation_id || ''
pendingDirAuths.value = pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId === helpConv2)
// F-09: 清理分离窗口生成态快照(per-conv key,清本会话快照;兼容旧单 key)
const helpConv = event.conversation_id || ''
if (helpConv) {
localStorage.removeItem(`df-ai-gen-${helpConv}`)
localStorage.removeItem(`df-ai-text-${helpConv}`)
}
localStorage.removeItem('df-ai-gen')
localStorage.removeItem('df-ai-text')
// 翻 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
}
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)
}
// 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误/求助时刷新侧边栏
// 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') {
// F-09: 多会话并发 — 仅从 Set 移除该 conv(其他会话可能仍在生成),不再置 null 全局清空
state.generatingConvs.delete(convId || '')
void loadConversations()
}
return
}
// 标记正在生成的对话(完成/错误/求助事件除外——三者后端均已终止 loop)
if (convId && event.type !== 'AiCompleted' && event.type !== 'AiError' && event.type !== 'AiHelpRequired') {
state.generatingConvs.add(convId)
}
// 流式看门狗:活跃事件(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
// 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
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,
}
}