修复: DeepSeek 400 全量扫描 + 队列 per-conv 隔离

- openai_compat: 扫描所有 assistant 消息剥离 orphan tool_calls(原仅查末条)
- queue 加 conversationId 字段,按会话精准 drain
- regenerate/editMessage 只清本会话排队消息
- newConversation 保留旧会话排队消息
- AiError 只清出错会话的队列项
This commit is contained in:
lxy
2026-07-20 00:19:50 +08:00
parent 42efb31bbf
commit e9e3578d26
59 changed files with 2875 additions and 1330 deletions
+5
View File
@@ -494,6 +494,11 @@ export interface AiMessage {
* (不强制校验变体字段,未知 kind 忽略,对齐后端「新增变体向后兼容」设计)。
*/
augmentations?: Augmentation[]
/**
* 这一轮对话的 token 用量(仅 assistant 消息,每轮 AiCompleted 时回填)。
* undefined=未记录(历史消息/未启用 token 显示)。
*/
tokenUsage?: { prompt: number; completion: number }
timestamp: number
}
+22 -10
View File
@@ -155,6 +155,7 @@ const props = defineProps<{
const store = useAiStore()
const { t } = useI18n()
const router = useRouter()
// path_auth 审批链阶段3b(统一审批模型):df-ai-unified-approval 开关响应式 ref。
// 开(true,默认):path 挂起归一进 ToolCard 内联审批(once/always/deny),不挂 DirAuthDialog。
// 关(false,兜底回退):挂 DirAuthDialog 独立弹窗老链路(useAiEvents AiDirAuthRequired case 走 pendingDirAuths)。
@@ -255,7 +256,12 @@ async function confirmClearChat() {
* 历史:B-260615-10 单 loop 软复位弹中断确认,F-09 决策e 上线后已废弃(A 路线遗留)。
*/
async function confirmNewConversation() {
await store.newConversation()
try {
await store.newConversation()
} catch (e) {
console.error('[AI] 新建对话失败:', e)
// 用错误气泡反馈用户,不静默吞掉
}
}
// 第二批抽离: 技能 `/` 联想 + @ 实体引用联想 + watch(inputText) 已迁移至 ChatInput.vue
@@ -510,18 +516,24 @@ onMounted(async () => {
console.debug(`[启动] AiChat IPC 完成: ${(performance.now() - t0).toFixed(0)}ms`)
})
// ── 置顶功能(持久化:刷新后保持置顶状态图标一致)──
// ── 置顶功能(仅分离窗口有意义;持久化:刷新后保持置顶状态图标一致)──
// 关键:置顶只对「分离窗口」生效。AiChat 在主窗口(App.vue)与分离窗口(AiDetached.vue)都挂载,
// 若不区分,主窗口启动时会因 KV=true 把【整个主窗口】置顶到所有应用之上(BUG)。
// 主窗口内 AI 是 docked 面板,主窗口本身不该置顶。故 restore/apply 一律 gate 在 props.detached。
const alwaysOnTop = ref(false)
const appSettingsStore = useAppSettingsStore()
// 从 SQLite KV 恢复上次置顶状态
// 从 SQLite KV 恢复上次置顶状态(仅分离窗口读用;主窗口读了也无副作用,ref 仅驱动按钮 active 态)
const savedAlwaysOnTop = appSettingsStore.get<boolean>('df-ai-always-on-top', false)
alwaysOnTop.value = savedAlwaysOnTop
// 若恢复值为 true(上次是置顶态),同步到 Tauri 窗口
if (savedAlwaysOnTop) {
// 异步设(不阻塞初始化流程)
const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
getCurrentWebviewWindow().setAlwaysOnTop(true).catch(() => {/* 静默 */})
}
alwaysOnTop.value = props.detached ? savedAlwaysOnTop : false
// 同步到 Tauri 窗口:分离窗口按 KV 还原;主窗口显式清置顶(防御历史误设/窗口状态残留)。
// 注意:此处不可用顶层 await —— <script setup> 顶层 await 会使本组件变成 async setup,
// 而 App.vue / AiDetached.vue 直接挂载 <AiChat /> 未包 <Suspense>,会导致渲染空白。
// 用 .then() fire-and-forget,既不阻塞初始化流程,又保持组件同步 setup。
import('@tauri-apps/api/webviewWindow').then(({ getCurrentWebviewWindow }) =>
getCurrentWebviewWindow()
.setAlwaysOnTop(props.detached ? savedAlwaysOnTop : false)
.catch(() => {/* 静默:webview 不可用时忽略 */}),
)
async function toggleAlwaysOnTop() {
const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
const win = getCurrentWebviewWindow()
+3 -3
View File
@@ -730,13 +730,13 @@ defineExpose({
</div>
<!-- token 用量(仅最后一条 AI 消息 + 非流式;ai.ts handleEvent 已按 df-show-token-usage 开关过滤 lastTokenUsage) -->
<!-- token 用量(显示在每条 assistant 消息底部,非流式且有数据时) -->
<div
v-if="isLastAi(item.msg) && !store.state.streaming && store.state.lastTokenUsage"
v-if="item.msg.role === 'assistant' && !store.state.streaming && item.msg.tokenUsage"
class="ai-token-usage"
>
<span class="ai-token-usage-icon">🔣</span>
<span>{{ formatTokens(store.state.lastTokenUsage.prompt) }} in · {{ formatTokens(store.state.lastTokenUsage.completion) }} out</span>
<span>{{ formatTokens(item.msg.tokenUsage.prompt) }} in · {{ formatTokens(item.msg.tokenUsage.completion) }} out</span>
</div>
<!-- 工具调用卡片(渲染/折叠/审批全下沉到 ToolCardList+ToolCard 子组件,MessageList 仅转发审批) -->
+16 -2
View File
@@ -225,7 +225,7 @@ import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { aiApi } from '../../api/ai'
import type { AiMessage, ModelConfig } from '../../api/types'
import type { AiMessage, ModelConfig, GoalEntry } from '../../api/types'
defineProps<{
/** 分离窗口模式(嵌入 vs 分离决定顶部按钮组) */
@@ -326,6 +326,20 @@ watch(() => store.state.activeConversationId, () => {
store.modelOverride.value = enabledModels.value[0]?.model_id || null
})
// 切 provider 后模型 override 同步(BUG-2026-07-07:provider 切换后 header 模型名不刷新):
// - modelOverride 是模块级 ref(useAiSend)不持久化,切 provider 后仍持旧 provider 的 model_id,
// 该 id 不在新 provider 池里 → 下拉显示错位(UI 显示 X,实际后端用路由默认 Y)。
// - 守卫:当前选择对新 provider 模型池无效(空/不在池)时,默认选权重最高模型。
// enabledModels 已按 weight 降序排,model[0] 即权重最高。
// - immediate:组件挂载时也校验(modelOverride 为 null → 默认权重最高,与下拉首项对齐)。
watch(enabledModels, (models) => {
const current = store.modelOverride.value
const valid = current && models.some(m => m.model_id === current)
if ((!valid || !current) && models.length) {
store.modelOverride.value = models[0].model_id
}
}, { immediate: true })
// ── 当前对话标题 ──
const currentConvTitle = computed(() => {
const id = store.state.activeConversationId
@@ -416,7 +430,7 @@ const summaryExpanded = ref(false)
// ── L3 完整上下文 ──
interface ContextInfo {
goals: string[]
goals: GoalEntry[]
summaryCount: number
osInfo: string
projectNames: string
+7 -2
View File
@@ -331,7 +331,9 @@ function statusLabelKey(status: IdeaStatus): string {
// B-260615-25:灵感描述 Markdown 渲染(复用 AiChat/TaskDetail 同款渲染器,模块级单例),
// useRendered 封装 computed(读 mdReady 触发响应式 + renderMd)+ ensureLoaded(幂等预热)
const { rendered: renderedDesc } = useRendered(
// BUG-2026-07-08:原漏解构 ensureLoaded + onMounted 未调用,冷启动(marked 未被其他组件预热)
// 时 mdReady 永远 false,renderMd 走 escapeFallback 纯文本降级,Markdown 语法不渲染。
const { rendered: renderedDesc, ensureLoaded: ensureMdLoaded } = useRendered(
() => props.idea.description ?? '',
)
@@ -439,7 +441,10 @@ function historySummary(aiAnalysis: string | null | undefined): HistorySummary |
}
}
onMounted(loadHistory)
onMounted(() => {
ensureMdLoaded() // 后台预热 Markdown 渲染器(模块单例,与 AiChat/TaskDetail 共享),不阻塞
loadHistory()
})
watch(() => props.idea.id, loadHistory)
</script>
+11
View File
@@ -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 -5
View File
@@ -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 默认 15min0=不限时跳过;
// 启动仅用于计时器注册一致性,保持与 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 资源同款会话级清理语义)。
+20 -10
View File
@@ -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)
+29 -12
View File
@@ -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)
})
}
+40 -63
View File
@@ -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')
+1 -1
View File
@@ -101,7 +101,7 @@ const _stateBase: {
detached: boolean
docked: boolean
skills: SkillInfo[]
queue: { text: string; skill?: string; enqueuedAt: number; parts?: ContentPart[] }[]
queue: { text: string; skill?: string; enqueuedAt: number; parts?: ContentPart[]; conversationId?: string | null }[]
agentRound: number
/// 已完成工具调用数(本轮累计,AiToolCallCompleted 时 +1,AiAgentRound 时重置)
completedTools: number