- status union: AiMessage 加 status 字面量联合,删 AiChat/MessageList AiMessageWithStatus 重复+cast - ApprovalPopup: 删 _placeholder hack(toolDisplayName 提纯函数) + dispatchApprovalIPC 共享分派消除三套重复 + always 按钮语义对齐 + closeWithFallback 兜底 + 拖动 JS API startDragging(避子元素覆盖) + Esc 快捷键
167 lines
8.8 KiB
TypeScript
167 lines
8.8 KiB
TypeScript
//! 工具审批 — 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 { 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'
|
||
|
||
/**
|
||
* F-260616-06: 审批按钮防抖守卫——同一 id 短期多次点击只发一次 IPC。
|
||
* IPC 往返 <100ms,300ms 窗口足够覆盖竞态。
|
||
*/
|
||
const _pendingApprovalIds = new Set<string>()
|
||
|
||
/**
|
||
* 工具审批:保持 pending_approval 让审批卡片可见(按钮 loading 由 ToolCard 本地 ref 持有),
|
||
* IPC 失败时改 completed+错误文案。后端回事件后由 useAiEvents 转 completed/rejected。
|
||
* B-260616-08:不再乐观置 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') {
|
||
// F-260616-06: 防抖——同一 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 = [] // B-32:IPC 失败即对话结束,清队列防生成中入队消息静默丢失
|
||
// 仅 IPC 真失败(审批已处理/网络断)走到这里:后端工具失败已改走 emit completed,不进此分支
|
||
// 不回滚 pending_approval(会卡死按钮),改设 completed + 错误提示,让用户知晓失败
|
||
console.error('[AI] 审批操作未送达后端:', e)
|
||
if (tc) {
|
||
tc.status = 'completed'
|
||
tc.result = t('ai.approvalNotDelivered', { error: e instanceof Error ? e.message : String(e) })
|
||
}
|
||
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,
|
||
}
|
||
}
|