重构: AI流式断线保文StreamResult三分支+重试对齐决策a1,推进链落df-nodes
This commit is contained in:
@@ -6,22 +6,24 @@
|
||||
//! L2 force: 排队>30s → 弹 confirm → 用户确认后调 ai_chat_force_send 复位残留并强制发出
|
||||
//!
|
||||
//! 模块级私有:
|
||||
//! - 无(队列存在 state.queue 中,看门狗/审批状态机依赖 events 与 stream)
|
||||
//! - _pendingApprovalIds:审批按钮防抖守卫
|
||||
//!
|
||||
//! 耦合:
|
||||
//! - sendMessage 调 useAiEvents.startListener(useAiEvents 导出但不在解构集中暴露给组件,
|
||||
//! 故 startListener 同时经 useAiEvents 导出,sendMessage 直接 import 调用)
|
||||
//! - sendMessage 调 useAiStream.resetStreamWatchdog
|
||||
//! - drainQueue 在 sendMessage 完成后由 handleEvent(AiCompleted) 调用 — 故 drainQueue 必须为模块级 export
|
||||
//! - drainQueue 由 handleEvent(AiCompleted) 经 ai-drain-queue 事件总线触发(非直接调用,
|
||||
//! 打破 events↔send 循环依赖);本模块模块级监听该事件
|
||||
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { aiApi } from '@/api'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import { state } from '@/stores/ai'
|
||||
import i18n from '@/i18n'
|
||||
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
|
||||
import { startListener, findToolCall } from './useAiEvents'
|
||||
import { nextMsgId } from './aiShared'
|
||||
import { startListener } from './useAiEvents'
|
||||
import { nextMsgId, findToolCall, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers } from './aiShared'
|
||||
|
||||
const t = ((i18n as any).global.t as (k: string, named?: Record<string, unknown>) => string).bind((i18n as any).global)
|
||||
|
||||
@@ -33,61 +35,14 @@ const QUEUE_LIMIT = 10
|
||||
/// 排队超时阈值(ms) — 超过后弹 confirm 让用户选择"强制发送"或继续等(B-260616-02)
|
||||
const QUEUE_TIMEOUT_MS = 30_000
|
||||
|
||||
/// 审批等待超时阈值(ms) — 用户 5 分钟内不处理则自动拒绝,防 pending_approval 永久卡死对话。
|
||||
/// 纯前端定时器:审批依赖页面交互,页面关闭/不处理即超时 auto-reject 合理(后端无超时)。
|
||||
/// TODO: 未来接 Settings 可配(df-ai-approval-timeout-ms),当前写死减范围(避免改 Settings.vue god file)
|
||||
const APPROVAL_TIMEOUT_MS = 5 * 60 * 1000
|
||||
// 审批计时器(startApprovalTimer/clearApprovalTimer/clearAllApprovalTimers)已下沉到 aiShared.ts
|
||||
// 打破 useAiEvents ↔ useAiSend 循环依赖。本模块 re-export 供组件使用。
|
||||
|
||||
/**
|
||||
* 审批超时计时器:toolCallId → timer。
|
||||
* 在 AiApprovalRequired(审批开始等待)启动,AiApprovalResult/AiToolCallCompleted(状态离开 pending_approval)、
|
||||
* onStreamTimeout(整流超时收尾)、stopListener(组件卸载)时清除。
|
||||
* 与 useAiEvents._toolTimers(慢执行 toast)同模式,但本计时器到点会改状态(auto-reject),
|
||||
* 故与 running 态 toast 解耦——审批态不应被 toast 计时器误触发。
|
||||
* F-260616-06: 审批按钮防抖守卫——同一 id 短期多次点击只发一次 IPC。
|
||||
* IPC 往返 <100ms,300ms 窗口足够覆盖竞态。
|
||||
*/
|
||||
const _approvalTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
/**
|
||||
* 启动审批超时计时器(幂等:同 id 重复启动不重建,沿用现有计时器)。
|
||||
* 到点回调:调 ai_approve(id, false) 自动拒绝 + push 一条系统错误消息到 state.messages
|
||||
* (参照 onStreamTimeout push 模式;composable 无 UI 上下文,toast 跨层复杂,系统消息更简单闭环)。
|
||||
* TODO: toast 跨层接线(目前仅 push 系统消息,无即时强提示)
|
||||
*/
|
||||
export function startApprovalTimer(toolCallId: string, toolName: string): void {
|
||||
if (_approvalTimers.has(toolCallId)) return
|
||||
const timer = setTimeout(() => {
|
||||
_approvalTimers.delete(toolCallId)
|
||||
// 自动拒绝:调 ai_approve(false) 通知后端不执行该工具
|
||||
aiApi.approve(toolCallId, false).catch(e => {
|
||||
// IPC 失败(网络断/后端已关):后端状态已乱,前端仍 push 提示让用户知晓
|
||||
console.error('[AI] 审批超时自动拒绝 IPC 未送达:', e)
|
||||
})
|
||||
// push 系统错误消息(参照 onStreamTimeout push 模式)
|
||||
state.messages.push({
|
||||
id: `approval-timeout-${nextMsgId()}`,
|
||||
role: 'assistant',
|
||||
content: t('ai.approvalTimeout', { toolName }),
|
||||
isError: true,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}, APPROVAL_TIMEOUT_MS)
|
||||
_approvalTimers.set(toolCallId, timer)
|
||||
}
|
||||
|
||||
/** 清除单个审批计时器(审批完成/拒绝时调用) */
|
||||
export function clearApprovalTimer(toolCallId: string): void {
|
||||
const timer = _approvalTimers.get(toolCallId)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
_approvalTimers.delete(toolCallId)
|
||||
}
|
||||
}
|
||||
|
||||
/** 清除全部审批计时器(onStreamTimeout/stopListener 整流收尾时调用) */
|
||||
export function clearAllApprovalTimers(): void {
|
||||
for (const timer of _approvalTimers.values()) clearTimeout(timer)
|
||||
_approvalTimers.clear()
|
||||
}
|
||||
const _pendingApprovalIds = new Set<string>()
|
||||
|
||||
/**
|
||||
* 入队时记录时间戳,供 UI 展示排队时长和触发超时提示。
|
||||
@@ -348,8 +303,12 @@ export async function tryForceSend(confirmFn: (msg: string) => Promise<boolean>)
|
||||
* B-260616-08:不再乐观置 running——原写法让 .ai-tool-approval 整块消失(切骨架屏),
|
||||
* 按钮无 loading 中间态、用户无重审入口;loading 现下沉到 ToolCard 局部 ref,语义正确。 */
|
||||
async function approveToolCall(toolCallId: string, approved: boolean) {
|
||||
// 复用 findToolCall(反向扫描)避免重复实现查找逻辑;仅缓存引用用于 IPC 失败回滚
|
||||
const tc = findToolCall(toolCallId)
|
||||
// F-260616-06: 防抖——同一 id 仍在处理中则跳过(竞态点击/网络慢重试)
|
||||
if (_pendingApprovalIds.has(toolCallId)) return
|
||||
_pendingApprovalIds.add(toolCallId)
|
||||
try {
|
||||
// 复用 findToolCall(反向扫描)避免重复实现查找逻辑;仅缓存引用用于 IPC 失败回滚
|
||||
const tc = findToolCall(toolCallId)
|
||||
// 重启看门狗覆盖审批执行→续生成窗口(useAiEvents.ts:162 AiApprovalRequired 已 clear,
|
||||
// 审批态无心跳兜底;approve 后后端要跑工具+续生成,期间任何后端异常不回则按钮永久 loading。
|
||||
// 复用通用 watchdog 而非加审批专用超时:复用同一收尾路径(onStreamTimeout 兜底复位 streaming),
|
||||
@@ -370,6 +329,9 @@ async function approveToolCall(toolCallId: string, approved: boolean) {
|
||||
}
|
||||
throw e
|
||||
}
|
||||
} finally {
|
||||
_pendingApprovalIds.delete(toolCallId)
|
||||
}
|
||||
}
|
||||
|
||||
/** 批量审批:遍历 pendingApprovals 逐个调用 approveToolCall */
|
||||
@@ -420,8 +382,17 @@ export function useAiSend() {
|
||||
stopChat,
|
||||
isQueueTimedOut,
|
||||
tryForceSend,
|
||||
// 审批计时器已下沉到 aiShared.ts,此处 re-export 供组件使用
|
||||
startApprovalTimer,
|
||||
clearApprovalTimer,
|
||||
clearAllApprovalTimers,
|
||||
}
|
||||
}
|
||||
|
||||
// ARC-06: 模块级监听 ai-drain-queue 事件(由 useAiEvents AiCompleted case 发射)
|
||||
// 打破 useAiEvents ↔ useAiSend 循环依赖:events 不再直接 import 调用 drainQueue
|
||||
let _drainUnlisten: (() => void) | null = null
|
||||
export async function initDrainQueueListener(): Promise<void> {
|
||||
if (_drainUnlisten) return
|
||||
_drainUnlisten = await listen('ai-drain-queue', () => { drainQueue() })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user