Files
DevFlow/src/composables/ai/useAiApproval.ts
T

173 lines
9.2 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.
//! 工具审批 — approveToolCall(单条) + batchApprove(批量遍历)
//!
//! 原 useAiSend.ts 拆分(fe-arch P0-1):审批链与发送/队列链职责分离。
//! 审批不调发送核心,仅依赖 aiShared(findToolCall)/aiApi.approve/state,
//! 是 useAiSend 三条链中最独立的闭包,故抽出独立模块。
//!
//! 耦合:
//! - findToolCall 来自 aiShared(原 useAiEvents↔useAiSend 循环依赖已由 aiShared 打破)
//! - resetStreamWatchdog/clearStreamWatchdog 来自 useAiStream(审批执行→续生成窗口覆盖)
//! - state.queue 清空:IPC 失败即对话结束(B-32 防生成中入队消息静默丢失)
//!
//! 模块级私有:
//! - _pendingApprovalIds:审批按钮防抖守卫(同 useAiSend 原实现)
import { ref } from 'vue'
import { emit } from '@tauri-apps/api/event'
import { aiApi } from '@/api'
import { state } from '@/stores/ai'
import { t } from '@/i18n/i18n-helpers'
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
import { findToolCall } from './aiShared'
import { dispatchApprovalIPC } from './useToolApproval'
/**
* 审批按钮防抖守卫——同一 id 短期多次点击只发一次 IPC。
* IPC 往返 <100ms300ms 窗口足够覆盖竞态。
*/
const _pendingApprovalIds = new Set<string>()
/**
* 工具审批:保持 pending_approval 让审批卡片可见(按钮 loading 由 ToolCard 本地 ref 持有),
* IPC 失败时改 completed+错误文案。后端回事件后由 useAiEvents 转 completed/rejected。
* 不再乐观置 running——原写法让 .ai-tool-approval 整块消失(切骨架屏),
* 按钮无 loading 中间态、用户无重审入口;loading 现下沉到 ToolCard 局部 ref,语义正确。
*
* path_auth 审批链阶段3b(统一审批模型):**按 tc.kind 分派**两条 IPC——
* - kind='path'(路径授权挂起):调 aiApi.authorizeDir(toolCallId, decision) once/session/always/deny。
* once=本次单次(执行后清) / session=当前会话(切会话清) / always=写持久白名单 / deny=拒绝。
* - kind='risk'(普通 RiskLevel 审批,默认):调 aiApi.approve(toolCallId, approved) 一次性 approve/reject。
* approved=true→放行执行,approved=false→拒绝。
* 入口据 findToolCall(toolCallId).kind 判定,kind 缺省走 risk 老路径(兼容非统一开关 / 老 toolCall)。
* **注意**:决策分派由 tc.kind 决定,与调用方是否传 decision 入参无关——decision 仅 path 类消费,
* 调用方传 decision 但 tc.kind='risk' 时仍走 approve(approved)分支(忽略 decision)。
*
* @param approved risk 类专用(approve/reject, approve=true/reject=false);path 类忽略。
* @param decision path 类专用('once'|'session'|'always'|'deny');risk 类忽略。调用方须据 tc.kind 传对应入参。
*/
async function approveToolCall(toolCallId: string, approved: boolean, decision?: 'once' | 'session' | 'always' | 'deny') {
// 防抖——同一 id 仍在处理中则跳过(竞态点击/网络慢重试)
if (_pendingApprovalIds.has(toolCallId)) return
_pendingApprovalIds.add(toolCallId)
try {
// 复用 findToolCall(反向扫描)避免重复实现查找逻辑;仅缓存引用用于 IPC 失败回滚
const tc = findToolCall(toolCallId)
// path_auth 审批链阶段3b:按 tc.kind 分派 IPC。kind='path' 走 authorizeDir(once/session/always/deny);
// 其余(risk/缺省)走 approve(approve/reject)。
const isPathKind = !!tc && tc.kind === 'path'
// 重启看门狗覆盖审批执行→续生成窗口(useAiEvents.ts AiApprovalRequired/AiDirAuthRequired 已 clear,
// 审批态无心跳兜底;approve 后后端要跑工具+续生成,期间任何后端异常不回则按钮永久 loading。
// 复用通用 watchdog 而非加审批专用超时:复用同一收尾路径(onStreamTimeout 兜底复位 streaming),
// 避免新增独立计时器与状态分支)
// TD-260621-03 per-conv:看门狗 timer 用**审批工具调用所属 conv**——tc 来自 findToolCall 反扫
// state.messages(当前活跃会话的单例视图),故 tc 所属 conv 即当前 activeConversationId。
// 切会话时 switchConversation 已整体替换 state.messages+pendingApprovals(单例,非 per-conv Map),
// 旧 conv 的 tc 不再可达,故 activeConversationId 是 tc 所属 conv 的正确等价表达。
// 复用此 convId 供 reset 与 catch 内 clear 对称使用(同会话 pair,不跨会话误清)。
const approvalConvId = state.activeConversationId || undefined
resetStreamWatchdog(approvalConvId)
try {
// IPC 分派规则(path→authorizeDir / risk→approve)抽离至 dispatchApprovalIPC(useToolApproval.ts),
// 与 ApprovalPopup.runApproval 同源,消除「同语义两套逻辑」漂移。
// tc 为 null(findToolCall 未找到,时序竞态)时 isPathKind=false,走 risk 兜底 approve 分支
// (与原实现等价:!!tc && tc.kind==='path' 短路)。
if (tc) {
await dispatchApprovalIPC(tc, approved, decision)
} else {
await aiApi.approve(toolCallId, approved)
}
// IPC 成功返回 = 后端已处理(执行工具/拒绝/写入白名单 + emit 事件 + try_continue)。
// 乐观更新 tc.status,不依赖后端事件来复位 loading(后端事件可能在 IPC 返回前后任意时刻到达,
// 且 findToolCall 若因时序找不到 tc 会致 loading 永不复位)。
// CR: 批审批场景下后端可能已完成工具执行并 emit AiToolCallCompleted,此时 tc.status 已为
// 'completed',乐观更新不能回退——故仅在 status!=='completed' 时覆写。
if (tc) {
const isDeny = isPathKind ? (decision === 'deny') : !approved
if (tc.status !== 'completed') {
tc.status = isDeny ? 'rejected' : 'running'
}
if (isDeny) {
tc.result = t('ai.aiTool.rejectedHint')
}
}
// 同步从 pendingApprovals 移除(不等后端 AiApprovalResult 事件来移,防 loading 卡死)
state.pendingApprovals = state.pendingApprovals.filter(p => p.id !== toolCallId)
} catch (e) {
// IPC 未送达:用户已点过按钮,清看门狗(避免 130s 后误触发假错误)再回滚到失败态
// TD-260621-03 per-conv:清审批 tc 所属会话的 timer(与上方 reset 同 conv,对称成对)。
clearStreamWatchdog(approvalConvId)
state.queue = [] // IPC 失败即对话结束,清队列防生成中入队消息静默丢失
// 仅 IPC 真失败(审批已处理/网络断)走到这里:后端工具失败已改走 emit completed,不进此分支
// 不回滚 pending_approval(会卡死按钮),改设 completed + 错误提示,让用户知晓失败
console.error('[AI] 审批操作未送达后端:', e)
// P1-e-d:审批失败给用户可见反馈——composable 无组件上下文,经 Tauri 事件总线
// emit('ai-toast'),由 AiChat.vue listen 弹本地 toast(对齐 ai-tool-slow-toast 先例)。
// 文案复用 ai.approvalNotDelivered(与下方 tc.result 同源)。
const errMsg = e instanceof Error ? e.message : String(e)
void emit('ai-toast', { msg: t('ai.approvalNotDelivered', { error: errMsg }), type: 'error' })
if (tc) {
tc.status = 'completed'
tc.result = t('ai.approvalNotDelivered', { error: errMsg })
}
throw e
}
} finally {
_pendingApprovalIds.delete(toolCallId)
}
}
/**
* 批量审批:遍历 pendingApprovals 逐个按 kind 分派调用 approveToolCall(决策:批量逐个,
* 非目录聚合——path 类每条独立决策,不合并同目录)。
*
* path_auth 审批链阶段3b:统一开关开后,pendingApprovals 可能混合 path + risk 两类挂起。
* 批量操作语义:
* - decision='approve':risk 类 approved=true;path 类 decision='once'(批量授权本次单次,
* 不批量写持久白名单——always 是单卡语义,批量误写持久白名单风险高,故批量只 once)。
* - decision='reject':risk 类 approved=false;path 类 decision='deny'。
* 单条失败不中断批量(已由 approveToolCall 内部回滚该条状态)。
*/
const _batchProcessing = ref(false)
async function batchApprove(decision: 'approve' | 'reject') {
if (_batchProcessing.value) return // 去重:批量进行中禁重复点击
_batchProcessing.value = true
// 快照当前待审批列表(遍历中 state.pendingApprovals 会因事件回调而缩短)
const items = [...state.pendingApprovals]
let successCount = 0
let failCount = 0
for (const p of items) {
try {
if (p.kind === 'path') {
// path 类:approve→once(本次单次,不批量写持久白名单), reject→deny
await approveToolCall(p.id, false, decision === 'approve' ? 'once' : 'deny')
} else {
// risk 类:approve/reject boolean
await approveToolCall(p.id, decision === 'approve')
}
successCount++
} catch {
// 单条失败不中断批量操作(已由 approveToolCall 内部回滚该条状态)
failCount++
// 继续处理剩余项
}
}
_batchProcessing.value = false
// 若有失败项,console.warn 诊断(用户可打开 DevTools 查看具体失败项)
if (failCount > 0) {
console.warn(`[AI] 批量审批完成: ${successCount} 成功, ${failCount} 失败`)
}
}
/** 批量处理中(供按钮禁用 + spinner 展示) */
export function isBatchProcessing(): boolean {
return _batchProcessing.value
}
export function useAiApproval() {
return {
approveToolCall,
batchApprove,
}
}