优化: L2统一状态机完整(ConvState持久化+事件暴露+前端status联合)
1a PerConvState.conv_state持久化(入口/reset/drop写收敛,CONV_STATE_ENABLED门控) 1b AiChatEvent加AiConvStateChanged变体,guard持app_handle在迁移点emit 1c 前端convStates真相源+停止按钮三态(status union)+MaxRoundsCard读conv_state 双轨过渡:enum视图与generating bool并行,off降级可回退
This commit is contained in:
@@ -5,9 +5,10 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use tauri::{AppHandle, Emitter};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
use crate::commands::ai::AiSession;
|
use crate::commands::ai::{AiChatEvent, AiSession};
|
||||||
|
|
||||||
use super::conv_state::{self, ConvState};
|
use super::conv_state::{self, ConvState};
|
||||||
|
|
||||||
@@ -44,10 +45,13 @@ pub(super) struct GeneratingGuard {
|
|||||||
/// L2 状态机视图(CONV_STATE_ENABLED 门控)。None = 开关 off 降级,guard 不持视图。
|
/// L2 状态机视图(CONV_STATE_ENABLED 门控)。None = 开关 off 降级,guard 不持视图。
|
||||||
/// 仅 guard 生命周期内的本地视图,不持久化(批2+ 提升至 PerConvState 字段)。
|
/// 仅 guard 生命周期内的本地视图,不持久化(批2+ 提升至 PerConvState 字段)。
|
||||||
state: Option<ConvState>,
|
state: Option<ConvState>,
|
||||||
|
/// L2 批2 1b:emit AiConvStateChanged 用的 AppHandle(Clone 廉价,run_agentic_loop 入参传入)。
|
||||||
|
/// CONV_STATE_ENABLED 门控:off 时仍持(无副作用),但 new/reset/drop 跳过 emit。
|
||||||
|
app_handle: AppHandle,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GeneratingGuard {
|
impl GeneratingGuard {
|
||||||
pub(super) fn new(session: Arc<Mutex<AiSession>>, conv_id: String) -> Self {
|
pub(super) fn new(session: Arc<Mutex<AiSession>>, conv_id: String, app_handle: AppHandle) -> Self {
|
||||||
// CONV_STATE_ENABLED 门控:off → state=None,guard 不持/不迁 enum(纯旧 bool 行为)。
|
// CONV_STATE_ENABLED 门控:off → state=None,guard 不持/不迁 enum(纯旧 bool 行为)。
|
||||||
// on → state 从 Idle 起,入口即经守卫迁移到 Generating(写收敛:状态机入口校验)。
|
// on → state 从 Idle 起,入口即经守卫迁移到 Generating(写收敛:状态机入口校验)。
|
||||||
let mut state = if conv_state::CONV_STATE_ENABLED {
|
let mut state = if conv_state::CONV_STATE_ENABLED {
|
||||||
@@ -59,7 +63,16 @@ impl GeneratingGuard {
|
|||||||
// Idle → Generating 守卫迁移。正常路径(self-loop 幂等 / 首次进入)合法;理论非法不发生
|
// Idle → Generating 守卫迁移。正常路径(self-loop 幂等 / 首次进入)合法;理论非法不发生
|
||||||
// (新 guard 必 Idle),失败记 warn 不阻断(对齐失败兜底原则)。
|
// (新 guard 必 Idle),失败记 warn 不阻断(对齐失败兜底原则)。
|
||||||
match s.transition_to(ConvState::Generating) {
|
match s.transition_to(ConvState::Generating) {
|
||||||
Ok(new_state) => *s = new_state,
|
Ok(new_state) => {
|
||||||
|
*s = new_state;
|
||||||
|
// L2 批2 1b:本地视图迁移成功后 emit ConvState=Generating 通知前端(同步,失败忽略)。
|
||||||
|
// 入口的持久化迁移(PerConvState.conv_state)由 run_agentic_loop 独立写,
|
||||||
|
// 此处仅反映 guard 视图层状态(双轨过渡,enum 视图与 bool 并行)。
|
||||||
|
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiConvStateChanged {
|
||||||
|
conv_state: new_state,
|
||||||
|
conversation_id: Some(conv_id.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
conv_id = %conv_id,
|
conv_id = %conv_id,
|
||||||
@@ -67,11 +80,11 @@ impl GeneratingGuard {
|
|||||||
"[ai] guard.new ConvState Idle→Generating 非法(状态机视图层,不阻断核心生成)"
|
"[ai] guard.new ConvState Idle→Generating 非法(状态机视图层,不阻断核心生成)"
|
||||||
);
|
);
|
||||||
// 状态机层失败:enum 保留 Idle(不写非法态),核心 generating 仍由调用方 set true。
|
// 状态机层失败:enum 保留 Idle(不写非法态),核心 generating 仍由调用方 set true。
|
||||||
// 即 enum 视图与 bool 暂时不同步,但 enum 仅视图不影响核心复位语义。
|
// 即 enum 视图与 bool 暂时不同步,但 enum 仅视图不影响核心复位语义。不 emit(避免误报非生成态)。
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Self { session, conv_id, done: false, state }
|
Self { session, conv_id, done: false, state, app_handle }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 显式复位 generating=false。emit 前调用保证顺序。幂等。
|
/// 显式复位 generating=false。emit 前调用保证顺序。幂等。
|
||||||
@@ -81,7 +94,27 @@ impl GeneratingGuard {
|
|||||||
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;
|
||||||
session.conv(&self.conv_id).generating = false;
|
let conv = session.conv(&self.conv_id);
|
||||||
|
conv.generating = false;
|
||||||
|
// L2 批2:ConvState 持久化(Generating→Idle 写收敛,CONV_STATE_ENABLED 门控)。
|
||||||
|
// 与本地 state 视图双写(本地保留批1,持久化为读侧/前端;1b 清理本地)。
|
||||||
|
if conv_state::CONV_STATE_ENABLED {
|
||||||
|
match conv.conv_state.transition_to(ConvState::Idle) {
|
||||||
|
Ok(ns) => {
|
||||||
|
conv.conv_state = ns;
|
||||||
|
// L2 批2 1b:持久化层 Idle 收敛成功后 emit 前端(同步,失败忽略)。
|
||||||
|
let _ = self.app_handle.emit("ai-chat-event", AiChatEvent::AiConvStateChanged {
|
||||||
|
conv_state: ns,
|
||||||
|
conversation_id: Some(self.conv_id.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => tracing::warn!(
|
||||||
|
conv_id = %self.conv_id,
|
||||||
|
error = %e,
|
||||||
|
"[ai] guard.reset ConvState→Idle 非法(持久化层,不阻断核心复位)"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
self.done = true;
|
self.done = true;
|
||||||
}
|
}
|
||||||
// 状态机视图同步:Generating → Idle(正常收敛退出)。仅记日志验证迁移合法(批2+ 接入
|
// 状态机视图同步:Generating → Idle(正常收敛退出)。仅记日志验证迁移合法(批2+ 接入
|
||||||
@@ -127,9 +160,24 @@ impl Drop for GeneratingGuard {
|
|||||||
}
|
}
|
||||||
let session = self.session.clone();
|
let session = self.session.clone();
|
||||||
let conv_id = self.conv_id.clone();
|
let conv_id = self.conv_id.clone();
|
||||||
|
// L2 批2 1b:Drop 无 await 上下文,持久化迁移 + emit 在 spawn 内异步完成(对齐核心复位 spawn 路径)。
|
||||||
|
// app_handle Clone 廉价,随 session/conv_id 一起 move 进 async 块。
|
||||||
|
let app_handle = self.app_handle.clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
let mut s = session.lock().await;
|
let mut s = session.lock().await;
|
||||||
s.conv(&conv_id).generating = false;
|
let conv = s.conv(&conv_id);
|
||||||
|
conv.generating = false;
|
||||||
|
// L2 批2:ConvState 持久化(异常退出兜底→Idle,CONV_STATE_ENABLED 门控)。
|
||||||
|
if conv_state::CONV_STATE_ENABLED {
|
||||||
|
if let Ok(ns) = conv.conv_state.transition_to(ConvState::Idle) {
|
||||||
|
conv.conv_state = ns;
|
||||||
|
// 1b:持久化 Idle 收敛后 emit 前端(异常退出兜底也需通知前端退出生成态)。
|
||||||
|
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiConvStateChanged {
|
||||||
|
conv_state: ns,
|
||||||
|
conversation_id: Some(conv_id.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -396,7 +396,8 @@ pub(crate) async fn run_agentic_loop(
|
|||||||
) {
|
) {
|
||||||
// B-260615-09: generating 状态由 RAII guard 收敛复位(正常 exit 显式 reset;panic/异常 Drop 兜底)
|
// B-260615-09: generating 状态由 RAII guard 收敛复位(正常 exit 显式 reset;panic/异常 Drop 兜底)
|
||||||
// F-260616-09 B 批2:guard 持 conv_id,复位改 per-conv.generating(设计 §4.3)。
|
// F-260616-09 B 批2:guard 持 conv_id,复位改 per-conv.generating(设计 §4.3)。
|
||||||
let mut guard = GeneratingGuard::new(session_arc.clone(), conv_id.clone());
|
// L2 批2 1b:guard 持 app_handle,ConvState 迁移后 emit AiConvStateChanged 推前端。
|
||||||
|
let mut guard = GeneratingGuard::new(session_arc.clone(), conv_id.clone(), app_handle.clone());
|
||||||
|
|
||||||
// F-260616-09 B 批2 入口桥接:loop 启动前确保 per_conv 存在(已存在则保留累积,不存在则建)。
|
// F-260616-09 B 批2 入口桥接:loop 启动前确保 per_conv 存在(已存在则保留累积,不存在则建)。
|
||||||
//
|
//
|
||||||
@@ -423,6 +424,18 @@ pub(crate) async fn run_agentic_loop(
|
|||||||
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;
|
conv.generating = true;
|
||||||
|
// L2 批2:ConvState 持久化(Idle→Generating 写收敛,CONV_STATE_ENABLED 门控)。
|
||||||
|
// enum 与 generating bool 双轨(bool 核心复位,enum 供读侧/前端);off 降级跳过。
|
||||||
|
if conv_state::CONV_STATE_ENABLED {
|
||||||
|
match conv.conv_state.transition_to(conv_state::ConvState::Generating) {
|
||||||
|
Ok(ns) => conv.conv_state = ns,
|
||||||
|
Err(e) => tracing::warn!(
|
||||||
|
conv_id = %conv_id,
|
||||||
|
error = %e,
|
||||||
|
"[ai] 入口 ConvState→Generating 非法(状态机持久化层,不阻断核心生成)"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// F-260614-04 / F-260614-04b: 多 Provider 负载均衡池 — 选主 + fallback 候选列表。
|
// F-260614-04 / F-260614-04b: 多 Provider 负载均衡池 — 选主 + fallback 候选列表。
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ pub mod stream_recv;
|
|||||||
pub mod title;
|
pub mod title;
|
||||||
pub mod tool_registry;
|
pub mod tool_registry;
|
||||||
|
|
||||||
|
use agentic::conv_state::ConvState;
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::AtomicBool;
|
use std::sync::atomic::AtomicBool;
|
||||||
@@ -197,6 +199,20 @@ pub enum AiChatEvent {
|
|||||||
options: Vec<String>,
|
options: Vec<String>,
|
||||||
conversation_id: Option<String>,
|
conversation_id: Option<String>,
|
||||||
},
|
},
|
||||||
|
/// L2 统一状态机(批2 1b,2026-06-22):对话生命周期状态变更通知。
|
||||||
|
///
|
||||||
|
/// 后端 `ConvState`(Idle/Generating/Stopping/Error/Compressed)经写收敛
|
||||||
|
/// (入口/guard reset/drop 迁移)后 emit 此事件,前端 status union 据此更新展示态
|
||||||
|
/// (替代散布的 generating bool 派生判断,收敛状态机读侧到事件流)。
|
||||||
|
///
|
||||||
|
/// 渐进/兜底:`CONV_STATE_ENABLED` 开关门控 —— off 时不 emit(前端按旧 bool 行为派生,
|
||||||
|
/// 向后兼容);on 时此事件与既有 generating 行为双轨,前端可按需切换消费源。
|
||||||
|
/// `conv_state` 序列化为 snake_case(idle/generating/stopping/error/compressed),
|
||||||
|
/// 供前端 union discriminator 直接使用。
|
||||||
|
AiConvStateChanged {
|
||||||
|
conv_state: ConvState,
|
||||||
|
conversation_id: Option<String>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -626,6 +642,10 @@ pub struct PerConvState {
|
|||||||
pub messages: ContextManager,
|
pub messages: ContextManager,
|
||||||
/// 是否正在生成
|
/// 是否正在生成
|
||||||
pub generating: bool,
|
pub generating: bool,
|
||||||
|
/// L2 统一状态机:对话生命周期状态(单一真相源,批2 持久化供读侧/前端消费)。
|
||||||
|
/// 写收敛:经 GeneratingGuard/入口 transition_to 守卫迁移,非直接赋值。
|
||||||
|
/// 与 generating bool 双轨过渡(bool 核心复位,enum 视图),批2+ 读侧迁移后 enum 主。
|
||||||
|
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>,
|
||||||
/// 即时停止唤醒(会话级):阻塞在 stream.next() 时 notify_one() 立即唤醒跳出 select!
|
/// 即时停止唤醒(会话级):阻塞在 stream.next() 时 notify_one() 立即唤醒跳出 select!
|
||||||
@@ -676,6 +696,7 @@ impl PerConvState {
|
|||||||
Self {
|
Self {
|
||||||
messages: ContextManager::new(ContextConfig::default()),
|
messages: ContextManager::new(ContextConfig::default()),
|
||||||
generating: false,
|
generating: false,
|
||||||
|
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()),
|
||||||
iteration_used: 0,
|
iteration_used: 0,
|
||||||
|
|||||||
@@ -287,6 +287,21 @@ export interface ModelConfig {
|
|||||||
*/
|
*/
|
||||||
export type AiErrorType = 'auth' | 'network' | 'timeout' | 'provider_config' | 'unknown'
|
export type AiErrorType = 'auth' | 'network' | 'timeout' | 'provider_config' | 'unknown'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L2 统一状态机 — 对话生命周期状态字面量(对齐后端 ConvState serde rename_all=snake_case,
|
||||||
|
* crates/df-ai/.../agentic/conv_state.rs)。5 态:
|
||||||
|
* - idle:空闲,可接新请求
|
||||||
|
* - generating:生成中(含审批挂起期,审批是 loop 内暂停点非独立态)
|
||||||
|
* - stopping:停中(用户已点停止,loop 收尾中,瞬态)
|
||||||
|
* - error:出错(用户可重试回 generating)
|
||||||
|
* - compressed:压缩中(Generating/Idle 期间瞬态派生,压缩完成回原态)
|
||||||
|
*
|
||||||
|
* 后端开关 CONV_STATE_ENABLED 门控:on 时 GeneratingGuard set/reset 同步迁移并发
|
||||||
|
* AiConvStateChanged 事件;off 时纯旧 generating bool 行为(可回退)。前端 conv_state 为
|
||||||
|
* 新增派生源,旧 streaming/generating bool 双轨过渡保留,不强制全替(防回归)。
|
||||||
|
*/
|
||||||
|
export type ConvState = 'idle' | 'generating' | 'stopping' | 'error' | 'compressed'
|
||||||
|
|
||||||
/** AI 聊天事件(后端 emit 的结构,conversation_id 用于多对话流式路由) */
|
/** AI 聊天事件(后端 emit 的结构,conversation_id 用于多对话流式路由) */
|
||||||
export type AiChatEvent = ({
|
export type AiChatEvent = ({
|
||||||
type: 'AiTextDelta'; delta: string
|
type: 'AiTextDelta'; delta: string
|
||||||
@@ -333,6 +348,13 @@ export type AiChatEvent = ({
|
|||||||
// 前端渲染求助卡显 reason + options 按钮供用户选,与 AiApprovalRequired(工具执行前审批)/
|
// 前端渲染求助卡显 reason + options 按钮供用户选,与 AiApprovalRequired(工具执行前审批)/
|
||||||
// AiMaxRoundsReached(达 max 被动截断)语义区分——求助=AI 主动「我搞不定」。
|
// AiMaxRoundsReached(达 max 被动截断)语义区分——求助=AI 主动「我搞不定」。
|
||||||
type: 'AiHelpRequired'; reason: string; context: string; options: string[]
|
type: 'AiHelpRequired'; reason: string; context: string; options: string[]
|
||||||
|
} | {
|
||||||
|
// L2 统一状态机(批2 1b/1c):ConvState 经 AiChatEvent 推前端,供 status union 消费。
|
||||||
|
// conv_state 值为 idle|generating|stopping|error|compressed(snake_case,对齐后端 ConvState serde)。
|
||||||
|
// 后端在 conv_state 变化时(入口→generating / reset·drop→idle)emit;conversation_id 标识
|
||||||
|
// 哪个会话的状态变了(F-09 多会话并发下前端按 convId 路由)。CONV_STATE_ENABLED 门控:off 时
|
||||||
|
// 后端不 emit,前端 conv_state 收不到 → 消费方须回退旧 streaming/generating bool 判断(双轨过渡)。
|
||||||
|
type: 'AiConvStateChanged'; conv_state: ConvState
|
||||||
} | {
|
} | {
|
||||||
// F-260616-07: 流式调用自动重试提示(前端可在错误气泡内显示「重试 n/m」)
|
// F-260616-07: 流式调用自动重试提示(前端可在错误气泡内显示「重试 n/m」)
|
||||||
type: 'AiStreamRetry'; attempt: number; max_attempts: number
|
type: 'AiStreamRetry'; attempt: number; max_attempts: number
|
||||||
|
|||||||
@@ -84,8 +84,26 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- L2 状态机停止按钮三态(批2 1c):conv_state 派生 generating/stopping/error 三态,
|
||||||
|
idle(或 conv_state 未收到回退旧 streaming=false)显发送按钮。双轨过渡不强制全替旧 bool。 -->
|
||||||
<button
|
<button
|
||||||
v-if="store.state.streaming"
|
v-if="stopBtnState === 'stopping'"
|
||||||
|
class="ai-send-btn ai-send-btn--stop ai-send-btn--stopping"
|
||||||
|
:title="$t('aiChat.stopping')"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else-if="stopBtnState === 'retry'"
|
||||||
|
class="ai-send-btn ai-send-btn--stop ai-send-btn--retry"
|
||||||
|
:title="$t('aiChat.stopFailedRetry')"
|
||||||
|
@click="store.stopChat"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else-if="stopBtnState === 'stop'"
|
||||||
class="ai-send-btn ai-send-btn--stop"
|
class="ai-send-btn ai-send-btn--stop"
|
||||||
:title="$t('aiChat.stopGenerating')"
|
:title="$t('aiChat.stopGenerating')"
|
||||||
@click="store.stopChat"
|
@click="store.stopChat"
|
||||||
@@ -119,6 +137,7 @@ import { ref, computed, nextTick, watch } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAiStore } from '../../stores/ai'
|
import { useAiStore } from '../../stores/ai'
|
||||||
import { useProjectStore } from '../../stores/project'
|
import { useProjectStore } from '../../stores/project'
|
||||||
|
import { getConvState } from '../../composables/ai/useAiEvents'
|
||||||
import type { AiMessage, ContentPart, SkillInfo, ProjectRecord, TaskRecord, IdeaRecord, MentionSpan } from '../../api/types'
|
import type { AiMessage, ContentPart, SkillInfo, ProjectRecord, TaskRecord, IdeaRecord, MentionSpan } from '../../api/types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -237,6 +256,28 @@ async function onDrop(e: DragEvent): Promise<void> {
|
|||||||
/** 发送可用:有文本或图片或已选技能(纯技能调用允许空文本) */
|
/** 发送可用:有文本或图片或已选技能(纯技能调用允许空文本) */
|
||||||
const canSend = computed(() => inputText.value.trim().length > 0 || pendingImages.value.length > 0 || !!pendingSkill.value)
|
const canSend = computed(() => inputText.value.trim().length > 0 || pendingImages.value.length > 0 || !!pendingSkill.value)
|
||||||
|
|
||||||
|
// L2 状态机停止按钮三态(批2 1c):从 conv_state 派生,旧 streaming/generating bool 双轨过渡兜底。
|
||||||
|
//
|
||||||
|
// 三态映射(对齐后端 ConvState 语义):
|
||||||
|
// - 'stopping':ConvState=stopping,停止中 → 显"停止中" disabled(防重复点);
|
||||||
|
// - 'stop': ConvState=generating(含审批挂起/压缩派生等活跃态)→ 显停止按钮可点;
|
||||||
|
// - 'retry': ConvState=error,停止失败可重试 → 显重试按钮(再发 stop 信号);
|
||||||
|
// - 'idle': 空闲(或 conv_state 未收到回退旧 bool=false)→ 显发送按钮。
|
||||||
|
//
|
||||||
|
// 兜底:conv_state 未追踪过(getConvState 返回 null,CONV_STATE_ENABLED=off 或老后端)时,
|
||||||
|
// 回退旧 store.state.streaming 判断(generating→显停止,否则 idle)。不强制全替旧 bool,防回归。
|
||||||
|
// 注:旧 streaming 是全局单值(非 per-conv),仅当前会话生成时为 true;F-09 多会话并发下 conv_state
|
||||||
|
// 更精确,但回退路径用 streaming 对单会话场景语义等价。
|
||||||
|
const stopBtnState = computed<'idle' | 'stop' | 'stopping' | 'retry'>(() => {
|
||||||
|
const convId = store.state.activeConversationId
|
||||||
|
const cs = getConvState(convId)
|
||||||
|
if (cs === 'stopping') return 'stopping'
|
||||||
|
if (cs === 'error') return 'retry'
|
||||||
|
if (cs === 'generating' || cs === 'compressed') return 'stop'
|
||||||
|
// conv_state 未收到(idle 或 null)→ 回退旧 streaming bool
|
||||||
|
return store.state.streaming ? 'stop' : 'idle'
|
||||||
|
})
|
||||||
|
|
||||||
// ── 技能 `/` 联想 ──
|
// ── 技能 `/` 联想 ──
|
||||||
const skillOpen = ref(false)
|
const skillOpen = ref(false)
|
||||||
const skillIndex = ref(0)
|
const skillIndex = ref(0)
|
||||||
@@ -912,6 +953,18 @@ defineExpose({
|
|||||||
.ai-send-btn--stop:hover {
|
.ai-send-btn--stop:hover {
|
||||||
background: #d63a3f;
|
background: #d63a3f;
|
||||||
}
|
}
|
||||||
|
/* L2 停止中态:disabled 灰显(防重复点),复用 :disabled 通用规则(opacity 0.4) */
|
||||||
|
.ai-send-btn--stopping {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
/* L2 停止失败可重试态:橙色边框提示(区别于红色停止),hover 加深 */
|
||||||
|
.ai-send-btn--retry {
|
||||||
|
background: #e5484d;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.ai-send-btn--retry:hover {
|
||||||
|
background: #d63a3f;
|
||||||
|
}
|
||||||
.ai-input-hint {
|
.ai-input-hint {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--df-text-dim);
|
color: var(--df-text-dim);
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAiStore } from '../../stores/ai'
|
import { useAiStore } from '../../stores/ai'
|
||||||
import { pendingMaxRounds } from '../../composables/ai/useAiEvents'
|
import { pendingMaxRounds, getConvState } from '../../composables/ai/useAiEvents'
|
||||||
import { aiApi } from '../../api'
|
import { aiApi } from '../../api'
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -47,6 +47,20 @@ const isViewingGenerating = computed(() =>
|
|||||||
store.isGenerating(store.state.activeConversationId),
|
store.isGenerating(store.state.activeConversationId),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// L2 状态机(批2 1c):达 max 可靠弹判断改读 conv_state(generating),诊断痛②看门狗误清 streaming
|
||||||
|
// 致卡片不弹的根解。conv_state 作新增派生源,旧 isGenerating 双轨过渡兜底——conv_state 未收到
|
||||||
|
// 时(CONV_STATE_ENABLED=off 或老后端)回退 isGenerating 判断,不强制全替防回归。
|
||||||
|
// 注:达 max 时后端仍 generating(conv_state=generating,审批挂起期亦是 generating),故读
|
||||||
|
// conv_state===generating 覆盖挂起态;compressed(压缩派生)也视为活跃可弹(达 max 与压缩互斥,
|
||||||
|
// 理论不并存,此处宽放以防边界)。
|
||||||
|
const isViewingGeneratingState = computed(() => {
|
||||||
|
const cs = getConvState(store.state.activeConversationId)
|
||||||
|
if (cs === 'generating' || cs === 'compressed') return true
|
||||||
|
if (cs === 'idle' || cs === 'error' || cs === 'stopping') return false
|
||||||
|
// conv_state 未追踪过(null)→ 回退旧 isGenerating bool 判断
|
||||||
|
return isViewingGenerating.value
|
||||||
|
})
|
||||||
|
|
||||||
// pendingMaxRounds(达 max 事件置的挂起 convId)+ 当前视图正在生成(防切走后误显)双重守卫。
|
// pendingMaxRounds(达 max 事件置的挂起 convId)+ 当前视图正在生成(防切走后误显)双重守卫。
|
||||||
// TD-260621-02 per-conv:pendingMaxRounds 存挂起 convId(string|null),精确比对
|
// TD-260621-02 per-conv:pendingMaxRounds 存挂起 convId(string|null),精确比对
|
||||||
// activeConversationId —— 仅当挂起会话 === 当前展示会话时显操作卡,F-09 并发下不会错显于其他会话。
|
// activeConversationId —— 仅当挂起会话 === 当前展示会话时显操作卡,F-09 并发下不会错显于其他会话。
|
||||||
@@ -56,7 +70,7 @@ const maxRoundsActing = ref(false)
|
|||||||
const showMaxRoundsCard = computed(() =>
|
const showMaxRoundsCard = computed(() =>
|
||||||
!!pendingMaxRounds.value
|
!!pendingMaxRounds.value
|
||||||
&& pendingMaxRounds.value === store.state.activeConversationId
|
&& pendingMaxRounds.value === store.state.activeConversationId
|
||||||
&& isViewingGenerating.value,
|
&& isViewingGeneratingState.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 点继续:调 ai_continue_loop。后端续 max_iterations 轮(iteration 从 0 重计)。
|
/** 点继续:调 ai_continue_loop。后端续 max_iterations 轮(iteration 从 0 重计)。
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
//! - nextMsgId 已下沉到 aiShared(原为本模块导出,useAiStream 亦依赖之构成循环依赖,故抽出)
|
//! - nextMsgId 已下沉到 aiShared(原为本模块导出,useAiStream 亦依赖之构成循环依赖,故抽出)
|
||||||
|
|
||||||
import { listen, emit } from '@tauri-apps/api/event'
|
import { listen, emit } from '@tauri-apps/api/event'
|
||||||
import { ref } from 'vue'
|
import { ref, reactive } 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'
|
||||||
@@ -29,7 +29,7 @@ import { nextMsgId, findToolCall, startApprovalTimer, clearApprovalTimer, clearA
|
|||||||
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 } from '@/api/types'
|
import type { AiChatEvent, AiMessage, AiToolCallInfo, ConvState } from '@/api/types'
|
||||||
|
|
||||||
let _unlistenAiEvent: (() => void) | null = null
|
let _unlistenAiEvent: (() => void) | null = null
|
||||||
let _unlistenConvChanged: (() => void) | null = null
|
let _unlistenConvChanged: (() => void) | null = null
|
||||||
@@ -138,6 +138,36 @@ 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 事件)。
|
||||||
|
//
|
||||||
|
// 模块级 reactive Map(convId → ConvState)直接 import 读(同 pendingMaxRounds/pendingHelp 模式,
|
||||||
|
// 不进 store.state,与生成生命周期语义独立于消息/会话列表)。停止按钮/MaxRoundsCard 读之
|
||||||
|
// 派生三态(generating/stopping/error),旧 streaming/generatingConvs bool 双轨过渡保留——
|
||||||
|
// conv_state 未收到时(CONV_STATE_ENABLED=off 或后端老版)消费方回退旧 bool 判断(兜底)。
|
||||||
|
//
|
||||||
|
// 写收敛:仅本模块 handleEvent 的 AiConvStateChanged case 写(setConvState 辅助),其他地方只读。
|
||||||
|
// 兜底同步:idle/error/compressed 在 case 内同步做旧 generatingConvs 收敛(AiCompleted/AiError 同义),
|
||||||
|
// 保持双轨真相一致——后端 emit 可能与 AiCompleted/AiError 不严格按序到达,任一来源触发收敛均可。
|
||||||
|
export const convStates = reactive(new Map<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() {
|
||||||
emit('ai-conversation-changed', {})
|
emit('ai-conversation-changed', {})
|
||||||
@@ -470,6 +500,34 @@ function handleToolEvent(event: AiChatEvent): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** L2 状态机域:消费 AiConvStateChanged,维护 per-conv conv_state 真相源。
|
||||||
|
*
|
||||||
|
* 非终止态(generating/stopping/compressed):仅写 convStates,不动看门狗/旧 bool
|
||||||
|
* (看门狗由活跃 delta/工具事件重置,这里不干预;旧 bool 由 AiCompleted/AiError 收敛保持双轨一致)。
|
||||||
|
* 终止态(idle/error):
|
||||||
|
* - 写 convStates(idle 删 Map 项让消费方回退旧 bool,与后端 generating=false 语义对齐)
|
||||||
|
* - 同步做旧 generatingConvs 收敛(delete,对齐 AiCompleted/AiError 收尾语义)——
|
||||||
|
* 后端 emit AiConvStateChanged{idle} 与 AiCompleted 可能不严格按序到达,任一先到即收敛,
|
||||||
|
* 双轨真相一致,后到的另一事件幂等(delete 已无项 no-op)。
|
||||||
|
* 返回值:true=已处理(命中 case);false=未命中。 */
|
||||||
|
function handleConvStateEvent(event: AiChatEvent): boolean {
|
||||||
|
if (event.type !== 'AiConvStateChanged') return false
|
||||||
|
const convId = event.conversation_id || state.activeConversationId
|
||||||
|
if (!convId) return true // 无 convId 无法路由,忽略(防误写全局态)
|
||||||
|
const cs = event.conv_state
|
||||||
|
setConvState(convId, cs)
|
||||||
|
// 终止态同步收敛旧 generatingConvs(双轨过渡,与 AiCompleted/AiError 同义)。
|
||||||
|
// compressed 非终止(派生态,压缩完回原态),不收敛。
|
||||||
|
if (cs === 'idle' || cs === 'error') {
|
||||||
|
state.generatingConvs.delete(convId)
|
||||||
|
} else if (cs === 'generating' || cs === 'stopping' || cs === 'compressed') {
|
||||||
|
// 非终止态同步加 generatingConvs(确保旧 bool 真相与 conv_state 一致,
|
||||||
|
// 防 AiConvStateChanged{generating} 先于首个 delta 到达时旧 bool 漏加致停止按钮/卡片错态)。
|
||||||
|
state.generatingConvs.add(convId)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
/** lifecycle 域:整轮收尾(AiCompleted)与异常中断(AiError) */
|
/** lifecycle 域:整轮收尾(AiCompleted)与异常中断(AiError) */
|
||||||
function handleLifecycleEvent(event: AiChatEvent): boolean {
|
function handleLifecycleEvent(event: AiChatEvent): boolean {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
@@ -480,6 +538,10 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
|
|||||||
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 || '')
|
state.generatingConvs.delete(event.conversation_id || '')
|
||||||
|
// L2 双轨过渡:AiCompleted 收尾同步收敛 convStates→idle(删 Map 项)。
|
||||||
|
// 后端 CONV_STATE_ENABLED=on 时 AiConvStateChanged{idle} 已先到此处幂等 no-op;
|
||||||
|
// off 或老后端无 AiConvStateChanged 时此处保证 convStates 不陈旧(消费方回退旧 bool 一致)。
|
||||||
|
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(避免清其他会话的挂起)。
|
||||||
if (pendingMaxRounds.value && pendingMaxRounds.value === (event.conversation_id || '')) {
|
if (pendingMaxRounds.value && pendingMaxRounds.value === (event.conversation_id || '')) {
|
||||||
@@ -544,6 +606,10 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
|
|||||||
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 || '')
|
state.generatingConvs.delete(event.conversation_id || '')
|
||||||
|
// L2 双轨过渡:AiError 收尾将 convStates 显式置 error(保留 error 项,供停止按钮显"重试"态)。
|
||||||
|
// 与 AiConvStateChanged{error} 双写幂等(后端 on 时状态事件已先到;off/老后端时此处保证 Error 态可见)。
|
||||||
|
// error 态在新会话发送(AiTextDelta 等首事件到达→generatingConvs.add)或下次 AiCompleted 时自然收敛。
|
||||||
|
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 异常中断,复位轮次
|
||||||
state.queue = [] // B-32:错误收尾清队列,防生成中入队的消息被静默丢失(drainQueue 仅 AiCompleted 触发)
|
state.queue = [] // B-32:错误收尾清队列,防生成中入队的消息被静默丢失(drainQueue 仅 AiCompleted 触发)
|
||||||
@@ -640,6 +706,10 @@ export function handleEvent(event: AiChatEvent) {
|
|||||||
state.activeConversationId = convId
|
state.activeConversationId = convId
|
||||||
void appSettings.set('df-ai-active-conv', convId)
|
void appSettings.set('df-ai-active-conv', convId)
|
||||||
}
|
}
|
||||||
|
// L2 状态机:AiConvStateChanged 是 per-conv 状态信号(F-09 多会话并发下须追踪所有会话),
|
||||||
|
// 在 isCurrent 守卫**之前**处理,避免切走会话时 conv_state 不更新致停止按钮/MaxRoundsCard 错态。
|
||||||
|
// 该事件非流式活跃信号,不重置看门狗、不进 generatingConvs 通用 add 逻辑(由 conv_state 值决定 add/delete)。
|
||||||
|
if (handleConvStateEvent(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
|
||||||
@@ -654,6 +724,10 @@ export function handleEvent(event: AiChatEvent) {
|
|||||||
// 标记正在生成的对话(完成/错误/求助事件除外——三者后端均已终止 loop)
|
// 标记正在生成的对话(完成/错误/求助事件除外——三者后端均已终止 loop)
|
||||||
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)
|
state.generatingConvs.add(convId)
|
||||||
|
// 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(各会话独立计时,互不顶替/连累)。
|
||||||
|
|||||||
@@ -98,6 +98,9 @@ export default {
|
|||||||
|
|
||||||
// ── Send ──
|
// ── Send ──
|
||||||
stopGenerating: 'Stop generating',
|
stopGenerating: 'Stop generating',
|
||||||
|
// L2 状态机停止按钮三态(ConvState 派生)
|
||||||
|
stopping: 'Stopping',
|
||||||
|
stopFailedRetry: 'Stop failed, click to retry',
|
||||||
|
|
||||||
// ── Input hint ──
|
// ── Input hint ──
|
||||||
inputHint: 'Enter to send · Shift+Enter for newline',
|
inputHint: 'Enter to send · Shift+Enter for newline',
|
||||||
|
|||||||
@@ -98,6 +98,9 @@ export default {
|
|||||||
|
|
||||||
// ── 发送 ──
|
// ── 发送 ──
|
||||||
stopGenerating: '停止生成',
|
stopGenerating: '停止生成',
|
||||||
|
// L2 状态机停止按钮三态(ConvState 派生)
|
||||||
|
stopping: '停止中',
|
||||||
|
stopFailedRetry: '停止失败,点击重试',
|
||||||
|
|
||||||
// ── 输入提示 ──
|
// ── 输入提示 ──
|
||||||
inputHint: 'Enter 发送 · Shift+Enter 换行',
|
inputHint: 'Enter 发送 · Shift+Enter 换行',
|
||||||
|
|||||||
Reference in New Issue
Block a user