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

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

View File

@@ -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)
}

View File

@@ -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<Mutex<AiSession>>,
/// 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::<crate::state::AppState>().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::<crate::state::AppState>().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::<crate::state::AppState>().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::<crate::state::AppState>().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::<crate::state::AppState>().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::<crate::state::AppState>().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);

View File

@@ -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");

View File

@@ -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 {

View File

@@ -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 自行维护。

View File

@@ -199,8 +199,17 @@ pub enum AiChatEvent {
AiContextCleared { conversation_id: Option<String> },
/// F-15 阶段2 手动 LLM 压缩开始:前端展示压缩中态(spinner/禁用按钮防重入)。
AiCompressing { conversation_id: Option<String> },
/// F-15 阶段2 手动 LLM 压缩完成:摘要已插入消息首位,前端可展示摘要 + 移除压缩中态
AiCompressed { conversation_id: Option<String>, 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<String>, summary: String },
/// F-15 阶段2 自动 LLM 压缩完成(agentic loop 触发,治 Task#1):对桌面端静默——
/// 桌面 useAiContext 仅复位 isCompressing(防按钮卡死兜底),不弹 toast 不 switchConversation。
/// 摘要 system 消息已由后端 insert_at,后续 stream 自然带出,无需整会话刷新。
/// miniapp 仍插摘要气泡(对端发生压缩告知用户)。
AiAutoCompressed { conversation_id: Option<String>, 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<AtomicBool>,
@@ -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()),

View File

@@ -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<AppState>`,单元测试难构造。
// 改测 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");
}
}

View File

@@ -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<String> = 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 无条件清空,