- 帧壳 `turnId` 在 socket 层不再被丢(`lib/ws.ts` 的 dispatchFrame 补 turnId/seq)—— 按路分桶的前提 - 并行帧组接入:`turn_begin` / `steer_ack` / `pending_ack` / `pending_drain` / `pending_full` / `chat_duplicate` ⇒ 六态 + 灰态 + `(sessionId, turnId)` 两级分桶;`appendChunk` 不再永远写尾气泡 - 多路忙态:`session_interrupted` 不再无条件当空闲(看 mode/activeTurns);`stream_end` 不再只按会话过滤 - `documents` 渲染(含 status/chars/truncated 与**逐字中文 reason**);`turnId` 历史行分组小标 - `System.tsx` 键名改正(真键 HOST/PORT/ADMIN_USERNAME/ADMIN_PASSWORD,避免照抄静默失效) - 主人档案**按平台分组** + 平台下拉 + 账号候选;**写盘仍展平数组**(后端契约不变) - 验证:tsc --noEmit ✅、npm run build ✅、22 个前端测试文件全通过(本次新增 3 文件 108 条断言) - 未做(如实):握手 history handler 与断线补拉、上行 interrupt 按钮、proactive_message/subagent_result 等帧落点、文档上传入口与图片预检、voice 管线、System 的 env 读端点
188 lines
8.3 KiB
TypeScript
188 lines
8.3 KiB
TypeScript
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<string, TurnBinding>;
|
||
/** 队列/并行额度的一句话提示(`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<ChatMessage>, sessionId?: string) => void;
|
||
/** 把「本端刚发、尚未被任何一路接手」的那条气泡改为某个投递态(队列深度是权威的,
|
||
* 气泡标注是尽力而为:`pending_ack`/`pending_full` 不带消息 id)。 */
|
||
markLocalDelivery: (delivery: MessageDelivery, patch?: Partial<ChatMessage>) => void;
|
||
/** `chat_duplicate` 命中:把本地乐观气泡对齐到服务端既有 id。 */
|
||
alignDuplicateMessage: (clientMsgId: string, messageId: string, sessionId?: string) => void;
|
||
}
|
||
|
||
/** store 的 set/get 结构类型(只取本切片用到的形态,避免与 zustand 的完整
|
||
* 重载签名纠缠)。`AppStore` 只作类型引入,编译后被擦除。 */
|
||
type SetState = (
|
||
partial: Partial<AppStore> | ((s: AppStore) => Partial<AppStore> | 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<string, TurnBinding> = {};
|
||
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)
|
||
)
|
||
),
|
||
};
|
||
}
|