重构: 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:
@@ -339,9 +339,11 @@ export type AiErrorType = 'auth' | 'network' | 'timeout' | 'provider_config' | '
|
||||
* - error:出错(用户可重试回 generating)
|
||||
* - compressed:压缩中(Generating/Idle 期间瞬态派生,压缩完成回原态)
|
||||
*
|
||||
* 后端开关 CONV_STATE_ENABLED 门控:on 时 GeneratingGuard set/reset 同步迁移并发
|
||||
* AiConvStateChanged 事件;off 时纯旧 generating bool 行为(可回退)。前端 conv_state 为
|
||||
* 新增派生源,旧 streaming/generating bool 双轨过渡保留,不强制全替(防回归)。
|
||||
* 批4 双轨收口后(2026-06-25):convStates(enum Map)是「会话是否生成中」的唯一真相源,
|
||||
* 旧 generatingConvs bool 轨已退役。CONV_STATE_ENABLED=off 或老后端不发 AiConvStateChanged 时,
|
||||
* useAiEvents.handleEvent 活跃事件兜底写 setConvState('generating') 作为 enum 写入口
|
||||
* (替代旧 bool 轨 add),消费方不再回退 bool 判断。streaming 单值是另一条过渡态
|
||||
* (F-09 per-conv streaming 收口前保留),不在本状态机口径内。
|
||||
*/
|
||||
export type ConvState = 'idle' | 'generating' | 'stopping' | 'error' | 'compressed'
|
||||
|
||||
@@ -400,8 +402,9 @@ export type AiChatEvent = ({
|
||||
// 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 判断(双轨过渡)。
|
||||
// 哪个会话的状态变了(F-09 多会话并发下前端按 convId 路由)。批4 双轨收口后:CONV_STATE_ENABLED=off
|
||||
// 或老后端不发本事件时,useAiEvents.handleEvent 活跃事件兜底写 setConvState('generating') 作为
|
||||
// enum 写入口(替代旧 bool 轨 add),消费方不再回退 streaming/generating bool 判断。
|
||||
type: 'AiConvStateChanged'; conv_state: ConvState
|
||||
} | {
|
||||
// F-260616-07: 流式调用自动重试提示(前端可在错误气泡内显示「重试 n/m」)
|
||||
|
||||
@@ -597,6 +597,8 @@ async function initContextEventListeners(): Promise<void> {
|
||||
void store.switchConversation(store.state.activeConversationId)
|
||||
}
|
||||
},
|
||||
// 治 Task#1:仅手动压缩触发(useAiContext 的 AiManualCompressed case 调此回调);
|
||||
// 自动压缩(AiAutoCompressed)静默不进此回调,避免每次发送误弹 toast + 误刷新打断流。
|
||||
onCompressed: (_convId, _summary) => {
|
||||
showToast(t('aiChat.compressSuccess'), 'info')
|
||||
// 刷新对话(历史消息落库标 compressed + system 摘要消息回填到视图)
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
//! - 审批计时器(startApprovalTimer/clearApprovalTimer/clearAllApprovalTimers):
|
||||
//! events 模块触发(start/clear/allClear),send 模块也需导出给组件,故下沉到共享层
|
||||
|
||||
import { reactive } from 'vue'
|
||||
import { aiApi } from '@/api'
|
||||
import { state } from '@/stores/ai'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import type { AiToolCallInfo } from '@/api/types'
|
||||
import type { AiToolCallInfo, ConvState } from '@/api/types'
|
||||
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
@@ -124,3 +125,48 @@ export function clearAllApprovalTimers(): void {
|
||||
for (const timer of _approvalTimers.values()) clearTimeout(timer)
|
||||
_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 错误/窗口崩溃恢复)跳过复位 →
|
||||
//! streaming 永久 true 卡死输入框(stop 按钮常驻、新消息入队不发)。
|
||||
//!
|
||||
//! 本模块把写侧收敛到单一 `setStreaming` 入口,做三件事:
|
||||
//! 本模块把写侧收敛到单一 `setStreaming` 入口,做两件事:
|
||||
//! 1. 合法性观测:`true→true` / `false→false` 幂等场景记 debug 日志(不拒绝——
|
||||
//! resetStreamWatchdog 等副作用场景需合法重入),异常模式(如 false 时仍在跑看门狗)
|
||||
//! 记 warn 日志供排查卡死。
|
||||
//! 2. 联动 generatingConvs:value=true 且带 convId → add(convId);value=false 且带 convId
|
||||
//! → delete(convId)。不带 convId 的复位(全局兜底)不动 generatingConvs,因其由
|
||||
//! AiCompleted/AiError 按 conversation_id 精确管理(避免误删并发会话生成态)。
|
||||
//! 3. feature flag `df-ai-generating-statemachine`:关时 guard 退化为直接赋值,
|
||||
//! 不校验不联动,回退原散布语义(灰度/回退开关)。
|
||||
//! 2. 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 价值在集中入口与日志而非拦截。
|
||||
|
||||
import { state } from '@/stores/ai'
|
||||
@@ -29,8 +31,7 @@ const appSettings = useAppSettingsStore()
|
||||
/// guard 是否启用(读 appSettings,默认开=true)。
|
||||
///
|
||||
/// 默认开的理由:本 guard 是收敛性改造(行为等价 + 加可观测),非行为变更,
|
||||
/// 默认开启直接收效;flag 仅作紧急回退通道(若发现联动 generatingConvs 引入回归,
|
||||
/// 用户可在设置里关掉回退原散布语义)。
|
||||
/// 默认开启直接收效;flag 仅作紧急回退通道(若发现回归,用户可在设置里关掉回退原散布语义)。
|
||||
function isGuardEnabled(): boolean {
|
||||
return appSettings.get<boolean>(STREAMING_GUARD_FLAG, true)
|
||||
}
|
||||
@@ -38,14 +39,13 @@ function isGuardEnabled(): boolean {
|
||||
/**
|
||||
* streaming 写收敛入口 —— 替代散布的 `state.streaming = true/false`(原 14 处,现全收敛至此)。
|
||||
* @param value 目标流式态
|
||||
* @param opts.convId 关联会话 id。value=true 时 add 进 generatingConvs;
|
||||
* value=false 时 delete 出 generatingConvs。省略则不动 generatingConvs
|
||||
* (全局兜底复位场景,generatingConvs 由 AiCompleted/AiError 精确管理)。
|
||||
* @param opts.convId 关联会话 id(批4 收口后仅用于日志可观测,不再联动 generatingConvs;
|
||||
* 生成态真相归 convStates,由 useAiEvents 兜底写 setConvState 标记)。
|
||||
* @param opts.reason 调用方标注(日志可观测,如 'AiCompleted'/'stopChat'/'onStreamTimeout')。
|
||||
*
|
||||
* 行为:
|
||||
* - flag 关 → 直接 `state.streaming = value`(回退散布语义,不联动不校验)。
|
||||
* - flag 开 → 幂等检测(value===当前值记 debug)、联动 generatingConvs、写日志。
|
||||
* - flag 关 → 直接 `state.streaming = value`(回退散布语义,不校验)。
|
||||
* - flag 开 → 幂等检测(value===当前值记 debug)、写日志。
|
||||
*/
|
||||
export function setStreaming(
|
||||
value: boolean,
|
||||
@@ -73,18 +73,8 @@ export function setStreaming(
|
||||
)
|
||||
}
|
||||
|
||||
// 联动 generatingConvs:仅在有 convId 时联动。
|
||||
// value=true:convId 入 Set(标记该会话生成中,侧栏/分离窗口据此显示)。
|
||||
// value=false:convId 出 Set(该会话收尾)。无 convId 的全局复位不动 Set
|
||||
// (并发会话生成态由各自 AiCompleted/AiError 精确 delete 管理,全局清会误杀)。
|
||||
if (convId) {
|
||||
if (value) {
|
||||
state.generatingConvs.add(convId)
|
||||
} else {
|
||||
state.generatingConvs.delete(convId)
|
||||
}
|
||||
}
|
||||
|
||||
// 批4 双轨收口:generating 态真相归 convStates(enum),本 guard 不再联动 generatingConvs
|
||||
// (bool 轨已退役)。streaming 单值是另一条过渡态(F-09 per-conv streaming 收口前保留)。
|
||||
state.streaming = value
|
||||
}
|
||||
|
||||
@@ -94,9 +84,8 @@ export function setStreaming(
|
||||
* 与 setStreaming(false) 区别:本函数额外清 currentText + queue,
|
||||
* 专用于看门狗超时 / 前端异常恢复等「必须彻底清态」场景。
|
||||
*
|
||||
* TD-260621-01 per-conv:看门狗超时携带 convId 时,仅清该 conv 的 generatingConvs
|
||||
* (经 setStreaming 联动 delete),不再全清误杀并发会话生成态。不带 convId(全局兜底,
|
||||
* 如 stopListener 卸载场景)时 generatingConvs 不动,由各自会话收尾事件精确管理。
|
||||
* TD-260621-01 per-conv:看门狗超时携带 convId 时,经 setStreaming 仅复位该会话 streaming 态;
|
||||
* 该会话的 convStates 项由各自会话收尾事件精确管理(on/off 后端均会 emit 收尾态)。
|
||||
*
|
||||
* 注:currentText/queue 仍是单例(F-09 域,本批不动),per-conv 超时在多会话并发下
|
||||
* 会清当前累积的 currentText——但 currentText 仅活跃视图写入(useAiEvents handleEvent delta 累积),
|
||||
|
||||
@@ -7,14 +7,15 @@
|
||||
//! - 模块级 listener:消费后端 emit 的 ai-chat-event 生命周期事件(serde 默认 PascalCase,
|
||||
//! 对齐 useAiEvents.ts 既有 9 事件):
|
||||
//! AiCompressing → isCompressing=true
|
||||
//! AiCompressed → isCompressing=false + (成功 toast 由调用方弹,摘要已在事件里)
|
||||
//! AiManualCompressed → isCompressing=false + (手动 IPC,成功 toast 由调用方弹,摘要已在事件里)
|
||||
//! AiAutoCompressed → isCompressing=false + (loop 自动压缩,静默仅复位,治 Task#1 不弹 toast)
|
||||
//! AiContextCleared → (刷新提示由调用方弹)
|
||||
//! AiError → isCompressing=false + (错误 toast 由调用方弹)
|
||||
//!
|
||||
//! 设计:
|
||||
//! - 与 useAiEvents.startListener 共存:后者监听 AiChatEvent 联合类型(不含
|
||||
//! AiCompressing/AiCompressed/AiContextCleared,因 types.ts 不在本任务白名单),
|
||||
//! 本模块注册第二个 listen('ai-chat-event') 监听器,专门按 type 字符串分发上述 4 类事件。
|
||||
//! AiCompressing/AiManualCompressed|AiAutoCompressed/AiContextCleared,因 types.ts 不在本任务白名单),
|
||||
//! 本模块注册第二个 listen('ai-chat-event') 监听器,专门按 type 字符串分发上述 5 类事件。
|
||||
//! 两个监听器都触发同一后端事件,但各取所需类型,Tauri 事件多 listener 是合法的。
|
||||
//! - toast/刷新副作用经回调注入(composable 无组件上下文,无 toast ref),
|
||||
//! AiChat.vue 调 initContextListener(onCleared/onCompressed/onError) 注入副作用,
|
||||
@@ -32,7 +33,7 @@ import { aiApi } from '@/api'
|
||||
import { state } from '@/stores/ai'
|
||||
import { resolveAiLang } from './aiShared'
|
||||
|
||||
/// 压缩中 loading(防重复点击;AiCompressing→true / AiCompressed|AiError→false)
|
||||
/// 压缩中 loading(防重复点击;AiCompressing→true / AiManualCompressed|AiAutoCompressed|AiError→false)
|
||||
const isCompressing = ref(false)
|
||||
|
||||
/// 已注册的 ai-chat-event 第二监听器(生命周期事件);幂等
|
||||
@@ -43,7 +44,8 @@ let _unlistenContext: UnlistenFn | null = null
|
||||
interface ContextCallbacks {
|
||||
/** 清空完成(AiContextCleared):toast + 刷新消息列表 */
|
||||
onCleared?: (conversationId: string) => void
|
||||
/** 压缩成功(AiCompressed):toast + 渲染摘要 system 消息 */
|
||||
/** 手动压缩成功(AiManualCompressed):toast + 渲染摘要 system 消息。
|
||||
* 注:仅 AiManualCompressed 触发;AiAutoCompressed(loop 自动)静默不进此回调(治 Task#1)。 */
|
||||
onCompressed?: (conversationId: string, summary: string) => void
|
||||
/** 压缩失败 / 错误(AiError with conversation_id):toast */
|
||||
onError?: (conversationId: string, message: string) => void
|
||||
@@ -76,10 +78,18 @@ async function initContextListener(callbacks: ContextCallbacks = {}): Promise<vo
|
||||
case 'AiCompressing':
|
||||
isCompressing.value = true
|
||||
break
|
||||
case 'AiCompressed':
|
||||
case 'AiManualCompressed':
|
||||
// 手动 IPC 压缩(用户点压缩按钮):复位 isCompressing + 弹 toast + 刷新视图回填摘要。
|
||||
isCompressing.value = false
|
||||
_callbacks.onCompressed?.(convId || '', payload.summary || '')
|
||||
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':
|
||||
_callbacks.onCleared?.(convId || '')
|
||||
break
|
||||
@@ -122,7 +132,7 @@ async function compressContext(): Promise<void> {
|
||||
try {
|
||||
await aiApi.compressContext(convId, resolveAiLang())
|
||||
// 后端 IPC 同步等到 LLM 压缩完成才 resolve(非 spawn 异步),成功即复位 isCompressing;
|
||||
// case 'AiCompressed' 事件也复位(双保险,防事件丢失致按钮永久禁用)
|
||||
// case 'AiManualCompressed' 事件也复位(双保险,防事件丢失致按钮永久禁用)
|
||||
isCompressing.value = false
|
||||
} catch (e) {
|
||||
isCompressing.value = false
|
||||
|
||||
@@ -10,7 +10,7 @@ import { state } from '@/stores/ai'
|
||||
import { notifyConversationChanged } from './useAiEvents'
|
||||
import { persistUiState } from './useAiPanel'
|
||||
import { setStreaming } from './streamingGuard'
|
||||
import { nextMsgId } from './aiShared'
|
||||
import { nextMsgId, getConvState } from './aiShared'
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
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
|
||||
// 发错会话。对齐 newConversation 复位语义;目标在后台生成时保留 true(stop/流式显示正确)。
|
||||
// 根治归 F-09 B 路线:streaming 改 per-conv 态。此处为 A 路线过渡补丁。
|
||||
// 走 setStreaming guard 收敛写入口(convId=id 联动幂等——has(id) 决定值,add/delete 幂等无副作用)。
|
||||
setStreaming(state.generatingConvs.has(id), { convId: id, reason: 'switchConversation-recompute' })
|
||||
// 批4 双轨收口:读 getConvState(enum 真相源)派生,替代旧 generatingConvs.has。
|
||||
// 桥接语义: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 {
|
||||
const rawMsgs = typeof detail.messages === 'string'
|
||||
|
||||
@@ -20,16 +20,19 @@
|
||||
//! - nextMsgId 已下沉到 aiShared(原为本模块导出,useAiStream 亦依赖之构成循环依赖,故抽出)
|
||||
|
||||
import { listen, emit } from '@tauri-apps/api/event'
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { aiApi } from '@/api'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import { state } from '@/stores/ai'
|
||||
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 { setStreaming } from './streamingGuard'
|
||||
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 _unlistenConvChanged: (() => void) | null = null
|
||||
@@ -138,35 +141,12 @@ export interface PendingHelp {
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
// L2 统一状态机 convStates/getConvState/setConvState 已下沉到 aiShared.ts(批4 双轨收口破环:
|
||||
// stores/ai.ts、useAiStream.ts、useAiConversations.ts 等读点 import getConvState 会与
|
||||
// useAiEvents 现有依赖构成环,故下沉到本模块既有的破环共享层)。本模块从 aiShared re-import。
|
||||
// 批4 收口后:convStates 是「会话是否生成中」的唯一真相源,旧 generatingConvs bool 轨已退役,
|
||||
// 不再有双轨同步逻辑(原 handleConvStateEvent 内 idle/error delete、generating/stopping/compressed
|
||||
// add 的兜底分支已删,enum 单写 setConvState 即收敛)。
|
||||
|
||||
/** 通知会话列表发生变化(供 newConversation/deleteConversation/rename/archive 等触发刷新侧栏) */
|
||||
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
|
||||
* (看门狗由活跃 delta/工具事件重置,这里不干预;旧 bool 由 AiCompleted/AiError 收敛保持双轨一致)。
|
||||
* 终止态(idle/error):
|
||||
* - 写 convStates(idle 删 Map 项让消费方回退旧 bool,与后端 generating=false 语义对齐)
|
||||
* - 同步做旧 generatingConvs 收敛(delete,对齐 AiCompleted/AiError 收尾语义)——
|
||||
* 后端 emit AiConvStateChanged{idle} 与 AiCompleted 可能不严格按序到达,任一先到即收敛,
|
||||
* 双轨真相一致,后到的另一事件幂等(delete 已无项 no-op)。
|
||||
* 批4 双轨收口后:enum(convStates)是唯一真相源,setConvState 单写即收敛。
|
||||
* 原 idle/error 同步 delete generatingConvs、非终止态同步 add 的双轨兜底分支已删
|
||||
* (bool 轨 generatingConvs 已退役)。看门狗由活跃 delta/工具事件重置,这里不干预。
|
||||
* 返回值: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)
|
||||
}
|
||||
setConvState(convId, event.conv_state)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -585,10 +551,9 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
|
||||
flushCurrentText()
|
||||
state.currentText = ''
|
||||
setStreaming(false, { convId: event.conversation_id || null, reason: 'AiCompleted' })
|
||||
state.generatingConvs.delete(event.conversation_id || '')
|
||||
// L2 双轨过渡:AiCompleted 收尾同步收敛 convStates→idle(删 Map 项)。
|
||||
// 批4 双轨收口:AiCompleted 收尾收敛 convStates→idle(删 Map 项,enum 单一真相源)。
|
||||
// 后端 CONV_STATE_ENABLED=on 时 AiConvStateChanged{idle} 已先到此处幂等 no-op;
|
||||
// off 或老后端无 AiConvStateChanged 时此处保证 convStates 不陈旧(消费方回退旧 bool 一致)。
|
||||
// off 或老后端无 AiConvStateChanged 时此处保证 convStates 不陈旧。
|
||||
convStates.delete(event.conversation_id || '')
|
||||
state.agentRound = 0 // AE-2025-07: agentic 结束,复位轮次(隐藏进度条)
|
||||
// TD-260621-02 per-conv:仅当 pendingMaxRounds===本 conv 才清 null(避免清其他会话的挂起)。
|
||||
@@ -653,10 +618,9 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
|
||||
// 保留"回答到一半"的部分回复(否则清 currentText 后部分内容消失)。
|
||||
flushCurrentText()
|
||||
setStreaming(false, { convId: event.conversation_id || null, reason: 'AiError' })
|
||||
state.generatingConvs.delete(event.conversation_id || '')
|
||||
// L2 双轨过渡:AiError 收尾将 convStates 显式置 error(保留 error 项,供停止按钮显"重试"态)。
|
||||
// 批4 双轨收口:AiError 收尾将 convStates 显式置 error(保留 error 项,供停止按钮显"重试"态)。
|
||||
// 与 AiConvStateChanged{error} 双写幂等(后端 on 时状态事件已先到;off/老后端时此处保证 Error 态可见)。
|
||||
// error 态在新会话发送(AiTextDelta 等首事件到达→generatingConvs.add)或下次 AiCompleted 时自然收敛。
|
||||
// error 态在新会话发送(首活跃事件到达兜底写 generating)或下次 AiCompleted 时自然收敛。
|
||||
if (event.conversation_id) convStates.set(event.conversation_id, 'error')
|
||||
state.currentText = ''
|
||||
state.agentRound = 0 // AE-2025-07: agentic 异常中断,复位轮次
|
||||
@@ -706,7 +670,9 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
|
||||
clearAllToolSlowTimers()
|
||||
flushCurrentText()
|
||||
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.agentRound = 0
|
||||
// 求助即终止 loop,清残留待审批项(对齐 AiError 分支语义,防残留审批卡误导)。
|
||||
@@ -756,32 +722,35 @@ export function handleEvent(event: AiChatEvent) {
|
||||
}
|
||||
// L2 状态机:AiConvStateChanged 是 per-conv 状态信号(F-09 多会话并发下须追踪所有会话),
|
||||
// 在 isCurrent 守卫**之前**处理,避免切走会话时 conv_state 不更新致停止按钮/MaxRoundsCard 错态。
|
||||
// 该事件非流式活跃信号,不重置看门狗、不进 generatingConvs 通用 add 逻辑(由 conv_state 值决定 add/delete)。
|
||||
// 该事件非流式活跃信号,不重置看门狗、不进活跃事件兜底写(由 handleConvStateEvent 写 enum)。
|
||||
if (handleConvStateEvent(event)) return
|
||||
// F-260622-02 跨端用户消息同步:在 isCurrent 守卫**之前**处理——AiUserMessage 是补 user 气泡
|
||||
// 信号(非流式活跃事件),不应触发 watchdog reset / generatingConvs add(后续 AiAgentRound/
|
||||
// 信号(非流式活跃事件),不应触发 watchdog reset / 活跃事件兜底写(后续 AiAgentRound/
|
||||
// AiTextDelta 等活跃事件会正常触发)。也不受"非当前会话"过滤——理论上此事件必带 conversation_id
|
||||
// 且属当前会话(后端 route_send_message 在放行时广播),即使因切走会话到达也仅补 user 气泡,
|
||||
// 不影响其他态(handleUserMessageEvent 去重幂等)。
|
||||
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
|
||||
if (!isCurrent) {
|
||||
if (event.type === 'AiCompleted' || event.type === 'AiError' || event.type === 'AiHelpRequired') {
|
||||
// F-09: 多会话并发 — 仅从 Set 移除该 conv(其他会话可能仍在生成),不再置 null 全局清空
|
||||
state.generatingConvs.delete(convId || '')
|
||||
// 批4 双轨收口:非当前会话终止事件,收敛 convStates(删 Map 项回 idle/不在生成),
|
||||
// 对齐 :592/:660/:711 当前会话分支语义。非当前会话的 error 态无消费方(MaxRoundsCard/
|
||||
// ChatInput 均读 activeConversationId),回退 null 与停止按钮 streaming 回退行为等价。
|
||||
convStates.delete(convId || '')
|
||||
void loadConversations()
|
||||
}
|
||||
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') {
|
||||
state.generatingConvs.add(convId)
|
||||
// L2 双轨过渡:会话进入生成(活跃事件到达)时,清 convStates 中可能残留的 error 项,
|
||||
// 避免上一轮错误态 conv_state 未收敛致停止按钮错显"重试"(此时实际已在生成)。
|
||||
// CONV_STATE_ENABLED=on 时后端 AiConvStateChanged{generating} 已更新;此处兜底 off/老后端。
|
||||
convStates.delete(convId)
|
||||
setConvState(convId, 'generating')
|
||||
}
|
||||
// 流式看门狗:活跃事件(delta/工具/新轮/审批结果)重置;审批等待/完成/错误在 case 内 clear
|
||||
// TD-260621-01 per-conv:传 convId 走 per-conv Map(各会话独立计时,互不顶替/连累)。
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
import { state } from '@/stores/ai'
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import { nextMsgId } from './aiShared'
|
||||
import { nextMsgId, getConvState } from './aiShared'
|
||||
import { forceResetStreaming } from './streamingGuard'
|
||||
import { emit } from '@tauri-apps/api/event'
|
||||
|
||||
@@ -42,9 +42,10 @@ let _legacyWatchdog: ReturnType<typeof setTimeout> | null = null
|
||||
* 看门狗超时回调:收尾 streaming 态并补错误消息(走 forceResetStreaming 兜底复位,
|
||||
* 对齐 [[devflow-generating-statemachine]])。
|
||||
*
|
||||
* TD-260621-01 per-conv:携带 convId 时,forceResetStreaming(reason, convId) 仅清该 conv 的
|
||||
* generatingConvs(经 setStreaming 联动 delete),不再全清误杀并发会话生成态。convId 为空
|
||||
* (legacy fallback 路径)时不带 convId,generatingConvs 不动(全局兜底,行为对齐旧版)。
|
||||
* TD-260621-01 per-conv:携带 convId 时,forceResetStreaming(reason, convId) 经 setStreaming
|
||||
* 仅复位该会话 streaming 态。批4 双轨收口后 generatingConvs 已退役,convStates 项由各会话
|
||||
* 收尾事件精确管理(on/off 后端均 emit 收尾态),本回调不再触碰生成态集合。convId 为空
|
||||
* (legacy fallback 路径)时全局兜底,行为对齐旧版。
|
||||
*/
|
||||
export function onStreamTimeout(convId?: string) {
|
||||
// 根治守卫(TD-260621-01 遗留):看门狗 reset(doSend 走 legacy _legacyWatchdog)/clear
|
||||
@@ -54,7 +55,12 @@ export function onStreamTimeout(convId?: string) {
|
||||
// 守卫:触发时若该 conv 已不在生成态(已正常收尾),判定为残留 timer,静默清不补错误消息。
|
||||
// 真卡死(streaming/generating 仍 true)不受影响,仍走原 forceReset + 报错兜底。
|
||||
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
|
||||
if (!stillGenerating) {
|
||||
clearStreamWatchdog(convId)
|
||||
@@ -78,7 +84,8 @@ export function onStreamTimeout(convId?: string) {
|
||||
}
|
||||
}
|
||||
// 卡死兜底走 guard 的 forceResetStreaming(复位 streaming + 清 currentText/queue + warn 日志)。
|
||||
// TD-260621-01:携带 convId 时仅清该 conv 的 generatingConvs(per-conv);不传时全局兜底不动 Set。
|
||||
// 批4 双轨收口:generatingConvs 已退役,本兜底仅复位 streaming 态;该会话 convStates 项由
|
||||
// 后端收尾事件(或下次活跃事件兜底写)收敛,无需此处手动清。
|
||||
forceResetStreaming('onStreamTimeout(130s 无数据)', convId)
|
||||
clearStreamWatchdog(convId)
|
||||
// AE-2025-06: 整流超时收尾 → 广播清全部审批超时计时器。
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { state } from '@/stores/ai'
|
||||
import { nextMsgId } from './aiShared'
|
||||
import { nextMsgId, getConvState, convStates } from './aiShared'
|
||||
import { setStreaming } from './streamingGuard'
|
||||
import { persistUiState } from './useAiPanel'
|
||||
|
||||
@@ -52,18 +52,24 @@ async function detachPanel(convId?: string) {
|
||||
}
|
||||
// 快照当前生成态,供分离窗口接管(保持正在进行的对话)
|
||||
// F-09:用 per-conv key 写入(多会话各自快照互不覆盖)
|
||||
if (state.streaming && targetConv && state.generatingConvs.has(targetConv)) {
|
||||
localStorage.setItem(genKey(targetConv), targetConv)
|
||||
localStorage.setItem(textKey(targetConv), state.currentText)
|
||||
// 批4 双轨收口:读 getConvState(enum 真相源)派生,替代旧 generatingConvs.has。
|
||||
// 桥接语义:has(convId)=true 等价于 conv_state∈{generating,stopping,compressed}(非终止三态)。
|
||||
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,
|
||||
// stores/ai.ts 模块级单例仅在同 webview 内共享),清主窗口 state 不影响分离窗口生成态。
|
||||
// 分离窗口接管生成后,主窗口不应再持该会话生成态残留(防重开面板时 UI 显生成中幽灵/进度条)。
|
||||
// F-09:仅从 Set 移除该 conv(其他会话可能仍在生成),不全局清空。
|
||||
// F-09:仅从 convStates 移除该 conv(其他会话可能仍在生成),不全局清空。
|
||||
// watchdog 在主窗口 AiChat 卸载(App.vue v-if detached)→ stopListener → clearStreamWatchdog 时已清,
|
||||
// 此处仅清内存 state 视觉残留;localStorage 快照保留供 resumeInDetached 恢复。
|
||||
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 url = window.location.origin + window.location.pathname + '#/ai-detached' + sep
|
||||
const win = new WebviewWindow(label, {
|
||||
@@ -135,7 +141,7 @@ function cleanupAllDetachedSnapshots(): void {
|
||||
* 1. state.currentText = df-ai-text-${convId}(已生成文本)
|
||||
* 2. push 占位 assistant 气泡(content='')
|
||||
* 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。
|
||||
* 这对主面板刷新场景至关重要:detachPanel 时主窗口 state 已被清 streaming=false
|
||||
@@ -194,7 +200,9 @@ export async function restoreGeneratingState(opts?: { fromMainPanel?: boolean; c
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,8 @@ const MESSAGE_CAP = 200
|
||||
const PARTS_SIZE_BUDGET = 50 * 1024 * 1024 // 50MB(base64 char 计)
|
||||
import { useAiEvents } from '@/composables/ai/useAiEvents'
|
||||
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 { useAiApproval } from '@/composables/ai/useAiApproval'
|
||||
import { useAiConversations } from '@/composables/ai/useAiConversations'
|
||||
@@ -63,11 +65,9 @@ export const state = reactive({
|
||||
streaming: false,
|
||||
currentText: '',
|
||||
pendingApprovals: [] as AiToolCallInfo[],
|
||||
// 正在生成的会话集合(F-09 多会话并发):后端 per-conv 已支持多会话同时生成,
|
||||
// 前端用 Set 表达"多会话同时生成"态。生成中切走时用于路由,后台事件不污染当前视图。
|
||||
// isGenerating(convId)/addGenerating/removeGenerating helper 经 useAiStore 暴露。
|
||||
// 单会话场景行为不变(Set 含 0 或 1 元素)。
|
||||
generatingConvs: new Set<string>(),
|
||||
// 批4 双轨收口:generatingConvs(Set,bool 轨)已退役。会话生成态真相归 convStates
|
||||
// (reactive Map,enum 轨),定义于 composables/ai/aiShared.ts(下沉破环)。
|
||||
// 消费方经 isGenerating(convId) helper 或直接 getConvState(convId) 读,不再读 state.xxx。
|
||||
providers: [] as AiProviderConfig[],
|
||||
activeProvider: null as string | null,
|
||||
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
|
||||
* 内调用 isGenerating(convId) 会建立响应式依赖,Set 变更时自动触发更新。
|
||||
* 单会话场景:Set 含 0 或 1 元素,行为与原单值 generatingConvId 等价。
|
||||
* 批4 收口:generatingConvs(Set,bool 轨)已退役,生成态真相归 convStates(enum Map,定义于
|
||||
* aiShared.ts)。本 helper 改派生自 getConvState:conv_state∈{generating,stopping,compressed}
|
||||
* (非终止三态)视为生成中,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 {
|
||||
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 {
|
||||
state,
|
||||
filteredConversations,
|
||||
// F-09: 生成态 Set helper(替代单值 generatingConvId 的读写)
|
||||
addGenerating,
|
||||
removeGenerating,
|
||||
// F-09: 生成态 helper(批4 双轨收口:仅读 isGenerating 派生自 getConvState;
|
||||
// addGenerating/removeGenerating 写 helper 已删,写收敛到 useAiEvents setConvState)
|
||||
isGenerating,
|
||||
...useAiEvents(),
|
||||
...useAiStream(),
|
||||
|
||||
Reference in New Issue
Block a user