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 读端点
This commit is contained in:
1 parent
6a545badbd
commit
eaada3af5d
34 files changed
+2457
-223
No files matched your search
@@ -0,0 +1,47 @@
|
||||
import type { AppStore } from "./useAppStore";
|
||||
|
||||
/**
|
||||
* zustand `persist` 中间件的配置(从 `useAppStore.ts` 拆出,AGENTS §10 文件 ≤500 行)。
|
||||
*
|
||||
* 只持久化「小且可重建」的东西:会话元数据 + 聚焦的会话 id + 角色目录 + 情绪 + 上下文 +
|
||||
* 后台思考条目。消息**不持久化**(后端是消息真源,design/16 §1)。v1 的形状(整份聊天 +
|
||||
* 单个会话 id)与 v2(一个全局 `currentPersona`)都无法描述「角色属于会话」的现状,
|
||||
* 统一丢弃并由接口重新取回。
|
||||
*/
|
||||
export const persistOptions = {
|
||||
name: "cyrene.app",
|
||||
version: 3,
|
||||
// v1 persisted the whole chat (`messages` + a single `sessionId`); v2 added
|
||||
// one resolved `currentPersona`, which can no longer describe the active role
|
||||
// now that the role belongs to a *session*. Both shapes are dropped:
|
||||
// `personas` is refilled from `GET /personas` and the role is re-derived from
|
||||
// the session list. `moods` is transient by nature (a restart resets a role's
|
||||
// mood), so it is not carried across either. A v1 state was never trusted for
|
||||
// the session metadata, so those users simply restart on the main session.
|
||||
migrate: (persisted: unknown, version: number) => {
|
||||
const p = (persisted ?? {}) as Partial<AppStore>;
|
||||
const fromV1 = version < 2;
|
||||
return {
|
||||
activeSessionId: fromV1 ? "" : p.activeSessionId ?? "",
|
||||
sessions: fromV1 ? {} : p.sessions ?? {},
|
||||
personas: p.personas ?? [],
|
||||
moods: {},
|
||||
context: p.context ?? {},
|
||||
backthink: p.backthink ?? [],
|
||||
};
|
||||
},
|
||||
partialize: (s: AppStore) => ({
|
||||
// messages deliberately excluded: the backend is the message truth
|
||||
// (design/16 §1), so we never persist the whole chat into localStorage —
|
||||
// and neither is `sessionMessages` (the per-session buckets of 裁决 16.4.5).
|
||||
// We keep the (small) session metadata + the focused session id so a
|
||||
// refresh lands on the same conversation, plus the role catalog so the
|
||||
// avatar/name/wallpaper resolve before the first fetch returns.
|
||||
activeSessionId: s.activeSessionId,
|
||||
sessions: s.sessions,
|
||||
personas: s.personas,
|
||||
moods: s.moods,
|
||||
context: s.context,
|
||||
backthink: s.backthink,
|
||||
}),
|
||||
};
|
||||
@@ -30,7 +30,13 @@ export type SessionMessages = Record<string, ChatMessage[]>;
|
||||
|
||||
/** 把一条后端消息并入消息列表:按 id upsert;没有同 id 时采纳本端同 role 的本地
|
||||
* 乐观气泡(本地 `uid` → 后端 `id`),使同一句话不会渲染两遍(design/16 §4.3 去重)。
|
||||
* 后端消息是内容的真源,本地气泡保留流式/工具/思考等本端状态。 */
|
||||
* 后端消息是内容的真源,本地气泡保留流式/工具/思考等本端状态。
|
||||
*
|
||||
* 采纳顺序(忙时并行后很重要,规范草案 §1.7):
|
||||
* ① 同 `turnId` 且尚未落库的气泡 —— `turn_begin` 已把本端气泡绑到这一路,turnId
|
||||
* 才是权威配对(并行时两条回复内容可能相似,靠内容判等会**配错路**);
|
||||
* ② 内容完全相同的那条(旧启发式,保持老后端行为);
|
||||
* ③ createdAt 最近的那条。 */
|
||||
export function mergeMessage(list: ChatMessage[], msg: ChatMessage): ChatMessage[] {
|
||||
const idx = list.findIndex((m) => m.id === msg.id);
|
||||
if (idx !== -1) {
|
||||
@@ -38,6 +44,14 @@ export function mergeMessage(list: ChatMessage[], msg: ChatMessage): ChatMessage
|
||||
next[idx] = { ...next[idx], ...msg };
|
||||
return next;
|
||||
}
|
||||
if (msg.turnId) {
|
||||
for (let i = list.length - 1; i >= 0; i -= 1) {
|
||||
const m = list[i];
|
||||
if (!m.sessionId && m.role === msg.role && m.turnId === msg.turnId) {
|
||||
return adopt(list, i, msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 没有同 id:找一个本端尚未落库的同 role 气泡(无 sessionId)来归一化。
|
||||
// 优先内容完全相同的那条,否则取 createdAt 最近的;从新到旧扫描。
|
||||
let best = -1;
|
||||
@@ -52,9 +66,14 @@ export function mergeMessage(list: ChatMessage[], msg: ChatMessage): ChatMessage
|
||||
}
|
||||
}
|
||||
if (best === -1) return [...list, msg];
|
||||
const m = list[best];
|
||||
return adopt(list, best, msg);
|
||||
}
|
||||
|
||||
/** 把本端乐观气泡归一为后端消息(id 换成服务端的,其余保留本端状态)。 */
|
||||
function adopt(list: ChatMessage[], at: number, msg: ChatMessage): ChatMessage[] {
|
||||
const m = list[at];
|
||||
const next = list.slice();
|
||||
next[best] = {
|
||||
next[at] = {
|
||||
...m,
|
||||
id: msg.id,
|
||||
sessionId: msg.sessionId ?? m.sessionId,
|
||||
@@ -62,6 +81,9 @@ export function mergeMessage(list: ChatMessage[], msg: ChatMessage): ChatMessage
|
||||
mode: msg.mode ?? m.mode,
|
||||
mood: msg.mood ?? m.mood,
|
||||
image: msg.image ?? m.image,
|
||||
documents: msg.documents ?? m.documents,
|
||||
turnId: msg.turnId ?? m.turnId,
|
||||
clientMsgId: msg.clientMsgId ?? m.clientMsgId,
|
||||
platform: msg.platform ?? m.platform,
|
||||
deviceId: msg.deviceId ?? m.deviceId,
|
||||
personaId: msg.personaId ?? m.personaId,
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import type { ChatMessage, MessageDelivery, TurnBinding } from "../lib/types";
|
||||
import { now, uid } from "../lib/util";
|
||||
|
||||
/**
|
||||
* 回合级(按 `(sessionId, turnId)` 二级分桶)的纯函数(忙时并行规范草案
|
||||
* design/15 §2.5)。与 `sessionCache.ts` 的分工:
|
||||
*
|
||||
* sessionCache 管「哪一份会话列表」(按 `sessionId` 一级分桶);
|
||||
* 本模块管「列表里哪一条气泡」(按 `turnId` 二级分桶 + 六态投递态)。
|
||||
*
|
||||
* 为什么必须分开:忙时并行下同一会话的两条回复会**交错**到达(同 sessionId、不同
|
||||
* turnId),任何「改列表尾那条助手气泡」的写法都会把两条回复糊成一条。所有按路写入
|
||||
* 都从这里走,规则只有一处,才不会在几条帧路径上走样。
|
||||
*
|
||||
* 全部是纯函数(只读 list,返回新 list 或 null 表示「帧与我们持有的气泡对不上,不改」),
|
||||
* 因此可以在 node 下逐条跑逻辑测试,不需要 jsdom(项目无浏览器测试环境)。
|
||||
*/
|
||||
|
||||
/** 找这条路(`turnId`)的助手气泡并就地变换。顺序:
|
||||
* ① 已绑定同 turnId 的气泡 —— 直接命中(唯一正确的分桶);
|
||||
* ② 还没有绑定:采纳**列表尾**那条本端刚开、既无 turnId 又是流式的手气泡
|
||||
* (`turn_begin` 可能比首个 `message.delta` 晚到一帧);
|
||||
* ③ 都不匹配返回 `null`:已绑到别的 turnId 的气泡**绝不复用** —— 那正是「两条回复
|
||||
* 糊成一条」的成因。丢一帧增量,好过把另一路的正文写进这一路。 */
|
||||
export function patchAssistantForTurn(
|
||||
list: ChatMessage[],
|
||||
turnId: string,
|
||||
fn: (m: ChatMessage) => ChatMessage
|
||||
): ChatMessage[] | null {
|
||||
for (let i = list.length - 1; i >= 0; i -= 1) {
|
||||
const m = list[i];
|
||||
if (m.role === "assistant" && m.turnId === turnId) {
|
||||
const next = list.slice();
|
||||
next[i] = fn(m);
|
||||
return next;
|
||||
}
|
||||
}
|
||||
const tail = list[list.length - 1];
|
||||
if (tail && tail.role === "assistant" && !tail.turnId && tail.streaming) {
|
||||
const next = list.slice();
|
||||
next[next.length - 1] = fn({ ...tail, turnId });
|
||||
return next;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 把一条 `turn_begin` 绑定落到该会话的消息列表上(规范草案 §2.4):
|
||||
* ① 归属本路的用户消息 → 灰态 `answering` + 绑定 turnId;
|
||||
* ② 本路需要一个**生长中的助手气泡**:优先采纳该用户消息之后最近的「未绑定且流式」
|
||||
* 助手气泡(本端 `beginAssistant` 开的那条),否则在列表尾新开一条 —— 远端发起的
|
||||
* 并行路在本端没有开过气泡,不新开就没有落点。
|
||||
* 返回 `null` 表示这条绑定在这个列表里还没有落点(用户消息尚未落库/尚未并入),
|
||||
* 调用方保留绑定,等 `message_persisted` 到达时再落(规范草案 §1.7)。 */
|
||||
export function applyTurnBinding(list: ChatMessage[], b: TurnBinding): ChatMessage[] | null {
|
||||
let next = list;
|
||||
let hit = false;
|
||||
for (const id of b.messageIds) {
|
||||
const i = next.findIndex((m) => m.id === id);
|
||||
if (i === -1) continue;
|
||||
const m = next[i];
|
||||
if (m.role !== "user") continue;
|
||||
hit = true;
|
||||
if (m.turnId !== b.turnId || m.delivery !== "answering") {
|
||||
if (next === list) next = list.slice();
|
||||
next[i] = {
|
||||
...m,
|
||||
turnId: b.turnId,
|
||||
delivery: "answering",
|
||||
rejectReason: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!hit) return null;
|
||||
if (next.some((m) => m.role === "assistant" && m.turnId === b.turnId)) return next;
|
||||
const anchor = next.findIndex((m) => b.messageIds.includes(m.id));
|
||||
for (let i = anchor + 1; i < next.length; i += 1) {
|
||||
const m = next[i];
|
||||
if (m.role === "assistant" && !m.turnId && m.streaming) {
|
||||
if (next === list) next = list.slice();
|
||||
next[i] = { ...m, turnId: b.turnId };
|
||||
return next;
|
||||
}
|
||||
}
|
||||
return [
|
||||
...next,
|
||||
{
|
||||
id: uid(),
|
||||
role: "assistant",
|
||||
content: "",
|
||||
streaming: true,
|
||||
turnId: b.turnId,
|
||||
sessionId: b.sessionId || undefined,
|
||||
createdAt: now(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 灰态收口(规范草案 §1.2 的**唯一**口径):把该 `turnId` 的用户消息从
|
||||
* 「已受理·回答中」改为「已答」。由该路的 `turn.end` 或该路的助手
|
||||
* `message_persisted`(兜底)触发;两个事件顺序不定,任一到即收口。
|
||||
* 返回 `null` 表示没有需要收口的气泡(不改状态,避免无谓重渲染)。 */
|
||||
export function clearGreyForTurn(list: ChatMessage[], turnId: string): ChatMessage[] | null {
|
||||
let next: ChatMessage[] | null = null;
|
||||
for (let i = 0; i < list.length; i += 1) {
|
||||
const m = list[i];
|
||||
if (m.role !== "user" || m.turnId !== turnId || m.delivery !== "answering") continue;
|
||||
if (next === null) next = list.slice();
|
||||
next[i] = { ...m, delivery: "answered" };
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 整路收口(`turn.end` / 该路的 `stream_end`):灰态收口 + 该路助手气泡停止流式。
|
||||
* 只动本 `turnId` 的气泡,另一路继续生长(这正是多路下必须按路收口的原因)。 */
|
||||
export function closeTurn(list: ChatMessage[], turnId: string): ChatMessage[] | null {
|
||||
const grey = clearGreyForTurn(list, turnId);
|
||||
let next = grey ?? list;
|
||||
let changed = grey !== null;
|
||||
for (let i = 0; i < next.length; i += 1) {
|
||||
const m = next[i];
|
||||
if (m.role !== "assistant" || m.turnId !== turnId || m.streaming !== true) continue;
|
||||
if (next === list) next = list.slice();
|
||||
next[i] = { ...m, streaming: false };
|
||||
changed = true;
|
||||
}
|
||||
return changed ? next : null;
|
||||
}
|
||||
|
||||
/** 把一条消息改成某个投递态(本地待落库/排队/被拒的乐观气泡用)。 */
|
||||
export function setDelivery(
|
||||
list: ChatMessage[],
|
||||
index: number,
|
||||
delivery: MessageDelivery,
|
||||
patch: Partial<ChatMessage> = {}
|
||||
): ChatMessage[] | null {
|
||||
if (index < 0 || index >= list.length) return null;
|
||||
const next = list.slice();
|
||||
next[index] = { ...next[index], delivery, ...patch };
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 找到「本端刚发、还没被任何一路接手」的那条用户消息的下标:`pending_ack` /
|
||||
* `pending_full` 只带会话与计数、不带消息 id,本端能确定的只有这个 —— 队列深度是
|
||||
* 权威的,气泡标注是尽力而为。已绑定 turnId 的气泡绝不会被改(它已被接手)。 */
|
||||
export function localPendingIndex(list: ChatMessage[]): number {
|
||||
for (let i = list.length - 1; i >= 0; i -= 1) {
|
||||
const m = list[i];
|
||||
if (m.role !== "user" || m.turnId) continue;
|
||||
if (m.delivery === "queued" || m.delivery === "sending" || m.delivery === undefined || m.delivery === "sent") {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** 按幂等键找一个气泡(`chat_duplicate` 用来把本地乐观气泡对齐到既有 id)。 */
|
||||
export function indexByClientMsgId(list: ChatMessage[], clientMsgId: string): number {
|
||||
if (!clientMsgId) return -1;
|
||||
return list.findIndex((m) => m.clientMsgId === clientMsgId);
|
||||
}
|
||||
|
||||
/** `chat_duplicate` 命中:没有新回合、没有新内容帧,只把本地气泡的 id 换成服务端
|
||||
* 既有 id(design/16 §4.3 去重),并把投递态落回「已送达」。 */
|
||||
export function alignDuplicate(
|
||||
list: ChatMessage[],
|
||||
clientMsgId: string,
|
||||
messageId: string,
|
||||
sessionId?: string
|
||||
): ChatMessage[] | null {
|
||||
const i = indexByClientMsgId(list, clientMsgId);
|
||||
if (i === -1) return null;
|
||||
const next = list.slice();
|
||||
next[i] = {
|
||||
...next[i],
|
||||
id: messageId,
|
||||
sessionId: sessionId ?? next[i].sessionId,
|
||||
delivery: "sent",
|
||||
};
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 某会话在途路数(绑定表里属于该会话的条数)。`session_busy`/`session_idle` 帧自带的
|
||||
* `activeTurns` 是权威;这里用于帧没带计数时的多路判定(如 `stream_end` 收尾时
|
||||
* 「是不是还有别的路在跑」)。 */
|
||||
export function turnsInSession(
|
||||
bindings: Record<string, TurnBinding>,
|
||||
sessionId: string
|
||||
): TurnBinding[] {
|
||||
return Object.values(bindings).filter((b) => b.sessionId === sessionId);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
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)
|
||||
)
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -28,6 +28,9 @@ import {
|
||||
writeSessionById,
|
||||
type SessionMessages,
|
||||
} from "./sessionCache";
|
||||
import { deliveryActions, turnActions, turnState, type TurnSlice } from "./turnSlice";
|
||||
import { patchAssistantForTurn } from "./turnCache";
|
||||
import { persistOptions } from "./persistOptions";
|
||||
|
||||
interface ChatContext {
|
||||
topic?: string;
|
||||
@@ -35,7 +38,7 @@ interface ChatContext {
|
||||
notes?: string[];
|
||||
}
|
||||
|
||||
export interface AppStore {
|
||||
export interface AppStore extends TurnSlice {
|
||||
/** Session metadata (main + secondary) keyed by session id. The message truth
|
||||
* lives server-side, so this is a small list — safe to persist. */
|
||||
sessions: Record<string, SessionMeta>;
|
||||
@@ -80,7 +83,9 @@ export interface AppStore {
|
||||
awakenState: AwakenState;
|
||||
awakenFrom: AwakenState | null;
|
||||
awakenTool: string | null;
|
||||
/** 会话编排(design/15 §5)——她是否在忙 + 挂起消息数(不阻塞输入框)。 */
|
||||
/** 会话编排(design/15 §5)——她是否在忙(不阻塞输入框)。挂起数 `pendingCount`、
|
||||
* 在途路数 `activeTurns`、在途回合绑定 `turnBindings` 与六态写入动作由
|
||||
* `store/turnSlice.ts` 提供(AGENTS §10 文件 ≤500 行)。 */
|
||||
sessionBusy: boolean;
|
||||
pendingCount: number;
|
||||
/** 最近一次主代理代审小字(design/11 §4;`approval_notice` 帧,裁决 13.4.3):
|
||||
@@ -149,14 +154,16 @@ export interface AppStore {
|
||||
* duplicate frame). */
|
||||
removeMessageById: (id: string, sessionId?: string) => void;
|
||||
|
||||
addUserMessage: (text: string, mode?: Mode, image?: string, deviceId?: string) => void;
|
||||
addUserMessage: (text: string, mode?: Mode, image?: string, deviceId?: string, clientMsgId?: string) => void;
|
||||
beginAssistant: (mode?: Mode, deviceId?: string) => string;
|
||||
/** Append a streamed delta to the assistant bubble of `sessionId` (default: the
|
||||
* active session) — a background turn keeps streaming into its own bucket. */
|
||||
appendChunk: (delta: string, sessionId?: string) => void;
|
||||
endStream: (sessionId?: string) => void;
|
||||
setThinking: (text: string, sessionId?: string) => void;
|
||||
addTool: (tool: ToolUsed, sessionId?: string) => void;
|
||||
* active session) — a background turn keeps streaming into its own bucket.
|
||||
* 给了 `turnId` 就按**二级分桶**写进那一路的气泡(忙时并行下两条回复交错到达,
|
||||
* 按列表尾写会把它们糊成一条);没给则沿用旧的「列表尾气泡」行为。 */
|
||||
appendChunk: (delta: string, sessionId?: string, turnId?: string) => void;
|
||||
endStream: (sessionId?: string, turnId?: string) => void;
|
||||
setThinking: (text: string, sessionId?: string, turnId?: string) => void;
|
||||
addTool: (tool: ToolUsed, sessionId?: string, turnId?: string) => void;
|
||||
|
||||
setAgents: (agents: AgentInfo[]) => void;
|
||||
upsertAgent: (agent: AgentInfo) => void;
|
||||
@@ -172,7 +179,6 @@ export interface AppStore {
|
||||
to: AwakenState,
|
||||
tool?: string | null
|
||||
) => void;
|
||||
setSessionBusy: (busy: boolean, pendingCount?: number) => void;
|
||||
/** 记录一条主代理代审小字(`approval_notice` WS 帧)。null 表示清除。 */
|
||||
setApprovalNotice: (notice: ApprovalNotice | null) => void;
|
||||
|
||||
@@ -210,6 +216,11 @@ export const useAppStore = create<AppStore>()(
|
||||
taskLogs: {},
|
||||
channelEvents: [],
|
||||
|
||||
// 忙时并行切片(在途路数 / 绑定表 / 队列提示 + 六态与回合生命周期动作)。
|
||||
...turnState(),
|
||||
...turnActions(set, get),
|
||||
...deliveryActions(set, get),
|
||||
|
||||
setWsStatus: (status) => set({ wsStatus: status }),
|
||||
setPersonas: (list) => set({ personas: list }),
|
||||
setContext: (patch) =>
|
||||
@@ -277,7 +288,7 @@ export const useAppStore = create<AppStore>()(
|
||||
removeMessageById: (id, sessionId) =>
|
||||
set((s) => writeSessionById(s, sessionId, (list) => dropMessage(list, id))),
|
||||
|
||||
addUserMessage: (text, mode, image, deviceId) =>
|
||||
addUserMessage: (text, mode, image, deviceId, clientMsgId) =>
|
||||
set((s) => ({
|
||||
messages: [
|
||||
...s.messages,
|
||||
@@ -288,6 +299,10 @@ export const useAppStore = create<AppStore>()(
|
||||
mode,
|
||||
image,
|
||||
deviceId,
|
||||
clientMsgId,
|
||||
/** ① 发送中:本地乐观气泡,落库(`message_persisted`)或幂等命中
|
||||
* (`chat_duplicate`)后归一为服务端 id 并转「已送达」。 */
|
||||
delivery: "sending",
|
||||
createdAt: now(),
|
||||
},
|
||||
],
|
||||
@@ -313,34 +328,46 @@ export const useAppStore = create<AppStore>()(
|
||||
return id;
|
||||
},
|
||||
|
||||
appendChunk: (delta, sessionId) =>
|
||||
appendChunk: (delta, sessionId, turnId) =>
|
||||
set((s) =>
|
||||
writeSession(s, sessionId ?? s.activeSessionId, (list) =>
|
||||
patchAssistantTail(list, (last) => ({
|
||||
...last,
|
||||
content: last.content + delta,
|
||||
}))
|
||||
turnId
|
||||
? patchAssistantForTurn(list, turnId, (m) => ({
|
||||
...m,
|
||||
content: m.content + delta,
|
||||
}))
|
||||
: patchAssistantTail(list, (last) => ({
|
||||
...last,
|
||||
content: last.content + delta,
|
||||
}))
|
||||
)
|
||||
),
|
||||
|
||||
endStream: (sessionId) =>
|
||||
endStream: (sessionId, turnId) =>
|
||||
set((s) =>
|
||||
writeSession(s, sessionId ?? s.activeSessionId, (list) =>
|
||||
patchAssistantTail(list, (last) => ({ ...last, streaming: false }))
|
||||
turnId
|
||||
? patchAssistantForTurn(list, turnId, (m) => ({
|
||||
...m,
|
||||
streaming: false,
|
||||
}))
|
||||
: patchAssistantTail(list, (last) => ({ ...last, streaming: false }))
|
||||
)
|
||||
),
|
||||
|
||||
setThinking: (text, sessionId) =>
|
||||
setThinking: (text, sessionId, turnId) =>
|
||||
set((s) =>
|
||||
writeSession(s, sessionId ?? s.activeSessionId, (list) =>
|
||||
patchAssistantTail(list, (last) => ({ ...last, thinking: text }))
|
||||
turnId
|
||||
? patchAssistantForTurn(list, turnId, (m) => ({ ...m, thinking: text }))
|
||||
: patchAssistantTail(list, (last) => ({ ...last, thinking: text }))
|
||||
)
|
||||
),
|
||||
|
||||
addTool: (tool, sessionId) =>
|
||||
addTool: (tool, sessionId, turnId) =>
|
||||
set((s) =>
|
||||
writeSession(s, sessionId ?? s.activeSessionId, (list) =>
|
||||
patchAssistantTail(list, (last) => {
|
||||
writeSession(s, sessionId ?? s.activeSessionId, (list) => {
|
||||
const add = (last: ChatMessage): ChatMessage => {
|
||||
const existing = last.tools ?? [];
|
||||
const idx = existing.findIndex((t) => t.name === tool.name);
|
||||
const tools =
|
||||
@@ -348,8 +375,11 @@ export const useAppStore = create<AppStore>()(
|
||||
? [...existing, tool]
|
||||
: existing.map((t, i) => (i === idx ? { ...t, ...tool } : t));
|
||||
return { ...last, tools };
|
||||
})
|
||||
)
|
||||
};
|
||||
return turnId
|
||||
? patchAssistantForTurn(list, turnId, add)
|
||||
: patchAssistantTail(list, add);
|
||||
})
|
||||
),
|
||||
|
||||
setAgents: (agents) => set({ agents }),
|
||||
@@ -393,11 +423,6 @@ export const useAppStore = create<AppStore>()(
|
||||
awakenFrom: from ?? s.awakenFrom,
|
||||
awakenTool: tool ?? s.awakenTool,
|
||||
})),
|
||||
setSessionBusy: (busy, pendingCount) =>
|
||||
set((s) => ({
|
||||
sessionBusy: busy,
|
||||
pendingCount: pendingCount ?? s.pendingCount,
|
||||
})),
|
||||
setApprovalNotice: (notice) => set({ approvalNotice: notice }),
|
||||
|
||||
appendTaskLog: (taskId, text) =>
|
||||
@@ -434,45 +459,12 @@ export const useAppStore = create<AppStore>()(
|
||||
awakenTool: null,
|
||||
sessionBusy: false,
|
||||
pendingCount: 0,
|
||||
activeTurns: 0,
|
||||
turnBindings: {},
|
||||
queueNotice: null,
|
||||
approvalNotice: null,
|
||||
})),
|
||||
}),
|
||||
{
|
||||
name: "cyrene.app",
|
||||
version: 3,
|
||||
// v1 persisted the whole chat (`messages` + a single `sessionId`); v2 added
|
||||
// one resolved `currentPersona`, which can no longer describe the active role
|
||||
// now that the role belongs to a *session*. Both shapes are dropped:
|
||||
// `personas` is refilled from `GET /personas` and the role is re-derived from
|
||||
// the session list. `moods` is transient by nature (a restart resets a role's
|
||||
// mood), so it is not carried across either. A v1 state was never trusted for
|
||||
// the session metadata, so those users simply restart on the main session.
|
||||
migrate: (persisted, version) => {
|
||||
const p = (persisted ?? {}) as Partial<AppStore>;
|
||||
const fromV1 = version < 2;
|
||||
return {
|
||||
activeSessionId: fromV1 ? "" : p.activeSessionId ?? "",
|
||||
sessions: fromV1 ? {} : p.sessions ?? {},
|
||||
personas: p.personas ?? [],
|
||||
moods: {},
|
||||
context: p.context ?? {},
|
||||
backthink: p.backthink ?? [],
|
||||
};
|
||||
},
|
||||
partialize: (s) => ({
|
||||
// messages deliberately excluded: the backend is the message truth
|
||||
// (design/16 §1), so we never persist the whole chat into localStorage —
|
||||
// and neither is `sessionMessages` (the per-session buckets of 裁决 16.4.5).
|
||||
// We keep the (small) session metadata + the focused session id so a
|
||||
// refresh lands on the same conversation, plus the role catalog so the
|
||||
// avatar/name/wallpaper resolve before the first fetch returns.
|
||||
activeSessionId: s.activeSessionId,
|
||||
sessions: s.sessions,
|
||||
personas: s.personas,
|
||||
moods: s.moods,
|
||||
context: s.context,
|
||||
backthink: s.backthink,
|
||||
}),
|
||||
}
|
||||
persistOptions
|
||||
)
|
||||
);
|
||||
Reference in new issue
Block a user