import type { AppStore } from "./useAppStore"; import type { ChatMessage, MessageDelivery, TurnBinding } from "../lib/types"; import { writeSession, writeSessionById, patchMessage } from "./sessionCache"; import { alignDuplicate, applyTurnBinding, clearGreyForTurn, closeTurn, localPendingIndex, setDelivery, turnsInSession, } from "./turnCache"; /** * 忙时并行的**回合切片**(design/15 §2.5 / 规范草案 §1.1 §2):忙态与在途路数、 * `turn_begin` 绑定表、六态投递态的写入动作。 * * 从 `useAppStore.ts` 拆出来(AGENTS §10 文件 ≤500 行):这些都是「按路」的状态, * 与消息桶(`sessionCache`)、与其余界面状态不是一回事,放一处才好对照规范逐条核对。 * 由于每个动作都要「写进对的那个会话桶」,这里统一走 `sessionCache` 的写入路径, * 规则不复制第二份。 */ /** 本切片贡献给 AppStore 的状态与动作。 */ export interface TurnSlice extends TurnState, TurnActions, DeliveryActions {} /** 忙时并行的**回合状态**(design/15 §2.5)。 */ export interface TurnState { /** 会话在途路数(`session_busy`/`session_idle`/`pending_drain`/`session_interrupted` * 帧的 `activeTurns`,权威)。忙 = `activeTurns > 0`;收到前恒 0(旧后端行为不变)。 */ activeTurns: number; /** 在途回合的绑定表,键 = 帧壳 `turnId`(`turn_begin` 建立、`turn.end` 丢弃)。 * 不持久化:它的唯一来源是这次连接收到的帧,刷新后由 REST 历史里的 `turnId` 接管。 */ turnBindings: Record; /** 队列/并行额度的一句话提示(`pending_ack.reason` / `pending_full` 时后端给的 * 计数与原因)。null = 无提示;文案由前端按计数拼,但**不编造后端没给的事实**。 */ queueNotice: string | null; } /** 在途回合的生命周期动作(起路 / 收口 / 绑定表维护)。 */ export interface TurnActions { /** 会话忙闲 + 挂起数 + 在途路数。三个参数都可选:缺省沿用旧值,因此老后端的 * `session_busy{busy}` / `session_idle` 逐字不变。 */ setSessionBusy: (busy: boolean, pendingCount?: number, activeTurns?: number) => void; /** 记录一条 `turn_begin` 绑定并立刻尝试落到消息列表(消息未落库则留在绑定表里, * 等 `message_persisted` 到达再落)。 */ beginTurn: (binding: TurnBinding) => void; /** 该路收口(`turn.end`):灰态转「已答」、该路助手气泡停止流式、丢弃绑定; * 若该会话已无在途路且队列空,顺手把忙态置闲(`session_idle` 是权威,这里是 * 兜底,条件与它逐字一致,不会提前置闲)。 */ endTurn: (turnId: string, sessionId?: string) => void; /** 只收灰态、不动流式气泡(助手 `message_persisted` 的兜底收口,规范草案 §1.2)。 */ clearGrey: (turnId: string, sessionId?: string) => void; /** 把该会话的**全部**绑定重新落到消息列表:`turn_begin` 早于用户行落库到达时, * 绑定只记在表里;用户行一到就用它把气泡补进灰态。无绑定/无落点时不改状态。 */ rebindSessionTurns: (sessionId: string) => void; /** 该会话的全部在途路作废(`session_interrupted` scope:"all"):取消的路不会再发 * `turn.end`,绑定必须清掉,否则忙态判定会永远认为「还有路在跑」。 */ dropSessionTurns: (sessionId: string) => void; setQueueNotice: (text: string | null) => void; } /** 六态投递态的写入动作(规范草案 §1.1)。 */ export interface DeliveryActions { /** 按 id 打投递态补丁(`pending_full.messageId` / `chat_busy.messageId` 等)。 */ markDelivery: (id: string, patch: Partial, sessionId?: string) => void; /** 把「本端刚发、尚未被任何一路接手」的那条气泡改为某个投递态(队列深度是权威的, * 气泡标注是尽力而为:`pending_ack`/`pending_full` 不带消息 id)。 */ markLocalDelivery: (delivery: MessageDelivery, patch?: Partial) => void; /** `chat_duplicate` 命中:把本地乐观气泡对齐到服务端既有 id。 */ alignDuplicateMessage: (clientMsgId: string, messageId: string, sessionId?: string) => void; } /** store 的 set/get 结构类型(只取本切片用到的形态,避免与 zustand 的完整 * 重载签名纠缠)。`AppStore` 只作类型引入,编译后被擦除。 */ type SetState = ( partial: Partial | ((s: AppStore) => Partial | AppStore) ) => void; type GetState = () => AppStore; /** 切片的初始状态(与动作分开,便于 useAppStore 各自 spread 且每个函数守住 ≤80 行)。 */ export function turnState(): TurnState { return { activeTurns: 0, turnBindings: {}, queueNotice: null }; } /** 在途回合的生命周期动作。 */ export function turnActions(set: SetState, get: GetState): TurnActions { return { setSessionBusy: (busy, pendingCount, activeTurns) => set((s) => ({ sessionBusy: busy, pendingCount: pendingCount ?? s.pendingCount, activeTurns: activeTurns ?? s.activeTurns, })), beginTurn: (b) => set((s) => { const sessionId = b.sessionId || s.activeSessionId; const turnBindings = { ...s.turnBindings, [b.turnId]: { ...b, sessionId } }; const patch = writeSession(s, sessionId, (list) => applyTurnBinding(list, b)); // `writeSession` 在「这一帧与持有的消息对不上」时原样返回 state(表示不改), // 那种情况下只更新绑定表,别把整份 state 当成补丁重放。 return { ...(patch === s ? {} : patch), turnBindings, activeTurns: b.activeTurns || s.activeTurns, }; }), endTurn: (turnId, sessionId) => set((s) => { const binding = s.turnBindings[turnId]; const sid = binding?.sessionId ?? sessionId ?? s.activeSessionId; const turnBindings = { ...s.turnBindings }; delete turnBindings[turnId]; const patch = writeSession(s, sid, (list) => closeTurn(list, turnId)); const remaining = turnsInSession(turnBindings, sid).length; const quiescent = remaining === 0 && s.pendingCount === 0; return { ...(patch === s ? {} : patch), turnBindings, ...(quiescent ? { sessionBusy: false, activeTurns: 0 } : {}), }; }), clearGrey: (turnId, sessionId) => set((s) => { const sid = s.turnBindings[turnId]?.sessionId ?? sessionId ?? s.activeSessionId; const patch = writeSession(s, sid, (list) => clearGreyForTurn(list, turnId)); return patch === s ? {} : patch; }), rebindSessionTurns: (sessionId) => set((s) => { const bindings = turnsInSession(s.turnBindings, sessionId); if (bindings.length === 0) return s; return writeSession(s, sessionId, (list) => { let next = list; let changed = false; for (const b of bindings) { const out = applyTurnBinding(next, b); if (out) { next = out; changed = true; } } return changed ? next : null; }); }), dropSessionTurns: (sessionId) => set((s) => { const turnBindings: Record = {}; for (const [k, v] of Object.entries(s.turnBindings)) { if (v.sessionId !== sessionId) turnBindings[k] = v; } return { turnBindings }; }), setQueueNotice: (text) => set({ queueNotice: text }), }; } /** 六态投递态的写入动作。 */ export function deliveryActions(set: SetState, get: GetState): DeliveryActions { return { markDelivery: (id, patch, sessionId) => set((s) => writeSessionById(s, sessionId, (list) => patchMessage(list, id, patch)) ), markLocalDelivery: (delivery, patch = {}) => set((s) => { const idx = localPendingIndex(s.messages); return writeSession(s, s.activeSessionId, (list) => setDelivery(list, idx, delivery, patch) ); }), alignDuplicateMessage: (clientMsgId, messageId, sessionId) => set((s) => writeSessionById(s, sessionId, (list) => alignDuplicate(list, clientMsgId, messageId, sessionId) ) ), }; }