重构: 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

@@ -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()),