修复: AI工具OOM/路径遍历+续跑锁收敛+UX交互批

This commit is contained in:
2026-06-17 14:07:16 +08:00
parent 2065335c8c
commit 6b67214395
15 changed files with 390 additions and 98 deletions

View File

@@ -128,6 +128,11 @@
:danger-label="t('common.confirm')"
@result="answerConfirm"
/>
<!-- UX-260617-14:审批 loading 超时兜底 toast(零依赖 reactive,复用 AiChat 同款样式) -->
<Transition name="ai-tool-toast">
<div v-if="approveToast.visible" class="ai-tool-toast ai-tool-toast--warning">{{ approveToast.msg }}</div>
</Transition>
</div>
</template>
@@ -202,7 +207,8 @@ function shouldKeepOpen(tc: AiToolCallInfo): boolean {
* - run_command exit_code≠0:结果 JSON 合法,parseResult 命中,看 exit_code 判失败
* - 通用工具失败:AR-6 路径后端塞 serde_json::Value::String("工具 X 执行失败: ..."),
* 前端 parseResult 返回 null(非 JSON 对象)→ 走 raw 文本兜底,无结构信号
* 此时按字符串前缀 "执行失败"/"failed" 启发式判定(对齐后端 err_msg 格式)。
* 此时按行首前缀 "执行失败"/"Error:"/"Failed:" 启发式判定(对齐后端 err_msg 格式,
* 行首锚定避免误伤含这些子串的正常产出,见 UX-260617-18)。
*
* 注意:status==='rejected'(用户拒绝)走独立分支,不算工具失败,不进此函数。
* running/pending_approval 自然 false(无 result 或 result 未稳定)。
@@ -220,7 +226,10 @@ function isToolFailure(tc: AiToolCallInfo): boolean {
if (!parseResult(tc.result)) {
const raw = typeof tc.result === 'string' ? tc.result : ''
if (!raw) return false
return /执行失败|failed|error[:\s]/i.test(raw)
// UX-260617-18:收窄为行首锚定(原 /执行失败|failed|error[:\s]/i 匹配任意位置的子串,
// 误伤 search_files 命中 error.log / run_command stderr 含 "warning" 等正常产出)。
// 仅当某行以「执行失败」(中文 err_msg 前缀)/「Error:」/「Failed:」开头才判失败。
return /^(执行失败|Error:|Failed:)/m.test(raw)
}
return false
}
@@ -464,10 +473,11 @@ function formatFileInfoResult(r: ToolResult): string {
</script>
<script setup lang="ts">
import { computed, ref, watch, onBeforeUnmount } from 'vue'
import { computed, ref, reactive, watch, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { useProjectStore } from '@/stores/project'
import { useConfirm } from '@/composables/useConfirm'
import { STREAM_TIMEOUT_MS } from '@/composables/ai/useAiStream'
import ConfirmDialog from './ConfirmDialog.vue'
const { t } = useI18n()
@@ -504,10 +514,23 @@ const emit = defineEmits<{
* AiApprovalResult/approved 后 hang 在工具执行前),全局看门狗只回滚 running 不会触及本卡。
* 故此处加本地兜底计时(对齐全局看门狗 STREAM_TIMEOUT_MS),到点复位 loading 让用户能重试/改判。
*/
const APPROVE_LOADING_TIMEOUT_MS = 130000 // 对齐 useAiStream.STREAM_TIMEOUT_MS
// UX-260617-14:复用 useAiStream 共享常量(原硬编码 130000,单一来源防漂移)。
const APPROVE_LOADING_TIMEOUT_MS = STREAM_TIMEOUT_MS
const approving = ref(false)
let approvingTimer: ReturnType<typeof setTimeout> | null = null
// ── 审批超时兜底 toast(零依赖,复用 AiChat/Settings 同款 reactive toast 模式) ──
// ToolCard 是子组件无根 toast 挂载点,且可能以分离窗口运行(App.vue 根 toast 不在 DOM),
// 故本卡自管一个最小 toast;仅在审批 loading 超时兜底时短暂提示。
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 对齐)。
* AiToolCallInfo 无 risk 字段(风险仅存后端),故前端按工具名判定。新增 High 工具时同步加这里。
@@ -538,9 +561,12 @@ async function onApprove(approved: boolean) {
}
approving.value = true
// 本地兜底:到点强制复位 loading(后端无回执、卡片仍 pending_approval 时给用户重试入口)
// UX-260617-14:超时复位同时给 warning toast + 控制台日志,原仅静默 approving=false 用户无感知。
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 })
}
@@ -554,6 +580,7 @@ watch(() => props.tc.status, (s) => {
// 组件卸载清计时器防泄漏
onBeforeUnmount(() => {
if (approvingTimer) clearTimeout(approvingTimer)
if (_approveToastTimer) clearTimeout(_approveToastTimer)
})
/** 一次解析结果供模板多次读(模板引用 6 次,computed 避免每次 patch 重 parse) */
@@ -707,6 +734,8 @@ function toolResultSummary(tc: AiToolCallInfo): string {
overflow: hidden;
max-width: var(--df-msg-max-width);
animation: fadeInUp 0.2s var(--df-ease);
/* UX-260617-14:相对定位锚定本卡 toast(absolute 子元素) */
position: relative;
/* UX-260616-03: border/shadow/opacity 过渡让折叠/失败态切换不突兀。
不做 body 高度动画(v-show display 切换 + flex gap 会留双倍间距致布局跳变,
见 ToolCardList 注释);opacity 过渡覆盖自动收起时的视觉跳变(约 200ms)。 */
@@ -1174,4 +1203,27 @@ function toolResultSummary(tc: AiToolCallInfo): string {
white-space: pre-wrap;
word-break: break-all;
}
/* -- UX-260617-14: 审批 loading 超时兜底 toast(复用 AiChat 同款视觉 token) -- */
.ai-tool-toast {
position: absolute;
top: 6px;
left: 50%;
transform: translateX(-50%);
padding: 6px 12px;
border-radius: var(--df-radius-sm);
font-size: 11px;
z-index: 100;
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
max-width: 90%;
overflow-wrap: anywhere;
pointer-events: none;
}
.ai-tool-toast--warning {
background: var(--df-warning-bg);
color: var(--df-warning);
border: 0.5px solid var(--df-warning);
}
.ai-tool-toast-enter-active, .ai-tool-toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
.ai-tool-toast-enter-from, .ai-tool-toast-leave-to { opacity: 0; transform: translate(-50%, -6px); }
</style>