新增: 小程序审批超时计时器(5min 自动拒绝兜底)

- 对齐桌面端 aiShared.startApprovalTimer,补齐小程序缺失的审批超时机制
- AiApprovalRequired/AiDirAuthRequired 触发 startApprovalTimer(每条独立计时)
- AiToolCallCompleted/AiApprovalResult 触发 clearApprovalTimer(审批落定)
- 切会话/新建/断连/重连/终态触发 clearAllApprovalTimers(防跨会话污染)
- 超时自动拒绝(approve(id,false))+ toast 提示用户
- 5min 阈值符合移动端使用习惯(桌面端 15min)
This commit is contained in:
2026-06-30 21:54:21 +08:00
parent 15eaa0c637
commit 773ebb6ef6

View File

@@ -215,6 +215,44 @@ function resetWatchdog(): void {
streamTimer = setTimeout(onStreamTimeout, STREAM_TIMEOUT_MS) streamTimer = setTimeout(onStreamTimeout, STREAM_TIMEOUT_MS)
} }
// ── 审批超时计时器(miniapp 补齐,对齐桌面 aiShared.startApprovalTimer)──
// 治「审批永久挂起」:用户没在 5min 内处理审批 → 自动拒绝(对应桌面端 APPROVAL_TIMEOUT_DEFAULT_MS=900000,
// miniapp 取 5min=300000 更符合移动端使用习惯)。每条审批独立计时,approve/拒绝/完成/切会话 清除。
const APPROVAL_TIMEOUT_MS = 300_000 // 5 分钟
const _approvalTimers = new Map<string, ReturnType<typeof setTimeout>>()
/** 启动审批超时计时器(AiApprovalRequired/AiDirAuthRequired 触发)。 */
function startApprovalTimer(tcId: string, toolName: string): void {
if (_approvalTimers.has(tcId)) return // 幂等
const timer = setTimeout(() => {
_approvalTimers.delete(tcId)
// 超时自动拒绝(对齐桌面端:到点调 aiApi.approve(id,false))
const idx = pendingApprovals.findIndex((p) => p.id === tcId)
if (idx >= 0) {
pendingApprovals.splice(idx, 1)
uni.showToast({ title: `审批超时已自动拒绝:${toolName}`, icon: 'none' })
// 发拒绝命令(device 收到后走 reject 链路,工具不执行,emit AiApprovalResult approved=false)
approve(tcId, false)
}
}, APPROVAL_TIMEOUT_MS)
_approvalTimers.set(tcId, timer)
}
/** 清除审批超时计时器(approve/拒绝/完成/切会话/断连调用)。 */
function clearApprovalTimer(tcId: string): void {
const t = _approvalTimers.get(tcId)
if (t) {
clearTimeout(t)
_approvalTimers.delete(tcId)
}
}
/** 清除所有审批计时器(切会话/断连/重连/终态)。 */
function clearAllApprovalTimers(): void {
for (const t of _approvalTimers.values()) clearTimeout(t)
_approvalTimers.clear()
}
/** 清除看门狗(终态/挂起/断连触发,timer 彻底停) */ /** 清除看门狗(终态/挂起/断连触发,timer 彻底停) */
function clearWatchdog(): void { function clearWatchdog(): void {
if (streamTimer) clearTimeout(streamTimer) if (streamTimer) clearTimeout(streamTimer)
@@ -468,6 +506,8 @@ export function handleEvent(event: AiChatEvent): void {
// 移除对应挂起审批(若有) // 移除对应挂起审批(若有)
const idx = pendingApprovals.findIndex((p) => p.id === event.id) const idx = pendingApprovals.findIndex((p) => p.id === event.id)
if (idx >= 0) pendingApprovals.splice(idx, 1) if (idx >= 0) pendingApprovals.splice(idx, 1)
// 审批计时器:工具完成 → 清(审批已生效)
clearApprovalTimer(event.id)
// P2-E:工具执行完成 → 清回填标志(审批已生效执行,不再回填) // P2-E:工具执行完成 → 清回填标志(审批已生效执行,不再回填)
if (lastApprovedTc.value && lastApprovedTc.value.id === event.id) { if (lastApprovedTc.value && lastApprovedTc.value.id === event.id) {
lastApprovedTc.value = null lastApprovedTc.value = null
@@ -495,6 +535,7 @@ export function handleEvent(event: AiChatEvent): void {
pendingApprovals.push(info) pendingApprovals.push(info)
} }
clearWatchdog() // 挂起等审批,停看门狗(用户操作后续生成会 reset 重启) clearWatchdog() // 挂起等审批,停看门狗(用户操作后续生成会 reset 重启)
startApprovalTimer(event.id, event.name) // 5min 超时自动拒绝兜底
const tc = findToolCall(event.id) const tc = findToolCall(event.id)
if (tc) { if (tc) {
tc.status = 'pending_approval' tc.status = 'pending_approval'
@@ -511,6 +552,7 @@ export function handleEvent(event: AiChatEvent): void {
} }
const idx = pendingApprovals.findIndex((p) => p.id === event.id) const idx = pendingApprovals.findIndex((p) => p.id === event.id)
if (idx >= 0) pendingApprovals.splice(idx, 1) if (idx >= 0) pendingApprovals.splice(idx, 1)
clearApprovalTimer(event.id) // 审批结果落定 → 清计时器
// P2-E:审批结果确认(批准/拒绝均落定)→ 清回填标志(该 id 已定,不再回填) // P2-E:审批结果确认(批准/拒绝均落定)→ 清回填标志(该 id 已定,不再回填)
if (lastApprovedTc.value && lastApprovedTc.value.id === event.id) { if (lastApprovedTc.value && lastApprovedTc.value.id === event.id) {
lastApprovedTc.value = null lastApprovedTc.value = null
@@ -520,6 +562,7 @@ export function handleEvent(event: AiChatEvent): void {
case 'AiDirAuthRequired': { case 'AiDirAuthRequired': {
clearWatchdog() // 挂起等授权,停看门狗 clearWatchdog() // 挂起等授权,停看门狗
startApprovalTimer(event.id, event.tool) // 5min 超时自动拒绝
// F-#95 P0-3:路径授权进 pendingApprovals(kind=path),chat 页渲染 once/always/deny 按钮 // F-#95 P0-3:路径授权进 pendingApprovals(kind=path),chat 页渲染 once/always/deny 按钮
// (替代旧 system 文案降级「请在桌面端处理」,miniapp 成轻量审批终端,治 loop 卡死)。 // (替代旧 system 文案降级「请在桌面端处理」,miniapp 成轻量审批终端,治 loop 卡死)。
const authInfo: AiToolCallInfo = { const authInfo: AiToolCallInfo = {
@@ -578,7 +621,7 @@ export function handleEvent(event: AiChatEvent): void {
generating.value = false generating.value = false
agentRound.value = 0 agentRound.value = 0
clearWatchdog() // 终态:停看门狗 clearWatchdog() // 终态:停看门狗
pendingApprovals.splice(0, pendingApprovals.length) pendingApprovals.splice(0, pendingApprovals.length); clearAllApprovalTimers()
// P2-E:审批失败回填 —— 刚 approve(approved=true)成功发出但 device 返 AiError(执行失败/已被 // P2-E:审批失败回填 —— 刚 approve(approved=true)成功发出但 device 返 AiError(执行失败/已被
// 桌面端处理),回填 pendingApprovals 让用户重试,避免 UI 误导「已批准」实际未生效。 // 桌面端处理),回填 pendingApprovals 让用户重试,避免 UI 误导「已批准」实际未生效。
if (lastApprovedTc.value) { if (lastApprovedTc.value) {
@@ -935,7 +978,7 @@ function switchConversation(convId: string): void {
lastApprovedTc.value = null // P2-E:切会话清审批回填标志(防跨会话误回填) lastApprovedTc.value = null // P2-E:切会话清审批回填标志(防跨会话误回填)
generating.value = false generating.value = false
// MINIAPP-03:清残留挂起状态,防旧会话审批卡/超时回调污染新会话视图(对齐 newConversation)。 // MINIAPP-03:清残留挂起状态,防旧会话审批卡/超时回调污染新会话视图(对齐 newConversation)。
pendingApprovals.splice(0, pendingApprovals.length) pendingApprovals.splice(0, pendingApprovals.length); clearAllApprovalTimers()
clearWatchdog() clearWatchdog()
// F-#95 扩展:发 load_messages 拉历史(device route_load_messages 读库 → AiMessageHistory 回流)。 // F-#95 扩展:发 load_messages 拉历史(device route_load_messages 读库 → AiMessageHistory 回流)。
@@ -1022,7 +1065,7 @@ function newConversation(): void {
maxRoundsActive.value = false // P1-E:新会话清 MaxRounds 挂起 maxRoundsActive.value = false // P1-E:新会话清 MaxRounds 挂起
lastApprovedTc.value = null // P2-E:新会话清审批回填标志 lastApprovedTc.value = null // P2-E:新会话清审批回填标志
generating.value = false generating.value = false
pendingApprovals.splice(0, pendingApprovals.length) pendingApprovals.splice(0, pendingApprovals.length); clearAllApprovalTimers()
} }
/** /**
@@ -1044,7 +1087,7 @@ function syncOnConnect(): void {
// req2:拉完整历史 // req2:拉完整历史
wsClient.send({ cmd: 'load_messages', args: { conversation_id: convId } }) wsClient.send({ cmd: 'load_messages', args: { conversation_id: convId } })
// req3:清本地 pending + 发 sync_pending 重推权威审批状态 // req3:清本地 pending + 发 sync_pending 重推权威审批状态
pendingApprovals.splice(0, pendingApprovals.length) pendingApprovals.splice(0, pendingApprovals.length); clearAllApprovalTimers()
wsClient.send({ cmd: 'sync_pending', args: { conversation_id: convId } }) wsClient.send({ cmd: 'sync_pending', args: { conversation_id: convId } })
} }