优化: 审批超时倒计时(60s变红)+分离窗口监听核验

This commit is contained in:
lxy
2026-08-15 18:46:13 +08:00
parent 871416637a
commit 13ecd7eba4
7 changed files with 64 additions and 10 deletions
+1 -1
View File
@@ -309,7 +309,7 @@ graph TD
### ✅ 2026-06-24 aichat 可靠性修复(消息重叠×5 + 授权弹窗卡死 + 工具执行心跳误报 · 诊断workflow→论证workflow) ### ✅ 2026-06-24 aichat 可靠性修复(消息重叠×5 + 授权弹窗卡死 + 工具执行心跳误报 · 诊断workflow→论证workflow)
> 剩余低优 [ ](非阻断): > 剩余低优 [ ](非阻断):
> - [ ] BUG-260624-02 残留:审批超时倒计时 UI(5min 无倒计时,ToolCard pending 态显 mm:ss 到期前 60s 变红)+ 核 detached 窗口 emit 作用域(分离窗口是否独立 startListener,否则 ai-pending-arrived 收不到) > - [x] BUG-260624-02 残留 ✅ 已销账(2026-08-15):ToolCard pending 态倒计时落地(`_approvalTimers` 升级携带 startedAt/timeoutMs + `getApprovalRemainingMs` 真值起点,expireSecs/expireLevel computed,≤60s `.is-urgent` 红;不限时回退正计时;i18n expireIn zh/en)+ detached 窗口核验无缺口(AiDetached→AiChat 独立 startListener,ai-chat-event app 级广播,主窗口 detach 时 stopListener 不竞态,结论注释 useAiWindow:107-115)
> - [x] F-09 根因:streaming/currentText 全局单例改 per-conv ✅ **2026-08-07 核验销账**(per_conv HashMap + 前端 convStates Map enum 轨 + AiConvStateChanged 事件 per-conv 路由,根治多会话串扰)(根治多会话串扰,见 docs/待决策.md) > - [x] F-09 根因:streaming/currentText 全局单例改 per-conv ✅ **2026-08-07 核验销账**(per_conv HashMap + 前端 convStates Map enum 轨 + AiConvStateChanged 事件 per-conv 路由,根治多会话串扰)(根治多会话串扰,见 docs/待决策.md)
> - [ ] CR-260622-01-P2-2 [P2低优·评估降级] — **HTML/markdown/JSON 等非代码文件无 session 级缓存**。**2026-06-24 评估**:已有 `TOOL_RESULT_COMPRESS_ENABLED`(mod.rs:98/1029-1078 view-only 摘要,>2KB tool_result 压缩,LLM 视图摘要非全文回灌 prompt)部分缓解;read_symbol 治代码文件(主场景,降 24.4x)。非代码文件完整 session 缓存(path→hash+content+patch失效)设计复杂 + LLM patch 后重读确认行为不确定(缓存命中提示可能不够 LLM 仍重读),归 B 路线 prompt 策略(约束 patch 后不重读)更合适,工具侧完整缓存低优暂缓。 > - [ ] CR-260622-01-P2-2 [P2低优·评估降级] — **HTML/markdown/JSON 等非代码文件无 session 级缓存**。**2026-06-24 评估**:已有 `TOOL_RESULT_COMPRESS_ENABLED`(mod.rs:98/1029-1078 view-only 摘要,>2KB tool_result 压缩,LLM 视图摘要非全文回灌 prompt)部分缓解;read_symbol 治代码文件(主场景,降 24.4x)。非代码文件完整 session 缓存(path→hash+content+patch失效)设计复杂 + LLM patch 后重读确认行为不确定(缓存命中提示可能不够 LLM 仍重读),归 B 路线 prompt 策略(约束 patch 后不重读)更合适,工具侧完整缓存低优暂缓。
+8 -2
View File
@@ -40,8 +40,12 @@
</div> </div>
</div> </div>
<div v-if="tc.reason" class="ai-tool-approval-reason"> {{ tc.reason }}</div> <div v-if="tc.reason" class="ai-tool-approval-reason"> {{ tc.reason }}</div>
<!-- BUG-260624-02: 审批挂起时长提示(>2分钟橙色,>5分钟红色) --> <!-- BUG-260624-02: 审批到期倒计时(与前端自动拒绝时刻精确对齐,60s 红色脉冲);
<div v-if="waitSecs > 0" class="ai-tool-wait-time" :class="'is-' + waitLevel"> 无活跃计时器(不限时/已到期)回退显挂起时长(>2分钟橙色,>5分钟红色) -->
<div v-if="expireSecs !== null" class="ai-tool-wait-time" :class="'is-' + expireLevel">
{{ $t('aiTool.expireIn', { time: formatWaitTime(expireSecs) }) }}
</div>
<div v-else-if="waitSecs > 0" class="ai-tool-wait-time" :class="'is-' + waitLevel">
{{ $t('aiTool.waitTime', { time: formatWaitTime(waitSecs) }) }} {{ $t('aiTool.waitTime', { time: formatWaitTime(waitSecs) }) }}
</div> </div>
<!-- AE-2025-03: write_file 审批 diff 预览(红删绿增,行级)旧文件不存在tc.diff 为空,回退显上方 args content --> <!-- AE-2025-03: write_file 审批 diff 预览(红删绿增,行级)旧文件不存在tc.diff 为空,回退显上方 args content -->
@@ -188,6 +192,8 @@ const {
waitSecs, waitSecs,
waitLevel, waitLevel,
formatWaitTime, formatWaitTime,
expireSecs,
expireLevel,
onApprove, onApprove,
onReject, onReject,
onAuthorizeOnce, onAuthorizeOnce,
+26 -7
View File
@@ -138,8 +138,15 @@ function getApprovalTimeoutMs(): number {
return raw return raw
} }
/** toolCallId → 审批超时计时器 */ /** toolCallId → 审批超时计时器 + 起点信息(startedAt/timeoutMs 供前端到期倒计时读剩余) */
const _approvalTimers = new Map<string, ReturnType<typeof setTimeout>>() interface ApprovalTimerInfo {
timer: ReturnType<typeof setTimeout>
/** 计时器启动时刻(ms)——即本笔审批超时窗口的真实起点 */
startedAt: number
/** 本计时器生效的超时阈值(ms) */
timeoutMs: number
}
const _approvalTimers = new Map<string, ApprovalTimerInfo>()
/** /**
* 启动审批超时计时器(幂等:同 id 重复启动不重建)。 * 启动审批超时计时器(幂等:同 id 重复启动不重建)。
@@ -176,21 +183,33 @@ export function startApprovalTimer(
timestamp: Date.now(), timestamp: Date.now(),
}) })
}, timeoutMs) }, timeoutMs)
_approvalTimers.set(toolCallId, timer) _approvalTimers.set(toolCallId, { timer, startedAt: Date.now(), timeoutMs })
}
/**
* 取某审批距前端自动拒绝的剩余毫秒。
* 审批超时是前端本计时器驱动的(到点才调 IPC 拒绝),故剩余值与实际拒绝时刻精确对齐,
* 优于后端 created_at(不序列化透传)或 ToolCard 本地到达时间的近似。
* 返回 null = 无活跃计时器(未挂起/已到期/用户配置不限时),调用方应回退其他展示。
*/
export function getApprovalRemainingMs(toolCallId: string): number | null {
const info = _approvalTimers.get(toolCallId)
if (!info) return null
return info.timeoutMs - (Date.now() - info.startedAt)
} }
/** 清除单个审批计时器 */ /** 清除单个审批计时器 */
export function clearApprovalTimer(toolCallId: string): void { export function clearApprovalTimer(toolCallId: string): void {
const timer = _approvalTimers.get(toolCallId) const info = _approvalTimers.get(toolCallId)
if (timer) { if (info) {
clearTimeout(timer) clearTimeout(info.timer)
_approvalTimers.delete(toolCallId) _approvalTimers.delete(toolCallId)
} }
} }
/** 清除全部审批计时器(整流超时收尾/组件卸载时调用) */ /** 清除全部审批计时器(整流超时收尾/组件卸载时调用) */
export function clearAllApprovalTimers(): void { export function clearAllApprovalTimers(): void {
for (const timer of _approvalTimers.values()) clearTimeout(timer) for (const info of _approvalTimers.values()) clearTimeout(info.timer)
_approvalTimers.clear() _approvalTimers.clear()
} }
+8
View File
@@ -105,6 +105,14 @@ async function detachPanel(convId?: string) {
// 异步传播。消除 detached=true 到 AiChat 实际卸载的窗口期(reattach 重叠期/HMR/极速 mount-unmount // 异步传播。消除 detached=true 到 AiChat 实际卸载的窗口期(reattach 重叠期/HMR/极速 mount-unmount
// 竞态下,onBeforeUnmount 可能延迟,旧 listener 残留会与分离窗口新 listener 并存竞态)。 // 竞态下,onBeforeUnmount 可能延迟,旧 listener 残留会与分离窗口新 listener 并存竞态)。
// stopListener 同步释放后端 listener + 清看门狗,分离窗口 resumeInDetached 起自己的 startListener。 // stopListener 同步释放后端 listener + 清看门狗,分离窗口 resumeInDetached 起自己的 startListener。
//
// BUG-260624-02 核验结论(2026-08-15):分离窗口事件监听独立存在,审批事件无缺口。
// 链路:本窗口 url '#/ai-detached' → AiDetached.vue 挂 AiChat(detached) → AiChat onMounted
// 调 store.startListener()(独立 JS context 各自注册);后端 ai-chat-event 为 app 级广播
// (emit 到全部 webview),故审批类事件(工具 pending/审批结果)分离窗口自身可收。
// 前端派生的 'ai-pending-arrived'(useAiStreamingEvents/useAiToolEvents emit)亦为全局广播,
// 分离窗口 ToolCardList 的 listener 同样收到。主窗口 listener 在此 stopListener 释清,
// 不与分离窗口并存竞态。审批超时计时器(aiShared)同样随 listener 在接管窗口内重建。
stopListener() stopListener()
} }
+17
View File
@@ -11,6 +11,7 @@ import { ref, reactive, computed, watch, onBeforeUnmount, type Ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useConfirm } from '@/composables/useConfirm' import { useConfirm } from '@/composables/useConfirm'
import { aiApi } from '@/api' import { aiApi } from '@/api'
import { getApprovalRemainingMs } from './aiShared'
import type { AiToolCallInfo } from '@/api/types' import type { AiToolCallInfo } from '@/api/types'
/** /**
@@ -103,6 +104,20 @@ export function useToolApproval(tc: Ref<AiToolCallInfo>, emit: ToolApprovalEmit)
return 'normal' return 'normal'
}) })
// ── 审批到期倒计时(BUG-260624-02)──
// 剩余 = aiShared 审批超时计时器真实起点推算(自动拒绝时刻精确对齐,非本地近似)。
// 读 waitSecs 建立每秒响应依赖(计时 tick 触发重算);null = 无活跃计时器
// (不限时/未挂起/已到期),调用方回退挂起时长展示。
const expireSecs = computed<number | null>(() => {
void waitSecs.value
const remain = getApprovalRemainingMs(tc.value.id)
if (remain === null || remain <= 0) return null
return Math.ceil(remain / 1000)
})
// 到期前 60s 变红(复用 waitLevel 的 urgent 样式:红色脉冲)
const expireLevel = computed<'normal' | 'urgent'>(() =>
expireSecs.value !== null && expireSecs.value <= 60 ? 'urgent' : 'normal')
// ── 审批超时兜底 toast ── // ── 审批超时兜底 toast ──
const approveToast = reactive({ visible: false, msg: '' }) const approveToast = reactive({ visible: false, msg: '' })
let _approveToastTimer: ReturnType<typeof setTimeout> | null = null let _approveToastTimer: ReturnType<typeof setTimeout> | null = null
@@ -274,6 +289,8 @@ export function useToolApproval(tc: Ref<AiToolCallInfo>, emit: ToolApprovalEmit)
waitSecs, waitSecs,
waitLevel, waitLevel,
formatWaitTime, formatWaitTime,
expireSecs,
expireLevel,
onApprove, onApprove,
onReject, onReject,
onAuthorizeOnce, onAuthorizeOnce,
+2
View File
@@ -10,6 +10,8 @@ export default {
executionSlow: 'Tool "{name}" is taking longer than expected, please wait…', executionSlow: 'Tool "{name}" is taking longer than expected, please wait…',
// approval pending duration hint (>2min orange, >5min red) // approval pending duration hint (>2min orange, >5min red)
waitTime: '⏱ Waiting {time}', waitTime: '⏱ Waiting {time}',
// approval expiry countdown (BUG-260624-02, aligned with frontend auto-reject, red ≤60s)
expireIn: '⏳ Auto-reject in {time}',
// tool execution failure marker (AR-6 keeps status=completed, red to distinguish from success) // tool execution failure marker (AR-6 keeps status=completed, red to distinguish from success)
executionFailed: 'Failed', executionFailed: 'Failed',
executionFailedHint: 'Tool execution failed, see details below', executionFailedHint: 'Tool execution failed, see details below',
+2
View File
@@ -10,6 +10,8 @@ export default {
executionSlow: '工具「{name}」执行较久,请稍候…', executionSlow: '工具「{name}」执行较久,请稍候…',
// 审批挂起时长提示(>2分钟橙色,>5分钟红色) // 审批挂起时长提示(>2分钟橙色,>5分钟红色)
waitTime: '⏱ 已等待 {time}', waitTime: '⏱ 已等待 {time}',
// 审批到期倒计时(BUG-260624-02,与前端自动拒绝时刻对齐,≤60s 红色)
expireIn: '⏳ {time} 后超时自动拒绝',
// 工具执行失败视觉标识(AR-6 下 status=completed,需红色区分于成功) // 工具执行失败视觉标识(AR-6 下 status=completed,需红色区分于成功)
executionFailed: '执行失败', executionFailed: '执行失败',
executionFailedHint: '工具执行失败,详情见下方', executionFailedHint: '工具执行失败,详情见下方',