修复: 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>

View File

@@ -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>

View File

@@ -9,10 +9,15 @@ import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai'
import { notifyConversationChanged } from './useAiEvents'
import { persistUiState } from './useAiPanel'
import type { AiToolCallInfo } from '@/api/types'
import { nextMsgId } from './aiShared'
import i18n from '@/i18n'
import type { AiMessage, AiToolCallInfo } from '@/api/types'
const appSettings = useAppSettingsStore()
// composable 无组件上下文,借全局 i18n 实例翻译错误文案(同 useAiSend/aiShared 模式)
const t = ((i18n as any).global.t as (k: string, named?: Record<string, unknown>) => string).bind((i18n as any).global)
/** 拉取会话列表;首次加载时若 appSettings 仍有有效 active-conv 且当前无活跃对话则恢复 */
async function loadConversations() {
try {
@@ -22,8 +27,21 @@ async function loadConversations() {
if (pending && !state.activeConversationId && state.conversations.some(c => c.id === pending)) {
await switchConversation(pending)
}
} catch {
// 静默失败,不影响主流程
} catch (e) {
// UX-260617-08:网络抖动/IPC 断开时原静默失败,列表可能突空用户不知原因。
// 此处保留旧列表(赋值语句抛出,旧 state.conversations 不被覆盖)+ 推错误气泡反馈。
// 仅首次加载(state.conversations 为空)时推气泡,避免拉取/侧栏刷新重试时反复弹错。
console.error('[AI] 加载会话列表失败:', e)
if (state.conversations.length === 0) {
state.messages.push({
id: `load-conv-fail-${nextMsgId()}`,
role: 'assistant',
content: t('ai.loadConvFail'),
isError: true,
timestamp: Date.now(),
} as AiMessage)
notifyConversationChanged()
}
}
}
@@ -114,8 +132,17 @@ export async function switchConversation(id: string) {
}
}),
}))
} catch {
state.messages = []
} catch (e) {
// UX-260617-08:历史消息解析/映射失败原仅 state.messages=[] → 切换后空白用户不知原因。
// 改为推错误气泡提示 + 控制台日志(保留 state.messages 不再清空,避免空白无反馈)。
console.error('[AI] 切换对话历史消息解析失败:', e)
state.messages.push({
id: `switch-conv-fail-${nextMsgId()}`,
role: 'assistant',
content: t('ai.switchConvFail'),
isError: true,
timestamp: Date.now(),
} as AiMessage)
}
// 加载历史对话 token 总量(来自 DB summary);切换对话清空实时值

View File

