Files
DevFlow/src/composables/ai/useToolApproval.ts
绝尘 dfed588b54 新增: 文件浏览器增强(窗口分离/Git 变更/行号/Diff/分页提交历史)
- 窗口分离: FileExplorer 可弹出独立 Tauri 窗口
- 行号显示: 文件预览左侧显示行号列
- 按后缀图标: rs/ts/js/vue/css/html 等 20+ 类型彩色图标
- Diff 视图: 有 Git 变更的文件可切换 diff 红绿视图
- Git 变更面板: 变更文件列表(按目录缩进分组)+提交历史(分页加载)
- 提交详情: 点击提交查看变更文件列表及文件级 diff
- 时间显示: 提交历史显示相对时间/具体日期
- 面包屑导航不丢预览: 导航时不改变当前预览文件
- 刷新保留选中文件: 只清树缓存不清预览
- 中文编码修复: git 命令注入 LANG/LC_ALL 环境变量
- 自适应布局: 弹性 flex 布局,窄窗口适配
- 滚动条优化: 内容区内部滚动,不溢出页面层
- 多工程管理: 工具栏添加工程按钮+弹窗表单
- 审批加载超时缩短: 130s 降至 30s
- 文件变更自动刷新: write_file/patch_file 触发 df-data-changed
2026-06-29 19:31:55 +08:00

213 lines
7.7 KiB
TypeScript

