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