修复: AI工具OOM/路径遍历+续跑锁收敛+UX交互批
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -45,8 +45,8 @@ import { useI18n } from 'vue-i18n'
|
||||
import ToolCard from './ToolCard.vue'
|
||||
import type { AiToolCallInfo } from '@/api/types'
|
||||
|
||||
// useI18n needed for $t() in template (collapseAll / expandAll)
|
||||
useI18n()
|
||||
// useI18n needed for $t() in template (collapseAll / expandAll) + groupDisplayName(i18n key)
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
/** 当前消息的工具调用列表 */
|
||||
@@ -166,6 +166,34 @@ function toggleAllGroups(): void {
|
||||
collapsedGroups.value = next
|
||||
}
|
||||
|
||||
/**
|
||||
* 分组显示名:工具名 → i18n 友好标签(英文化,原硬编码中文)。
|
||||
* UX-260617-17:用 aiTool.toolGroup.* 子键查表;未命中的工具回退 _ → 空格风格。
|
||||
*/
|
||||
// tool.name → aiTool.toolGroup.* 子键(与后端 tool_registry 工具名一一对应)
|
||||
const TOOL_GROUP_I18N_KEY: Record<string, string> = {
|
||||
read_file: 'readFile',
|
||||
write_file: 'writeFile',
|
||||
list_directory: 'listDirectory',
|
||||
create_task: 'createTask',
|
||||
create_project: 'createProject',
|
||||
create_idea: 'createIdea',
|
||||
list_tasks: 'listTasks',
|
||||
list_projects: 'listProjects',
|
||||
list_ideas: 'listIdeas',
|
||||
delete_project: 'deleteProject',
|
||||
restore_project: 'restoreProject',
|
||||
purge_project: 'purgeProject',
|
||||
update_project: 'updateProject',
|
||||
run_workflow: 'runWorkflow',
|
||||
}
|
||||
|
||||
function groupDisplayName(group: ToolCallGroup): string {
|
||||
const subKey = TOOL_GROUP_I18N_KEY[group.name]
|
||||
if (subKey) return t(`aiTool.toolGroup.${subKey}`)
|
||||
return group.name.replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* 单卡是否展开:
|
||||
* - 分组未折叠 → 用户手动 expandedCards 或首卡默认展开
|
||||
@@ -253,28 +281,6 @@ function groupIcon(name: string): string {
|
||||
if (name.includes('delete')) return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>'
|
||||
return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>'
|
||||
}
|
||||
|
||||
/** 分组显示名:工具名 + 数量提示 */
|
||||
function groupDisplayName(group: ToolCallGroup): string {
|
||||
const nameMap: Record<string, string> = {
|
||||
read_file: '读取文件',
|
||||
write_file: '写入文件',
|
||||
list_directory: '列出目录',
|
||||
create_task: '创建任务',
|
||||
create_project: '创建项目',
|
||||
create_idea: '创建灵感',
|
||||
list_tasks: '列出任务',
|
||||
list_projects: '列出项目',
|
||||
list_ideas: '列出灵感',
|
||||
delete_project: '删除项目',
|
||||
restore_project: '恢复项目',
|
||||
purge_project: '彻底删除项目',
|
||||
update_project: '更新项目',
|
||||
run_workflow: '运行工作流',
|
||||
}
|
||||
const label = nameMap[group.name] || group.name.replace(/_/g, ' ')
|
||||
return label
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
Reference in New Issue
Block a user