Files
DevFlow/src/composables/ai/useAiStream.ts

187 lines
10 KiB
TypeScript

//! 流式看门狗 — 无数据超时兜底
//!
//! 后端断连/异常不发 AiCompleted/AiError 时,前端自动收尾+提示,
//! 避免 streaming 永久 true 卡死对话(用户报"流式文字停在中途"即此类)。
//!
//! 耦合说明:
//! - sendMessage 启动生成时 resetStreamWatchdog() 启动计时
//! - handleEvent 每个活跃事件(delta/工具/新轮/审批结果)重置;审批等待/完成/错误 clear
//! - 超时回调 onStreamTimeout 直接改 state 并补一条错误消息
//!
//! 依赖:nextMsgId 取自 aiShared(原从 useAiEvents 取,构成 useAiEvents ↔ useAiStream
//! 循环依赖;下沉到 aiShared 后本模块不再 import useAiEvents,环消除)
import { state } from '@/stores/ai'
import { t } from '@/i18n/i18n-helpers'
import { nextMsgId } from './aiShared'
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
// TD-260621-01 per-conv 看门狗:模块级单计时器 → convId → timer Map。
//
// 背景(F-09 多会话并发):原单 timer 下,A 会话 delta 重置计时器会顶掉 B 会话已挂起的 timer,
// 或 A 超时连累 B 的 generatingConvs(全清)。per-conv Map 后,各会话独立计时,互不顶替/连累。
//
// 向后兼容(避撞 F-09 禁动域):convId 可选。禁动域 useAiSend.ts/useAiApproval.ts 的 14 处调用
// 不传 convId,走 _legacyWatchdog fallback(模块级单 timer,行为对齐旧版);本批域 useAiEvents.ts
// 的调用点(已知 convId)传 convId 走 per-conv Map。两路并存,等 F-09 统一迁移调用点后可删 fallback。
//
// per-conv 路径与 legacy 路径互不干扰:convId 路径只动 Map,legacy 路径只动 _legacyWatchdog。
// 实际并发场景:F-09 已让 useAiEvents 按 conversation_id 路由事件(handleEvent:546 isCurrent 判定),
// 非 current conv 的事件不触发 reset/clear(reset/clear 在 isCurrent 分支内调用),故 per-conv
// 路径天然只跟踪当前展示会话的活跃 timer,跨会话切换时旧 conv 的 timer 由其 AiCompleted/AiError 精确清。
const _watchdogTimers = new Map<string, ReturnType<typeof setTimeout>>()
// 禁动域 fallback 单 timer(useAiSend/useAiApproval 无 convId 调用走此,行为对齐旧版)。
let _legacyWatchdog: ReturnType<typeof setTimeout> | null = null
/**
* 看门狗超时回调:收尾 streaming 态并补错误消息(走 forceResetStreaming 兜底复位,
* 对齐 [[devflow-generating-statemachine]])。
*
* TD-260621-01 per-conv:携带 convId 时,forceResetStreaming(reason, convId) 仅清该 conv 的
* generatingConvs(经 setStreaming 联动 delete),不再全清误杀并发会话生成态。convId 为空
* (legacy fallback 路径)时不带 convId,generatingConvs 不动(全局兜底,行为对齐旧版)。
*/
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
? state.generatingConvs.has(convId)
: 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
}
}
}
// 卡死兜底走 guard 的 forceResetStreaming(复位 streaming + 清 currentText/queue + warn 日志)。
// TD-260621-01:携带 convId 时仅清该 conv 的 generatingConvs(per-conv);不传时全局兜底不动 Set。
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') {
hasCompletedTool = true
}
}
}
const content = hasCompletedTool
? t('ai.streamInterruptedAfterTool')
: t('ai.streamInterrupted')
state.messages.push({
id: `timeout-${nextMsgId()}`,
role: 'assistant',
content,
isError: true,
timestamp: Date.now(),
})
}
/**
* 启动/重置看门狗(活跃事件或 sendMessage 时调用)。
*
* TD-260621-01 per-conv:传 convId → 走 per-conv Map(arm 前 clear 该 conv 旧 timer,防重入堆积);
* 省略 convId(禁动域 useAiSend/useAiApproval 调用)→ 走 _legacyWatchdog fallback(单 timer,行为对齐旧版)。
* setTimeout 回调闭包捕获 convId,到期 onStreamTimeout(convId) 精确清该 conv 态。
*/
export function resetStreamWatchdog(convId?: string) {
if (convId) {
const old = _watchdogTimers.get(convId)
if (old) clearTimeout(old)
const timer = setTimeout(() => onStreamTimeout(convId), STREAM_TIMEOUT_MS)
_watchdogTimers.set(convId, timer)
// 根治(TD-260621-01 遗留):切到 per-conv 时清 legacy(doSend 启动的 legacy timer 与活跃事件
// per-conv 共存期,legacy 是残留)。防 AiCompleted clear(convId) 清不到 legacy 致 130s 误报。
if (_legacyWatchdog) { clearTimeout(_legacyWatchdog); _legacyWatchdog = null }
} else {
// 禁动域 fallback:行为对齐旧版单 timer。
if (_legacyWatchdog) clearTimeout(_legacyWatchdog)
_legacyWatchdog = setTimeout(() => onStreamTimeout(), STREAM_TIMEOUT_MS)
}
}
/**
* 清除看门狗(完成/错误/审批等待时调用)。
*
* TD-260621-01 per-conv:传 convId → 仅清该 conv 的 timer;省略 → 清 _legacyWatchdog fallback。
* 注:省略 convId 时**不**清 Map(避免禁动域全局 clear 误清并发会话的 per-conv timer)。
* stopListener 卸载场景需清全部,调 clearAllStreamWatchdogs()。
*/
export function clearStreamWatchdog(convId?: string) {
if (convId) {
const timer = _watchdogTimers.get(convId)
if (timer) { clearTimeout(timer); _watchdogTimers.delete(convId) }
// 根治(TD-260621-01 遗留):doSend reset 走 legacy,AiCompleted clear 走 per-conv Map,clear 清不到
// legacy 致 130s 残留误报。批2 单 loop 阶段 legacy 单例只属当前会话,clear(convId) 兜底清 legacy 安全;
// 批3+ 多会话落地时 useAiSend reset 迁 per-conv + 删 legacy fallback,此兜底随之移除。
if (_legacyWatchdog) { clearTimeout(_legacyWatchdog); _legacyWatchdog = null }
} else {
if (_legacyWatchdog) { clearTimeout(_legacyWatchdog); _legacyWatchdog = null }
}
}
/** 清除全部看门狗 timer(per-conv Map + legacy fallback)—— stopListener 卸载场景调用,
* 防回调写已卸载组件的 state。 */
export function clearAllStreamWatchdogs() {
for (const timer of _watchdogTimers.values()) clearTimeout(timer)
_watchdogTimers.clear()
if (_legacyWatchdog) { clearTimeout(_legacyWatchdog); _legacyWatchdog = null }
}
export function useAiStream() {
return {
onStreamTimeout,
resetStreamWatchdog,
clearStreamWatchdog,
clearAllStreamWatchdogs,
}
}