feat(web): 前端与后端同步一批(帧壳 turnId / 并行六态与两级分桶 / documents 渲染 / System 键名 / 主人档案分组)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 帧壳 `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:
AskaEth committed 2026-10-05 11:09:24 +08:00
1 parent 6a545badbd
commit eaada3af5d
34 files changed
+2457 -223

No files matched your search

+47
View File
@@ -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,
}),
};
+25 -3
View File
@@ -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,
+190
View File
@@ -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);
}
+187
View File
@@ -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)
)
),
};
}
+58 -66
View File
@@ -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
)
);