@@ -326,11 +326,15 @@ export function handleEvent(event: AiChatEvent) {
case 'AiError': {
clearStreamWatchdog()
clearAllToolSlowTimers() // B-260616-12: 错误收尾清全部工具慢执行计时器与已提示集合
clearAllApprovalTimers() // UX-260617-10: 错误中断释放所有审批超时计时器,防回调改 state 触发已卸载/已错流程
state.streaming = false
state.generatingConvId = null
state.currentText = ''
state.agentRound = 0 // AE-2025-07: agentic 异常中断,复位轮次
state.queue = [] // B-32:错误收尾清队列,防生成中入队的消息被静默丢失(drainQueue 仅 AiCompleted 触发)
// UX-260617-10: 错误收尾清残留待审批项——错误发生时若有工具停在 pending_approval,
// 残留可点击审批按钮会让用户误以为还能批(实际后端已终止),残留审批卡误导操作。
state.pendingApprovals = []
pendingMaxRounds.value = false // F-260616-03: 异常中断,清操作卡
localStorage.removeItem('df-ai-gen')
localStorage.removeItem('df-ai-text')
@@ -356,14 +360,23 @@ export async function startListener() {
if (_unlistenAiEvent && _unlistenConvChanged && _unlistenApprovalClear) return
// 并发去重:防 onMounted 与 sendMessage 首发竞态重复注册 listener → 文字双倍
if (_startPromise) return _startPromise
// UX-260617-09: 内部任一 await reject 时,onMounted/sendMessage 的 await 在 Vue async
// 生命周期/void 调用点处成为未捕获 rejection(无 console 输出)→ listener 未注册且无任何提示,
// 表现为「发消息永远不回」。这里 catch + console.error 保留可观测性,再 throw 让外层
// finally 清 _startPromise(并发去重复位),调用方仍可 try/catch 兜底。
_startPromise = (async () => {
_unlistenAiEvent = await aiApi.onEvent(handleEvent)
_unlistenConvChanged = await listen('ai-conversation-changed', () => { void loadConversations() })
// AE-2025-06: onStreamTimeout(流式整流超时收尾)广播的清理事件 → 清全部审批超时计时器。
// useAiStream 不 import useAiSend(避循环依赖),经事件总线中转
_unlistenApprovalClear = await listen('ai-approval-clear-timers', () => { clearAllApprovalTimers() })
// B-260616-01: L0 握手 — 通知后端前端已就绪,后端据此清除 HMR/刷新导致的残留 generating 状态
emit('ai-client-ready', { lastConvId: state.activeConversationId })
try {
_unlistenAiEvent = await aiApi.onEvent(handleEvent)
_unlistenConvChanged = await listen('ai-conversation-changed', () => { void loadConversations() })
// AE-2025-06: onStreamTimeout(流式整流超时收尾)广播的清理事件 → 清全部审批超时计时器
// useAiStream 不 import useAiSend(避循环依赖),经事件总线中转。
_unlistenApprovalClear = await listen('ai-approval-clear-timers', () => { clearAllApprovalTimers() })
// B-260616-01: L0 握手 — 通知后端前端已就绪,后端据此清除 HMR/刷新导致的残留 generating 状态
emit('ai-client-ready', { lastConvId: state.activeConversationId })
} catch (err) {
console.error('[useAiEvents] startListener 注册失败,事件监听未生效:', err)
throw err
}
})()
try {
await _startPromise
@@ -382,6 +395,10 @@ function stopListener() {
_unlistenAiEvent = null
_unlistenConvChanged = null
_unlistenApprovalClear = null
// UX-260617-26: stop 清零去重 promise——极速 mount/unmount/mount(HMR)时,首 mount 的
// _startPromise 可能仍在 pending(未 await 完即 unmount),再 mount 若命中并发去重分支会
// 返回这个已代表「旧注册」的过期 promise,新 listener 实际未注册。
_startPromise = null
clearStreamWatchdog()
clearAllToolSlowTimers() // B-260616-12: 卸载时释放所有工具慢执行计时器,防回调改 state 触发已卸载组件
clearAllApprovalTimers() // AE-2025-06: 卸载时释放所有审批超时计时器,防回调 push 消息触发已卸载组件

View File

@@ -480,6 +480,13 @@ async function sendQueuedNow(index: number) {
/** 停止当前生成:本地先复位 streaming再发停止信号。
* 不依赖后端 AiCompleted 收尾——审批态看门狗已 clear(useAiEvents),若 AiCompleted 竞态丢失则 streaming 永久 true 卡死,故本地兜底。
*
* UX-260617-19: stop IPC 失败时的兜底。本地已先置 streaming=false但若 stop 信号未送达后端,
* 后端会继续生成并发回 delta/Completed 事件,与用户已感知的「已停止」语义冲突;更坏情况是
* 后端 hang 既不收尾也不发事件(stop_flag 未置位、无 watchdog 兜底则无恢复路径)。
* 故 stop 失败时不 clearStreamWatchdog反而重启看门狗后端若继续生成活跃 delta 会不断重置它
* 不误触发;后端若真 hang130s 后 onStreamTimeout 兜底复位 streaming+清队列+推错误气泡。
* 同时推一条 stopLoopFailed 错误消息让用户知晓停止未生效(可再点或等待收尾)。
*/
async function stopChat() {
state.streaming = false // 本地先复位,不依赖后端 AiCompleted审批态看门狗已 clear竞态丢失则卡死
@@ -487,7 +494,22 @@ async function stopChat() {
// UX-260616-05 决策 a(逐条续发):不再清队列,保留供 AiCompleted 链式触发 drainQueue 续发。
// 后端 agentic.rs:190 emit AiCompleted 注释明确「保证前端收事件时后端已可接下一条,队列续发不被拒」;
// 当前未完成回复已入库(agentic.rs:185 save_conversation)保留为截断回复。
await aiApi.stopChat()
try {
await aiApi.stopChat()
} catch (e) {
// stop 信号未送达后端:重启看门狗(后端继续生成时活跃事件会重置它,真 hang 则 130s 后兜底收尾),
// 避免后端仍在跑却无任何恢复路径(原 clearStreamWatchdog 已把兜底关掉)。
resetStreamWatchdog()
const errMsg = e instanceof Error ? e.message : String(e)
state.messages.push({
id: `stop-err-${nextMsgId()}`,
role: 'assistant',
content: t('aiChat.stopLoopFailed', { msg: errMsg }),
isError: true,
timestamp: Date.now(),
} as AiMessage)
console.error('[AI] stopChat IPC 失败,已重启看门狗兜底:', e)
}
}
export function useAiSend() {

View File

@@ -38,6 +38,9 @@ export default {
approvalNotDelivered: 'Approval operation failed to reach the backend: {error}',
queuedSendFailed: 'Failed to send queued message: {error}',
approvalTimeout: '⚰ Approval timed out, automatically rejected: {toolName}',
// UX-260617-08: conversation load/switch failure feedback (network jitter/IPC down → empty list/blank, was silent)
loadConvFail: 'Failed to load conversations. Please check your network or backend connection.',
switchConvFail: 'Failed to switch conversation. Please retry.',
// AE-2025-07: Agentic loop progress bar copy
// Note: max round / completed tool count are not surfaced by backend this round;
// template conditionally omits them. Add *WithMax keys once backend exposes them.

View File

@@ -82,6 +82,30 @@ export default {
// Tool result detail format (plain-text result + approval placeholder appends arg summary)
resultDetail: '{raw} ({detail})',
// UX-260617-17: ToolCardList group header (shown when 2+ same-name tools; was hardcoded zh)
toolGroup: {
readFile: 'Read File',
writeFile: 'Write File',
listDirectory: 'List Directory',
createTask: 'Create Task',
createProject: 'Create Project',
createIdea: 'Create Idea',
listTasks: 'List Tasks',
listProjects: 'List Projects',
listIdeas: 'List Ideas',
deleteProject: 'Delete Project',
restoreProject: 'Restore Project',
purgeProject: 'Purge Project',
updateProject: 'Update Project',
runWorkflow: 'Run Workflow',
},
// UX-260617-14: approval loading fallback timeout notice (no backend ack, card still pending_approval → reset + prompt)
approveLoadingTimeout: 'Approval response timed out, please retry or check the backend',
// UX-260617-18: tool failure detection copy (regex narrowed to line-anchored err_msg prefix)
// (reuse executionFailed/executionFailedHint, no new key)
// Approval argument key labels
argLabel: {
id: 'ID',

View File

@@ -38,6 +38,9 @@ export default {
approvalNotDelivered: '审批操作未送达后端:{error}',
queuedSendFailed: '队列消息发送失败:{error}',
approvalTimeout: '⚰ 审批超时,已自动拒绝:{toolName}',
// UX-260617-08:对话加载/切换失败反馈(网络抖动/IPC 断开 → 列表空/切换空白,原静默无反馈)
loadConvFail: '对话列表加载失败,请检查网络或后端连接',
switchConvFail: '对话切换失败,请重试',
// AE-2025-07: Agentic 循环进度条文案
// 注:max 轮次/已完成工具数本轮后端暂不透传,模板条件渲染省略;后端补全后新增 *WithMax key
agenticProgress: '🔄 循环 {round} · ⏳{pending}待审批',

View File

@@ -85,6 +85,30 @@ export default {
// 工具结果详情格式(非 JSON 纯文本 + 审批占位时补参数摘要)
resultDetail: '{raw}{detail}',
// UX-260617-17:ToolCardList 分组标题(同名工具 >=2 时显示;原硬编码中文,英文化)
toolGroup: {
readFile: '读取文件',
writeFile: '写入文件',
listDirectory: '列出目录',
createTask: '创建任务',
createProject: '创建项目',
createIdea: '创建灵感',
listTasks: '列出任务',
listProjects: '列出项目',
listIdeas: '列出灵感',
deleteProject: '删除项目',
restoreProject: '恢复项目',
purgeProject: '彻底删除项目',
updateProject: '更新项目',
runWorkflow: '运行工作流',
},
// UX-260617-14:审批 loading 超时兜底提示(后端无回执,卡片仍 pending_approval 时复位 + 提示用户)
approveLoadingTimeout: '审批响应超时,请重试或检查后端',
// UX-260617-18:工具失败检测文案(收窄正则后,行首锚定 err_msg 前缀)
// (复用 executionFailed/executionFailedHint,不新增)
// 审批参数键标签
argLabel: {
id: 'ID',