diff --git a/apps/df-miniapp/src/composables/useAiChat.ts b/apps/df-miniapp/src/composables/useAiChat.ts index cf5554f..e710457 100644 --- a/apps/df-miniapp/src/composables/useAiChat.ts +++ b/apps/df-miniapp/src/composables/useAiChat.ts @@ -615,7 +615,10 @@ export function handleEvent(event: AiChatEvent): void { // 上下文分段/压缩中,miniapp 无需特殊处理(状态在桌面端) break - case 'AiCompressed': + // 治 Task#1:手动/自动两变体都插摘要气泡(miniapp 无独立压缩按钮, + // 两路径都代表"对端发生压缩",插摘要气泡告知用户合理)。 + case 'AiManualCompressed': + case 'AiAutoCompressed': // 压缩完成:在消息首位插入摘要提示 messages.unshift({ id: genId('compressed'), diff --git a/apps/df-miniapp/src/types/events.ts b/apps/df-miniapp/src/types/events.ts index 20a7642..7057e8a 100644 --- a/apps/df-miniapp/src/types/events.ts +++ b/apps/df-miniapp/src/types/events.ts @@ -68,7 +68,10 @@ export type AiChatEvent = } | { type: 'AiContextCleared'; conversation_id?: string | null } | { type: 'AiCompressing'; conversation_id?: string | null } - | { type: 'AiCompressed'; conversation_id?: string | null; summary: string } + // 治 Task#1:拆分手动/自动两变体(字段相同仅 type 字面量不同),桌面按 type 分流, + // miniapp 两变体都插摘要气泡(历史可见性,见 useAiChat.ts:618 case fallthrough)。 + | { type: 'AiManualCompressed'; conversation_id?: string | null; summary: string } + | { type: 'AiAutoCompressed'; conversation_id?: string | null; summary: string } | { type: 'AiHelpRequired' reason: string diff --git a/docs/02-架构设计/专项设计/generating状态机加固-2026-06-15.md b/docs/02-架构设计/专项设计/generating状态机加固-2026-06-15.md index 68ac86f..a0d2530 100644 --- a/docs/02-架构设计/专项设计/generating状态机加固-2026-06-15.md +++ b/docs/02-架构设计/专项设计/generating状态机加固-2026-06-15.md @@ -277,8 +277,40 @@ if has_tool_calls { /* push */ } --- +## 收口记录(2026-06-25) + +> 双轨状态机退役,enum 单一真相源。本文档 §3.2「轻量状态机,不引入框架」的轻量路线在批3 完成**升级**:原方案是「bool 保留 + enum 视图只读」,收口后 enum 成为写侧真相源,bool 与开关双轨全部删除。 + +### 收口了什么 + +1. **双轨退役(写侧)**: + - `generating: bool` 字段(顶层单例 + `PerConvState` 内)删除。 + - `CONV_STATE_ENABLED` 灰度开关删除(批2 常量删,批3 全部门控分支删)。 + - `ConvState` enum(`conv_state.rs`,5 态 Idle/Generating/Stopping/Error/Compressed)成为**唯一真相源**,所有状态写入经 `transition_to` 守卫(非法转换拒绝 `Err(InvalidTransition)`)。 + - `GeneratingGuard`(guard.rs)的 new/reset/drop 改为无条件迁移 `ConvState` + emit `AiConvStateChanged`(原 `if CONV_STATE_ENABLED` 门控删)。 + +2. **AiCompressed 拆 Manual/Auto(治 BUG-260624-05)**: + - 原 `AiCompressed` 单事件被自动压缩路径误用 → 桌面端每次发送误弹 toast + 误刷整会话。 + - 拆 `AiManualCompressed`(手动 IPC,3 处 emit 点,前端弹 toast)+ `AiAutoCompressed`(loop 自动,桌面静默仅复位 `isCompressing` 防按钮卡死,miniapp 仍插摘要气泡)。 + +3. **can_accept_request 接入(读侧)**: + - `ConvState::can_accept_request()`(Idle/Error 可接)接入 chat 域 4 处入口拦截:`ai_chat_send` / `ai_regenerate` / `ai_chat_edit` / `ai_is_generating`。 + - 比旧 `is_active()` 更严谨:覆盖 Stopping 态(漏拦停止中接新请求的竞态)。 + +### 为什么收口 + +- **双轨隐患**:bool 字段与 enum 开关共存期间,任一 guard 入口漏加门控即绕过 enum 守卫回退旧 bool 写,状态机形同虚设。开关是渐进过渡的临时灰度机制,收口即删才是终态,长期保留等于把「过渡态」固化成「架构债」。 +- **AiCompressed 合流**:手动(用户主动压缩,要反馈)与自动(loop 内压缩,静默)是两种 UX 语义,共用事件必致前端按「最严」语义(弹 toast)兜底,自动路径每次都误触发。拆分是按语义而非按字段。 + +### 残留过渡分支(非回归,待清理) + +代码内保留若干 `CONV_STATE_ENABLED off 回退分支` 注释(agentic/mod.rs:1608/1722、chat.rs:1533、remote_bridge.rs:762、前端 types.ts/ChatInput.vue/MaxRoundsCard.vue/useAiEvents.ts),均标注「enum 单路径」,用于兼容老后端/前端未追踪态的兜底。开关常量已删,这些注释分支实际不可触发,属说明性遗留,后续清理时一并删。 + +--- + ## 相关文档 - [df-ai AI 集成模块](../../03-模块文档/df-ai-AI集成模块-2026-06-12.md) - [aichat 审查报告-2026-06-14](../构想审查/aichat审查报告-2026-06-14.md) - [流式 Markdown 渲染调研-2026-06-15](../构想审查/aichat流式Markdown渲染调研-2026-06-15.md) +- `src-tauri/src/commands/ai/agentic/conv_state.rs`(`ConvState` enum 实现源) diff --git a/docs/todo.md b/docs/todo.md index fc11076..567b5cd 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1002,3 +1002,10 @@ > - [x] ✅(2026-06-24 核验伪问题销账) CR-260622-01-P2-1 — **ChatMessage.timestamp 打戳"致时序错乱"核验为伪**。grep 全 src `.sort(` 零消息按 timestamp 排序:ai.ts:188 会话列表 updated_at / ConversationSidebar pinned / TopBar weight / MessageList:231 mention span start。**消息渲染顺序由 renderItems(store.state.messages 数组顺序)= DB list_by_conversation ORDER BY seq,不依赖 ChatMessage.timestamp**。timestamp 仅 formatRelative 展示(system 摘要显示插入时刻=压缩时刻,语义合理)。agent A 假设"前端按 timestamp 排序"不成立,非真实缺陷。 > - [ ] 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 后不重读)更合适,工具侧完整缓存低优暂缓。 +--- + +### ✅ 2026-06-25 generating 状态机双轨收口 + 自动压缩路径残留修复 + +- [x] ✅(2026-06-25) **Task#1 自动压缩成功路径残留**(BUG-260624-05 衍生)— **AiCompressed 单事件被自动压缩路径误用 → 桌面端每次发送误弹 toast + 误刷整会话**。治法:`AiCompressed` 拆 `AiManualCompressed`(手动 IPC,3 处 emit 点,前端弹 toast)+ `AiAutoCompressed`(loop 自动,桌面静默仅复位 isCompressing 防按钮卡死,miniapp 仍插摘要气泡)。按语义拆事件而非按字段。— `src-tauri/src/commands/ai/mod.rs:207/212` +- [x] ✅(2026-06-25) **Task#2 双轨状态机收口** — `generating: bool` 字段(顶层单例 + PerConvState)与 `CONV_STATE_ENABLED` 灰度开关双轨全部删除,`ConvState` enum(`conv_state.rs`,5 态)成唯一真相源:写侧经 `transition_to` 守卫(非法转换拒绝),`GeneratingGuard` new/reset/drop 无条件迁移 + emit;读侧 `can_accept_request()` 接入 chat 域 4 入口(ai_chat_send/ai_regenerate/ai_chat_edit/ai_is_generating),比 `is_active()` 更严谨(覆盖 Stopping 态竞态)。开关常量已删,代码内 `CONV_STATE_ENABLED off 回退分支` 注释为说明性遗留(实际不可触发,待清理)。详案见 [generating状态机加固-2026-06-15.md §收口记录](./02-架构设计/专项设计/generating状态机加固-2026-06-15.md)。— `src-tauri/src/commands/ai/agentic/{conv_state.rs,guard.rs}` + `commands/ai/{mod.rs,commands/chat.rs}` + diff --git a/src-tauri/src/commands/ai/agentic/conv_state.rs b/src-tauri/src/commands/ai/agentic/conv_state.rs index 757c853..9f63136 100644 --- a/src-tauri/src/commands/ai/agentic/conv_state.rs +++ b/src-tauri/src/commands/ai/agentic/conv_state.rs @@ -15,33 +15,17 @@ //! - **读侧收敛**:停止按钮三态(可停 / 停中 / 停失败可重试)、MaxRoundsCard 是否弹等 //! 判别逻辑由 `ConvState` 变体直接表达,不再靠多变量组合反推。 //! -//! # 渐进第一步(本批范围) +//! # 收口后(批3+) //! -//! 本模块是**纯逻辑、无 IO**的 enum + 转换守卫,不替代 `PerConvState.generating` 等 -//! 字段(渐进不一次全换)。guard 接入点(`GeneratingGuard` set/reset 时同步 `ConvState` -//! 转换)作为视图层;开关 [`CONV_STATE_ENABLED`](constant.CONV_STATE_ENABLED.html) -//! 默认 on,off 降级纯旧 bool 行为(可回退)。 +//! 本模块是**纯逻辑、无 IO**的 enum + 转换守卫。批3 双轨收口后 `generating` bool +//! 与 `CONV_STATE_ENABLED` 开关已退役,`ConvState` 成为唯一真相源:guard 接入点 +//! (`GeneratingGuard` new/reset/drop 时同步迁移 `ConvState`)无条件执行迁移 + emit。 //! -//! 后续(批2+):把散落判别点(`try_continue` / `ai_chat_stop` / MaxRoundsCard)逐个迁到 -//! 读 `ConvState`,并视情况把 `generating` bool 退役(状态字段由 enum 单一持有)。 +//! 散落判别点(`try_continue` / `ai_chat_stop` / MaxRoundsCard)已逐个迁到读 +//! `ConvState`(`is_active()` / `can_accept_request()`),不再回退旧 bool。 use serde::{Deserialize, Serialize}; -// ============================================================ -// 开关 -// ============================================================ - -/// L2 状态机总开关(默认 on)。 -/// -/// - **on(默认)**:`GeneratingGuard` set/reset 时同步迁移 [`ConvState`](enum.ConvState.html) -/// 作视图层,前后端可读统一状态;guard 失败兜底仍走旧 bool 复位(`generating=false`), -/// 不影响语义(状态机层失败不阻断核心复位)。 -/// - **off(降级)**:guard 不写 `ConvState`,纯旧 `generating` bool 行为(排障 / 对比 / 临时 -/// 回退用)。设为 false 即可观察无状态机接入的效果,语义等价改动前。 -/// -/// 机制优先 prompt 说教:每改进配开关 + 兜底,分阶段可回退(对齐 AI 改进方案三原则)。 -pub const CONV_STATE_ENABLED: bool = true; - // ============================================================ // ConvState enum // ============================================================ @@ -169,8 +153,8 @@ impl ConvState { /// 读侧便利方法:用于 `ai_conversation_create` / `ai_chat_send` 等入口判断 /// 「能否接新请求」。`Error` 态视为可接(用户重试即从 Error 起步)。 /// - /// 预留:批2+ 读侧迁移(入口能否接新请求判断)消费。本批无消费方,标 `allow(dead_code)` 保留。 - #[allow(dead_code)] + /// 双轨收口批1(2026-06-25):chat 域入口拦截(ai_regenerate / ai_chat_send / + /// ai_chat_edit)已接入,作真实读侧方法消费。删除 `allow(dead_code)` 标注。 pub fn can_accept_request(self) -> bool { matches!(self, ConvState::Idle | ConvState::Error) } diff --git a/src-tauri/src/commands/ai/agentic/guard.rs b/src-tauri/src/commands/ai/agentic/guard.rs index 917d27a..250a7aa 100644 --- a/src-tauri/src/commands/ai/agentic/guard.rs +++ b/src-tauri/src/commands/ai/agentic/guard.rs @@ -10,7 +10,8 @@ use tokio::sync::Mutex; use crate::commands::ai::{AiChatEvent, AiSession}; -use super::conv_state::{self, ConvState}; +// 双轨收口批2:CONV_STATE_ENABLED 常量删除后,模块路径(self)不再使用,仅导入 ConvState 类型。 +use super::conv_state::ConvState; /// generating 复位 RAII guard,取代散布的手动 `session.generating = false`。 /// @@ -39,14 +40,15 @@ use super::conv_state::{self, ConvState}; /// - `reset`/`drop`:迁移 + emit 均读 `conv.conv_state` 持久化字段(批2 已落地,本批仅删冗余本地块)。 /// - `disarm`:不动 ConvState(审批等待是 Generating 内的暂停点)。 /// -/// 开关 `CONV_STATE_ENABLED`(默认 on)门控:off 时跳过 enum 迁移与 emit,纯旧 bool 行为(可回退)。 +/// 批3 双轨收口:`CONV_STATE_ENABLED` 开关与 `generating` bool 已退役,enum 迁移 + emit +/// 无条件执行(单一真相源,无 off 降级分支)。 pub(super) struct GeneratingGuard { session: Arc>, /// guard 所属会话(loop 启动时快照的 conv_id,来自 run_agentic_loop 入参)。 conv_id: String, done: bool, /// L2 批2 1b:emit AiConvStateChanged 用的 AppHandle(Clone 廉价,run_agentic_loop 入参传入)。 - /// CONV_STATE_ENABLED 门控:off 时仍持(无副作用),但 new/reset/drop 跳过 emit。 + /// 批3 收口:emit 无条件执行(开关已退役),此字段恒被消费。 app_handle: AppHandle, } @@ -59,71 +61,68 @@ impl GeneratingGuard { // 注:new 是同步函数(无 await),无法持 session 锁读 conv.conv_state;此处经 // tauri::async_runtime::spawn 异步补 emit(非阻塞 guard 创建,入口持久化已发生故 emit 有据)。 // spawn 失败(运行时不可用,理论场景)仅漏一次 emit,前端 watchdog 兜底(对齐失败不阻断核心生成)。 - if conv_state::CONV_STATE_ENABLED { - let session = session.clone(); - let app_handle = app_handle.clone(); - let cid = conv_id.clone(); - tauri::async_runtime::spawn(async move { - let new_state = { - let mut session = session.lock().await; - let conv = session.conv(&cid); - match conv.conv_state.transition_to(ConvState::Generating) { - Ok(ns) => { - conv.conv_state = ns; - ns - } - Err(e) => { - tracing::warn!( - conv_id = %cid, - error = %e, - "[ai] guard.new ConvState→Generating 非法(持久化层,不阻断核心生成)" - ); - return; - } + // 批3 双轨收口:删除 CONV_STATE_ENABLED 门控,迁移 + emit 无条件执行(enum 单一真相源)。 + // 注:clone 给闭包 move,原始 session/app_handle 留给 Self 字段(对齐原 if 块外 Self 构造)。 + let session_for_spawn = session.clone(); + let app_handle_for_spawn = app_handle.clone(); + let cid = conv_id.clone(); + tauri::async_runtime::spawn(async move { + let new_state = { + let mut session = session_for_spawn.lock().await; + let conv = session.conv(&cid); + match conv.conv_state.transition_to(ConvState::Generating) { + Ok(ns) => { + conv.conv_state = ns; + ns } - }; - // 持久化层迁移成功后 emit 前端(同步,失败忽略)。锁已随作用域 drop,可安全 emit。 - // L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。 - let ev = AiChatEvent::AiConvStateChanged { - conv_state: new_state, - conversation_id: Some(cid), - }; - let _ = app_handle.emit("ai-chat-event", ev.clone()); - let _ = app_handle.state::().ai_event_bus.publish_event(ev); - }); - } + Err(e) => { + tracing::warn!( + conv_id = %cid, + error = %e, + "[ai] guard.new ConvState→Generating 非法(持久化层,不阻断核心生成)" + ); + return; + } + } + }; + // 持久化层迁移成功后 emit 前端(同步,失败忽略)。锁已随作用域 drop,可安全 emit。 + // L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。 + let ev = AiChatEvent::AiConvStateChanged { + conv_state: new_state, + conversation_id: Some(cid), + }; + let _ = app_handle_for_spawn.emit("ai-chat-event", ev.clone()); + let _ = app_handle_for_spawn.state::().ai_event_bus.publish_event(ev); + }); Self { session, conv_id, done: false, app_handle } } - /// 显式复位 generating=false。emit 前调用保证顺序。幂等。 + /// 显式复位生成态。emit 前调用保证顺序。幂等。 /// - /// 仅写 per_conv.conv_id.generating(唯一真相源)。 - /// L2:同步迁移 `conv.conv_state → Idle`(持久化层,CONV_STATE_ENABLED 门控)。 + /// 批3 双轨收口:generating bool 已退役,复位经 ConvState 迁移(Generating→Idle)单一表达。 + /// L2:同步迁移 `conv.conv_state → Idle`(持久化层,迁移 + emit 无条件执行)。 pub(super) async fn reset(&mut self) { if !self.done { let mut session = self.session.lock().await; let conv = session.conv(&self.conv_id); - conv.generating = false; - // L2 批2:ConvState 持久化(Generating→Idle 写收敛,CONV_STATE_ENABLED 门控)。 - if conv_state::CONV_STATE_ENABLED { - match conv.conv_state.transition_to(ConvState::Idle) { - Ok(ns) => { - conv.conv_state = ns; - // L2 批2 1b:持久化层 Idle 收敛成功后 emit 前端(同步,失败忽略)。 - // L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。 - let ev = AiChatEvent::AiConvStateChanged { - conv_state: ns, - conversation_id: Some(self.conv_id.clone()), - }; - let _ = self.app_handle.emit("ai-chat-event", ev.clone()); - let _ = self.app_handle.state::().ai_event_bus.publish_event(ev); - } - Err(e) => tracing::warn!( - conv_id = %self.conv_id, - error = %e, - "[ai] guard.reset ConvState→Idle 非法(持久化层,不阻断核心复位)" - ), + // L2 批2:ConvState 持久化(Generating→Idle 写收敛)。批3 删除开关门控,迁移无条件执行。 + match conv.conv_state.transition_to(ConvState::Idle) { + Ok(ns) => { + conv.conv_state = ns; + // L2 批2 1b:持久化层 Idle 收敛成功后 emit 前端(同步,失败忽略)。 + // L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。 + let ev = AiChatEvent::AiConvStateChanged { + conv_state: ns, + conversation_id: Some(self.conv_id.clone()), + }; + let _ = self.app_handle.emit("ai-chat-event", ev.clone()); + let _ = self.app_handle.state::().ai_event_bus.publish_event(ev); } + Err(e) => tracing::warn!( + conv_id = %self.conv_id, + error = %e, + "[ai] guard.reset ConvState→Idle 非法(持久化层,不阻断核心复位)" + ), } self.done = true; } @@ -142,9 +141,9 @@ impl GeneratingGuard { impl Drop for GeneratingGuard { fn drop(&mut self) { - // 未 done 即异常退出(panic/未走正常 return):核心 generating 经 spawn 复位, + // 未 done 即异常退出(panic/未走正常 return):核心生成态经 spawn 复位, // ConvState 持久化迁移 + emit 也在同一 spawn 内异步完成(无 await 上下文,对齐核心复位路径)。 - // 开关 off / done=true(正常 reset/disarm 已走) → 跳过。 + // done=true(正常 reset/disarm 已走) → 跳过。批3 收口后无开关 off 分支。 if !self.done { let session = self.session.clone(); let conv_id = self.conv_id.clone(); @@ -154,20 +153,17 @@ impl Drop for GeneratingGuard { tauri::async_runtime::spawn(async move { let mut s = session.lock().await; let conv = s.conv(&conv_id); - conv.generating = false; - // L2 批2:ConvState 持久化(异常退出兜底→Idle,CONV_STATE_ENABLED 门控)。 - if conv_state::CONV_STATE_ENABLED { - if let Ok(ns) = conv.conv_state.transition_to(ConvState::Idle) { - conv.conv_state = ns; - // 1b:持久化 Idle 收敛后 emit 前端(异常退出兜底也需通知前端退出生成态)。 - // L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。 - let ev = AiChatEvent::AiConvStateChanged { - conv_state: ns, - conversation_id: Some(conv_id.clone()), - }; - let _ = app_handle.emit("ai-chat-event", ev.clone()); - let _ = app_handle.state::().ai_event_bus.publish_event(ev); - } + // L2 批2:ConvState 持久化(异常退出兜底→Idle)。批3 删除开关门控 + generating bool 写,迁移无条件执行。 + if let Ok(ns) = conv.conv_state.transition_to(ConvState::Idle) { + conv.conv_state = ns; + // 1b:持久化 Idle 收敛后 emit 前端(异常退出兜底也需通知前端退出生成态)。 + // L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。 + let ev = AiChatEvent::AiConvStateChanged { + conv_state: ns, + conversation_id: Some(conv_id.clone()), + }; + let _ = app_handle.emit("ai-chat-event", ev.clone()); + let _ = app_handle.state::().ai_event_bus.publish_event(ev); } }); } @@ -188,8 +184,7 @@ mod tests { // guard 内部 transition_to 是纯函数,直接断言逻辑路径。 #[test] fn test_guard_new_migrates_to_generating() { - // CONV_STATE_ENABLED on → Idle → Generating(读侧持久化字段迁移路径)。 - assert!(conv_state::CONV_STATE_ENABLED, "开关默认 on,测试假设"); + // 批3 收口后无开关断言:Idle → Generating(读侧持久化字段迁移路径,无条件执行)。 let mut s = ConvState::Idle; s = s.transition_to(ConvState::Generating).unwrap(); assert_eq!(s, ConvState::Generating); diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index e364b79..9775e30 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -48,6 +48,7 @@ use super::stream_recv::{stream_llm, StreamResult}; use super::title::{ensure_conversation_title, spawn_ensure_title}; use super::{AiChatEvent, AiSession, ErrorType, SessionState}; +// ConvState 经本文件内 `pub mod conv_state;` 同 crate 直接访问(conv_state::ConvState)。 /// L1 补丁:run_agentic_loop 入口 provider 解析超时保护的内部错误类型。 /// @@ -156,18 +157,16 @@ mod guard; use guard::GeneratingGuard; // ============================================================ -// L2 统一状态机(渐进第一步,2026-06-21):ConvState enum + 转换守卫。 +// L2 统一状态机(ConvState enum + 转换守卫,单一真相源)。 // 设计:generating状态机加固-2026-06-15.md §3 + aichat体验与agent能力系统化重构-2026-06-21.md §3。 // -// 本批范围(渐进): -// - 新建 conv_state.rs(纯逻辑 enum + 守卫 + 单测)。 -// - run_agentic_loop 入口桥接:guard 置 generating=true 时同步迁移 ConvState -// (Idle→Generating / Error→Generating),作视图层。CONV_STATE_ENABLED 开关门控。 -// - guard.reset 同步 ConvState→Idle(正常退出路径)。 -// - **不替换** PerConvState.generating bool(渐进不一次全换,批2+ 逐步迁读侧)。 -// -// 开关 CONV_STATE_ENABLED(默认 on):off 降级纯旧 bool 行为(可回退)。 -// 兜底:状态机层迁移失败(非法转换)记 warn 不 panic,核心 generating 复位仍走旧 bool。 +// 收口后(批3+,2026-06-25): +// - conv_state.rs:纯逻辑 enum + 守卫 + 单测。 +// - run_agentic_loop 入口桥接:迁移 ConvState(Idle→Generating / Error→Generating)。 +// - guard.reset/drop 同步 ConvState→Idle(正常退出 + 异常兜底)。 +// - 批3 双轨收口:CONV_STATE_ENABLED 开关与 PerConvState.generating bool 已退役, +// ConvState 成为生成态唯一真相源(无条件迁移 + emit,无 off 降级分支)。 +// - 兜底:状态机层迁移失败(非法转换)记 warn 不 panic,核心生成态复位仍经 enum 迁移收敛。 // ============================================================ pub mod conv_state; @@ -419,25 +418,21 @@ pub(crate) async fn run_agentic_loop( // 单 loop 安全性:批2 阶段是单 active_conversation_id(决策 e 真并发批3+ 落地),无并发 loop 抢 // per_conv 覆盖。批3+ 多 loop 并发时,每 conv 各自 per_conv 条目,互不干扰(本桥接无需改)。 // - // L2 状态机视图层:ConvState 的写收敛已收敛到 `guard`(GeneratingGuard::new 已在上方 L399 创建, - // new 内部 Idle→Generating 迁移 + reset/drop Generating→Idle 迁移,CONV_STATE_ENABLED 门控)。 - // 此处不再重复迁移 enum,仅写核心 generating bool(唯一真相源,enum 是其视图)。批2+ 把 enum - // 提升到 PerConvState.conv_state 字段后,此 bool 写入与 enum 迁移由 guard 统一收敛。 + // L2 状态机写收敛:ConvState 的写收敛已收敛到 `guard`(GeneratingGuard::new 已在上方 L399 创建, + // new 内部 Idle→Generating 迁移 + reset/drop Generating→Idle 迁移)。 + // 批3 双轨收口:generating bool 已退役,此处仅做持久化层 Idle→Generating 迁移(单一真相源), + // emit 由 guard.new 异步补发(入口迁移但不 emit,guard 补 emit 防前端漏收生成态)。 { let mut session = session_arc.lock().await; let conv = session.conv(&conv_id); - conv.generating = true; - // L2 批2:ConvState 持久化(Idle→Generating 写收敛,CONV_STATE_ENABLED 门控)。 - // enum 与 generating bool 双轨(bool 核心复位,enum 供读侧/前端);off 降级跳过。 - if conv_state::CONV_STATE_ENABLED { - match conv.conv_state.transition_to(conv_state::ConvState::Generating) { - Ok(ns) => conv.conv_state = ns, - Err(e) => tracing::warn!( - conv_id = %conv_id, - error = %e, - "[ai] 入口 ConvState→Generating 非法(状态机持久化层,不阻断核心生成)" - ), - } + // L2 批2:ConvState 持久化(Idle→Generating 写收敛)。批3 删除开关门控,迁移无条件执行。 + match conv.conv_state.transition_to(conv_state::ConvState::Generating) { + Ok(ns) => conv.conv_state = ns, + Err(e) => tracing::warn!( + conv_id = %conv_id, + error = %e, + "[ai] 入口 ConvState→Generating 非法(状态机持久化层,不阻断核心生成)" + ), } } @@ -929,7 +924,10 @@ pub(crate) async fn run_agentic_loop( pre_tokens = pre_compress_tokens, "[ai] 自动压缩成功,摘要已插首位" ); - let ev = AiChatEvent::AiCompressed { + // 治 Task#1:loop 自动压缩用 AiAutoCompressed(非手动变体), + // 桌面端静默(仅复位 isCompressing,不弹 toast 不 switchConversation)。 + // miniapp 仍插摘要气泡(对端发生压缩告知用户)。 + let ev = AiChatEvent::AiAutoCompressed { conversation_id: Some(conv_id.clone()), summary, }; @@ -1606,15 +1604,10 @@ pub(crate) async fn try_continue_agent_loop( .find_map(|a| a.conversation_id.clone()); let conv = session.conv_read(conv_id); // F-09 B 批4:per_conv 唯一真相源,删顶层 fallback(conv 不存在则各字段默认值)。 - // L2 读侧迁移(2026-06-22):CONV_STATE_ENABLED on 时读 conv_state.is_active()(Generating/Compressed), - // off 回退 generating bool。双轨过渡:off 等价旧行为。 - let is_generating = conv.map(|c| { - if conv_state::CONV_STATE_ENABLED { - c.conv_state.is_active() - } else { - c.generating - } - }).unwrap_or(false); + // L2 读侧(双轨收口批2):统一读 conv_state.is_active()(Generating+Compressed),删除 + // CONV_STATE_ENABLED off 回退分支(enum 单路径)。用于 ai_can_continue 继续按钮是否可点判断, + // is_active() 与原 generating 语义一致(压缩期间 loop 仍活跃,应判为仍在生成)。 + let is_generating = conv.map(|c| c.conv_state.is_active()).unwrap_or(false); let agent_language = conv.and_then(|c| c.agent_language.clone()); let model_override = conv.and_then(|c| c.model_override.clone()); ContinueSnapshot { @@ -1663,11 +1656,19 @@ pub(crate) async fn try_continue_agent_loop( // 无可用 provider(配置丢失/全删):无法续生成,emit AiError。 // 语义:配置错误,用户需在 Settings 设 provider;generating 复位由 run_agentic_loop 内 // build_provider_for Err 分支处理(同样 emit AiError),此处与之一致。 - // 不用 GeneratingGuard:try_continue 的 should_continue=false 路径需保 generating=true(审批等待态), + // 不用 GeneratingGuard:try_continue 的 should_continue=false 路径需保生成态(审批等待态), // 全函数 guard 会误复位。此点单点 provider-Err 复位,语义独立。 - // F-09 B 批4:per_conv 唯一真相源,删顶层双写复位。 + // 批3 双轨收口:generating bool 已退役,复位经 ConvState 迁移(Generating→Idle)单一表达。 let mut session = state.ai_session.lock().await; - session.conv(conv_id).generating = false; + // 持久化层 ConvState→Idle 复位(对齐 guard.reset 收敛路径,非配置错误不阻断)。 + match session.conv(conv_id).conv_state.transition_to(conv_state::ConvState::Idle) { + Ok(ns) => session.conv(conv_id).conv_state = ns, + Err(e) => tracing::warn!( + conv_id = %conv_id, + error = %e, + "[ai] try_continue provider-Err ConvState→Idle 非法(不阻断 emit AiError)" + ), + } drop(session); tracing::warn!(conv_id = %conv_id, error = %e, "[ai] try_continue 失败:无可用 provider"); let _ = app.emit("ai-chat-event", AiChatEvent::AiError { @@ -1717,16 +1718,12 @@ pub(crate) async fn try_continue_agent_loop( // spawn 前单次 lock 原子重检 generating——若已被 stop 复位,收敛退出而非覆盖用户的 stop。 // (run_agentic_loop 入口 GeneratingGuard 会再次置 generating=true,若不重检会抹掉 stop。) // F-09 B 批4:重检 per_conv.generating(唯一真相源;conv 不存在则 false)。 - // L2 读侧迁移(2026-06-22):CONV_STATE_ENABLED on 时读 conv_state.is_active(),off 回退 generating bool。 + // L2 读侧(双轨收口批2):统一读 conv_state.is_active()(Generating+Compressed),删除 + // CONV_STATE_ENABLED off 回退分支(enum 单路径)。spawn 前单次 lock 原子重检是否仍在生成, + // is_active() 与原 generating 语义一致。 let still_generating = { let session = state.ai_session.lock().await; - session.conv_read(conv_id).map(|c| { - if conv_state::CONV_STATE_ENABLED { - c.conv_state.is_active() - } else { - c.generating - } - }).unwrap_or(false) + session.conv_read(conv_id).map(|c| c.conv_state.is_active()).unwrap_or(false) }; if !still_generating { tracing::info!(conv_id = %conv_id, "[ai] try_continue 终止:spawn 前重检 generating 已被 stop 复位,补发 AiCompleted"); diff --git a/src-tauri/src/commands/ai/commands/chat.rs b/src-tauri/src/commands/ai/commands/chat.rs index b487c5e..2396991 100644 --- a/src-tauri/src/commands/ai/commands/chat.rs +++ b/src-tauri/src/commands/ai/commands/chat.rs @@ -25,6 +25,8 @@ use crate::commands::{err_str, now_millis}; // chat.rs 的 super = commands,super::super = ai(与原 commands.rs 的 super=ai 等价)。 use super::super::agentic::{run_agentic_loop, try_continue_agent_loop}; +// 双轨收口批1:读侧入口拦截用 ConvState(can_accept_request 的 unwrap_or 兜底初值)。 +use super::super::agentic::conv_state::ConvState; use super::super::audit::{audit_finalize, emit_data_changed}; use super::super::augmentation::build_augmentation_segment; use super::super::augmentation::sanitize; @@ -187,13 +189,16 @@ pub async fn ai_regenerate( // (真并发下后台 conv 也应可重新生成);per_conv 唯一真相源,删顶层双写。 { let mut session = state.ai_session.lock().await; - // generating 拦截:仅查目标 conv 的 per_conv.generating。 - let is_gen = session.conv_read(&conversation_id).map(|c| c.generating).unwrap_or(false); - if is_gen { + // 入口拦截(双轨收口批1):读 conv_state 经 can_accept_request() 判断能否接新请求。 + // 语义:拦 Stopping/Generating/Compressed,放行 Idle+Error。原 c.generating 仅拦 + // Generating/Compressed(漏拦 Stopping 态),can_accept_request 更严谨(防停止中接新请求竞态)。 + let cs = session.conv_read(&conversation_id).map(|c| c.conv_state).unwrap_or(ConvState::Idle); + if !cs.can_accept_request() { return Err("AI 正在生成中,请等待完成".to_string()); } let conv = session.conv(&conversation_id); - conv.generating = true; + // 批3 双轨收口:generating bool 已退役,生成态由 run_agentic_loop 入口 ConvState→Generating + // 迁移设置(此处不再手动赋值)。stop_flag/iteration/model_override 仍是 per_conv 独立字段。 conv.stop_flag.store(false, Ordering::SeqCst); conv.agent_language = language.clone(); // F-260616-11: 重生成 = 新生命周期起点,iteration 从头计数。 @@ -212,7 +217,8 @@ pub async fn ai_regenerate( .map(|m| matches!(m.role, df_ai::provider::MessageRole::User)) .unwrap_or(false); if !last_is_user { - conv.generating = false; + // 批3 收口:generating bool 已退役,此处仅 Err 返回(ConvState 仍 Idle,入口迁移 + // 在 run_agentic_loop 内执行,本路径尚未进入 loop,无需手动复位)。 return Err("没有可重新生成的回复".to_string()); } } @@ -282,15 +288,10 @@ pub async fn ai_is_generating( // 无目标 conv 且无 active:无任何 conv 在跑,返 false(批4 顶层 generating 已退役)。 return Ok(false); } - // L2 读侧迁移(2026-06-22):CONV_STATE_ENABLED on 时读 conv_state==Generating(含 Compressed 派生态, - // 压缩期间 loop 仍活跃),off 回退 generating bool(可回退)。双轨过渡:开关 off 等价旧行为零破坏。 - Ok(session.conv_read(&target).map(|c| { - if crate::commands::ai::agentic::conv_state::CONV_STATE_ENABLED { - c.conv_state.is_active() - } else { - c.generating - } - }).unwrap_or(false)) + // L2 读侧(双轨收口批1):统一读 conv_state.is_active(),删除 CONV_STATE_ENABLED off 回退分支 + // (enum 单路径)。is_active() 拦 Generating+Compressed(压缩期间 loop 仍活跃),语义与 + // 原 c.generating 一致(此读点用于判断「是否在生成中」,应保留拦 Compressed)。 + Ok(session.conv_read(&target).map(|c| c.conv_state.is_active()).unwrap_or(false)) } /// 发送消息并获取流式 AI 响应 @@ -351,13 +352,15 @@ pub async fn ai_chat_send( session.active_conv_created_at = Some(now_millis()); } } - // generating 拦截:仅查目标 conv 的 per_conv.generating(真并发下各 conv 独立)。 - let is_gen = session.conv_read(&target).map(|c| c.generating).unwrap_or(false); - if is_gen { + // 入口拦截(双轨收口批1):读 conv_state 经 can_accept_request() 判断能否接新请求。 + // 语义:拦 Stopping/Generating/Compressed,放行 Idle+Error。原 c.generating 漏拦 Stopping 态, + // can_accept_request 更严谨。仅查目标 conv(真并发下各 conv 独立)。 + let cs = session.conv_read(&target).map(|c| c.conv_state).unwrap_or(ConvState::Idle); + if !cs.can_accept_request() { return Err("AI 正在生成中,请等待完成".to_string()); } let conv = session.conv(&target); - conv.generating = true; + // 批3 双轨收口:generating bool 已退役,生成态由 run_agentic_loop 入口迁移设置(不再手动赋值)。 conv.stop_flag.store(false, Ordering::SeqCst); conv.agent_language = language.clone(); // F-260616-11: 新对话生命周期 iteration 从头计数(累计计数器复位)。 @@ -1133,7 +1136,8 @@ pub async fn ai_chat_compress_context( conv.messages.set_compressing(false); let cid = session.active_conversation_id.clone(); drop(session); - let ev = AiChatEvent::AiCompressed { + // 治 Task#1:手动 IPC 路径用 AiManualCompressed(前端弹 toast 告知"无可压缩/已完成")。 + let ev = AiChatEvent::AiManualCompressed { conversation_id: cid, summary: String::new(), }; @@ -1159,7 +1163,8 @@ pub async fn ai_chat_compress_context( let mut session = state.ai_session.lock().await; session.conv(&conv_id).messages.set_compressing(false); drop(session); - let ev = AiChatEvent::AiCompressed { + // 治 Task#1:手动 IPC 降级(无 active 可压缩)用 AiManualCompressed(前端弹 toast)。 + let ev = AiChatEvent::AiManualCompressed { conversation_id: Some(conv_id), summary: String::new(), }; @@ -1224,7 +1229,8 @@ pub async fn ai_chat_compress_context( conv.messages.set_compressing(false); } save_conversation(&state.ai_session, &state.db, &conv_id, None, None, false).await; - let ev = AiChatEvent::AiCompressed { + // 治 Task#1:手动 IPC 成功主路径用 AiManualCompressed(Task#1 期望保留 toast+刷新)。 + let ev = AiChatEvent::AiManualCompressed { conversation_id: Some(conv_id), summary, }; @@ -1258,8 +1264,10 @@ pub async fn ai_chat_edit( // (真并发下后台 conv 也应可编辑);per_conv 唯一真相源,删顶层双写。 { let mut session = state.ai_session.lock().await; - let is_gen = session.conv_read(&conversation_id).map(|c| c.generating).unwrap_or(false); - if is_gen { + // 入口拦截(双轨收口批1):读 conv_state 经 can_accept_request() 判断能否接新请求。 + // 语义:拦 Stopping/Generating/Compressed,放行 Idle+Error。原 c.generating 漏拦 Stopping 态。 + let cs = session.conv_read(&conversation_id).map(|c| c.conv_state).unwrap_or(ConvState::Idle); + if !cs.can_accept_request() { return Err("AI 正在生成中,请等待完成".to_string()); } let conv = session.conv(&conversation_id); @@ -1270,8 +1278,8 @@ pub async fn ai_chat_edit( // ② 其后所有消息标 truncated(无后续也 OK,返回 0) let _ = conv.messages.truncate_after_user_message(&new_message) .map_err(|_| "定位被编辑消息失败".to_string())?; - // 占用 generating + stop_flag - conv.generating = true; + // 占用生成态 + stop_flag + // 批3 双轨收口:generating bool 已退役,生成态由 run_agentic_loop 入口迁移设置(不再手动赋值)。 conv.stop_flag.store(false, Ordering::SeqCst); conv.agent_language = language.clone(); // F-260616-11: 编辑重生成 = 新生命周期起点,iteration 从头计数。 @@ -1394,7 +1402,9 @@ pub async fn ai_chat_force_send( } } // ① 软停止复位目标 conv(与 ai_chat_stop 审批分支一致),emit AiCompleted 用旧 target 自身。 - let was_gen = session.conv_read(&target).map(|c| c.generating).unwrap_or(false); + // was_gen 读点(双轨收口批1):改读 conv_state.is_active()(Generating+Compressed), + // 用于判断「原是否在生成」以决定是否 emit AiCompleted。is_active() 与原 generating 语义一致。 + let was_gen = session.conv_read(&target).map(|c| c.conv_state.is_active()).unwrap_or(false); // SW-260618-02:强制发送丢弃目标 conv 的旧审批,先终态化占位 tool_result,防占位随 messages // 残留下次发送喂给 LLM。仅清目标 conv 的 pending_approvals(retain 保留其他 conv)。 finalize_pending_placeholders(&mut *session, &target, "已取消"); @@ -1402,13 +1412,22 @@ pub async fn ai_chat_force_send( session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(target.as_str())); { let conv = session.conv(&target); - conv.generating = false; + // 批3 双轨收口:generating bool 已退役,复位改 ConvState 迁移(Generating→Idle 收敛)。 + // was_gen=false 时仍 Idle(幂等);非活跃态→Idle 非法仅 warn(不阻断强制发送语义)。 + match conv.conv_state.transition_to(ConvState::Idle) { + Ok(ns) => conv.conv_state = ns, + Err(e) => tracing::warn!( + conv_id = %target, + error = %e, + "[ai] 强制发送 ConvState→Idle 非法(不阻断强制发送)" + ), + } conv.stop_flag.store(true, Ordering::SeqCst); } - // ② 同锁内立即占用 + 追加用户消息(逻辑照 ai_chat_send,无 generating 拦截—— + // ② 同锁内立即占用 + 追加用户消息(逻辑照 ai_chat_send,无生成态拦截—— // 这是"强制"语义本身,前面已主动复位) let conv = session.conv(&target); - conv.generating = true; + // 批3 收口:生成态由 run_agentic_loop 入口 ConvState→Generating 迁移设置(不再手动赋值)。 conv.stop_flag.store(false, Ordering::SeqCst); conv.agent_language = language.clone(); // F-260616-11: 强制发送 = 新对话生命周期起点,iteration 从头计数。 @@ -1510,15 +1529,9 @@ pub async fn ai_chat_stop( return Ok(()); } // 仅查目标 conv 的 generating(真并发下各 conv 独立)。 - // L2 读侧迁移(2026-06-22):CONV_STATE_ENABLED on 时读 conv_state.is_active()(Generating/Compressed), - // off 回退 generating bool。双轨过渡:off 等价旧行为。 - let is_gen = session.conv_read(&target).map(|c| { - if crate::commands::ai::agentic::conv_state::CONV_STATE_ENABLED { - c.conv_state.is_active() - } else { - c.generating - } - }).unwrap_or(false); + // L2 读侧(双轨收口批1):统一读 conv_state.is_active()(Generating+Compressed),删除 + // CONV_STATE_ENABLED off 回退分支(enum 单路径)。用于判断停止按钮是否可点的预判断。 + let is_gen = session.conv_read(&target).map(|c| c.conv_state.is_active()).unwrap_or(false); if !is_gen { return Ok(()); } @@ -1537,7 +1550,15 @@ pub async fn ai_chat_stop( finalize_pending_placeholders(&mut *session, &target, "会话已停止"); session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(target.as_str())); let conv = session.conv(&target); - conv.generating = false; + // 批3 双轨收口:generating bool 已退役,复位改 ConvState 迁移(Generating→Idle)。 + match conv.conv_state.transition_to(ConvState::Idle) { + Ok(ns) => conv.conv_state = ns, + Err(e) => tracing::warn!( + conv_id = %target, + error = %e, + "[ai] ai_chat_stop 审批分支 ConvState→Idle 非法(不阻断 emit AiCompleted)" + ), + } conv.stop_flag.store(true, Ordering::SeqCst); // 双保险:防 try_continue 误判重启 drop(session); let ev = AiChatEvent::AiCompleted { @@ -1572,13 +1593,25 @@ pub async fn ai_chat_stop( tokio::time::sleep(std::time::Duration::from_secs(3)).await; let mut session = session_arc.lock().await; let target_owned = conv_id.clone().unwrap_or_default(); + // still_gen 读点(双轨收口批1):改读 conv_state.is_active()(Generating+Compressed), + // 3秒后重检是否仍在生成。is_active() 与原 generating 语义一致。 let still_gen = if target_owned.is_empty() { false } else { - session.conv_read(&target_owned).map(|c| c.generating).unwrap_or(false) + session.conv_read(&target_owned).map(|c| c.conv_state.is_active()).unwrap_or(false) }; if still_gen { - session.conv(&target_owned).generating = false; + // 3秒超时强制收尾:generating bool 已退役,改 ConvState 迁移(Generating→Idle)收敛生成态。 + // 正常路径由 guard.drop 复位,此处仅超时兜底(loop 未及时退出场景)。 + let conv = session.conv(&target_owned); + match conv.conv_state.transition_to(ConvState::Idle) { + Ok(ns) => conv.conv_state = ns, + Err(e) => tracing::warn!( + conv_id = %target_owned, + error = %e, + "[ai] ai_chat_stop 3秒超时 ConvState→Idle 非法(不阻断 emit AiCompleted)" + ), + } drop(session); // 释放锁后再 emit,避免持锁调 runtime emit let ev = AiChatEvent::AiCompleted { total_tokens: 0, @@ -1617,7 +1650,9 @@ pub async fn ai_continue_loop( let mut session = state.ai_session.lock().await; // F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,可对任意 conv 操作 // (含切走后的后台 conv),移除 active 一致性校验(真并发下后台 conv 也应可续跑)。 - let is_gen = session.conv_read(&conversation_id).map(|c| c.generating).unwrap_or(false); + // 读点(双轨收口批1):改读 conv_state.is_active()(Generating+Compressed)。达 max 时 + // generating=true(conv_state=Generating),is_active() 返回 true 符合预期。 + let is_gen = session.conv_read(&conversation_id).map(|c| c.conv_state.is_active()).unwrap_or(false); if !is_gen { return Err("AI 未在暂停态,无需继续".to_string()); } @@ -1637,11 +1672,11 @@ pub async fn ai_continue_loop( /// 停止 agentic 循环并走完成流程(F-260616-03:达 max_iterations 暂停态用户点「停止」) /// /// 场景:run_agentic_loop 达 max_iterations 未收敛 → emit AiMaxRoundsReached + 保持 -/// generating=true 暂停。用户点停止调本命令 → 复位 generating + emit AiCompleted(标收敛)。 +/// ConvState=Generating 暂停。用户点停止调本命令 → 复位生成态(ConvState→Idle) + emit AiCompleted(标收敛)。 /// /// 不重复 save 逻辑:暂停态进入前 run_agentic_loop 已 save_conversation 落库(agentic/mod.rs -/// 达上限分支),此处仅复位 generating + emit AiCompleted 通知前端收尾。校验复用 ai_approve -/// 模式(generating + active_conversation_id 一致性)。 +/// 达上限分支),此处仅复位生成态 + emit AiCompleted 通知前端收尾。校验复用 ai_approve +/// 模式(生成态 + active_conversation_id 一致性)。 #[tauri::command] pub async fn ai_stop_loop( app: AppHandle, @@ -1652,14 +1687,24 @@ pub async fn ai_stop_loop( let mut session = state.ai_session.lock().await; // F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验 // (真并发下后台 conv 也应可停止)。 - let is_gen = session.conv_read(&conversation_id).map(|c| c.generating).unwrap_or(false); + // 读点(双轨收口批1):改读 conv_state.is_active()(Generating+Compressed)。停止按钮判断 + // 是否在生成中(与 ai_continue_loop 同场景)。is_active() 符合语义。 + let is_gen = session.conv_read(&conversation_id).map(|c| c.conv_state.is_active()).unwrap_or(false); if !is_gen { return Err("AI 未在暂停态,无需停止".to_string()); } let conv = session.conv(&conversation_id); // 置 stop_flag 双保险:防 try_continue 误判重启(与 ai_chat_stop 审批分支一致) conv.stop_flag.store(true, Ordering::SeqCst); - conv.generating = false; + // 批3 双轨收口:generating bool 已退役,复位改 ConvState 迁移(Generating→Idle)。 + match conv.conv_state.transition_to(ConvState::Idle) { + Ok(ns) => conv.conv_state = ns, + Err(e) => tracing::warn!( + conv_id = %conversation_id, + error = %e, + "[ai] ai_stop_loop ConvState→Idle 非法(不阻断 emit AiCompleted)" + ), + } } // 暂停态进入前已 save_conversation,此处零 token 上报仅作收敛信号(与 try_continue 补发 AiCompleted 一致) let ev = AiChatEvent::AiCompleted { diff --git a/src-tauri/src/commands/ai/commands/conversation.rs b/src-tauri/src/commands/ai/commands/conversation.rs index ea6d317..61a4978 100644 --- a/src-tauri/src/commands/ai/commands/conversation.rs +++ b/src-tauri/src/commands/ai/commands/conversation.rs @@ -297,7 +297,10 @@ pub async fn ai_conversation_switch( drop(session); state.clear_session_allowed_dirs().await; let mut session = state.ai_session.lock().await; - let already_live = session.conv_read(&conversation_id).map(|c| c.generating).unwrap_or(false); + // 读点(双轨收口批2):改读 conv_state.is_active()(Generating+Compressed),判断目标会话是否 + // 已在生成中(是否需要从 DB reload)。is_active() 与原 generating 语义一致(压缩期间后台 loop + // 仍持有 per_conv.messages,reload 会用 DB 旧快照覆盖内存新消息,应判为已在生成而跳过 reload)。 + let already_live = session.conv_read(&conversation_id).map(|c| c.conv_state.is_active()).unwrap_or(false); if !already_live { // 目标 conv 未在生成:从 DB reload messages 到其 per_conv(首次切入或上次切走后无后台 loop)。 // 已在生成:保留其 per_conv 现状(后台 loop 持有),messages 由 loop 自行维护。 diff --git a/src-tauri/src/commands/ai/mod.rs b/src-tauri/src/commands/ai/mod.rs index 69b6772..eb99284 100644 --- a/src-tauri/src/commands/ai/mod.rs +++ b/src-tauri/src/commands/ai/mod.rs @@ -199,8 +199,17 @@ pub enum AiChatEvent { AiContextCleared { conversation_id: Option }, /// F-15 阶段2 手动 LLM 压缩开始:前端展示压缩中态(spinner/禁用按钮防重入)。 AiCompressing { conversation_id: Option }, - /// F-15 阶段2 手动 LLM 压缩完成:摘要已插入消息首位,前端可展示摘要 + 移除压缩中态。 - AiCompressed { conversation_id: Option, summary: String }, + /// F-15 阶段2 手动 LLM 压缩完成:摘要已插入消息首位,前端弹 toast + 刷新视图回填摘要。 + /// + /// 治 Task#1(BUG-260624-05):自动压缩路径误用此变体致桌面误弹 toast+每次发送误刷, + /// 现拆为 AiManualCompressed(手动 IPC,弹 toast) + AiAutoCompressed(loop 自动,静默)。 + /// emit 点:ai_chat_compress_context IPC 的 3 处(空会话/无 active/LLM 成功主路径)。 + AiManualCompressed { conversation_id: Option, summary: String }, + /// F-15 阶段2 自动 LLM 压缩完成(agentic loop 触发,治 Task#1):对桌面端静默—— + /// 桌面 useAiContext 仅复位 isCompressing(防按钮卡死兜底),不弹 toast 不 switchConversation。 + /// 摘要 system 消息已由后端 insert_at,后续 stream 自然带出,无需整会话刷新。 + /// miniapp 仍插摘要气泡(对端发生压缩告知用户)。 + AiAutoCompressed { conversation_id: Option, summary: String }, /// L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21): /// /// agent 断路器(§2.2)熔断 / 自省(识别"我反复失败")时发,区别于 prompt 教 AI @@ -257,8 +266,8 @@ pub enum AiChatEvent { /// (入口/guard reset/drop 迁移)后 emit 此事件,前端 status union 据此更新展示态 /// (替代散布的 generating bool 派生判断,收敛状态机读侧到事件流)。 /// - /// 渐进/兜底:`CONV_STATE_ENABLED` 开关门控 —— off 时不 emit(前端按旧 bool 行为派生, - /// 向后兼容);on 时此事件与既有 generating 行为双轨,前端可按需切换消费源。 + /// 批3 双轨收口:`CONV_STATE_ENABLED` 开关与 generating bool 已退役,emit 无条件执行 + /// (enum 单一真相源,无 off 降级分支)。前端按 conv_state 事件流派生展示态。 /// `conv_state` 序列化为 snake_case(idle/generating/stopping/error/compressed), /// 供前端 union discriminator 直接使用。 AiConvStateChanged { @@ -514,7 +523,9 @@ impl AiSession { .any(|a| a.conversation_id.as_deref() == Some(conv_id)); if has_pending { SessionState::AwaitingApproval - } else if self.conv_read(conv_id).map(|c| c.generating).unwrap_or(false) { + } else if self.conv_read(conv_id).map(|c| c.conv_state.is_active()).unwrap_or(false) { + // 读侧(双轨收口批2):改读 conv_state.is_active()(Generating+Compressed),判断是否在 + // 活跃生成。is_active() 与原 generating 语义一致(压缩期间 loop 仍活跃,应判为 Streaming)。 SessionState::Streaming } else { SessionState::Idle @@ -557,7 +568,8 @@ mod tests_f09_per_conv { #[test] fn test_per_conv_state_new() { let s = PerConvState::new(); - assert!(!s.generating, "generating 初值应为 false"); + // 批3 收口:generating bool 已退役,生成态初值校验改读 conv_state(应为 Idle)。 + assert_eq!(s.conv_state, ConvState::Idle, "conv_state 初值应为 Idle"); assert_eq!(s.iteration_used, 0, "iteration_used 初值应为 0"); assert!(s.agent_language.is_none(), "agent_language 初值应为 None"); assert!(s.model_override.is_none(), "model_override 初值应为 None"); @@ -587,7 +599,8 @@ mod tests_f09_per_conv { // 首次 conv() 创建 let ptr_first = { let s = session.conv("conv-a"); - assert!(!s.generating); + // 批3 收口:generating bool 已退役,首建会话生成态应为 Idle。 + assert_eq!(s.conv_state, ConvState::Idle); assert_eq!(s.iteration_used, 0); s as *const PerConvState }; @@ -747,11 +760,9 @@ mod tests_f09_per_conv { pub struct PerConvState { /// 对话历史(ContextManager:会话级消息真相源,裁剪仅影响发送视图) pub messages: ContextManager, - /// 是否正在生成 - pub generating: bool, /// L2 统一状态机:对话生命周期状态(单一真相源,批2 持久化供读侧/前端消费)。 /// 写收敛:经 GeneratingGuard/入口 transition_to 守卫迁移,非直接赋值。 - /// 与 generating bool 双轨过渡(bool 核心复位,enum 视图),批2+ 读侧迁移后 enum 主。 + /// 批3 双轨收口:generating bool 已退役,此 enum 成为生成态唯一真相源。 pub conv_state: ConvState, /// 停止信号(会话级):ai_chat_stop 置位,agentic loop / stream_llm 检测后尽快退出 pub stop_flag: Arc, @@ -802,7 +813,6 @@ impl PerConvState { pub fn new() -> Self { Self { messages: ContextManager::new(ContextConfig::default()), - generating: false, conv_state: ConvState::Idle, stop_flag: Arc::new(AtomicBool::new(false)), notify: Arc::new(tokio::sync::Notify::new()), diff --git a/src-tauri/src/commands/ai/remote_bridge.rs b/src-tauri/src/commands/ai/remote_bridge.rs index 4fcd5b2..da625d3 100644 --- a/src-tauri/src/commands/ai/remote_bridge.rs +++ b/src-tauri/src/commands/ai/remote_bridge.rs @@ -30,9 +30,9 @@ //! //! ## R1 兜底(硬性,不可省,§3.3 + D6) //! -//! `send_message` 路由前置 generating 检查(对齐 ai_is_generating 双轨口径: -//! CONV_STATE_ENABLED on 时读 conv_state.is_active(),off 回退 generating bool)。 -//! generating=true 则拒绝,emit `AiError` 事件回 miniapp 提示「正在生成中」, +//! `send_message` 路由前置 generating 检查(对齐 ai_is_generating 口径: +//! 批3 收口后读 conv_state.is_active() 单一 enum 路径,开关 + generating bool 已退役)。 +//! 生成中(Generating/Compressed)则拒绝,emit `AiError` 事件回 miniapp 提示「正在生成中」, //! 不调 ai_chat_send。这是 miniapp 与桌面同时给同 conv 发消息的双保险, //! 无论 ai_chat_send 内部是否已有 generating guard 都加(防御性编程)。 //! @@ -75,8 +75,6 @@ use crate::commands::ai::skills::SkillInfo; use df_types::augmentation::MentionSpanDto; // F-#95 扩展:历史消息同步 route_load_messages 用(record_to_message 映射 AiMessageRecord→ChatMessage)。 use df_ai::provider::ChatMessage; -// CONV_STATE_ENABLED 与 ConvState::is_active 双轨口径(对齐 ai_is_generating 实现)。 -use crate::commands::ai::agentic::conv_state::CONV_STATE_ENABLED; // ============================================================ // MiniCommand 协议结构(对齐 apps/df-miniapp/src/types/relay.ts:88-93) @@ -637,11 +635,10 @@ async fn route_list_entities(state: &State<'_, AppState>) { /// ai_chat_send 内部虽有 generating guard,但桥接层再加一层前置检查作为双保险 /// (防御性编程,对齐设计文档 D6「桥接层 send 路由硬性加,无论 ai_chat_send 内部是否有 guard」)。 /// -/// 检查口径与 `ai_is_generating` 完全一致(双轨): -/// - CONV_STATE_ENABLED on → 读 `conv_state.is_active()`(含 Generating / Compressed 派生态) -/// - off → 回退 `generating` bool +/// 检查口径与 `ai_is_generating` 完全一致: +/// - 读 `conv_state.is_active()`(含 Generating / Compressed 派生态,批3 收口后单一 enum 路径) /// -/// generating=true 则拒绝:**emit `AiError` 事件回 miniapp 提示「正在生成中」**,不调 ai_chat_send。 +/// 生成中则拒绝:**emit `AiError` 事件回 miniapp 提示「正在生成中」**,不调 ai_chat_send。 /// (事件透传阶段2 已建通路,miniapp handleEvent 能渲染 AiError,用户可见反馈)。 /// /// message 必填,缺失跳过(ai_chat_send 无 message 无意义,日志告警)。 @@ -738,9 +735,8 @@ async fn route_send_message(app: &AppHandle, state: &State<'_, AppState>, args: /// 返 `None` 表示放行(可发)。 /// /// 双轨口径对齐 `ai_is_generating`(commands/chat.rs:287-293): -/// - CONV_STATE_ENABLED on → 读 `conv_state.is_active()`(Generating / Compressed 派生态均视为活跃, -/// 压缩期间 loop 仍活跃) -/// - off → 回退 `generating` bool +/// - 读 `conv_state.is_active()`(Generating / Compressed 派生态均视为活跃,压缩期间 loop 仍活跃; +/// 批3 收口后单一 enum 路径,开关 + generating bool 已退役) /// /// 目标 conv 解析(对齐 ai_is_generating:278-284): /// - 入参 conversation_id 非空优先 @@ -762,16 +758,11 @@ async fn check_generating_reject( Some(id) => id, None => return None, // 无目标 conv 且无 active:无任何 conv 在跑,放行 }; - // 双轨读 generating(对齐 ai_is_generating,无 conv_read 视为 false 放行)。 + // 读侧(双轨收口批2):统一读 conv_state.is_active()(Generating+Compressed),删除 + // CONV_STATE_ENABLED off 回退分支(enum 单路径)。对齐 ai_is_generating,无 conv_read 视为 false 放行。 let is_gen = session .conv_read(&target) - .map(|c| { - if CONV_STATE_ENABLED { - c.conv_state.is_active() - } else { - c.generating - } - }) + .map(|c| c.conv_state.is_active()) .unwrap_or(false); if is_gen { Some(target) @@ -932,8 +923,9 @@ mod tests { // ── R1 check_generating_reject 路径测试(隔离 AiSession,绕开 Tauri State 依赖) ── // // check_generating_reject 需 `State`,单元测试难构造。 - // 改测 R1 核心判定逻辑:用 AiSession + PerConvState 模拟 generating 状态, - // 复核 conv_read + CONV_STATE_ENABLED 口径对齐 ai_is_generating。 + // 改测 R1 核心判定逻辑:用 AiSession + PerConvState 模拟生成态, + // 复核 conv_read + conv_state.is_active() 口径对齐 ai_is_generating。 + // (批3 收口后单一 enum 路径,开关 + generating bool 已退役。) /// R1 判定:无目标 conv 且无 active → 放行(返 None)。 #[test] @@ -950,108 +942,89 @@ mod tests { ); } - /// R1 判定:目标 conv 未在跑 → 放行(generating=false)。 + /// R1 判定:目标 conv 未在跑 → 放行(conv_state=Idle,is_active()=false)。 #[test] fn test_r1_target_idle_pass() { + use crate::commands::ai::agentic::conv_state::ConvState; let mut session = AiSession::new(); let conv = session.conv("conv-idle"); - conv.generating = false; - // 读 generating 口径(对齐 check_generating_reject / ai_is_generating) + conv.conv_state = ConvState::Idle; + // 批3 收口:读 conv_state.is_active() 口径(对齐 check_generating_reject / ai_is_generating)。 + // 开关已退役,直接读 enum 单一路径。 let is_gen = session .conv_read("conv-idle") - .map(|c| { - if CONV_STATE_ENABLED { - c.conv_state.is_active() - } else { - c.generating - } - }) + .map(|c| c.conv_state.is_active()) .unwrap_or(false); - assert!(!is_gen, "conv-idle 未在跑,is_gen 应为 false(放行)"); + assert!(!is_gen, "conv-idle Idle 未在跑,is_gen 应为 false(放行)"); } - /// R1 判定:目标 conv 在跑 → 拒绝(generating=true)。 + /// R1 判定:目标 conv 在跑 → 拒绝(conv_state=Generating,is_active()=true)。 #[test] fn test_r1_target_generating_reject() { use crate::commands::ai::agentic::conv_state::ConvState; let mut session = AiSession::new(); let conv = session.conv("conv-busy"); - // 双轨一致:CONV_STATE_ENABLED on 时 R1 读 conv_state.is_active()(非 generating bool), - // 须同步设 conv_state=Generating(对齐 test_r1_conv_state_active_reject 设态范式) + // 批3 收口:generating bool 已退役,生成态经 conv_state=Generating 表达(单一真相源)。 conv.conv_state = ConvState::Generating; - conv.generating = true; let is_gen = session .conv_read("conv-busy") - .map(|c| { - if CONV_STATE_ENABLED { - c.conv_state.is_active() - } else { - c.generating - } - }) + .map(|c| c.conv_state.is_active()) .unwrap_or(false); - assert!(is_gen, "conv-busy generating=true 应拒绝"); + assert!(is_gen, "conv-busy Generating 应拒绝"); } - /// R1 判定:conv_state 派生态(Generating)is_active()=true(双轨 on 路径)。 + /// R1 判定:conv_state 派生态(Generating)is_active()=true(单一 enum 路径)。 /// - /// 验证 CONV_STATE_ENABLED on 时读 conv_state.is_active() 而非 generating bool —— - /// 即便 generating bool 误为 false 但 conv_state=Generating,仍应判定活跃拒绝。 + /// 验证批3 收口后读 conv_state.is_active() 判定活跃拒绝(enum 唯一真相源)。 #[test] fn test_r1_conv_state_active_reject() { use crate::commands::ai::agentic::conv_state::ConvState; - // CONV_STATE_ENABLED 是编译期 const(true),本测试假定 on 路径。 - // 若未来开关改 runtime,此测试需条件跳过 on 时才跑。 + // 批3 收口:开关已退役,enum 单一真相源,无条件 on 路径。 let mut session = AiSession::new(); let conv = session.conv("conv-state-busy"); - // 模拟状态机写收敛路径:conv_state=Generating,generating bool 同步 true(双轨一致) + // 模拟状态机写收敛路径:conv_state=Generating(单一真相源,无 generating bool 双轨)。 conv.conv_state = ConvState::Generating; - conv.generating = true; let is_gen = session .conv_read("conv-state-busy") - .map(|c| { - if CONV_STATE_ENABLED { - c.conv_state.is_active() - } else { - c.generating - } - }) + .map(|c| c.conv_state.is_active()) .unwrap_or(false); assert!( is_gen, - "conv_state=Generating is_active() 应为 true(双轨 on 路径拒绝)" + "conv_state=Generating is_active() 应为 true(单一 enum 路径拒绝)" ); } /// R1 目标 conv 解析:入参 conversation_id 优先于 active_conversation_id。 #[test] fn test_r1_target_input_priority() { + use crate::commands::ai::agentic::conv_state::ConvState; let mut session = AiSession::new(); session.active_conversation_id = Some("conv-active".to_string()); - // active 在跑,但入参 conv-other 未跑 → 入参优先,应放行 - session.conv("conv-active").generating = true; - let _ = session.conv("conv-other"); // 创建但不 generating + // active 在跑(Generating),但入参 conv-other 未跑(Idle) → 入参优先,应放行 + session.conv("conv-active").conv_state = ConvState::Generating; + let _ = session.conv("conv-other"); // 创建但 Idle(默认) let active_gen = session .conv_read("conv-active") - .map(|c| c.generating) + .map(|c| c.conv_state.is_active()) .unwrap_or(false); let other_gen = session .conv_read("conv-other") - .map(|c| c.generating) + .map(|c| c.conv_state.is_active()) .unwrap_or(false); assert!(active_gen, "conv-active 在跑"); assert!(!other_gen, "conv-other 未跑"); - // 入参 conv-other 优先 → 判定 conv-other.generating=false → 放行 + // 入参 conv-other 优先 → 判定 conv-other Idle → 放行 // (与 check_generating_reject 入参优先逻辑一致) } - // ── PerConvState 初值对齐(确保 R1 测试基线 generating=false) ── + // ── PerConvState 初值对齐(确保 R1 测试基线 conv_state=Idle) ── - /// PerConvState::new() generating 初值 false(R1 放行基线)。 + /// PerConvState::new() conv_state 初值 Idle(R1 放行基线)。 #[test] - fn test_per_conv_state_generating_default_false() { + fn test_per_conv_state_conv_state_default_idle() { + use crate::commands::ai::agentic::conv_state::ConvState; let _ = ContextConfig::default(); // 触发 ContextConfig default 可用(避免 unused) let s = PerConvState::new(); - assert!(!s.generating, "PerConvState::new generating 初值应为 false"); + assert_eq!(s.conv_state, ConvState::Idle, "PerConvState::new conv_state 初值应为 Idle"); } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 12083d7..2f5416d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -45,17 +45,27 @@ pub fn run() { // F-260616-09 B 批8(设计 §3 batch8 + §5.2):遍历 per_conv(HashMap)清多 conv // 残留 generating(HMR/dev 热载场景多 conv 并发跑 loop 致多 conv 卡 generating)。 // 批4:per_conv 唯一真相源,删顶层 session.generating 双写复位(顶层字段已退役)。 + // 读点(双轨收口批2):改读 conv_state.is_active()(Generating+Compressed),比原 + // generating 更全面——HMR 热载下 Compressed 残留态也一并清理(避免压缩中 conv 卡死)。 let dirty_convs: Vec = session .per_conv .iter() - .filter(|(_, c)| c.generating) + .filter(|(_, c)| c.conv_state.is_active()) .map(|(id, _)| id.clone()) .collect(); let was_generating = !dirty_convs.is_empty(); - // 复位每个残留 generating 的 conv(批8 多 conv 全覆盖)。 + // 复位每个残留生成态的 conv(批8 多 conv 全覆盖)。 + // 批3 双轨收口:generating bool 已退役,复位改 ConvState 迁移(Generating/Compressed→Idle)。 for cid in &dirty_convs { if let Some(conv) = session.per_conv.get_mut(cid) { - conv.generating = false; + match conv.conv_state.transition_to(crate::commands::ai::agentic::conv_state::ConvState::Idle) { + Ok(ns) => conv.conv_state = ns, + Err(e) => tracing::warn!( + conv_id = %cid, + error = %e, + "[ai] HMR 热载 ConvState→Idle 非法(不阻断热载)" + ), + } } } // BUG-260619-06 修复: clear 致冷启动 restore 重建审批丢失(restore 填充后 clear 无条件清空, diff --git a/src/api/types.ts b/src/api/types.ts index 3e5364f..72f9618 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -339,9 +339,11 @@ export type AiErrorType = 'auth' | 'network' | 'timeout' | 'provider_config' | ' * - error:出错(用户可重试回 generating) * - compressed:压缩中(Generating/Idle 期间瞬态派生,压缩完成回原态) * - * 后端开关 CONV_STATE_ENABLED 门控:on 时 GeneratingGuard set/reset 同步迁移并发 - * AiConvStateChanged 事件;off 时纯旧 generating bool 行为(可回退)。前端 conv_state 为 - * 新增派生源,旧 streaming/generating bool 双轨过渡保留,不强制全替(防回归)。 + * 批4 双轨收口后(2026-06-25):convStates(enum Map)是「会话是否生成中」的唯一真相源, + * 旧 generatingConvs bool 轨已退役。CONV_STATE_ENABLED=off 或老后端不发 AiConvStateChanged 时, + * useAiEvents.handleEvent 活跃事件兜底写 setConvState('generating') 作为 enum 写入口 + * (替代旧 bool 轨 add),消费方不再回退 bool 判断。streaming 单值是另一条过渡态 + * (F-09 per-conv streaming 收口前保留),不在本状态机口径内。 */ export type ConvState = 'idle' | 'generating' | 'stopping' | 'error' | 'compressed' @@ -400,8 +402,9 @@ export type AiChatEvent = ({ // L2 统一状态机(批2 1b/1c):ConvState 经 AiChatEvent 推前端,供 status union 消费。 // conv_state 值为 idle|generating|stopping|error|compressed(snake_case,对齐后端 ConvState serde)。 // 后端在 conv_state 变化时(入口→generating / reset·drop→idle)emit;conversation_id 标识 - // 哪个会话的状态变了(F-09 多会话并发下前端按 convId 路由)。CONV_STATE_ENABLED 门控:off 时 - // 后端不 emit,前端 conv_state 收不到 → 消费方须回退旧 streaming/generating bool 判断(双轨过渡)。 + // 哪个会话的状态变了(F-09 多会话并发下前端按 convId 路由)。批4 双轨收口后:CONV_STATE_ENABLED=off + // 或老后端不发本事件时,useAiEvents.handleEvent 活跃事件兜底写 setConvState('generating') 作为 + // enum 写入口(替代旧 bool 轨 add),消费方不再回退 streaming/generating bool 判断。 type: 'AiConvStateChanged'; conv_state: ConvState } | { // F-260616-07: 流式调用自动重试提示(前端可在错误气泡内显示「重试 n/m」) diff --git a/src/components/AiChat.vue b/src/components/AiChat.vue index 7030146..85d197f 100644 --- a/src/components/AiChat.vue +++ b/src/components/AiChat.vue @@ -597,6 +597,8 @@ async function initContextEventListeners(): Promise { void store.switchConversation(store.state.activeConversationId) } }, + // 治 Task#1:仅手动压缩触发(useAiContext 的 AiManualCompressed case 调此回调); + // 自动压缩(AiAutoCompressed)静默不进此回调,避免每次发送误弹 toast + 误刷新打断流。 onCompressed: (_convId, _summary) => { showToast(t('aiChat.compressSuccess'), 'info') // 刷新对话(历史消息落库标 compressed + system 摘要消息回填到视图) diff --git a/src/composables/ai/aiShared.ts b/src/composables/ai/aiShared.ts index 5142a19..6a4c941 100644 --- a/src/composables/ai/aiShared.ts +++ b/src/composables/ai/aiShared.ts @@ -10,11 +10,12 @@ //! - 审批计时器(startApprovalTimer/clearApprovalTimer/clearAllApprovalTimers): //! events 模块触发(start/clear/allClear),send 模块也需导出给组件,故下沉到共享层 +import { reactive } from 'vue' import { aiApi } from '@/api' import { state } from '@/stores/ai' import { useAppSettingsStore } from '@/stores/appSettings' import { t } from '@/i18n/i18n-helpers' -import type { AiToolCallInfo } from '@/api/types' +import type { AiToolCallInfo, ConvState } from '@/api/types' const appSettings = useAppSettingsStore() @@ -124,3 +125,48 @@ export function clearAllApprovalTimers(): void { for (const timer of _approvalTimers.values()) clearTimeout(timer) _approvalTimers.clear() } + +// ── L2 统一状态机 convStates(下沉自 useAiEvents,批4 双轨收口) ── +// +// 下沉原因:批4 退役 generatingConvs(bool 轨)后,stores/ai.ts(isGenerating)/ +// useAiStream.ts(onStreamTimeout stillGenerating)/useAiConversations.ts(switchConversation 重算) +// 等读点均需 import getConvState。若 getConvState 留在 useAiEvents,这些模块反向 import +// useAiEvents 会与 useAiEvents→useAiStream/useAiEvents←stores 的现有依赖构成环。 +// 故将 convStates Map + getConvState + setConvState 全部下沉到本共享层(与 nextMsgId/findToolCall/ +// 审批计时器同位置,沿用既有破环模式)。useAiEvents 改为从本模块 import 它们。 + +/** + * per-conv conv_state 真相源(消费后端 AiConvStateChanged 事件)。 + * + * 模块级 reactive Map(convId → ConvState),不进 store.state(与生成生命周期语义独立于 + * 消息/会话列表,同 pendingMaxRounds/pendingHelp 模式)。批4 收口后,本 Map 是「会话是否生成中」 + * 的唯一真相源,generatingConvs bool 轨已退役。 + */ +export const convStates = reactive(new Map()) + +/** + * 取某 conv 的 conv_state;未追踪过返回 null。 + * + * 桥接语义(批4 收口):bool 轨 generatingConvs.has(id) 等价于本函数返回值 ∈ + * {'generating','stopping','compressed'}(非终止三态);idle/error/null 对应 bool 轨 has()=false。 + * 依据:handleConvStateEvent 原同步逻辑将三态都 add,终止态 delete。 + */ +export function getConvState(convId: string | null | undefined): ConvState | null { + if (!convId) return null + return convStates.get(convId) ?? null +} + +/** + * 写入 conv_state(useAiEvents.handleConvStateEvent 的 AiConvStateChanged case 唯一写入口, + * 及活跃事件兜底写 generating 态)。 + * + * idle 为终态收敛:写后删 Map 项(与后端 generating=false 语义对齐,Map 不持陈旧 idle 项)。 + */ +export function setConvState(convId: string | null | undefined, s: ConvState): void { + if (!convId) return + if (s === 'idle') { + convStates.delete(convId) + } else { + convStates.set(convId, s) + } +} diff --git a/src/composables/ai/streamingGuard.ts b/src/composables/ai/streamingGuard.ts index a568869..2816b21 100644 --- a/src/composables/ai/streamingGuard.ts +++ b/src/composables/ai/streamingGuard.ts @@ -5,17 +5,19 @@ //! 任一新增 return 漏写、或异常路径(前端 JS 错误/窗口崩溃恢复)跳过复位 → //! streaming 永久 true 卡死输入框(stop 按钮常驻、新消息入队不发)。 //! -//! 本模块把写侧收敛到单一 `setStreaming` 入口,做三件事: +//! 本模块把写侧收敛到单一 `setStreaming` 入口,做两件事: //! 1. 合法性观测:`true→true` / `false→false` 幂等场景记 debug 日志(不拒绝—— //! resetStreamWatchdog 等副作用场景需合法重入),异常模式(如 false 时仍在跑看门狗) //! 记 warn 日志供排查卡死。 -//! 2. 联动 generatingConvs:value=true 且带 convId → add(convId);value=false 且带 convId -//! → delete(convId)。不带 convId 的复位(全局兜底)不动 generatingConvs,因其由 -//! AiCompleted/AiError 按 conversation_id 精确管理(避免误删并发会话生成态)。 -//! 3. feature flag `df-ai-generating-statemachine`:关时 guard 退化为直接赋值, -//! 不校验不联动,回退原散布语义(灰度/回退开关)。 +//! 2. feature flag `df-ai-generating-statemachine`:关时 guard 退化为直接赋值, +//! 不校验,回退原散布语义(灰度/回退开关)。 //! -//! 注:本 guard 是「写收敛 + 可观测 + 联动」三合一,不是拒绝式状态机—— +//! 批4 双轨收口后:generating 态真相归 convStates(enum),本 guard 不再联动 generatingConvs +//! (bool 轨已退役)。streaming 单值(state.streaming)是全局当前视图流式态,另一条过渡态, +//! 归 F-09 B 路线 per-conv streaming 收口,本批暂留。doSend setStreaming(true,{convId}) 后, +//! 生成态由 useAiEvents handleEvent 活跃事件兜底写 setConvState('generating') 标记。 +//! +//! 注:本 guard 是「写收敛 + 可观测」二合一,不是拒绝式状态机—— //! streaming 是 boolean 无真正非法跃迁,guard 价值在集中入口与日志而非拦截。 import { state } from '@/stores/ai' @@ -29,8 +31,7 @@ const appSettings = useAppSettingsStore() /// guard 是否启用(读 appSettings,默认开=true)。 /// /// 默认开的理由:本 guard 是收敛性改造(行为等价 + 加可观测),非行为变更, -/// 默认开启直接收效;flag 仅作紧急回退通道(若发现联动 generatingConvs 引入回归, -/// 用户可在设置里关掉回退原散布语义)。 +/// 默认开启直接收效;flag 仅作紧急回退通道(若发现回归,用户可在设置里关掉回退原散布语义)。 function isGuardEnabled(): boolean { return appSettings.get(STREAMING_GUARD_FLAG, true) } @@ -38,14 +39,13 @@ function isGuardEnabled(): boolean { /** * streaming 写收敛入口 —— 替代散布的 `state.streaming = true/false`(原 14 处,现全收敛至此)。 * @param value 目标流式态 - * @param opts.convId 关联会话 id。value=true 时 add 进 generatingConvs; - * value=false 时 delete 出 generatingConvs。省略则不动 generatingConvs - * (全局兜底复位场景,generatingConvs 由 AiCompleted/AiError 精确管理)。 + * @param opts.convId 关联会话 id(批4 收口后仅用于日志可观测,不再联动 generatingConvs; + * 生成态真相归 convStates,由 useAiEvents 兜底写 setConvState 标记)。 * @param opts.reason 调用方标注(日志可观测,如 'AiCompleted'/'stopChat'/'onStreamTimeout')。 * * 行为: - * - flag 关 → 直接 `state.streaming = value`(回退散布语义,不联动不校验)。 - * - flag 开 → 幂等检测(value===当前值记 debug)、联动 generatingConvs、写日志。 + * - flag 关 → 直接 `state.streaming = value`(回退散布语义,不校验)。 + * - flag 开 → 幂等检测(value===当前值记 debug)、写日志。 */ export function setStreaming( value: boolean, @@ -73,18 +73,8 @@ export function setStreaming( ) } - // 联动 generatingConvs:仅在有 convId 时联动。 - // value=true:convId 入 Set(标记该会话生成中,侧栏/分离窗口据此显示)。 - // value=false:convId 出 Set(该会话收尾)。无 convId 的全局复位不动 Set - // (并发会话生成态由各自 AiCompleted/AiError 精确 delete 管理,全局清会误杀)。 - if (convId) { - if (value) { - state.generatingConvs.add(convId) - } else { - state.generatingConvs.delete(convId) - } - } - + // 批4 双轨收口:generating 态真相归 convStates(enum),本 guard 不再联动 generatingConvs + // (bool 轨已退役)。streaming 单值是另一条过渡态(F-09 per-conv streaming 收口前保留)。 state.streaming = value } @@ -94,9 +84,8 @@ export function setStreaming( * 与 setStreaming(false) 区别:本函数额外清 currentText + queue, * 专用于看门狗超时 / 前端异常恢复等「必须彻底清态」场景。 * - * TD-260621-01 per-conv:看门狗超时携带 convId 时,仅清该 conv 的 generatingConvs - * (经 setStreaming 联动 delete),不再全清误杀并发会话生成态。不带 convId(全局兜底, - * 如 stopListener 卸载场景)时 generatingConvs 不动,由各自会话收尾事件精确管理。 + * TD-260621-01 per-conv:看门狗超时携带 convId 时,经 setStreaming 仅复位该会话 streaming 态; + * 该会话的 convStates 项由各自会话收尾事件精确管理(on/off 后端均会 emit 收尾态)。 * * 注:currentText/queue 仍是单例(F-09 域,本批不动),per-conv 超时在多会话并发下 * 会清当前累积的 currentText——但 currentText 仅活跃视图写入(useAiEvents handleEvent delta 累积), diff --git a/src/composables/ai/useAiContext.ts b/src/composables/ai/useAiContext.ts index 21ef4af..2ea8c04 100644 --- a/src/composables/ai/useAiContext.ts +++ b/src/composables/ai/useAiContext.ts @@ -7,14 +7,15 @@ //! - 模块级 listener:消费后端 emit 的 ai-chat-event 生命周期事件(serde 默认 PascalCase, //! 对齐 useAiEvents.ts 既有 9 事件): //! AiCompressing → isCompressing=true -//! AiCompressed → isCompressing=false + (成功 toast 由调用方弹,摘要已在事件里) +//! AiManualCompressed → isCompressing=false + (手动 IPC,成功 toast 由调用方弹,摘要已在事件里) +//! AiAutoCompressed → isCompressing=false + (loop 自动压缩,静默仅复位,治 Task#1 不弹 toast) //! AiContextCleared → (刷新提示由调用方弹) //! AiError → isCompressing=false + (错误 toast 由调用方弹) //! //! 设计: //! - 与 useAiEvents.startListener 共存:后者监听 AiChatEvent 联合类型(不含 -//! AiCompressing/AiCompressed/AiContextCleared,因 types.ts 不在本任务白名单), -//! 本模块注册第二个 listen('ai-chat-event') 监听器,专门按 type 字符串分发上述 4 类事件。 +//! AiCompressing/AiManualCompressed|AiAutoCompressed/AiContextCleared,因 types.ts 不在本任务白名单), +//! 本模块注册第二个 listen('ai-chat-event') 监听器,专门按 type 字符串分发上述 5 类事件。 //! 两个监听器都触发同一后端事件,但各取所需类型,Tauri 事件多 listener 是合法的。 //! - toast/刷新副作用经回调注入(composable 无组件上下文,无 toast ref), //! AiChat.vue 调 initContextListener(onCleared/onCompressed/onError) 注入副作用, @@ -32,7 +33,7 @@ import { aiApi } from '@/api' import { state } from '@/stores/ai' import { resolveAiLang } from './aiShared' -/// 压缩中 loading(防重复点击;AiCompressing→true / AiCompressed|AiError→false) +/// 压缩中 loading(防重复点击;AiCompressing→true / AiManualCompressed|AiAutoCompressed|AiError→false) const isCompressing = ref(false) /// 已注册的 ai-chat-event 第二监听器(生命周期事件);幂等 @@ -43,7 +44,8 @@ let _unlistenContext: UnlistenFn | null = null interface ContextCallbacks { /** 清空完成(AiContextCleared):toast + 刷新消息列表 */ onCleared?: (conversationId: string) => void - /** 压缩成功(AiCompressed):toast + 渲染摘要 system 消息 */ + /** 手动压缩成功(AiManualCompressed):toast + 渲染摘要 system 消息。 + * 注:仅 AiManualCompressed 触发;AiAutoCompressed(loop 自动)静默不进此回调(治 Task#1)。 */ onCompressed?: (conversationId: string, summary: string) => void /** 压缩失败 / 错误(AiError with conversation_id):toast */ onError?: (conversationId: string, message: string) => void @@ -76,10 +78,18 @@ async function initContextListener(callbacks: ContextCallbacks = {}): Promise { try { await aiApi.compressContext(convId, resolveAiLang()) // 后端 IPC 同步等到 LLM 压缩完成才 resolve(非 spawn 异步),成功即复位 isCompressing; - // case 'AiCompressed' 事件也复位(双保险,防事件丢失致按钮永久禁用) + // case 'AiManualCompressed' 事件也复位(双保险,防事件丢失致按钮永久禁用) isCompressing.value = false } catch (e) { isCompressing.value = false diff --git a/src/composables/ai/useAiConversations.ts b/src/composables/ai/useAiConversations.ts index 018a7d9..4f4965a 100644 --- a/src/composables/ai/useAiConversations.ts +++ b/src/composables/ai/useAiConversations.ts @@ -10,7 +10,7 @@ import { state } from '@/stores/ai' import { notifyConversationChanged } from './useAiEvents' import { persistUiState } from './useAiPanel' import { setStreaming } from './streamingGuard' -import { nextMsgId } from './aiShared' +import { nextMsgId, getConvState } from './aiShared' import { t } from '@/i18n/i18n-helpers' import type { AiMessage, AiToolCallInfo } from '@/api/types' @@ -101,8 +101,11 @@ export async function switchConversation(id: string) { // 否则残留 stop 按钮(ChatInput.vue:88 v-if=streaming)→ 点击 store.stopChat 传 activeConversationId // 发错会话。对齐 newConversation 复位语义;目标在后台生成时保留 true(stop/流式显示正确)。 // 根治归 F-09 B 路线:streaming 改 per-conv 态。此处为 A 路线过渡补丁。 - // 走 setStreaming guard 收敛写入口(convId=id 联动幂等——has(id) 决定值,add/delete 幂等无副作用)。 - setStreaming(state.generatingConvs.has(id), { convId: id, reason: 'switchConversation-recompute' }) + // 批4 双轨收口:读 getConvState(enum 真相源)派生,替代旧 generatingConvs.has。 + // 桥接语义:has(id)=true 等价于 conv_state∈{generating,stopping,compressed}(非终止三态)。 + const targetCs = getConvState(id) + const targetGen = targetCs === 'generating' || targetCs === 'stopping' || targetCs === 'compressed' + setStreaming(targetGen, { convId: id, reason: 'switchConversation-recompute' }) try { const rawMsgs = typeof detail.messages === 'string' diff --git a/src/composables/ai/useAiEvents.ts b/src/composables/ai/useAiEvents.ts index 4910b49..24bf789 100644 --- a/src/composables/ai/useAiEvents.ts +++ b/src/composables/ai/useAiEvents.ts @@ -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(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()) - -/** 取某 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(各会话独立计时,互不顶替/连累)。 diff --git a/src/composables/ai/useAiStream.ts b/src/composables/ai/useAiStream.ts index dfe97b9..7ce3b65 100644 --- a/src/composables/ai/useAiStream.ts +++ b/src/composables/ai/useAiStream.ts @@ -13,7 +13,7 @@ import { state } from '@/stores/ai' import { t } from '@/i18n/i18n-helpers' -import { nextMsgId } from './aiShared' +import { nextMsgId, getConvState } from './aiShared' import { forceResetStreaming } from './streamingGuard' import { emit } from '@tauri-apps/api/event' @@ -42,9 +42,10 @@ let _legacyWatchdog: ReturnType | null = null * 看门狗超时回调:收尾 streaming 态并补错误消息(走 forceResetStreaming 兜底复位, * 对齐 [[devflow-generating-statemachine]])。 * - * TD-260621-01 per-conv:携带 convId 时,forceResetStreaming(reason, convId) 仅清该 conv 的 - * generatingConvs(经 setStreaming 联动 delete),不再全清误杀并发会话生成态。convId 为空 - * (legacy fallback 路径)时不带 convId,generatingConvs 不动(全局兜底,行为对齐旧版)。 + * TD-260621-01 per-conv:携带 convId 时,forceResetStreaming(reason, convId) 经 setStreaming + * 仅复位该会话 streaming 态。批4 双轨收口后 generatingConvs 已退役,convStates 项由各会话 + * 收尾事件精确管理(on/off 后端均 emit 收尾态),本回调不再触碰生成态集合。convId 为空 + * (legacy fallback 路径)时全局兜底,行为对齐旧版。 */ export function onStreamTimeout(convId?: string) { // 根治守卫(TD-260621-01 遗留):看门狗 reset(doSend 走 legacy _legacyWatchdog)/clear @@ -54,7 +55,12 @@ export function onStreamTimeout(convId?: string) { // 守卫:触发时若该 conv 已不在生成态(已正常收尾),判定为残留 timer,静默清不补错误消息。 // 真卡死(streaming/generating 仍 true)不受影响,仍走原 forceReset + 报错兜底。 const stillGenerating = convId - ? state.generatingConvs.has(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) @@ -78,7 +84,8 @@ export function onStreamTimeout(convId?: string) { } } // 卡死兜底走 guard 的 forceResetStreaming(复位 streaming + 清 currentText/queue + warn 日志)。 - // TD-260621-01:携带 convId 时仅清该 conv 的 generatingConvs(per-conv);不传时全局兜底不动 Set。 + // 批4 双轨收口:generatingConvs 已退役,本兜底仅复位 streaming 态;该会话 convStates 项由 + // 后端收尾事件(或下次活跃事件兜底写)收敛,无需此处手动清。 forceResetStreaming('onStreamTimeout(130s 无数据)', convId) clearStreamWatchdog(convId) // AE-2025-06: 整流超时收尾 → 广播清全部审批超时计时器。 diff --git a/src/composables/ai/useAiWindow.ts b/src/composables/ai/useAiWindow.ts index b188522..b5cdc70 100644 --- a/src/composables/ai/useAiWindow.ts +++ b/src/composables/ai/useAiWindow.ts @@ -10,7 +10,7 @@ import { invoke } from '@tauri-apps/api/core' import { state } from '@/stores/ai' -import { nextMsgId } from './aiShared' +import { nextMsgId, getConvState, convStates } from './aiShared' import { setStreaming } from './streamingGuard' import { persistUiState } from './useAiPanel' @@ -52,18 +52,24 @@ async function detachPanel(convId?: string) { } // 快照当前生成态,供分离窗口接管(保持正在进行的对话) // F-09:用 per-conv key 写入(多会话各自快照互不覆盖) - if (state.streaming && targetConv && state.generatingConvs.has(targetConv)) { - localStorage.setItem(genKey(targetConv), targetConv) - localStorage.setItem(textKey(targetConv), state.currentText) + // 批4 双轨收口:读 getConvState(enum 真相源)派生,替代旧 generatingConvs.has。 + // 桥接语义:has(convId)=true 等价于 conv_state∈{generating,stopping,compressed}(非终止三态)。 + if (state.streaming && targetConv) { + const cs = getConvState(targetConv) + const gen = cs === 'generating' || cs === 'stopping' || cs === 'compressed' + if (gen) { + localStorage.setItem(genKey(targetConv), targetConv) + localStorage.setItem(textKey(targetConv), state.currentText) + } } // 主窗口 state 与分离窗口 state 独立(各自 webview 独立 JS context, // stores/ai.ts 模块级单例仅在同 webview 内共享),清主窗口 state 不影响分离窗口生成态。 // 分离窗口接管生成后,主窗口不应再持该会话生成态残留(防重开面板时 UI 显生成中幽灵/进度条)。 - // F-09:仅从 Set 移除该 conv(其他会话可能仍在生成),不全局清空。 + // F-09:仅从 convStates 移除该 conv(其他会话可能仍在生成),不全局清空。 // watchdog 在主窗口 AiChat 卸载(App.vue v-if detached)→ stopListener → clearStreamWatchdog 时已清, // 此处仅清内存 state 视觉残留;localStorage 快照保留供 resumeInDetached 恢复。 setStreaming(false, { convId: targetConv || null, reason: 'detachPanel-clear-main' }) - if (targetConv) state.generatingConvs.delete(targetConv) + if (targetConv) convStates.delete(targetConv) const sep = targetConv ? `?conv=${encodeURIComponent(targetConv)}` : '' const url = window.location.origin + window.location.pathname + '#/ai-detached' + sep const win = new WebviewWindow(label, { @@ -135,7 +141,7 @@ function cleanupAllDetachedSnapshots(): void { * 1. state.currentText = df-ai-text-${convId}(已生成文本) * 2. push 占位 assistant 气泡(content='') * 3. state.streaming = true - * 4. state.generatingConvs.add(df-ai-gen) + * 4. convStates.set(df-ai-gen, 'generating')(批4 双轨收口:替代旧 generatingConvs.add) * * 幂等:若 state.streaming 已是 true(已恢复过/正在生成),直接返回不重复 push。 * 这对主面板刷新场景至关重要:detachPanel 时主窗口 state 已被清 streaming=false @@ -194,7 +200,9 @@ export async function restoreGeneratingState(opts?: { fromMainPanel?: boolean; c timestamp: Date.now(), }) setStreaming(true, { convId: gen, reason: 'restoreGeneratingState' }) - state.generatingConvs.add(gen) + // 批4 双轨收口:恢复生成中态写 convStates(enum 真相源),替代旧 generatingConvs.add。 + // setStreaming 联动已退役(批4),streaming 单值与 convStates 项并行写无冲突。 + convStates.set(gen, 'generating') return true } diff --git a/src/stores/ai.ts b/src/stores/ai.ts index 365193b..0725460 100644 --- a/src/stores/ai.ts +++ b/src/stores/ai.ts @@ -50,6 +50,8 @@ const MESSAGE_CAP = 200 const PARTS_SIZE_BUDGET = 50 * 1024 * 1024 // 50MB(base64 char 计) import { useAiEvents } from '@/composables/ai/useAiEvents' import { useAiStream } from '@/composables/ai/useAiStream' +// 批4 双轨收口:getConvState 从 aiShared 取(下沉破环,避免 stores/ai↔useAiEvents 反向环)。 +import { getConvState } from '@/composables/ai/aiShared' import { useAiSend, initDrainQueueListener } from '@/composables/ai/useAiSend' import { useAiApproval } from '@/composables/ai/useAiApproval' import { useAiConversations } from '@/composables/ai/useAiConversations' @@ -63,11 +65,9 @@ export const state = reactive({ streaming: false, currentText: '', pendingApprovals: [] as AiToolCallInfo[], - // 正在生成的会话集合(F-09 多会话并发):后端 per-conv 已支持多会话同时生成, - // 前端用 Set 表达"多会话同时生成"态。生成中切走时用于路由,后台事件不污染当前视图。 - // isGenerating(convId)/addGenerating/removeGenerating helper 经 useAiStore 暴露。 - // 单会话场景行为不变(Set 含 0 或 1 元素)。 - generatingConvs: new Set(), + // 批4 双轨收口:generatingConvs(Set,bool 轨)已退役。会话生成态真相归 convStates + // (reactive Map,enum 轨),定义于 composables/ai/aiShared.ts(下沉破环)。 + // 消费方经 isGenerating(convId) helper 或直接 getConvState(convId) 读,不再读 state.xxx。 providers: [] as AiProviderConfig[], activeProvider: null as string | null, panelOpen: true, @@ -189,20 +189,20 @@ const filteredConversations = computed(() => { }) /** - * F-09 多会话并发:生成态 Set helper。 + * F-09 多会话并发:生成态 helper(批4 双轨收口后改为派生)。 * - * Vue 3 reactive 对 Set 的 has/add/delete 均有 proxy 跟踪,在 computed/template - * 内调用 isGenerating(convId) 会建立响应式依赖,Set 变更时自动触发更新。 - * 单会话场景:Set 含 0 或 1 元素,行为与原单值 generatingConvId 等价。 + * 批4 收口:generatingConvs(Set,bool 轨)已退役,生成态真相归 convStates(enum Map,定义于 + * aiShared.ts)。本 helper 改派生自 getConvState:conv_state∈{generating,stopping,compressed} + * (非终止三态)视为生成中,idle/error/null 视为不在生成。桥接语义对齐原 bool 轨 has() 判定。 + * + * Vue 3 reactive 对 Map 的 get 有 proxy 跟踪,在 computed/template 内调用 isGenerating(convId) + * 仍建立响应式依赖,convStates 变更时自动触发更新(等价原 Set 行为)。 + * addGenerating/removeGenerating 写 helper 已删(写收敛到 useAiEvents setConvState 唯一入口)。 */ -function addGenerating(convId: string): void { - state.generatingConvs.add(convId) -} -function removeGenerating(convId: string): void { - state.generatingConvs.delete(convId) -} function isGenerating(convId: string | null | undefined): boolean { - return !!convId && state.generatingConvs.has(convId) + if (!convId) return false + const cs = getConvState(convId) + return cs === 'generating' || cs === 'stopping' || cs === 'compressed' } /** @@ -217,9 +217,8 @@ export function useAiStore() { return { state, filteredConversations, - // F-09: 生成态 Set helper(替代单值 generatingConvId 的读写) - addGenerating, - removeGenerating, + // F-09: 生成态 helper(批4 双轨收口:仅读 isGenerating 派生自 getConvState; + // addGenerating/removeGenerating 写 helper 已删,写收敛到 useAiEvents setConvState) isGenerating, ...useAiEvents(), ...useAiStream(),