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

@@ -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() {