Files
Cyrene_v2/frontend/web/src/store/useAppStore.ts
T
AskaEth 89434eadef
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(flow): graph_update 按路分桶——修真并行两路的图互相覆盖(FV2)
【取证(症状成立,且比登记描述更糟)】两路真并行时两路最终落盘**同一个 turn_id**,
其中一路的图彻底消失,且 A 路的工具被画在 B 路的图上。逐层定位:发布器 graphs 只按
sessionID 建键(后起的路整张顶掉前者)· 帧载荷基线的键集被既有测试精确断言为
{id,nodes,edges,sessionId,startedAt,endedAt}(无任何区分路的键)· 前端每会话单桶 ·
落盘 (session_id,turn_id) 唯一键两路相撞 · 冷启动 LastGraph 只有一张。
该 bug 早已公开登记(design/15 §2.5.10-1「本轮未解决」、契约 §6-19「仍**未修**」)。

【修法】
- 归属桶 {会话}[#{路}](loop.FlowOwnerKey,与 checkpoint 键同字符口径);路只来自
  ParallelLegFrom(ctx) ⇒ 单路逐字不变(桶 = 会话 id),既有调用点语义零改动
- 图 id 的第二分量改用**建图序号**(每次 +1、永不复用):同一轮里可以有并行路 +
  队首续跑的串行路,用轮次号会让两条串行路撞同一个 web-1
- 轮次号只在上一轮的路全部收尾后推进,并丢掉更早轮次的桶(否则内存随路数无界增长)
- 新增只读出口 LatestLanes ⇒ GET /admin/flow/graph 的 lanes[](最近一轮每一路),
  graph 仍是「最新一路」= 向后兼容
- 同源洞一并修:子代理归属原取 AgentSpec.ParentSession(会话、不含路)⇒ 并行路里派的
  子代理 span 被静默丢弃;新增 AgentSpec.FlowOwner 承载「会话#路」,ParentSession 继续
  只承载会话(总线载荷 subagent_result.sessionId 不受影响)
- 前端:store 增 flowLanes(按 turnId 分格、按轮淘汰、硬上限 4)+ LaneTracks 泳道卡;
  单路/未给 lanes 时渲染路径与改前逐字相同

【无迁移】turn_id 是自由文本、每路 id 唯一 ⇒ 0022 的唯一索引天然分两行(0023 仍空着)

【我的验收】① 隔离 worktree(30 文件)build/vet/linux/darwin 全过、69 包全绿 0 FAIL;
② 我亲手打红变异:让 FlowOwnerKey 忽略路 ⇒ TestFlowPublisher_ParallelLegsDoNotOverwriteEachOther
FAIL;③ 前端独立复跑 npx tsc --noEmit + npm run build 通过(4.12s)

⚠️ 契约 v0.77 的登记在紧随其后的 docs(contract) 提交里(同一个工作时段内)。
2026-10-06 08:16:03 +08:00

499 lines
20 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 { create } from "zustand";
import { persist } from "zustand/middleware";
import type {
AdminChannelMessage,
AgentInfo,
ApprovalNotice,
AwakenState,
BackthinkItem,
ChatMessage,
FlowGraph,
Mode,
PersonaRecord,
SessionMeta,
ToolUsed,
WsStatus,
} from "../lib/types";
import { now, uid } from "../lib/util";
import {
currentGraph,
dropMessage,
mergeHistory,
mergeMessage,
patchAssistantTail,
patchMessage,
writeFlowGraph,
replacePage,
withBucket,
writeSession,
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;
lastAgentMode?: string;
notes?: string[];
}
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>;
/** The session the UI is currently focused on (design/16 §2.1). "" until the
* session list is loaded. Messages in `messages` always belong to this id. */
activeSessionId: string;
wsStatus: WsStatus;
/** Messages of the *active* session, in display order (oldest first). Switching
* sessions no longer clears it: the list on screen is stashed into that session's
* bucket in `sessionMessages` and the target session's bucket is taken out
* (design/16 §4.3; 裁决 16.4.5). */
messages: ChatMessage[];
/** Message buckets of the sessions that are **not** on screen — the active
* session's list lives in `messages`. A frame (persisted message, stream delta,
* edit, delete) is written into the bucket its `sessionId` names instead of being
* dropped, so a background conversation keeps accumulating and is rendered whole
* the moment it is switched to (裁决 16.4.5). Bounded by what the backend pushed
* during this page's lifetime; the backend stays the message truth (design/16 §1)
* and a switch-in is refetched page by page. Never persisted. */
sessionMessages: SessionMessages;
/** Latest mood per role id (design/22 §4.6). The `mood` WS event is
* persona-scoped and carries no `sessionId`, so each role gets its own bucket
* and the UI shows the bucket of the *active session's* role — role X's mood
* must not sit above role Y's conversation. `""` holds events that arrived
* without a persona (legacy/unscoped frames). Seeded from the role catalog's
* role-level mood on load (`seedMoods` + `personaMoodSeed`). */
moods: Record<string, string>;
context: ChatContext;
agents: AgentInfo[];
backthink: BackthinkItem[];
/** Per-session flow graphs, keyed by the graph's own `sessionId` (裁决 16.4.2):
* a backthink / sub-session graph no longer overwrites the graph of the
* conversation the admin panel is looking at. Bounded by `MAX_GRAPHS`
* (store/sessionCache.ts), most recently updated first. */
flowGraphs: Record<string, FlowGraph>;
/** Per-session **lane** graphs (切片 FV2): every 路 (leg) of the session's latest
* round, oldest first, keyed by the graph's own `turnId`. Real parallel turns
* publish one graph per leg; they used to overwrite each other in the single
* session bucket. `flowGraphs` keeps its «session → newest graph» meaning (compat),
* the Flow page reads this list to draw each leg of the round as its own lane. */
flowLanes: Record<string, FlowGraph[]>;
/** Compatibility alias for the admin Flow / Agents pages: the graph of the
* *active* session, falling back to the main session's latest one. Kept in sync
* by `setFlowGraph` / `setActiveSession` / `resetChat`. */
flowGraph: FlowGraph | null;
deviceUpdates: string[];
/** 唤醒式能力三态(design/14 §5)——当前唤醒状态、上次切换的来源态、最近点亮的工具。 */
awakenState: AwakenState;
awakenFrom: AwakenState | null;
awakenTool: string | null;
/** 会话编排(design/15 §5)——她是否在忙(不阻塞输入框)。挂起数 `pendingCount`、
* 在途路数 `activeTurns`、在途回合绑定 `turnBindings` 与六态写入动作由
* `store/turnSlice.ts` 提供(AGENTS §10 文件 ≤500 行)。 */
sessionBusy: boolean;
pendingCount: number;
/** 最近一次主代理代审小字(design/11 §4;`approval_notice` 帧,裁决 13.4.3):
* 敏感操作经代审后显示一行「xxx 想要干什么,我审查后批准了」。同一时刻只留
* 最近一条(新的覆盖旧的),且**不持久化** —— 代审是实时事件,刷新后不重放。 */
approvalNotice: ApprovalNotice | null;
/** 适配器消息日志实时事件(`channel_persisted` WS 帧,design/16 §4.6 §4)——
* 隔离会话(群聊/陌生人)落库后秒推,供 IM 适配器日志页增量插入。最新在前,
* 最多保留最近 ~200 条,按 id 去重。web 聊天无此字段的消费者。 */
channelEvents: AdminChannelMessage[];
/** Push one `channel_persisted` message (newest first, dedup by id, capped ~200). */
pushChannelEvent: (msg: AdminChannelMessage) => void;
/** The role catalog from `GET /personas` (design/22 §4.6). The role the UI
* renders is *derived* from it plus the active session — never stored — so the
* picker, the avatar and the mood cannot drift apart. Empty until loaded (or
* when the fetch fails), and the UI then keeps its neutral placeholder. */
personas: PersonaRecord[];
/** Replace the role catalog (`GET /personas`). */
setPersonas: (list: PersonaRecord[]) => void;
setWsStatus: (status: WsStatus) => void;
setContext: (patch: Partial<ChatContext>) => void;
/** Record one role's new mood. `personaId` is the role the event named; "" keeps
* an unscoped/legacy frame in its own bucket. A null mood drops the bucket. */
setMood: (mood: string | null, personaId?: string) => void;
/** Merge role-level moods read from `GET /personas` into the buckets (design/01
* §2: mood follows the role, so the catalog is an authoritative seed). Values
* already known are overwritten by the server's truth; buckets the seed does
* not mention are left alone, so a WS frame that arrived first is never
* cleared. Build the seed with `personaMoodSeed`. */
seedMoods: (seed: Record<string, string>) => void;
/** Replace the session metadata list. */
setSessions: (list: SessionMeta[]) => void;
/** Zero one session's unread badge locally (Android 协同 B3/D4). Called when the
* user opens the session and its page has loaded, and on the `session_read`
* frame another end broadcasts — a no-op for an already-read or unknown
* session, so neither path can invent a row or re-render the list needlessly. */
clearUnread: (sessionId: string) => void;
/** Focus a session: stash the list on screen into its own bucket and take the
* target session's bucket out (裁决 16.4.5). Never drops a visited session's
* loaded page, so switching back shows it immediately while the backend page
* is refetched. */
setActiveSession: (id: string) => void;
/** Replace the active session's message list with the backend page. A streaming
* assistant bubble that has not been persisted yet is carried over (see
* `replacePage`). An empty list clears the panel (the fetch-failed path). */
setMessages: (list: ChatMessage[]) => void;
/** Prepend an older page (chronological) onto the active session's messages. */
prependMessages: (list: ChatMessage[]) => void;
/** Append a message to the bucket its `sessionId` names, or merge by id if it
* already exists there. On `message_persisted` it also adopts a same-role local
* optimistic bubble (rewriting its uid to the backend id) so a message never
* renders twice (design/16 §4.3 dedup). A message without a session id (a local
* optimistic bubble) belongs to the active session. */
appendOrUpdateMessage: (msg: ChatMessage) => void;
/** Merge one handshake `history` replay page (contract §3.2) into the session the
* frame names — up to 50 already-persisted rows, once per (re)connect. Dedup by
* id and adopt a local optimistic bubble only on an exact key (`clientMsgId` for
* users / `turnId` for assistants), never by the content/time heuristic: this is
* the reconnect catch-up path, so a wrong adopt would rewrite a streaming bubble
* with an old reply. */
mergeHistoryPage: (sessionId: string, msgs: ChatMessage[]) => void;
/** Patch one already-rendered message in place by id — the `message_edited` WS
* path. With `sessionId` the patch is confined to that session's bucket; without
* one the id is searched across the active list and every bucket (ids are
* globally unique). A miss is a no-op, so an edit can never inject a row. */
updateMessageById: (id: string, patch: Partial<ChatMessage>, sessionId?: string) => void;
/** Drop one message by id — the `message_deleted` WS path. Same scoping rules as
* `updateMessageById`; a miss is a no-op (another session's message, or a
* duplicate frame). */
removeMessageById: (id: string, sessionId?: 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.
* 给了 `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;
addBackthink: (item: BackthinkItem) => void;
revealBackthink: (id: string, conclusion: string) => void;
markBackthinkPushed: (id: string) => void;
/** Store one `graph_update` snapshot under the session it belongs to and refresh
* the `flowGraph` alias when that session is the one on screen (裁决 16.4.2). */
setFlowGraph: (graph: FlowGraph, sessionId?: string) => void;
addDeviceUpdate: (text: string) => void;
setAwaken: (
from: AwakenState | undefined,
to: AwakenState,
tool?: string | null
) => void;
/** 记录一条主代理代审小字(`approval_notice` WS 帧)。null 表示清除。 */
setApprovalNotice: (notice: ApprovalNotice | null) => void;
taskLogs: Record<string, string>;
appendTaskLog: (taskId: string, text: string) => void;
clearTaskLog: (taskId: string) => void;
resetChat: () => void;
}
export const useAppStore = create<AppStore>()(
persist(
(set, get) => ({
sessions: {},
activeSessionId: "",
wsStatus: "idle",
messages: [],
sessionMessages: {},
moods: {},
context: {},
agents: [
{ id: "main", name: "昔涟", status: "idle", role: "owner" },
],
backthink: [],
flowGraphs: {},
flowLanes: {},
flowGraph: null,
deviceUpdates: [],
awakenState: "quiet",
awakenFrom: null,
awakenTool: null,
sessionBusy: false,
pendingCount: 0,
approvalNotice: null,
personas: [],
taskLogs: {},
channelEvents: [],
// 忙时并行切片(在途路数 / 绑定表 / 队列提示 + 六态与回合生命周期动作)。
...turnState(),
...turnActions(set, get),
...deliveryActions(set, get),
setWsStatus: (status) => set({ wsStatus: status }),
setPersonas: (list) => set({ personas: list }),
setContext: (patch) =>
set((s) => ({ context: { ...s.context, ...patch } })),
setMood: (mood, personaId = "") =>
set((s) => {
const moods = { ...s.moods };
if (mood) moods[personaId] = mood;
else delete moods[personaId];
return { moods };
}),
seedMoods: (seed) =>
set((s) => (Object.keys(seed).length ? { moods: { ...s.moods, ...seed } } : s)),
setSessions: (list) =>
set({
sessions: Object.fromEntries(list.map((s) => [s.id, s])),
}),
setActiveSession: (id) =>
set((s) => {
if (id === s.activeSessionId) return s;
// 离开的会话:把屏幕上的列表存回它自己的桶(未落库的流式内容随之保留)。
// 还没有归属("")时不建桶,与切换前一致。
const buckets = s.activeSessionId
? withBucket(s.sessionMessages, s.activeSessionId, s.messages)
: s.sessionMessages;
const next = { ...buckets };
const cached = next[id] ?? [];
delete next[id];
return {
activeSessionId: id,
messages: cached,
sessionMessages: next,
flowGraph: currentGraph(s.flowGraphs, id),
};
}),
clearUnread: (sessionId) =>
set((s) => {
const meta = s.sessions[sessionId];
if (!meta?.unreadCount) return s;
return {
sessions: { ...s.sessions, [sessionId]: { ...meta, unreadCount: 0 } },
};
}),
setMessages: (list) =>
set((s) => ({ messages: replacePage(s.messages, list) })),
prependMessages: (list) =>
set((s) => {
const seen = new Set(s.messages.map((m) => m.id));
const older = list.filter((m) => !seen.has(m.id));
return older.length ? { messages: [...older, ...s.messages] } : s;
}),
appendOrUpdateMessage: (msg) =>
set((s) =>
writeSession(s, msg.sessionId ?? s.activeSessionId, (list) =>
mergeMessage(list, msg)
)
),
mergeHistoryPage: (sessionId, list) =>
set((s) => {
if (!list.length) return s;
return writeSession(s, sessionId || s.activeSessionId, (cur) =>
mergeHistory(cur, list)
);
}),
updateMessageById: (id, patch, sessionId) =>
set((s) =>
writeSessionById(s, sessionId, (list) => patchMessage(list, id, patch))
),
removeMessageById: (id, sessionId) =>
set((s) => writeSessionById(s, sessionId, (list) => dropMessage(list, id))),
addUserMessage: (text, mode, image, deviceId, clientMsgId) =>
set((s) => ({
messages: [
...s.messages,
{
id: uid(),
role: "user",
content: text,
mode,
image,
deviceId,
clientMsgId,
/** ① 发送中:本地乐观气泡,落库(`message_persisted`)或幂等命中
* (`chat_duplicate`)后归一为服务端 id 并转「已送达」。 */
delivery: "sending",
createdAt: now(),
},
],
})),
beginAssistant: (mode, deviceId) => {
const id = uid();
set((s) => ({
messages: [
...s.messages,
{
id,
role: "assistant",
content: "",
mode,
streaming: true,
tools: [],
deviceId,
createdAt: now(),
},
],
}));
return id;
},
appendChunk: (delta, sessionId, turnId) =>
set((s) =>
writeSession(s, sessionId ?? s.activeSessionId, (list) =>
turnId
? patchAssistantForTurn(list, turnId, (m) => ({
...m,
content: m.content + delta,
}))
: patchAssistantTail(list, (last) => ({
...last,
content: last.content + delta,
}))
)
),
endStream: (sessionId, turnId) =>
set((s) =>
writeSession(s, sessionId ?? s.activeSessionId, (list) =>
turnId
? patchAssistantForTurn(list, turnId, (m) => ({
...m,
streaming: false,
}))
: patchAssistantTail(list, (last) => ({ ...last, streaming: false }))
)
),
setThinking: (text, sessionId, turnId) =>
set((s) =>
writeSession(s, sessionId ?? s.activeSessionId, (list) =>
turnId
? patchAssistantForTurn(list, turnId, (m) => ({ ...m, thinking: text }))
: patchAssistantTail(list, (last) => ({ ...last, thinking: text }))
)
),
addTool: (tool, sessionId, turnId) =>
set((s) =>
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 =
idx === -1
? [...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 }),
upsertAgent: (agent) =>
set((s) => {
const idx = s.agents.findIndex((a) => a.id === agent.id);
if (idx === -1) return { agents: [...s.agents, agent] };
const agents = s.agents.slice();
agents[idx] = { ...agents[idx], ...agent };
return { agents };
}),
addBackthink: (item) =>
set((s) => ({ backthink: [item, ...s.backthink] })),
revealBackthink: (id, conclusion) =>
set((s) => ({
backthink: s.backthink.map((b) =>
b.id === id ? { ...b, conclusion, shouldNotify: true } : b
),
})),
markBackthinkPushed: (id) =>
set((s) => ({
backthink: s.backthink.map((b) =>
b.id === id ? { ...b, pushed: true } : b
),
})),
setFlowGraph: (graph, sessionId) =>
set((s) => {
// 归属会话:图自己带的 sessionId(后端 RunGraph 建图时写入)优先,其次帧壳的,
// 最后落到当前会话。每张图只进它自己的桶,互不覆盖(裁决 16.4.2);真并行时
// 同一条会话的每一路再各占一格(切片 FV2,见 sessionCache.writeFlowGraph)。
const sid = graph.sessionId ?? sessionId ?? s.activeSessionId;
const next = writeFlowGraph(s.flowGraphs, s.flowLanes, sid, graph);
return {
...next,
flowGraph: currentGraph(next.flowGraphs, s.activeSessionId),
};
}),
addDeviceUpdate: (text) =>
set((s) => ({ deviceUpdates: [text, ...s.deviceUpdates].slice(0, 50) })),
setAwaken: (from, to, tool) =>
set((s) => ({
awakenState: to,
awakenFrom: from ?? s.awakenFrom,
awakenTool: tool ?? s.awakenTool,
})),
setApprovalNotice: (notice) => set({ approvalNotice: notice }),
appendTaskLog: (taskId, text) =>
set((s) => ({
taskLogs: {
...s.taskLogs,
[taskId]: (s.taskLogs[taskId] ?? "") + text,
},
})),
clearTaskLog: (taskId) =>
set((s) => {
if (!(taskId in s.taskLogs)) return s;
const taskLogs = { ...s.taskLogs };
delete taskLogs[taskId];
return { taskLogs };
}),
pushChannelEvent: (msg) =>
set((s) => {
if (s.channelEvents.some((m) => m.id === msg.id)) return s;
return { channelEvents: [msg, ...s.channelEvents].slice(0, 200) };
}),
resetChat: () =>
set((s) => ({
messages: [],
sessionMessages: {},
moods: {},
flowGraphs: {},
flowLanes: {},
flowGraph: null,
backthink: s.backthink,
awakenState: "quiet",
awakenFrom: null,
awakenTool: null,
sessionBusy: false,
pendingCount: 0,
activeTurns: 0,
turnBindings: {},
queueNotice: null,
approvalNotice: null,
})),
}),
persistOptions
)
);