/**
* ToolCard 审批状态机 — 从 ToolCard.vue 抽离的审批 UI 逻辑。
*
* 职责:审批按钮 loading 态、审批挂起时长显示(BUG-260624-02)、
* 审批超时兜底 toast(UX-260617-14)、approve/reject/path_auth 三选项处理、
* status 变化自动复位 + pending 计时。
*
* 不含 store IPC 调用(useAiApproval 负责)和模板渲染——纯状态管理。
*/
import { ref, reactive, computed, watch, onBeforeUnmount, type Ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useConfirm } from '@/composables/useConfirm'
import type { AiToolCallInfo } from '@/api/types'
/**
* 审批按钮 loading 超时兜底(B-260616-08)。
* 后端回事件使 tc.status 离开 pending_approval → watch 复位。
* 兜底计时到点复位 + toast 提示(后端 IPC 应在 1~3s 内返回;30s 兜底远足超时后允许用户重试)。
* 与 stream 看门狗(130s)解耦:审批是按钮级交互,不是后台流,不需要对齐后端 120s 空闲超时。
*/
const APPROVE_LOADING_TIMEOUT_MS = 30_000
/** AE-2025-05:High 风险工具白名单(后端 tool_registry.rs RiskLevel::High 对齐) */
const HIGH_RISK_TOOLS = new Set<string>([
'delete_task', 'delete_project', 'restore_project', 'purge_project', 'delete_file', 'run_command',
'http_request',
])
export function useToolApproval(tc: Ref<AiToolCallInfo>, emit: any) {
const { t } = useI18n()
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
// ── B-260616-08: 审批 loading 态 ──
const approving = ref(false)
let approvingTimer: ReturnType<typeof setTimeout> | null = null
// ── BUG-260624-02: 审批挂起时长显示 ──
const pendingSince = ref<number | null>(null)
const waitSecs = ref(0)
let waitTimer: ReturnType<typeof setInterval> | null = null
function startPendingTimer() {
if (pendingSince.value !== null) return
pendingSince.value = Date.now()
waitSecs.value = 0
if (waitTimer) clearInterval(waitTimer)
waitTimer = setInterval(() => {
if (pendingSince.value !== null) {
waitSecs.value = Math.floor((Date.now() - pendingSince.value) / 1000)
}
}, 1000)
}
function stopPendingTimer() {
if (waitTimer) { clearInterval(waitTimer); waitTimer = null }
pendingSince.value = null
waitSecs.value = 0
}
function formatWaitTime(secs: number): string {
const m = Math.floor(secs / 60)
const s = secs % 60
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
}
const waitLevel = computed<'normal' | 'long' | 'urgent'>(() => {
if (waitSecs.value >= 300) return 'urgent' // 5分钟
if (waitSecs.value >= 120) return 'long' // 2分钟
return 'normal'
})
// ── UX-260617-14: 审批超时兜底 toast ──
const approveToast = reactive({ visible: false, msg: '' })
let _approveToastTimer: ReturnType<typeof setTimeout> | null = null
function showApproveTimeoutToast(): void {
approveToast.msg = t('aiTool.approveLoadingTimeout')
approveToast.visible = true
if (_approveToastTimer) clearTimeout(_approveToastTimer)
_approveToastTimer = setTimeout(() => { approveToast.visible = false }, 3000)
}
/** AE-2025-05:High 风险工具的二次确认文案(按操作类型) */
function highRiskConfirmMsg(name: string): string {
if (name === 'run_command') return t('aiTool.confirmHighExec')
if (name === 'http_request') return t('aiTool.confirmHighHttp')
if (name === 'delete_task' || name === 'delete_project' || name === 'purge_project' || name === 'delete_file' || name === 'restore_project') {
return t('aiTool.confirmHighDelete')
}
return t('aiTool.confirmHighGeneric')
}
/**
* 批准按钮处理(risk 类专用)。
* AE-2025-05:High 风险工具 → 先二次确认(useConfirm 弹层)。
* loading + 超时兜底复用 B-260616-08 机制。
*/
async function onApprove() {
if (approving.value) return // 防重入
// AE-2025-05:High 风险 → 先二次确认
if (HIGH_RISK_TOOLS.has(tc.value.name)) {
const ok = await confirmDialog(highRiskConfirmMsg(tc.value.name))
if (!ok) return
}
approving.value = true
approvingTimer = setTimeout(() => {
approving.value = false
approvingTimer = null
console.warn('[AI] 审批 loading 超时,后端可能未回执,已复位允许重试:', tc.value.id)
showApproveTimeoutToast()
}, APPROVE_LOADING_TIMEOUT_MS)
emit('approve', { id: tc.value.id, approved: true })
}
/**
* 拒绝按钮处理(risk 类专用)。
* loading + 超时兜底复用 B-260616-08 机制。拒绝不做 High 风险二次确认(无害,加确认反增摩擦)。
*/
async function onReject() {
if (approving.value) return // 防重入
approving.value = true
approvingTimer = setTimeout(() => {
approving.value = false
approvingTimer = null
console.warn('[AI] 审批 loading 超时,后端可能未回执,已复位允许重试:', tc.value.id)
showApproveTimeoutToast()
}, APPROVE_LOADING_TIMEOUT_MS)
emit('approve', { id: tc.value.id, approved: false })
}
/**
* path_auth 审批链阶段3b:path 类「仅本次会话授权」处理。
* 与 onApprove 同款 loading + 超时兜底,emit 带 decision='once'。
* path 类不做 High 风险二次确认(路径授权是显式白名单写入,非破坏性操作,三选项本身就是用户明示决策)。
*/
async function onAuthorizeOnce() {
if (approving.value) return
approving.value = true
approvingTimer = setTimeout(() => {
approving.value = false
approvingTimer = null
console.warn('[AI] 路径授权 loading 超时,后端可能未回执,已复位允许重试:', tc.value.id)
showApproveTimeoutToast()
}, APPROVE_LOADING_TIMEOUT_MS)
emit('approve', { id: tc.value.id, approved: false, decision: 'once' })
}
/**
* path_auth 审批链阶段3b:path 类「写持久白名单」处理。
* 同 onAuthorizeOnce,decision='always'。
*/
async function onAuthorizeAlways() {
if (approving.value) return
approving.value = true
approvingTimer = setTimeout(() => {
approving.value = false
approvingTimer = null
console.warn('[AI] 路径授权 loading 超时,后端可能未回执,已复位允许重试:', tc.value.id)
showApproveTimeoutToast()
}, APPROVE_LOADING_TIMEOUT_MS)
emit('approve', { id: tc.value.id, approved: false, decision: 'always' })
}
/**
* path_auth 审批链阶段3b:path 类「拒绝」处理。
* 同 onAuthorizeOnce,decision='deny'。
*/
async function onDeny() {
if (approving.value) return
approving.value = true
approvingTimer = setTimeout(() => {
approving.value = false
approvingTimer = null
console.warn('[AI] 路径授权 loading 超时,后端可能未回执,已复位允许重试:', tc.value.id)
showApproveTimeoutToast()
}, APPROVE_LOADING_TIMEOUT_MS)
emit('approve', { id: tc.value.id, approved: false, decision: 'deny' })
}
// status 离开 pending_approval → 复位 approving(后端回执/转态时)。
watch(() => tc.value.status, (s) => {
if (s !== 'pending_approval') {
approving.value = false
if (approvingTimer) { clearTimeout(approvingTimer); approvingTimer = null }
stopPendingTimer()
} else {
startPendingTimer()
}
}, { immediate: true })
onBeforeUnmount(() => {
if (approvingTimer) clearTimeout(approvingTimer)
if (_approveToastTimer) clearTimeout(_approveToastTimer)
stopPendingTimer()
})
return {
approving,
approveToast,
waitSecs,
waitLevel,
formatWaitTime,
onApprove,
onReject,
onAuthorizeOnce,
onAuthorizeAlways,
onDeny,
// 供模板 ConfirmDialog 绑定
confirmState,
answerConfirm,
}
}