重构: 对话透明化 L1 + ③.2 审批拆分 + ⑥.4 @展开 + DAG 展示
修复 tool_result 压缩零效果(BUG-260628-01):单行/少行/JSON 大字符 串字段逃逸压缩的问题,基于实测数据(53/94 次迭代零节省)调参, 增加字符级保底截断,压缩有效率从 8% 提升至 ~85%+
This commit is contained in:
+22
-140
@@ -54,21 +54,21 @@
|
||||
<div v-if="tc.kind === 'path'" class="ai-tool-actions ai-tool-actions--path">
|
||||
<button class="ai-tool-btn ai-tool-btn--approve"
|
||||
:disabled="approving"
|
||||
@click="onAuthorize('once')">
|
||||
@click="onAuthorizeOnce">
|
||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
||||
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
{{ $t('aiChat.dirAuthOnce') }}
|
||||
</button>
|
||||
<button class="ai-tool-btn ai-tool-btn--always"
|
||||
:disabled="approving"
|
||||
@click="onAuthorize('always')">
|
||||
@click="onAuthorizeAlways">
|
||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
||||
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
{{ $t('aiChat.dirAuthAlways') }}
|
||||
</button>
|
||||
<button class="ai-tool-btn ai-tool-btn--reject"
|
||||
:disabled="approving"
|
||||
@click="onAuthorize('deny')">
|
||||
@click="onDeny">
|
||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
||||
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
{{ $t('aiChat.dirAuthDeny') }}
|
||||
@@ -77,14 +77,14 @@
|
||||
<div v-else class="ai-tool-actions">
|
||||
<button class="ai-tool-btn ai-tool-btn--approve"
|
||||
:disabled="approving"
|
||||
@click="onApprove(true)">
|
||||
@click="onApprove">
|
||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
||||
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
{{ $t('aiTool.approve') }}
|
||||
</button>
|
||||
<button class="ai-tool-btn ai-tool-btn--reject"
|
||||
:disabled="approving"
|
||||
@click="onApprove(false)">
|
||||
@click="onReject">
|
||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
||||
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
{{ $t('aiTool.reject') }}
|
||||
@@ -124,10 +124,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, reactive, watch, onBeforeUnmount } from 'vue'
|
||||
import { computed, toRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { STREAM_TIMEOUT_MS } from '@/composables/ai/useAiStream'
|
||||
import {
|
||||
shouldKeepOpen,
|
||||
parseResult,
|
||||
@@ -137,6 +135,7 @@ import {
|
||||
} from '@/composables/ai/useToolCard'
|
||||
import { parseDiffLines } from '@/composables/ai/useToolCardRender'
|
||||
import { useToolCardHeader } from '@/composables/ai/useToolCardHeader'
|
||||
import { useToolApproval } from '@/composables/ai/useToolApproval'
|
||||
import type { AiToolCallInfo } from '@/api/types'
|
||||
import ConfirmDialog from './ConfirmDialog.vue'
|
||||
import ToolResultBody from './ToolResultBody.vue'
|
||||
@@ -165,141 +164,24 @@ const emit = defineEmits<{
|
||||
approve: [{ id: string; approved: boolean; decision?: 'once' | 'always' | 'deny' }]
|
||||
}>()
|
||||
|
||||
// AE-2025-05:本卡自治二次确认状态机(每张 ToolCard 各持一份 confirmState,与 AiChat 父级隔离)。
|
||||
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
||||
|
||||
// 头部显示逻辑(工具名/审批参数语义化回显)抽离至 composable,传入 tc getter 保持响应式。
|
||||
const { toolDisplayName, displayArgValue, argsEntries } = useToolCardHeader(() => props.tc)
|
||||
|
||||
/**
|
||||
* 审批按钮 loading 态(B-260616-08)。点击后置 true 禁用两按钮防重复点击 + spinner;
|
||||
* 后端回事件使 tc.status 离开 pending_approval → watch 复位。兜底计时到点复位 + toast 提示
|
||||
* (对齐全局看门狗 STREAM_TIMEOUT_MS,后端无回执时给用户重试入口)。
|
||||
*/
|
||||
const APPROVE_LOADING_TIMEOUT_MS = STREAM_TIMEOUT_MS
|
||||
const approving = ref(false)
|
||||
let approvingTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// ── 审批挂起时长显示(BUG-260624-02) ──
|
||||
// 背景:AI 调用需审批的工具(patch_file/write_file 等)后,ToolCard 弹审批卡常驻
|
||||
// 直到用户批/拒。用户可能走开忘了批,卡片无任何时长提示。
|
||||
// 方案选型(A/B/C 三选一,选 C):
|
||||
// A 不做(删 todo)——挂起审批容易被遗忘,AI loop 卡死
|
||||
// B 加超时+倒计时——后端加 timer + 超时后行为难定义(自动拒=丢改动/自动批=危险)
|
||||
// C 显示挂起时长(本方案)——纯前端展示,不动后端,不改审批行为
|
||||
// 实现:进入 pending_approval 时记录起始时刻,每秒刷新 waitSecs。
|
||||
// >2分钟变橙色,>5分钟变红色,提示用户尽快审批。
|
||||
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'
|
||||
})
|
||||
|
||||
// ── 审批超时兜底 toast(本卡自管,分离窗口运行时 App 根 toast 不在 DOM) ──
|
||||
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 风险工具白名单(后端 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',
|
||||
])
|
||||
|
||||
/** 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')
|
||||
}
|
||||
|
||||
async function onApprove(approved: boolean) {
|
||||
if (approving.value) return // 防重入
|
||||
// AE-2025-05:High 风险 + 批准 → 先二次确认;拒绝不再二次确认(无害,加确认反增摩擦)
|
||||
if (approved && HIGH_RISK_TOOLS.has(props.tc.name)) {
|
||||
const ok = await confirmDialog(highRiskConfirmMsg(props.tc.name))
|
||||
if (!ok) return
|
||||
}
|
||||
approving.value = true
|
||||
approvingTimer = setTimeout(() => {
|
||||
approving.value = false
|
||||
approvingTimer = null
|
||||
console.warn('[AI] 审批 loading 超时,后端可能未回执,已复位允许重试:', props.tc.id)
|
||||
showApproveTimeoutToast()
|
||||
}, APPROVE_LOADING_TIMEOUT_MS)
|
||||
emit('approve', { id: props.tc.id, approved })
|
||||
}
|
||||
|
||||
/**
|
||||
* path_auth 审批链阶段3b:path 类(路径授权挂起)三选项处理。
|
||||
* once=仅本次会话授权 / always=写持久白名单 / deny=拒绝。
|
||||
* 与 onApprove 同款 loading + 超时兜底,但 emit 带 decision(approved=false 仅占位,
|
||||
* 实际语义由 decision 决定;调用方 store.approveToolCall 按 decision 走 authorizeDir)。
|
||||
* path 类不做 High 风险二次确认(路径授权是显式白名单写入,非破坏性操作,once/always/deny 三选项
|
||||
* 本身就是用户明示决策,二次确认反增摩擦)。
|
||||
*/
|
||||
async function onAuthorize(decision: 'once' | 'always' | 'deny') {
|
||||
if (approving.value) return // 防重入
|
||||
approving.value = true
|
||||
approvingTimer = setTimeout(() => {
|
||||
approving.value = false
|
||||
approvingTimer = null
|
||||
console.warn('[AI] 路径授权 loading 超时,后端可能未回执,已复位允许重试:', props.tc.id)
|
||||
showApproveTimeoutToast()
|
||||
}, APPROVE_LOADING_TIMEOUT_MS)
|
||||
// approved=false 仅占位:store.approveToolCall 据 decision 走 authorizeDir,approved 入参被忽略。
|
||||
emit('approve', { id: props.tc.id, approved: false, decision })
|
||||
}
|
||||
|
||||
// status 离开 pending_approval → 复位 approving(后端回执/转态时)。原 cmdOutputExpanded/
|
||||
// httpBodyExpanded 初始化逻辑已随结果区迁入 ToolResultBody,此处仅留 approving 复位。
|
||||
watch(() => props.tc.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()
|
||||
})
|
||||
// 审批状态机(按钮 loading/挂起时长/toast/approve+reject+path_auth 三选项)抽离至 composable。
|
||||
const {
|
||||
approving,
|
||||
approveToast,
|
||||
waitSecs,
|
||||
waitLevel,
|
||||
formatWaitTime,
|
||||
onApprove,
|
||||
onReject,
|
||||
onAuthorizeOnce,
|
||||
onAuthorizeAlways,
|
||||
onDeny,
|
||||
confirmState,
|
||||
answerConfirm,
|
||||
} = useToolApproval(toRef(props, 'tc'), emit)
|
||||
|
||||
/** 一次解析结果供失败判定复用(头部 isFailed 用于卡片 border/status-dot 着色) */
|
||||
const parsed = computed(() => parseResult(props.tc.result))
|
||||
|
||||
Reference in New Issue
Block a user