重构: aichat 双轨状态机收口 + AiCompressed 事件拆分

- generating bool + CONV_STATE_ENABLED 开关双轨退役,ConvState enum 单一真相源
- can_accept_request 接入 chat 域入口(覆盖 Stopping 竞态,严谨于 is_active)
- AiCompressed 拆 AiManualCompressed/AiAutoCompressed(治自动压缩误触桌面toast+刷新)
- convStates/getConvState 下沉 aiShared.ts 破循环依赖
This commit is contained in:
2026-06-25 03:20:42 +08:00
parent fc705443bd
commit c011f864fd
22 changed files with 526 additions and 428 deletions

View File

@@ -20,16 +20,19 @@
//! - nextMsgId 已下沉到 aiShared(原为本模块导出,useAiStream 亦依赖之构成循环依赖,故抽出)
import { listen, emit } from '@tauri-apps/api/event'
import { ref, reactive } from 'vue'
import { ref } from 'vue'
import { aiApi } from '@/api'
import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai'
import { t } from '@/i18n/i18n-helpers'
import { nextMsgId, findToolCall, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers } from './aiShared'
import { nextMsgId, findToolCall, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, convStates, setConvState } from './aiShared'
// 批4 双轨收口:getConvState 下沉到 aiShared,本模块 re-export 保持消费方
// (ChatInput.vue/MaxRoundsCard.vue 等)import 路径不变,组件零改动透明继承。
export { getConvState } from './aiShared'
import { resetStreamWatchdog, clearStreamWatchdog, clearAllStreamWatchdogs } from './useAiStream'
import { setStreaming } from './streamingGuard'
import { loadConversations } from './useAiConversations'
import type { AiChatEvent, AiMessage, AiToolCallInfo, ConvState } from '@/api/types'
import type { AiChatEvent, AiMessage, AiToolCallInfo } from '@/api/types'
let _unlistenAiEvent: (() => void) | null = null
let _unlistenConvChanged: (() => void) | null = null
@@ -138,35 +141,12 @@ export interface PendingHelp {
}
export const pendingHelp = ref<PendingHelp | null>(null)
// L2 统一状态机(批2 1c):per-conv conv_state 真相源(消费后端 AiConvStateChanged 事件)。
//
// 模块级 reactive Map(convId → ConvState)直接 import 读(同 pendingMaxRounds/pendingHelp 模式,
// 不进 store.state,与生成生命周期语义独立于消息/会话列表)。停止按钮/MaxRoundsCard 读之
// 派生三态(generating/stopping/error),旧 streaming/generatingConvs bool 双轨过渡保留——
// conv_state 未收到时(CONV_STATE_ENABLED=off 或后端老版)消费方回退旧 bool 判断(兜底)。
//
// 写收敛:仅本模块 handleEvent 的 AiConvStateChanged case 写(setConvState 辅助),其他地方只读。
// 兜底同步:idle/error/compressed 在 case 内同步做旧 generatingConvs 收敛(AiCompleted/AiError 同义),
// 保持双轨真相一致——后端 emit 可能与 AiCompleted/AiError 不严格按序到达,任一来源触发收敛均可。
export const convStates = reactive(new Map<string, ConvState>())
/** 取某 conv 的 conv_state;未追踪过返回 null(消费方据此回退旧 bool 判断) */
export function getConvState(convId: string | null | undefined): ConvState | null {
if (!convId) return null
return convStates.get(convId) ?? null
}
/** 写入 conv_state(本模块内 AiConvStateChanged case 唯一写入口) */
function setConvState(convId: string | null | undefined, s: ConvState): void {
if (!convId) return
// idle 为终态收敛:写后可保留也可删,这里删以让 getConvState 回退旧 bool(语义对齐:
// 后端 generating=false 即 Idle,前端旧 bool 也是 false,删 Map 项后消费方回退 bool 即得正确态)。
if (s === 'idle') {
convStates.delete(convId)
} else {
convStates.set(convId, s)
}
}
// L2 统一状态机 convStates/getConvState/setConvState 已下沉到 aiShared.ts(批4 双轨收口破环:
// stores/ai.ts、useAiStream.ts、useAiConversations.ts 等读点 import getConvState 会与
// useAiEvents 现有依赖构成环,故下沉到本模块既有的破环共享层)。本模块从 aiShared re-import。
// 批4 收口后:convStates 是「会话是否生成中」的唯一真相源,旧 generatingConvs bool 轨已退役,
// 不再有双轨同步逻辑(原 handleConvStateEvent 内 idle/error delete、generating/stopping/compressed
// add 的兜底分支已删,enum 单写 setConvState 即收敛)。
/** 通知会话列表发生变化(供 newConversation/deleteConversation/rename/archive 等触发刷新侧栏) */
export function notifyConversationChanged() {
@@ -521,31 +501,17 @@ function handleToolEvent(event: AiChatEvent): boolean {
}
}
/** L2 状态机域:消费 AiConvStateChanged,维护 per-conv conv_state 真相源。
/** L2 状态机域:消费 AiConvStateChanged,写 convStates(per-conv conv_state 真相源)
*
* 非终止态(generating/stopping/compressed):仅写 convStates,不动看门狗/旧 bool
* (看门狗由活跃 delta/工具事件重置,这里不干预;旧 bool 由 AiCompleted/AiError 收敛保持双轨一致)。
* 终止态(idle/error):
* - 写 convStates(idle 删 Map 项让消费方回退旧 bool,与后端 generating=false 语义对齐)
* - 同步做旧 generatingConvs 收敛(delete,对齐 AiCompleted/AiError 收尾语义)——
* 后端 emit AiConvStateChanged{idle} 与 AiCompleted 可能不严格按序到达,任一先到即收敛,
* 双轨真相一致,后到的另一事件幂等(delete 已无项 no-op)。
* 批4 双轨收口后:enum(convStates)是唯一真相源,setConvState 单写即收敛。
* 原 idle/error 同步 delete generatingConvs、非终止态同步 add 的双轨兜底分支已删
* (bool 轨 generatingConvs 已退役)。看门狗由活跃 delta/工具事件重置,这里不干预。
* 返回值:true=已处理(命中 case);false=未命中。 */
function handleConvStateEvent(event: AiChatEvent): boolean {
if (event.type !== 'AiConvStateChanged') return false
const convId = event.conversation_id || state.activeConversationId
if (!convId) return true // 无 convId 无法路由,忽略(防误写全局态)
const cs = event.conv_state
setConvState(convId, cs)
// 终止态同步收敛旧 generatingConvs(双轨过渡,与 AiCompleted/AiError 同义)。
// compressed 非终止(派生态,压缩完回原态),不收敛。
if (cs === 'idle' || cs === 'error') {
state.generatingConvs.delete(convId)
} else if (cs === 'generating' || cs === 'stopping' || cs === 'compressed') {
// 非终止态同步加 generatingConvs(确保旧 bool 真相与 conv_state 一致,
// 防 AiConvStateChanged{generating} 先于首个 delta 到达时旧 bool 漏加致停止按钮/卡片错态)。
state.generatingConvs.add(convId)
}
setConvState(convId, event.conv_state)
return true
}
@@ -585,10 +551,9 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
flushCurrentText()
state.currentText = ''
setStreaming(false, { convId: event.conversation_id || null, reason: 'AiCompleted' })
state.generatingConvs.delete(event.conversation_id || '')
// L2 双轨过渡:AiCompleted 收尾同步收敛 convStates→idle(删 Map 项)。
// 批4 双轨收口:AiCompleted 收尾收敛 convStates→idle(删 Map 项,enum 单一真相源)。
// 后端 CONV_STATE_ENABLED=on 时 AiConvStateChanged{idle} 已先到此处幂等 no-op;
// off 或老后端无 AiConvStateChanged 时此处保证 convStates 不陈旧(消费方回退旧 bool 一致)
// off 或老后端无 AiConvStateChanged 时此处保证 convStates 不陈旧。
convStates.delete(event.conversation_id || '')
state.agentRound = 0 // AE-2025-07: agentic 结束,复位轮次(隐藏进度条)
// TD-260621-02 per-conv:仅当 pendingMaxRounds===本 conv 才清 null(避免清其他会话的挂起)。
@@ -653,10 +618,9 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
// 保留"回答到一半"的部分回复(否则清 currentText 后部分内容消失)。
flushCurrentText()
setStreaming(false, { convId: event.conversation_id || null, reason: 'AiError' })
state.generatingConvs.delete(event.conversation_id || '')
// L2 双轨过渡:AiError 收尾将 convStates 显式置 error(保留 error 项,供停止按钮显"重试"态)。
// 批4 双轨收口:AiError 收尾将 convStates 显式置 error(保留 error 项,供停止按钮显"重试"态)。
// 与 AiConvStateChanged{error} 双写幂等(后端 on 时状态事件已先到;off/老后端时此处保证 Error 态可见)。
// error 态在新会话发送(AiTextDelta 等首事件到达→generatingConvs.add)或下次 AiCompleted 时自然收敛。
// error 态在新会话发送(首活跃事件到达兜底写 generating)或下次 AiCompleted 时自然收敛。
if (event.conversation_id) convStates.set(event.conversation_id, 'error')
state.currentText = ''
state.agentRound = 0 // AE-2025-07: agentic 异常中断,复位轮次
@@ -706,7 +670,9 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
clearAllToolSlowTimers()
flushCurrentText()
setStreaming(false, { convId: event.conversation_id || null, reason: 'AiHelpRequired' })
state.generatingConvs.delete(event.conversation_id || '')
// 批4 双轨收口:求助即终止 loop,收敛 convStates(删 Map 项回 null/不在生成)。
// HelpRequiredCard 守卫仅判 pendingHelp.conversationId===active,不读 conv_state,删项安全。
convStates.delete(event.conversation_id || '')
state.currentText = ''
state.agentRound = 0
// 求助即终止 loop,清残留待审批项(对齐 AiError 分支语义,防残留审批卡误导)。
@@ -756,32 +722,35 @@ export function handleEvent(event: AiChatEvent) {
}
// L2 状态机:AiConvStateChanged 是 per-conv 状态信号(F-09 多会话并发下须追踪所有会话),
// 在 isCurrent 守卫**之前**处理,避免切走会话时 conv_state 不更新致停止按钮/MaxRoundsCard 错态。
// 该事件非流式活跃信号,不重置看门狗、不进 generatingConvs 通用 add 逻辑(由 conv_state 值决定 add/delete)。
// 该事件非流式活跃信号,不重置看门狗、不进活跃事件兜底写(由 handleConvStateEvent 写 enum)。
if (handleConvStateEvent(event)) return
// F-260622-02 跨端用户消息同步:在 isCurrent 守卫**之前**处理——AiUserMessage 是补 user 气泡
// 信号(非流式活跃事件),不应触发 watchdog reset / generatingConvs add(后续 AiAgentRound/
// 信号(非流式活跃事件),不应触发 watchdog reset / 活跃事件兜底写(后续 AiAgentRound/
// AiTextDelta 等活跃事件会正常触发)。也不受"非当前会话"过滤——理论上此事件必带 conversation_id
// 且属当前会话(后端 route_send_message 在放行时广播),即使因切走会话到达也仅补 user 气泡,
// 不影响其他态(handleUserMessageEvent 去重幂等)。
if (handleUserMessageEvent(event)) return
// 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误/求助时刷新侧边栏
// L1 求助协议(§2.3):AiHelpRequired 同 AiError 后端已 guard.reset 终止 loop,需移出生成
// L1 求助协议(§2.3):AiHelpRequired 同 AiError 后端已 guard.reset 终止 loop,需收敛生成
const isCurrent = !convId || convId === state.activeConversationId
if (!isCurrent) {
if (event.type === 'AiCompleted' || event.type === 'AiError' || event.type === 'AiHelpRequired') {
// F-09: 多会话并发 — 仅从 Set 移除该 conv(其他会话可能仍在生成),不再置 null 全局清空
state.generatingConvs.delete(convId || '')
// 批4 双轨收口:非当前会话终止事件,收敛 convStates(删 Map 项回 idle/不在生成),
// 对齐 :592/:660/:711 当前会话分支语义。非当前会话的 error 态无消费方(MaxRoundsCard/
// ChatInput 均读 activeConversationId),回退 null 与停止按钮 streaming 回退行为等价。
convStates.delete(convId || '')
void loadConversations()
}
return
}
// 标记正在生成的对话(完成/错误/求助事件除外——三者后端均已终止 loop)
// 批4 双轨收口:活跃事件(delta/工具/新轮/审批结果)到达即标记该会话生成中。
// 完成后端 CONV_STATE_ENABLED=off 或老后端不发 AiConvStateChanged{generating} 时,此处作为
// enum 兜底写入口(替代原 generatingConvs.add + 清残留 error 双操作,合并为 setConvState('generating') 一步)。
// CONV_STATE_ENABLED=on 时后端 AiConvStateChanged{generating} 已先到(handleConvStateEvent 在
// isCurrent 守卫前处理),此处 setConvState 同值 set 幂等覆盖,无副作用。完成/错误/求助事件除外
// (三者后端均已终止 loop,在各自 case 内由 convStates.delete/set('error') 收敛)。
if (convId && event.type !== 'AiCompleted' && event.type !== 'AiError' && event.type !== 'AiHelpRequired') {
state.generatingConvs.add(convId)
// L2 双轨过渡:会话进入生成(活跃事件到达)时,清 convStates 中可能残留的 error 项,
// 避免上一轮错误态 conv_state 未收敛致停止按钮错显"重试"(此时实际已在生成)。
// CONV_STATE_ENABLED=on 时后端 AiConvStateChanged{generating} 已更新;此处兜底 off/老后端。
convStates.delete(convId)
setConvState(convId, 'generating')
}
// 流式看门狗:活跃事件(delta/工具/新轮/审批结果)重置;审批等待/完成/错误在 case 内 clear
// TD-260621-01 per-conv:传 convId 走 per-conv Map(各会话独立计时,互不顶替/连累)。