From eda2a5f887c5010c825c9c170a0715c054325536 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Mon, 22 Jun 2026 01:03:49 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96:=20aichat=20L2=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E6=9C=BA=E5=8F=8C=E8=BD=A8=E6=B8=85=E7=90=86+?= =?UTF-8?q?=E8=AF=BB=E4=BE=A7=E8=BF=81=E7=A7=BB+L3=E4=BA=8B=E4=BB=B6?= =?UTF-8?q?=E6=80=BB=E7=BA=BFemit=E5=8F=8C=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L2 双轨清理:删 guard 本地 state 冗余字段(ConvState 已落 PerConvState 持久化作读侧真相源)。读侧迁移:ai_is_generating/ai_chat_stop 加 CONV_STATE_ENABLED 门控读 conv_state.is_active()。L3 emit 双写:mod.rs 关键事件 AiCompleted/AiError/AiAgentRound 经 ai_event_bus.publish 双写 (EVENT_BUS_ENABLED 门控,与 app.emit 并存非替换,高频 delta 不双写)。 双轨过渡收尾,可一键回退(CONV_STATE_ENABLED off 回退 bool 真相源)。 --- .../src/commands/ai/agentic/conv_state.rs | 6 +- src-tauri/src/commands/ai/agentic/guard.rs | 135 ++++++++---------- src-tauri/src/commands/ai/agentic/mod.rs | 105 ++++++++++++-- src-tauri/src/commands/ai/commands/chat.rs | 20 ++- 4 files changed, 174 insertions(+), 92 deletions(-) diff --git a/src-tauri/src/commands/ai/agentic/conv_state.rs b/src-tauri/src/commands/ai/agentic/conv_state.rs index 109d021..757c853 100644 --- a/src-tauri/src/commands/ai/agentic/conv_state.rs +++ b/src-tauri/src/commands/ai/agentic/conv_state.rs @@ -157,9 +157,9 @@ impl ConvState { /// 审批挂起(读视图 `SessionState::AwaitingApproval`)期间 `ConvState` 仍是 `Generating`, /// 故本方法返回 true——审批挂起不算「停止生成」。 /// - /// 预留:批2+ 读侧迁移(try_continue / ai_chat_stop / 前端停止三态)消费。本批仅建状态机 + - /// 入口校验,无消费方,标 `allow(dead_code)` 保留(对齐零调用方区分真死 vs 预留原则)。 - #[allow(dead_code)] + /// 预留:批2+ 读侧迁移(try_continue / ai_chat_stop / 前端停止三态)消费。 + /// L2 读侧迁移(2026-06-22):已接入 ai_is_generating / ai_chat_stop / try_continue_agent_loop, + /// 不再标 allow(dead_code)(有真实消费方)。 pub fn is_active(self) -> bool { matches!(self, ConvState::Generating | ConvState::Compressed) } diff --git a/src-tauri/src/commands/ai/agentic/guard.rs b/src-tauri/src/commands/ai/agentic/guard.rs index d28f3c2..9dfd12f 100644 --- a/src-tauri/src/commands/ai/agentic/guard.rs +++ b/src-tauri/src/commands/ai/agentic/guard.rs @@ -26,25 +26,25 @@ use super::conv_state::{self, ConvState}; /// (per-conv 唯一真相源)。顶层 `session.generating` 字段已在批4 删除,reset/Drop 仅写 per_conv; /// IPC(ai_is_generating/ai_chat_send 等)亦改读 per_conv,无需双写桥接。 /// -/// L2 统一状态机(渐进第一步,2026-06-21):guard 内嵌 `ConvState` 作**写收敛视图层**。 +/// L2 统一状态机收尾(2026-06-22):本地 `state: Option` 视图层冗余字段已删除。 /// -/// - `new` 时按 `conv_state::CONV_STATE_ENABLED` 门控,把内部 `state` 从 `Idle` 经守卫迁移到 -/// `Generating`(非法转换记 warn 不阻断,对齐「机制优先,失败兜底」原则)。 -/// - `reset` / `drop` 收尾时同步迁移 `Generating → Idle`(状态机视图与 `generating` bool 双轨, -/// bool 仍是核心真相源,enum 仅视图)。off 降级:guard 不持/不迁 enum,纯旧 bool 行为(可回退)。 +/// 背景:批1 曾在 guard 内嵌 `state` 作「写收敛视图层」(双轨过渡),批2 已把 `ConvState` 落到 +/// `PerConvState.conv_state` 字段持久化(读侧真相源)。批2 同时在 `reset`/`drop` 内读 +/// `conv.conv_state` 持久化字段做迁移 + emit AiConvStateChanged——故本地 `state` 成纯冗余 +/// (双源,且本地 state 在 Drop 无 await 上下文无法持久化,语义本就不完整)。 /// -/// **渐进取舍(为什么 enum 放 guard 而非 PerConvState 字段)**:guard 是 loop 生命周期的天然 -/// 边界(set/reset/Drop 成对),把 enum 绑 guard 即覆盖全部生成态进入/退出点,无需改 PerConvState -/// 字段(改字段会牵动所有读写点,属批2+ 范围)。本批 enum 仅作 guard 内部视图,不对外持久化; -/// 批2+ 把 enum 提升到 PerConvState.conv_state 字段后,guard 仍可保留作写收敛入口(枚举源切换)。 +/// 收尾删除后: +/// - `new`:读 `conv.conv_state` 持久化字段,若 Idle→Generating 迁移成功则 emit 通知前端 +/// (入口 `run_agentic_loop` 已独立写持久化迁移但**不 emit**,故此处补 emit 防前端漏收生成态)。 +/// - `reset`/`drop`:迁移 + emit 均读 `conv.conv_state` 持久化字段(批2 已落地,本批仅删冗余本地块)。 +/// - `disarm`:不动 ConvState(审批等待是 Generating 内的暂停点)。 +/// +/// 开关 `CONV_STATE_ENABLED`(默认 on)门控:off 时跳过 enum 迁移与 emit,纯旧 bool 行为(可回退)。 pub(super) struct GeneratingGuard { session: Arc>, /// guard 所属会话(loop 启动时快照的 conv_id,来自 run_agentic_loop 入参)。 conv_id: String, done: bool, - /// L2 状态机视图(CONV_STATE_ENABLED 门控)。None = 开关 off 降级,guard 不持视图。 - /// 仅 guard 生命周期内的本地视图,不持久化(批2+ 提升至 PerConvState 字段)。 - state: Option, /// L2 批2 1b:emit AiConvStateChanged 用的 AppHandle(Clone 廉价,run_agentic_loop 入参传入)。 /// CONV_STATE_ENABLED 门控:off 时仍持(无副作用),但 new/reset/drop 跳过 emit。 app_handle: AppHandle, @@ -52,52 +52,56 @@ pub(super) struct GeneratingGuard { impl GeneratingGuard { pub(super) fn new(session: Arc>, conv_id: String, app_handle: AppHandle) -> Self { - // CONV_STATE_ENABLED 门控:off → state=None,guard 不持/不迁 enum(纯旧 bool 行为)。 - // on → state 从 Idle 起,入口即经守卫迁移到 Generating(写收敛:状态机入口校验)。 - let mut state = if conv_state::CONV_STATE_ENABLED { - Some(ConvState::Idle) - } else { - None - }; - if let Some(s) = state.as_mut() { - // Idle → Generating 守卫迁移。正常路径(self-loop 幂等 / 首次进入)合法;理论非法不发生 - // (新 guard 必 Idle),失败记 warn 不阻断(对齐失败兜底原则)。 - match s.transition_to(ConvState::Generating) { - Ok(new_state) => { - *s = new_state; - // L2 批2 1b:本地视图迁移成功后 emit ConvState=Generating 通知前端(同步,失败忽略)。 - // 入口的持久化迁移(PerConvState.conv_state)由 run_agentic_loop 独立写, - // 此处仅反映 guard 视图层状态(双轨过渡,enum 视图与 bool 并行)。 - let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiConvStateChanged { - conv_state: new_state, - conversation_id: Some(conv_id.clone()), - }); - } - Err(e) => { - tracing::warn!( - conv_id = %conv_id, - error = %e, - "[ai] guard.new ConvState Idle→Generating 非法(状态机视图层,不阻断核心生成)" - ); - // 状态机层失败:enum 保留 Idle(不写非法态),核心 generating 仍由调用方 set true。 - // 即 enum 视图与 bool 暂时不同步,但 enum 仅视图不影响核心复位语义。不 emit(避免误报非生成态)。 - } - } + // L2 收尾:入口 `run_agentic_loop` 已独立完成 Idle→Generating 的持久化迁移(写 conv.conv_state), + // 但**不 emit** AiConvStateChanged。此处补 emit 通知前端进入生成态——读 conv.conv_state + // 持久化字段(真相源)做迁移校验,迁移成功则 emit,非法记 warn 不阻断。 + // + // 注: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; + } + } + }; + // 持久化层迁移成功后 emit 前端(同步,失败忽略)。锁已随作用域 drop,可安全 emit。 + let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiConvStateChanged { + conv_state: new_state, + conversation_id: Some(cid), + }); + }); } - Self { session, conv_id, done: false, state, app_handle } + Self { session, conv_id, done: false, app_handle } } /// 显式复位 generating=false。emit 前调用保证顺序。幂等。 /// /// 仅写 per_conv.conv_id.generating(唯一真相源)。 - /// L2:同步迁移 `ConvState → Idle`(状态机视图,CONV_STATE_ENABLED 门控)。 + /// L2:同步迁移 `conv.conv_state → Idle`(持久化层,CONV_STATE_ENABLED 门控)。 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 门控)。 - // 与本地 state 视图双写(本地保留批1,持久化为读侧/前端;1b 清理本地)。 if conv_state::CONV_STATE_ENABLED { match conv.conv_state.transition_to(ConvState::Idle) { Ok(ns) => { @@ -117,19 +121,6 @@ impl GeneratingGuard { } self.done = true; } - // 状态机视图同步:Generating → Idle(正常收敛退出)。仅记日志验证迁移合法(批2+ 接入 - // PerConvState.conv_state 字段后此处写字段持久化)。done=true 后再次 reset 幂等,enum 已 Idle - // 自环通过。开关 off → state=None 跳过(纯旧 bool 行为)。 - if let Some(s) = self.state.as_mut() { - match s.transition_to(ConvState::Idle) { - Ok(new_state) => *s = new_state, - Err(e) => tracing::warn!( - conv_id = %self.conv_id, - error = %e, - "[ai] guard.reset ConvState →Idle 非法(状态机视图层,不阻断核心复位)" - ), - } - } } /// 解除 Drop 兜底复位但不复位 generating。审批等待 return 路径调用: @@ -145,19 +136,10 @@ impl GeneratingGuard { impl Drop for GeneratingGuard { fn drop(&mut self) { - // 状态机视图同步(panic/异常退出路径):未 done 即异常退出,enum 应从 Generating→Idle 收敛。 - // 仅记日志(无 await 上下文,不写持久化——批2+ 接入字段后此处可写)。 + // 未 done 即异常退出(panic/未走正常 return):核心 generating 经 spawn 复位, + // ConvState 持久化迁移 + emit 也在同一 spawn 内异步完成(无 await 上下文,对齐核心复位路径)。 // 开关 off / done=true(正常 reset/disarm 已走) → 跳过。 if !self.done { - if let Some(s) = self.state.as_mut() { - if let Err(e) = s.transition_to(ConvState::Idle) { - tracing::warn!( - conv_id = %self.conv_id, - error = %e, - "[ai] guard.drop ConvState →Idle 非法(异常退出兜底,核心 generating 仍 spawn 复位)" - ); - } - } let session = self.session.clone(); let conv_id = self.conv_id.clone(); // L2 批2 1b:Drop 无 await 上下文,持久化迁移 + emit 在 spawn 内异步完成(对齐核心复位 spawn 路径)。 @@ -184,21 +166,20 @@ impl Drop for GeneratingGuard { } // ============================================================ -// 单元测试(guard 内嵌 ConvState 视图层) +// 单元测试(guard ConvState 迁移语义,纯逻辑路径验证) // ============================================================ #[cfg(test)] mod tests { use super::*; - /// guard.new 默认迁移 Idle→Generating,内部视图应为 Generating(CONV_STATE_ENABLED on)。 + /// guard.new 路径:持久化层 Idle→Generating 迁移(读 conv.conv_state)。 /// - /// 注:不构造真实 AiSession(lock/Mutex 建会话状态成本高),仅校验 state 字段的迁移语义—— - /// new 内部 transition_to 是纯函数,直接断言逻辑路径。此处用相同的纯函数路径验证 guard 语义。 + /// 注:不构造真实 AiSession(lock/Mutex 建会话状态成本高),仅校验迁移语义—— + // guard 内部 transition_to 是纯函数,直接断言逻辑路径。 #[test] fn test_guard_new_migrates_to_generating() { - // 模拟 guard.new 内部状态机迁移路径(纯逻辑,无 session): - // CONV_STATE_ENABLED on → Idle → Generating。 + // CONV_STATE_ENABLED on → Idle → Generating(读侧持久化字段迁移路径)。 assert!(conv_state::CONV_STATE_ENABLED, "开关默认 on,测试假设"); let mut s = ConvState::Idle; s = s.transition_to(ConvState::Generating).unwrap(); @@ -216,7 +197,7 @@ mod tests { /// 异常 Drop 路径同样 Generating→Idle(兜底),验证迁移合法。 #[test] fn test_guard_drop_path_migrates_to_idle() { - // guard.drop 异常路径(!done)迁移 Generating→Idle。 + // guard.drop 异常路径(!done)迁移 Generating→Idle(读持久化字段)。 let mut s = ConvState::Generating; s = s.transition_to(ConvState::Idle).unwrap(); assert_eq!(s, ConvState::Idle); diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index ed7d1b4..28f344b 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use std::sync::atomic::Ordering; -use tauri::{AppHandle, Emitter}; +use tauri::{AppHandle, Emitter, Manager}; use tokio::sync::Mutex; use df_ai::ai_tools::AiToolRegistry; @@ -39,6 +39,8 @@ use df_storage::models::AiProviderRecord; use crate::state::{AppState, LlmConcurrency}; +use crate::commands::ai::event_bus::AiBusEvent; + use super::audit::process_tool_calls; use super::compress::compress_via_llm; use super::conversation::{save_conversation, TokenAccumulator}; @@ -514,11 +516,17 @@ pub(crate) async fn run_agentic_loop( Ok(Err(ProviderResolveError::EnsureKeyFailed(msg))) => { guard.reset().await; let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError { - error: msg, + error: msg.clone(), // ensure_resolved_key 失败 = key 缺失/钥匙串损坏,归 Auth error_type: Some(ErrorType::Auth), conversation_id: Some(conv_id.clone()), }); + // L3 emit 双写(2026-06-22):关键 AiError publish 到事件总线,供跨模块订阅。 + // EVENT_BUS_ENABLED 门控在 publish 内部(false 静默丢弃),无消费者时空转不报错。 + let _ = app_handle.state::().ai_event_bus.publish(AiBusEvent::Error { + error: msg, + conversation_id: Some(conv_id.clone()), + }); return; } Err(_elapsed) => { @@ -526,11 +534,17 @@ pub(crate) async fn run_agentic_loop( // 走 AiError 分支复位 generating,对齐 ensure_resolved_key 失败处理口径。 guard.reset().await; tracing::error!(conv_id = %conv_id, "[ai] provider 解析超时(30s),可能 DB/keyring 卡死"); + let err_msg = "Provider 解析超时(30s),请检查数据库/钥匙串状态后重试".to_string(); let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError { - error: "Provider 解析超时(30s),请检查数据库/钥匙串状态后重试".to_string(), + error: err_msg.clone(), error_type: Some(ErrorType::Unknown), conversation_id: Some(conv_id.clone()), }); + // L3 emit 双写:超时 AiError publish 到事件总线(同上,门控在 publish 内)。 + let _ = app_handle.state::().ai_event_bus.publish(AiBusEvent::Error { + error: err_msg, + conversation_id: Some(conv_id.clone()), + }); return; } }; @@ -698,6 +712,13 @@ pub(crate) async fn run_agentic_loop( guard.reset().await; // generating 复位后再 emit Completed:保证前端收事件时后端已可接下一条(发送队列续发不被"正在生成中"拒绝) let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()) }); + // L3 emit 双写:入口 stop 的 AiCompleted publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。 + let _ = app_handle.state::().ai_event_bus.publish(AiBusEvent::Completed { + total_tokens: usage.total_tokens, + prompt_tokens: tokens.prompt(), + completion_tokens: tokens.completion(), + conversation_id: Some(conv_id.clone()), + }); return; } @@ -779,8 +800,14 @@ pub(crate) async fn run_agentic_loop( // 新一轮通知前端(第二轮起),前端需新建 assistant 消息 if iteration > 0 { + let round_n = (iteration + 1) as u32; let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiAgentRound { - round: (iteration + 1) as u32, + round: round_n, + conversation_id: Some(conv_id.clone()), + }); + // L3 emit 双写:AiAgentRound publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。 + let _ = app_handle.state::().ai_event_bus.publish(AiBusEvent::Round { + round: round_n, conversation_id: Some(conv_id.clone()), }); } @@ -1142,10 +1169,15 @@ pub(crate) async fn run_agentic_loop( // UX-260618-15: stream_llm 不再 emit AiError,此处统一 emit 最终错误气泡(单气泡聚合)。 guard.reset().await; let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError { - error, + error: error.clone(), error_type: Some(ErrorType::Network), conversation_id: Some(conv_id.clone()), }); + // L3 emit 双写:Fatal AiError publish 到事件总线(门控在 publish 内)。 + let _ = app_handle.state::().ai_event_bus.publish(AiBusEvent::Error { + error, + conversation_id: Some(conv_id.clone()), + }); return; } } @@ -1163,12 +1195,18 @@ pub(crate) async fn run_agentic_loop( "[ai] 全 provider 候选流前失败重试耗尽,放弃本轮", ); guard.reset().await; + let err_msg = last_exhausted_error + .unwrap_or_else(|| "AI 调用失败:所有候选 provider 重试耗尽".to_string()); let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError { - error: last_exhausted_error - .unwrap_or_else(|| "AI 调用失败:所有候选 provider 重试耗尽".to_string()), + error: err_msg.clone(), error_type: Some(ErrorType::Network), conversation_id: Some(conv_id.clone()), }); + // L3 emit 双写:全 candidate 耗尽 AiError publish 到事件总线(门控在 publish 内)。 + let _ = app_handle.state::().ai_event_bus.publish(AiBusEvent::Error { + error: err_msg, + conversation_id: Some(conv_id.clone()), + }); return; } } @@ -1223,6 +1261,13 @@ pub(crate) async fn run_agentic_loop( conversation_id: Some(conv_id.clone()), incomplete: Some(true), }); + // L3 emit 双写:MidStream 保文 AiCompleted publish 到事件总线(门控在 publish 内)。 + let _ = app_handle.state::().ai_event_bus.publish(AiBusEvent::Completed { + total_tokens: usage.total_tokens, + prompt_tokens: tokens.prompt(), + completion_tokens: tokens.completion(), + conversation_id: Some(conv_id.clone()), + }); return; } // F-260616-09 B 批5: global/per_conv permit 已上移 loop 入口(L520-521)整 loop 持有, @@ -1283,6 +1328,13 @@ pub(crate) async fn run_agentic_loop( guard.reset().await; // generating 复位后再 emit Completed:保证前端收事件时后端已可接下一条(发送队列续发不被"正在生成中"拒绝) let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()) }); + // L3 emit 双写:stream 后 stop 的 AiCompleted publish 到事件总线(门控在 publish 内)。 + let _ = app_handle.state::().ai_event_bus.publish(AiBusEvent::Completed { + total_tokens: usage.total_tokens, + prompt_tokens: tokens.prompt(), + completion_tokens: tokens.completion(), + conversation_id: Some(conv_id.clone()), + }); return; } @@ -1470,6 +1522,14 @@ pub(crate) async fn run_agentic_loop( guard.reset().await; // generating 复位后再 emit Completed:落库/标题/提炼已在后台,前端立即感知完成 let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage_total, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()) }); + // L3 emit 双写:正常完成 AiCompleted publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内, + // 无消费者空转留批3 真实消费者接入)。AiTextDelta/AiToolCall* 高频事件不双写(无消费者空转)。 + let _ = app_handle.state::().ai_event_bus.publish(AiBusEvent::Completed { + total_tokens: usage_total, + prompt_tokens: tokens.prompt(), + completion_tokens: tokens.completion(), + conversation_id: Some(conv_id.clone()), + }); } /// 检查是否所有待审批已处理,如果是则恢复 agentic 循环 @@ -1526,7 +1586,15 @@ 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 不存在则各字段默认值)。 - let is_generating = conv.map(|c| c.generating).unwrap_or(false); + // 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); let agent_language = conv.and_then(|c| c.agent_language.clone()); let model_override = conv.and_then(|c| c.model_override.clone()); ContinueSnapshot { @@ -1580,11 +1648,16 @@ pub(crate) async fn try_continue_agent_loop( drop(session); tracing::warn!(conv_id = %conv_id, error = %e, "[ai] try_continue 失败:无可用 provider"); let _ = app.emit("ai-chat-event", AiChatEvent::AiError { - error: e, + error: e.clone(), // 无可用 provider(配置丢失/全删):用户需在 Settings 设 provider,归 ProviderConfig error_type: Some(ErrorType::ProviderConfig), conversation_id: Some(conv_id.to_string()), }); + // L3 emit 双写:try_continue provider-Err AiError publish 到事件总线(门控在 publish 内)。 + let _ = app.state::().ai_event_bus.publish(AiBusEvent::Error { + error: e, + conversation_id: Some(conv_id.to_string()), + }); return; } }; @@ -1620,9 +1693,16 @@ 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。 let still_generating = { let session = state.ai_session.lock().await; - session.conv_read(conv_id).map(|c| c.generating).unwrap_or(false) + session.conv_read(conv_id).map(|c| { + if conv_state::CONV_STATE_ENABLED { + c.conv_state.is_active() + } else { + c.generating + } + }).unwrap_or(false) }; if !still_generating { tracing::info!(conv_id = %conv_id, "[ai] try_continue 终止:spawn 前重检 generating 已被 stop 复位,补发 AiCompleted"); @@ -1642,6 +1722,11 @@ pub(crate) async fn try_continue_agent_loop( round: 0, conversation_id: Some(conv_id_owned.clone()), }); + // L3 emit 双写:try_continue 续跑 AiAgentRound publish 到事件总线(门控在 publish 内)。 + let _ = app.state::().ai_event_bus.publish(AiBusEvent::Round { + round: 0, + conversation_id: Some(conv_id_owned.clone()), + }); tauri::async_runtime::spawn(async move { run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id_owned, knowledge_config, llm_concurrency, max_iterations, max_retries, start_iteration, model_override).await; diff --git a/src-tauri/src/commands/ai/commands/chat.rs b/src-tauri/src/commands/ai/commands/chat.rs index 1ed217f..38f7bb0 100644 --- a/src-tauri/src/commands/ai/commands/chat.rs +++ b/src-tauri/src/commands/ai/commands/chat.rs @@ -282,7 +282,15 @@ pub async fn ai_is_generating( // 无目标 conv 且无 active:无任何 conv 在跑,返 false(批4 顶层 generating 已退役)。 return Ok(false); } - Ok(session.conv_read(&target).map(|c| c.generating).unwrap_or(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)) } /// 发送消息并获取流式 AI 响应 @@ -1438,7 +1446,15 @@ pub async fn ai_chat_stop( return Ok(()); } // 仅查目标 conv 的 generating(真并发下各 conv 独立)。 - let is_gen = session.conv_read(&target).map(|c| c.generating).unwrap_or(false); + // 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); if !is_gen { return Ok(()); }