Files
Cyrene_v2/frontend/web/src/store/turnSlice.ts
T
AskaEth eaada3af5d
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(web): 前端与后端同步一批(帧壳 turnId / 并行六态与两级分桶 / documents 渲染 / System 键名 / 主人档案分组)
- 帧壳 `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 读端点
2026-10-05 11:09:24 +08:00

188 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
)
),
};
}