修复: DeepSeek 400 全量扫描 + 队列 per-conv 隔离
- openai_compat: 扫描所有 assistant 消息剥离 orphan tool_calls(原仅查末条) - queue 加 conversationId 字段,按会话精准 drain - regenerate/editMessage 只清本会话排队消息 - newConversation 保留旧会话排队消息 - AiError 只清出错会话的队列项
This commit is contained in:
@@ -21,6 +21,7 @@
|
||||
//! streaming 是 boolean 无真正非法跃迁,guard 价值在集中入口与日志而非拦截。
|
||||
|
||||
import { state } from '@/stores/ai'
|
||||
import { convStates } from './aiShared'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
|
||||
/// feature flag key(appSettings KV)。关=回退散布语义(灰度/回退用)。
|
||||
@@ -100,4 +101,14 @@ export function forceResetStreaming(reason: string, convId?: string): void {
|
||||
setStreaming(false, { convId: convId ?? null, reason })
|
||||
state.currentText = ''
|
||||
state.queue = []
|
||||
// BUG-2026-07-17 根治:同时清理 convStates 项。
|
||||
// 原实现仅清 streaming/currentText/queue,但按钮 stopBtnState 优先读 convStates
|
||||
// (ChatInput.vue:243),convStates=generating 时仍显红色停止按钮。后端未发
|
||||
// AiConvStateChanged 时(conv_state 永久卡住),必须前端主动清 convStates 才能解锁 UI。
|
||||
if (convId) {
|
||||
convStates.delete(convId)
|
||||
} else {
|
||||
// 全局兜底:无 convId 时清所有生成态(卡死恢复场景,精确性不重要)
|
||||
convStates.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { state } from '@/stores/ai'
|
||||
import { notifyConversationChanged } from './useAiEvents'
|
||||
import { persistUiState } from './useAiPanel'
|
||||
import { setStreaming } from './streamingGuard'
|
||||
import { nextMsgId, getConvState, clearConvStreamState, startApprovalTimer } from './aiShared'
|
||||
import { nextMsgId, getConvState, clearConvStreamState, startApprovalTimer, clearAllApprovalTimers } from './aiShared'
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import type { AiConversationDetail, AiMessage, AiToolCallInfo, ConvId } from '@/api/types'
|
||||
|
||||
@@ -76,9 +76,8 @@ async function newConversation() {
|
||||
// 不清 generatingConvs(保留旧 conv 生成态跟踪,侧栏显双会话生成,AiCompleted/AiError 按
|
||||
// conversation_id 正确收尾旧 conv)。原 state.generatingConvs.clear() 是 A 路线 F-260616-09
|
||||
// 单 loop 软隔离遗留,真并发下致后台生成前端丢失跟踪,废弃。
|
||||
// queue 清空防旧会话排队消息带进新会话 drain;agentRound 复位 0;searchQuery 清空防新会话
|
||||
// 侧栏被旧搜索过滤。此三项均为新 conv 本地视图复位(非跨 conv 跟踪),保留。
|
||||
state.queue = []
|
||||
// agentRound 复位 0;searchQuery 清空防新会话侧栏被旧搜索过滤。
|
||||
// queue 保留旧会话排队消息(它们有 conversationId,会在旧会话 AiCompleted 时按 ID 精准 drain)
|
||||
state.agentRound = 0
|
||||
state.searchQuery = ''
|
||||
await loadConversations()
|
||||
@@ -90,6 +89,9 @@ let _latestSwitchId = 0
|
||||
|
||||
/** 切换到指定会话:加载历史消息(含 tool_calls 回填 + tool_result 映射 + pending 审批恢复) */
|
||||
export async function switchConversation(id: string) {
|
||||
// AIC-FIX-17-P0-2:切换会话时清空所有审批计时器(防切走后过期 timer 误拒后台 pending + 错误气泡推错视图)。
|
||||
// 新会话的审批计时器由下文 restore pending 路径的 startApprovalTimer 重新建立。
|
||||
clearAllApprovalTimers()
|
||||
const mySwitchId = ++_latestSwitchId
|
||||
// 允许生成中切换:后台继续生成,事件按 conversation_id 路由不污染当前视图
|
||||
let detail: AiConversationDetail
|
||||
@@ -241,7 +243,7 @@ export async function switchConversation(id: string) {
|
||||
dir: tc.dir,
|
||||
reason: tc.reason,
|
||||
})
|
||||
// 重新启动审批计时器(APPROVAL_TIMEOUT_MS=Infinity 已决策,不会超时自动拒绝,
|
||||
// 重新启动审批计时器(APPROVAL_TIMEOUT_MS 默认 15min,0=不限时跳过;
|
||||
// 启动仅用于计时器注册一致性,保持与 AiApprovalRequired 事件处理对称)
|
||||
startApprovalTimer(tc.id, tc.name, kind)
|
||||
}
|
||||
@@ -258,6 +260,9 @@ export async function switchConversation(id: string) {
|
||||
|
||||
/** 删除会话;若删的是当前活跃会话则清空消息+移除活跃 id 持久化 */
|
||||
async function deleteConversation(id: string) {
|
||||
// AIC-FIX-17-P0-2:删除会话时清空所有审批计时器(防已删会话的过期 timer 到期误拒审批 +
|
||||
// 错误气泡推错视图)。
|
||||
clearAllApprovalTimers()
|
||||
await aiApi.deleteConversation(id)
|
||||
// F-09 per-conv:清该会话的 stream state(streaming/currentText),防 convStreamStates Map 无限增长
|
||||
// (与 convStates/待审批等 per-conv 资源同款会话级清理语义)。
|
||||
|
||||
@@ -642,7 +642,9 @@ function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | '
|
||||
function handleLifecycleEvent(event: AiChatEvent): boolean {
|
||||
switch (event.type) {
|
||||
case 'AiCompleted': {
|
||||
console.warn('[FE-AICOMPLETED] 收到 conv=', event.conversation_id, 'streaming=', state.streaming)
|
||||
cleanupTerminatedConversation(event.conversation_id || '', 'AiCompleted')
|
||||
console.warn('[FE-AICOMPLETED] cleanup 后 streaming=', state.streaming)
|
||||
// UX-2025-04 / CR-30-2 / 决策 a1: 流中途失败保文——后端 emit AiCompleted(incomplete=true),
|
||||
// 前端追加系统提示气泡(镜像后端 session.messages 的 system 提示)。
|
||||
// 注:此系统提示仅前端展示,后端已独立 push 到 session.messages 落库。
|
||||
@@ -674,17 +676,30 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
|
||||
} else {
|
||||
state.convTokenTotal = { prompt: event.prompt_tokens, completion: event.completion_tokens, total: event.total_tokens }
|
||||
}
|
||||
// 每轮 token 写入对应 assistant 消息(最后一条 AI 消息),供 MessageList 逐条显示。
|
||||
for (let i = state.messages.length - 1; i >= 0; i--) {
|
||||
const m = state.messages[i]
|
||||
if (m.role === 'assistant' && !m.isError) {
|
||||
m.tokenUsage = { prompt: event.prompt_tokens, completion: event.completion_tokens }
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
notifyConversationChanged()
|
||||
// 队列续发:当前完成后自动发下一条(经事件总线桥接,避免 import useAiSend 构成循环依赖)
|
||||
emit('ai-drain-queue', {})
|
||||
emit('ai-drain-queue', { conversationId: event.conversation_id })
|
||||
return true
|
||||
}
|
||||
|
||||
case 'AiError': {
|
||||
cleanupTerminatedConversation(event.conversation_id || '', 'AiError')
|
||||
clearAllApprovalTimers() // UX-260617-10: 错误中断释放所有审批超时计时器,防回调改 state 触发已卸载/已错流程
|
||||
state.queue = [] // B-32:错误收尾清队列,防生成中入队的消息被静默丢失(drainQueue 仅 AiCompleted 触发)
|
||||
// 只清除出错会话的队列项,不误伤其他会话的排队消息
|
||||
if (event.conversation_id) {
|
||||
state.queue = state.queue.filter(q => q.conversationId !== event.conversation_id)
|
||||
} else {
|
||||
state.queue = []
|
||||
}
|
||||
// UX-260617-10: 错误收尾清残留待审批项——错误发生时若有工具停在 pending_approval,
|
||||
// 残留可点击审批按钮会让用户误以为还能批(实际后端已终止),残留审批卡误导操作。
|
||||
state.pendingApprovals = []
|
||||
@@ -809,21 +824,16 @@ export function handleEvent(event: AiChatEvent) {
|
||||
/** 启动事件监听(幂等 + 并发去重,onMounted 与 sendMessage 首发竞态不会重复注册) */
|
||||
export async function startListener() {
|
||||
// 已注册 → 直接复用(幂等;sendMessage 每次调用不重复注册)
|
||||
if (_unlistenAiEvent && _unlistenConvChanged && _unlistenApprovalClear) return
|
||||
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 () => {
|
||||
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)
|
||||
|
||||
@@ -24,7 +24,7 @@ import { t } from '@/i18n/i18n-helpers'
|
||||
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
|
||||
import { setStreaming } from './streamingGuard'
|
||||
import { startListener } from './useAiEvents'
|
||||
import { nextMsgId, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, resolveAiLang } from './aiShared'
|
||||
import { nextMsgId, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, resolveAiLang, convStates } from './aiShared'
|
||||
|
||||
/// 待发送队列上限(超过抛错提示用户)
|
||||
const QUEUE_LIMIT = 10
|
||||
@@ -90,7 +90,6 @@ async function doSend(text: string, skill?: string, force = false, parts?: Conte
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
setStreaming(true, { convId: state.activeConversationId, reason: 'doSend' })
|
||||
state.currentText = ''
|
||||
resetStreamWatchdog() // 启动流式看门狗,无数据超时兜底
|
||||
@@ -112,8 +111,11 @@ async function doSend(text: string, skill?: string, force = false, parts?: Conte
|
||||
} catch (e) {
|
||||
// IPC 失败(spawn 前/provider 配置错等):回滚 streaming 防光标卡死 + 移除 user 消息与空气泡占位;
|
||||
// 重新抛出由 handleSend 回填输入框,用户可重试
|
||||
setStreaming(false, { convId: state.activeConversationId, reason: 'doSend-ipc-fail' })
|
||||
clearStreamWatchdog() // 清看门狗,防 130s 后 onStreamTimeout 误推错误气泡
|
||||
// BUG-2026-07-17:彻底清理 convStates,避免按钮永久红色
|
||||
const convId = state.activeConversationId
|
||||
setStreaming(false, { convId, reason: 'doSend-ipc-fail' })
|
||||
clearStreamWatchdog(convId || undefined)
|
||||
if (convId) convStates.delete(convId)
|
||||
state.messages = state.messages.filter(m => m.id !== aiMsgId && m.id !== userMsgId)
|
||||
throw e
|
||||
}
|
||||
@@ -149,7 +151,9 @@ async function regenerate() {
|
||||
|
||||
setStreaming(true, { convId: state.activeConversationId, reason: 'regenerate' })
|
||||
state.currentText = ''
|
||||
state.queue = [] // 重生成视同新发,清残留队列
|
||||
// 只清当前会话的排队消息,不误伤其他会话
|
||||
const regenConvId = state.activeConversationId
|
||||
state.queue = state.queue.filter(q => q.conversationId !== regenConvId)
|
||||
resetStreamWatchdog()
|
||||
|
||||
await startListener()
|
||||
@@ -219,7 +223,9 @@ async function editMessage(newMessage: string) {
|
||||
|
||||
setStreaming(true, { convId: state.activeConversationId, reason: 'editMessage' })
|
||||
state.currentText = ''
|
||||
state.queue = [] // 编辑重生成视同新发,清残留队列
|
||||
// 只清当前会话的排队消息,不误伤其他会话
|
||||
const editConvId = state.activeConversationId
|
||||
state.queue = state.queue.filter(q => q.conversationId !== editConvId)
|
||||
resetStreamWatchdog()
|
||||
|
||||
await startListener()
|
||||
@@ -251,15 +257,22 @@ async function editMessage(newMessage: string) {
|
||||
* 回填失败消息到队首并停 drain(不静默丢用户输入,保留剩余队列供手动重试/编辑/取消)。
|
||||
* 成功路径不受影响——doSend 成功后端会再 emit AiCompleted 续 drain 链路。
|
||||
*/
|
||||
export function drainQueue() {
|
||||
export function drainQueue(convId?: string | null) {
|
||||
if (state.queue.length === 0) return
|
||||
const next = state.queue.shift()!
|
||||
// 找队首属于目标 conv 的消息;若穿越(非当前 conv 的项堵在队首)则跳过整条队列
|
||||
const targetId = convId ?? state.activeConversationId
|
||||
const idx = state.queue.findIndex(q => q.conversationId === targetId)
|
||||
if (idx === -1) return
|
||||
const next = state.queue.splice(idx, 1)[0]!
|
||||
const activeConvId = state.activeConversationId
|
||||
void (async () => {
|
||||
try {
|
||||
await sendMessage(next.text, next.skill, false, next.parts)
|
||||
} catch (e) {
|
||||
setStreaming(false, { convId: state.activeConversationId, reason: 'drainQueue-fail' })
|
||||
clearStreamWatchdog()
|
||||
// BUG-2026-07-17: 失败时彻底收尾,带 convId 精准清理 per-conv watchdog + convStates
|
||||
setStreaming(false, { convId: activeConvId, reason: 'drainQueue-fail' })
|
||||
clearStreamWatchdog(activeConvId || undefined)
|
||||
if (activeConvId) convStates.delete(activeConvId)
|
||||
const errMsg = e instanceof Error ? e.message : String(e)
|
||||
state.queue.unshift(next) // 回填失败消息到队首,保留剩余队列(不静默丢用户输入)
|
||||
state.messages.push({
|
||||
@@ -313,12 +326,14 @@ async function sendMessage(text: string, skill?: string, forceMode = false, part
|
||||
throw new Error(t('ai.queueFull', { limit: QUEUE_LIMIT }))
|
||||
}
|
||||
setStreaming(true, { convId: state.activeConversationId, reason: 'L1-enqueue' })
|
||||
// F-260614-05 Phase 2b: 入队项挂 parts(供 drainQueue 续发时本地 user 消息渲染图)
|
||||
// F-260614-05 Phase 2b: 入队项挂 parts(供 drainQueue/续发时本地 user 消息渲染图)
|
||||
// 同时挂 conversationId,供 drainQueue 按会话精准续发(防跨会话串话)
|
||||
state.queue.push({
|
||||
text: text.trim(),
|
||||
skill: skill || undefined,
|
||||
enqueuedAt: Date.now(),
|
||||
parts: parts && parts.length > 0 ? parts : undefined,
|
||||
conversationId: state.activeConversationId,
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -481,5 +496,7 @@ export function useAiSend() {
|
||||
let _drainUnlisten: (() => void) | null = null
|
||||
export async function initDrainQueueListener(): Promise<void> {
|
||||
if (_drainUnlisten) return
|
||||
_drainUnlisten = await listen('ai-drain-queue', () => { drainQueue() })
|
||||
_drainUnlisten = await listen('ai-drain-queue', (event: any) => {
|
||||
drainQueue(event?.payload?.conversationId ?? null)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,14 +13,19 @@
|
||||
|
||||
import { state } from '@/stores/ai'
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import { nextMsgId, getConvState } from './aiShared'
|
||||
import { nextMsgId, convStates } from './aiShared'
|
||||
import type { MessageId } from '@/api/types'
|
||||
import { forceResetStreaming } from './streamingGuard'
|
||||
import { emit } from '@tauri-apps/api/event'
|
||||
|
||||
/// ≥ 后端 STREAM_IDLE_TIMEOUT(120s, stream_recv.rs STREAM_IDLE_TIMEOUT)+余量;
|
||||
/// 前端若短于后端,慢首 token 会被前端先误杀
|
||||
export const STREAM_TIMEOUT_MS = 130000
|
||||
/// 流式整体超时(ms)。
|
||||
///
|
||||
/// 设计权衡:
|
||||
/// - 后端 stream_llm.rs 首 chunk 超时 10s + STREAM_IDLE_TIMEOUT 120s,正常请求远不到此
|
||||
/// - 后端 execute_with_heartbeat 工具执行补 30s AiHeartbeat,正常工具执行也不会到此
|
||||
/// - 但 LLM 重试间期(breaker 3轮)无 heartbeat——LLM 生成(~15s)+工具执行(~5s)循环
|
||||
/// 3轮总耗约 60-75s,45s 会误杀。改为 90s:覆盖 breaker 3轮 + margin。
|
||||
export const STREAM_TIMEOUT_MS = 90000
|
||||
|
||||
// TD-260621-01 per-conv 看门狗:模块级单计时器 → convId → timer Map。
|
||||
//
|
||||
@@ -49,78 +54,50 @@ let _legacyWatchdog: ReturnType<typeof setTimeout> | null = null
|
||||
* (legacy fallback 路径)时全局兜底,行为对齐旧版。
|
||||
*/
|
||||
export function onStreamTimeout(convId?: string) {
|
||||
// 根治守卫(TD-260621-01 遗留):看门狗 reset(doSend 走 legacy _legacyWatchdog)/clear
|
||||
// (AiCompleted 走 per-conv _watchdogTimers Map)路径不对齐,clear 传 convId 时只清 Map 不动 legacy,
|
||||
// 致正常收尾(AiCompleted 已清 streaming)后 legacy timer 残留,130s 到期触发本函数补
|
||||
// "⚠ 工具已执行完成,后续回复中断"误报气泡(实测:用户报"回复完后输出该报错")。
|
||||
// 守卫:触发时若该 conv 已不在生成态(已正常收尾),判定为残留 timer,静默清不补错误消息。
|
||||
// 真卡死(streaming/generating 仍 true)不受影响,仍走原 forceReset + 报错兜底。
|
||||
const stillGenerating = convId
|
||||
? (() => {
|
||||
// 批4 双轨收口:读 convStates(enum 真相源)派生,替代旧 generatingConvs.has。
|
||||
// 桥接语义:has(convId)=true 等价于 conv_state∈{generating,stopping,compressed}(非终止三态)。
|
||||
const cs = getConvState(convId)
|
||||
return cs === 'generating' || cs === 'stopping' || cs === 'compressed'
|
||||
})()
|
||||
: state.streaming
|
||||
if (!stillGenerating) {
|
||||
clearStreamWatchdog(convId)
|
||||
return
|
||||
}
|
||||
// 兜底守卫(BUG-260624-03):工具执行中(running 卡)不计整流超时。
|
||||
// 后端 execute_with_heartbeat(audit/mod.rs)已为工具执行补 30s AiHeartbeat,正常执行
|
||||
// 不会到 130s 超时;到此且仍有 running 卡 = 心跳也丢失(老后端/provider 双重异常),
|
||||
// 静默续等(reset 重计 130s)给工具完成/心跳恢复机会,不抛"工具已执行完成后续中断"误报。
|
||||
// 注:真死锁(running 永驻)时续等会持续,但优于误报流中断(forceReset 致状态不一致);
|
||||
// running 状态由后端 AiToolCallCompleted 驱动(非 ToolCard approving 计时器,那仅复位按钮 loading),
|
||||
// 用户可手动 stop,且 A 心跳已覆盖正常执行,真死锁罕见。
|
||||
for (let i = state.messages.length - 1; i >= 0; i--) {
|
||||
const toolCalls = state.messages[i].toolCalls
|
||||
if (!toolCalls) continue
|
||||
for (let j = 0; j < toolCalls.length; j++) {
|
||||
if (toolCalls[j].status === 'running') {
|
||||
resetStreamWatchdog(convId)
|
||||
return
|
||||
}
|
||||
// BUG-2026-07-17 根治性重构:状态机必须能自我收敛。
|
||||
//
|
||||
// 原问题:streaming=true 写在多个入口(doSend/regenerate/edit/L1入队),
|
||||
// 但 false 只在 cleanupTerminatedConversation 里。任一漏掉即永久卡住。
|
||||
//
|
||||
// 新逻辑:看门狗是状态机自我收敛的唯一包底,超时即彻底重置:
|
||||
// - 清 streaming=false
|
||||
// - 清 convStates 项(按钮 stopBtnState 读它)
|
||||
// - 清 currentText/queue
|
||||
// - 清 watchdog timer
|
||||
// 不管 stillGenerating 检测(即使已经 false 也再清理一次,完备包底)。
|
||||
console.warn(`[AI-Watchdog] 超时触发,强制收敛状态${convId ? `(convId=${convId})` : '(全局)'}`)
|
||||
|
||||
// 彻底清理三处状态源,保证状态机收敛到 idle
|
||||
forceResetStreaming('onStreamTimeout(无数据包底)', convId)
|
||||
if (convId) {
|
||||
convStates.delete(convId)
|
||||
} else {
|
||||
// AIC-FIX-17-P0-1:legacy 单 timer 只跟踪当前活跃会话,不清所有 conv(防误杀其他并发会话)。
|
||||
if (state.activeConversationId) {
|
||||
convStates.delete(state.activeConversationId)
|
||||
}
|
||||
}
|
||||
// 卡死兜底走 guard 的 forceResetStreaming(复位 streaming + 清 currentText/queue + warn 日志)。
|
||||
// 批4 双轨收口:generatingConvs 已退役,本兜底仅复位 streaming 态;该会话 convStates 项由
|
||||
// 后端收尾事件(或下次活跃事件兜底写)收敛,无需此处手动清。
|
||||
forceResetStreaming('onStreamTimeout(130s 无数据)', convId)
|
||||
clearStreamWatchdog(convId)
|
||||
// AE-2025-06: 整流超时收尾 → 广播清全部审批超时计时器。
|
||||
// 本模块不 import useAiSend(会构成 useAiSend↔useAiStream 循环依赖,与现有破环原则冲突),
|
||||
// 复用 ai-tool-slow-toast 同款事件总线模式:emit 由 useAiEvents.startListener listen 后调
|
||||
// clearAllApprovalTimers。流式已中断,审批继续挂定时器无意义(用户也会随之停止交互)。
|
||||
|
||||
// 广播清审批计时器(流式已中断,审批计时器无意义)
|
||||
void emit('ai-approval-clear-timers', {})
|
||||
// 一次性遍历同时做两件事(单次 O(n),不再分两遍扫):
|
||||
// 1) 回滚 running 态工具调用 → rejected:approveToolCall 已不乐观置 running(B-260616-08),
|
||||
// 此分支兜底后端事件先转 running 再 hang 的卡片,避免永久骨架屏。B-260615-07/B-33 残留。
|
||||
// (approve 后无 running 转换而 hang 的卡片由 ToolCard 本地 approving 计时器兜底复位,
|
||||
// 不在此全局回滚 pending_approval——会误伤其他真正待审批的卡片。)
|
||||
// 2) 探测任一 completed 卡片以区分错误文案(工具已执行完后续回复中断 vs 纯流式中断)。
|
||||
// 反向扫尾部提前退出(同 findToolCall 策略);不复用 useAiEvents.findToolCall:本模块
|
||||
// 不依赖 useAiEvents(已下沉 nextMsgId 到 aiShared 破环),且此处需全量回滚而非按 id 定位。
|
||||
// 注:messages 遍历仍是单例(currentText 一样),per-conv 超时只切到当前 messages 视图;
|
||||
// 多会话并发下其他会话的 messages 不在此 state.messages(切走即整体替换),无跨会话误回滚。
|
||||
|
||||
// 检测是否是 "工具已执行完后续中断"(区别文案)
|
||||
let hasCompletedTool = false
|
||||
for (let i = state.messages.length - 1; i >= 0; i--) {
|
||||
const toolCalls = state.messages[i].toolCalls
|
||||
if (!toolCalls) continue
|
||||
for (let j = 0; j < toolCalls.length; j++) {
|
||||
const tc = toolCalls[j]
|
||||
// TD-260621-01 修复(用户实测卡死根因):不再把 running→rejected。
|
||||
// path_auth/risk 挂起的 toolCall 停在 running(path_auth 不置 pending_approval),
|
||||
// 看门狗超时自动拒绝会误杀用户未操作的审批(用户报"显示用户拒绝了此操作,但根本没操作")。
|
||||
// 看门狗只做流断兜底(清 streaming/currentText/queue),工具/审批态由各自生命周期
|
||||
// (AiToolCallCompleted/AiApprovalResult/ai_authorize_dir)管理。running 骨架屏由
|
||||
// ToolCard 本地 approving 计时器兜底,或 path_auth 授权后 AiToolCallCompleted 转 completed。
|
||||
if (tc.status === 'completed') {
|
||||
if (toolCalls[j].status === 'completed') {
|
||||
hasCompletedTool = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (hasCompletedTool) break
|
||||
}
|
||||
|
||||
// 补一条错误气泡(仅在实际还在生成态时补,避免重复补)
|
||||
// 这里直接补——forceResetStreaming 已重置状态,不会重复触发
|
||||
const content = hasCompletedTool
|
||||
? t('ai.streamInterruptedAfterTool')
|
||||
: t('ai.streamInterrupted')
|
||||
|
||||
Reference in New Issue
Block a user