//! streaming 状态写收敛 guard(对齐 memory [[devflow-generating-statemachine]] 前端落地)。 //! //! 背景:`state.streaming`(当前活跃视图的流式态)原本散布在 8+ 处直接赋值 //! (useAiSend/useAiEvents/useAiConversations/useAiWindow/useAiPanel/useAiStream), //! 任一新增 return 漏写、或异常路径(前端 JS 错误/窗口崩溃恢复)跳过复位 → //! streaming 永久 true 卡死输入框(stop 按钮常驻、新消息入队不发)。 //! //! 本模块把写侧收敛到单一 `setStreaming` 入口,做两件事: //! 1. 合法性观测:`true→true` / `false→false` 幂等场景记 debug 日志(不拒绝—— //! resetStreamWatchdog 等副作用场景需合法重入),异常模式(如 false 时仍在跑看门狗) //! 记 warn 日志供排查卡死。 //! 2. feature flag `df-ai-generating-statemachine`:关时 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' import { convStates, getConvStreamState, setConvStreaming } from './aiShared' import { useAppSettingsStore } from '@/stores/appSettings' /// feature flag key(appSettings KV)。关=回退散布语义(灰度/回退用)。 const STREAMING_GUARD_FLAG = 'df-ai-generating-statemachine' const appSettings = useAppSettingsStore() /// guard 是否启用(读 appSettings,默认开=true)。 /// /// 默认开的理由:本 guard 是收敛性改造(行为等价 + 加可观测),非行为变更, /// 默认开启直接收效;flag 仅作紧急回退通道(若发现回归,用户可在设置里关掉回退原散布语义)。 function isGuardEnabled(): boolean { return appSettings.get(STREAMING_GUARD_FLAG, true) } /** * streaming 写收敛入口 —— 替代散布的 `state.streaming = true/false`(原 14 处,现全收敛至此)。 * @param value 目标流式态 * @param opts.convId 关联会话 id(批4 收口后仅用于日志可观测,不再联动 generatingConvs; * 生成态真相归 convStates,由 useAiEvents 兜底写 setConvState 标记)。 * @param opts.reason 调用方标注(日志可观测,如 'AiCompleted'/'stopChat'/'onStreamTimeout')。 * * 行为: * - flag 关 → 直接 `state.streaming = value`(回退散布语义,不校验)。 * - flag 开 → 幂等检测(value===当前值记 debug)、写日志。 */ export function setStreaming( value: boolean, opts: { convId?: string | null; reason?: string } = {}, ): void { const { convId, reason } = opts // flag 关:回退散布语义,直接赋值不动其他。 if (!isGuardEnabled()) { if (convId) setConvStreaming(convId, value) else state.streaming = value return } const prev = convId ? (getConvStreamState(convId)?.streaming ?? false) : state.streaming // 幂等观测:同值重复写不拒绝,仅记 debug 日志供排查卡死场景。 if (prev === value) { console.debug( `[AI-Guard] setStreaming 幂等(prev=${prev}, convId=${convId ?? 'none'}, reason=${reason ?? 'unknown'})`, ) } else { console.debug( `[AI-Guard] setStreaming ${prev}→${value} (convId=${convId ?? 'none'}, reason=${reason ?? 'unknown'})`, ) } // 显式 convId 写目标会话 per-conv 流式态;缺省写当前活跃会话(单会话语义不变)。 if (convId) setConvStreaming(convId, value) else state.streaming = value } /** * 强制复位 streaming=false 的兜底入口(panic/卡死恢复用)。 * * 与 setStreaming(false) 区别:本函数额外清 currentText + queue, * 专用于看门狗超时 / 前端异常恢复等「必须彻底清态」场景。 * * TD-260621-01 per-conv:看门狗超时携带 convId 时,经 setStreaming 仅复位该会话 streaming 态; * 该会话的 convStates 项由各自会话收尾事件精确管理(on/off 后端均会 emit 收尾态)。 * * 注:currentText/queue 仍是单例(F-09 域,本批不动),per-conv 超时在多会话并发下 * 会清当前累积的 currentText——但 currentText 仅活跃视图写入(useAiEvents handleEvent delta 累积), * 而超时仅发生在该 conv 长时间无数据,此场景 currentText 多为空或已 flush, * 实际误伤面远小于 generatingConvs 全清(队列 queue 的清理由 F-09 统一改,见 TD-260621-01 注释)。 * * @param reason 兜底来源(日志可观测) * @param convId 关联会话 id(看门狗 per-conv 超时传;省略=全局兜底) */ export function forceResetStreaming(reason: string, convId?: string): void { console.warn(`[AI-Guard] forceResetStreaming(卡死兜底): ${reason} (convId=${convId ?? 'none'})`) setStreaming(false, { convId: convId ?? null, reason }) state.currentText = '' state.queue = [] // BUG-2026-07-17 根治:同时清理 convStates 项。 // 原实现仅清 streaming/currentText/queue,但按钮 stopBtnState 优先读 convStates // (ChatInput.vue:243),convStates=generating 时仍显红色停止按钮。后端未发 // AiConvStateChanged 时(conv_state 永久卡住),必须前端主动清 convStates 才能解锁 UI。 if (convId) { convStates.delete(convId) } else { // 全局兜底:无 convId 时清所有生成态(卡死恢复场景,精确性不重要) convStates.clear() } }