feat(web,ws): 会话级消息缓存 + 图桶 + 忙态按会话过滤(裁决 16.4.5/16.4.2 + 修 16.4.1 的帧缺口)

- 16.4.5:非激活会话的帧**不再丢弃**,按自己的 sessionId 落进该会话桶(新增 store/sessionCache.ts 纯函数 +
  useAppStore.sessionMessages/flowGraphs);切会话改为「存回/取出」而非清空;流式增量 O(1) 定位桶、
  只延长已存在的助手气泡(不凭一帧 delta 凭空造消息)
- 16.4.2:图按会话入桶(上限 12 个),Backthink 页取 sess-backthink 的图而不再被用户会话图顶替;
  空态区分「实时通道未连接」与「尚未产生链路图」;wire_runtime 的图源白名单注释按裁决口径重写(零行为改动)
- 修 16.4.1 的缺口:handlers/session.ts 补 `session_idle` / `session_interrupted`(此前落到未知帧兜底 ⇒
  sessionBusy 可能卡 true ⇒ 预约永不落地);忙态**置忙与置闲两侧**统一按 frameIsActive 过滤(只过滤置闲
  会让后台会话的忙帧点亮徽标而它自己的 idle 被丢 ⇒ 永久卡忙)
- 清理:删除 lib/streamScope.ts(口径已被 16.4.5 推翻)与只剩测试在用的 messageEventApplies
- 复核:tsc 0 错 + vite build 成功;messageSync 69 / session 19 项逻辑测试;4 条变异反证
This commit is contained in:
AskaEth committed 2026-10-04 13:41:02 +08:00
1 parent f454273c5a
commit b480b04dd0
15 files changed
+733 -300

No files matched your search

+143 -144
View File
@@ -15,6 +15,19 @@ import type {
WsStatus,
} from "../lib/types";
import { now, uid } from "../lib/util";
import {
currentGraph,
dropMessage,
mergeMessage,
patchAssistantTail,
patchMessage,
putGraph,
replacePage,
withBucket,
writeSession,
writeSessionById,
type SessionMessages,
} from "./sessionCache";
interface ChatContext {
topic?: string;
@@ -30,9 +43,19 @@ export interface AppStore {
* 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). Reset on
* session switch and refilled from the backend (design/16 §4/§5). */
/** 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
@@ -43,6 +66,14 @@ export interface AppStore {
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>;
/** 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)——当前唤醒状态、上次切换的来源态、最近点亮的工具。 */
@@ -91,37 +122,50 @@ export interface AppStore {
* 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 and clear its message cache (refilled by the caller). */
/** 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 (initial page on switch). */
/** 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, or merge by id if it already exists. 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). */
/** 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;
/** Patch one already-rendered message in place by id — the `message_edited` WS
* path. A no-op when the id is not in the active session, so an edit broadcast
* for another conversation can never inject a row here. */
updateMessageById: (id: string, patch: Partial<ChatMessage>) => void;
/** Drop one message by id — the `message_deleted` WS path. A no-op when the id
* is absent (another session's message, or a duplicate frame). */
removeMessageById: (id: string) => void;
* 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) => void;
beginAssistant: (mode?: Mode, deviceId?: string) => string;
appendChunk: (delta: string) => void;
endStream: () => void;
setThinking: (text: string) => void;
addTool: (tool: ToolUsed) => void;
/** 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;
setAgents: (agents: AgentInfo[]) => void;
upsertAgent: (agent: AgentInfo) => void;
addBackthink: (item: BackthinkItem) => void;
revealBackthink: (id: string, conclusion: string) => void;
markBackthinkPushed: (id: string) => void;
setFlowGraph: (graph: FlowGraph) => 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,
@@ -146,12 +190,14 @@ export const useAppStore = create<AppStore>()(
activeSessionId: "",
wsStatus: "idle",
messages: [],
sessionMessages: {},
moods: {},
context: {},
agents: [
{ id: "main", name: "昔涟", status: "idle", role: "owner" },
],
backthink: [],
flowGraphs: {},
flowGraph: null,
deviceUpdates: [],
awakenState: "quiet",
@@ -182,7 +228,24 @@ export const useAppStore = create<AppStore>()(
set({
sessions: Object.fromEntries(list.map((s) => [s.id, s])),
}),
setActiveSession: (id) => set({ activeSessionId: id, messages: [] }),
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];
@@ -191,76 +254,28 @@ export const useAppStore = create<AppStore>()(
sessions: { ...s.sessions, [sessionId]: { ...meta, unreadCount: 0 } },
};
}),
setMessages: (list) => set({ messages: list }),
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 { messages: [...older, ...s.messages] };
return older.length ? { messages: [...older, ...s.messages] } : s;
}),
appendOrUpdateMessage: (msg) =>
set((s) => {
// Exact id match → merge. Keeps local streaming/tools/thinking, takes
// the backend's content/mode/mood as truth.
const idx = s.messages.findIndex((m) => m.id === msg.id);
if (idx !== -1) {
const messages = s.messages.slice();
messages[idx] = { ...messages[idx], ...msg };
return { messages };
}
// No exact id: adopt a local optimistic bubble (no sessionId) of the
// same role, so a message the local browser already rendered
// optimistically is never duplicated when message_persisted arrives
// (design/16 §4.3 dedup by id). Prefer a content match, else the
// nearest by createdAt; scan newest-first.
let best = -1;
let bestDist = Number.MAX_SAFE_INTEGER;
for (let i = s.messages.length - 1; i >= 0; i--) {
const m = s.messages[i];
if (m.sessionId || m.role !== msg.role || m.id === msg.id) continue;
const dist = m.content === msg.content ? 0 : Math.abs(m.createdAt - msg.createdAt);
if (dist < bestDist) {
bestDist = dist;
best = i;
}
}
if (best !== -1) {
const m = s.messages[best];
const messages = s.messages.slice();
messages[best] = {
...m,
id: msg.id,
sessionId: msg.sessionId ?? m.sessionId,
content: msg.content || m.content,
mode: msg.mode ?? m.mode,
mood: msg.mood ?? m.mood,
image: msg.image ?? m.image,
platform: msg.platform ?? m.platform,
deviceId: msg.deviceId ?? m.deviceId,
personaId: msg.personaId ?? m.personaId,
createdAt: msg.createdAt ?? m.createdAt,
};
return { messages };
}
// No local bubble (e.g. a remote browser that never optimistically
// rendered this message): append.
return { messages: [...s.messages, msg] };
}),
set((s) =>
writeSession(s, msg.sessionId ?? s.activeSessionId, (list) =>
mergeMessage(list, msg)
)
),
updateMessageById: (id, patch) =>
set((s) => {
const idx = s.messages.findIndex((m) => m.id === id);
if (idx === -1) return s;
const messages = s.messages.slice();
messages[idx] = { ...messages[idx], ...patch };
return { messages };
}),
updateMessageById: (id, patch, sessionId) =>
set((s) =>
writeSessionById(s, sessionId, (list) => patchMessage(list, id, patch))
),
removeMessageById: (id) =>
set((s) => {
const messages = s.messages.filter((m) => m.id !== id);
return messages.length === s.messages.length ? s : { messages };
}),
removeMessageById: (id, sessionId) =>
set((s) => writeSessionById(s, sessionId, (list) => dropMessage(list, id))),
addUserMessage: (text, mode, image, deviceId) =>
set((s) => ({
@@ -298,47 +313,44 @@ export const useAppStore = create<AppStore>()(
return id;
},
appendChunk: (delta) =>
set((s) => {
const messages = s.messages.slice();
const last = messages[messages.length - 1];
if (!last || last.role !== "assistant") return s;
messages[messages.length - 1] = { ...last, content: last.content + delta };
return { messages };
}),
appendChunk: (delta, sessionId) =>
set((s) =>
writeSession(s, sessionId ?? s.activeSessionId, (list) =>
patchAssistantTail(list, (last) => ({
...last,
content: last.content + delta,
}))
)
),
endStream: () =>
set((s) => {
const messages = s.messages.slice();
const last = messages[messages.length - 1];
if (!last || last.role !== "assistant") return s;
messages[messages.length - 1] = { ...last, streaming: false };
return { messages };
}),
endStream: (sessionId) =>
set((s) =>
writeSession(s, sessionId ?? s.activeSessionId, (list) =>
patchAssistantTail(list, (last) => ({ ...last, streaming: false }))
)
),
setThinking: (text) =>
set((s) => {
const messages = s.messages.slice();
const last = messages[messages.length - 1];
if (!last || last.role !== "assistant") return s;
messages[messages.length - 1] = { ...last, thinking: text };
return { messages };
}),
setThinking: (text, sessionId) =>
set((s) =>
writeSession(s, sessionId ?? s.activeSessionId, (list) =>
patchAssistantTail(list, (last) => ({ ...last, thinking: text }))
)
),
addTool: (tool) =>
set((s) => {
const messages = s.messages.slice();
const last = messages[messages.length - 1];
if (!last || last.role !== "assistant") return s;
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));
messages[messages.length - 1] = { ...last, tools };
return { messages };
}),
addTool: (tool, sessionId) =>
set((s) =>
writeSession(s, sessionId ?? s.activeSessionId, (list) =>
patchAssistantTail(list, (last) => {
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 };
})
)
),
setAgents: (agents) => set({ agents }),
upsertAgent: (agent) =>
@@ -365,7 +377,14 @@ export const useAppStore = create<AppStore>()(
),
})),
setFlowGraph: (graph) => set({ flowGraph: graph }),
setFlowGraph: (graph, sessionId) =>
set((s) => {
// 归属会话:图自己带的 sessionId(后端 RunGraph 建图时写入)优先,其次帧壳的,
// 最后落到当前会话。每张图只进它自己的桶,互不覆盖(裁决 16.4.2)。
const sid = graph.sessionId ?? sessionId ?? s.activeSessionId;
const flowGraphs = putGraph(s.flowGraphs, sid, graph);
return { flowGraphs, flowGraph: currentGraph(flowGraphs, s.activeSessionId) };
}),
addDeviceUpdate: (text) =>
set((s) => ({ deviceUpdates: [text, ...s.deviceUpdates].slice(0, 50) })),
setAwaken: (from, to, tool) =>
@@ -405,7 +424,9 @@ export const useAppStore = create<AppStore>()(
resetChat: () =>
set((s) => ({
messages: [],
sessionMessages: {},
moods: {},
flowGraphs: {},
flowGraph: null,
backthink: s.backthink,
awakenState: "quiet",
@@ -440,7 +461,8 @@ export const useAppStore = create<AppStore>()(
},
partialize: (s) => ({
// messages deliberately excluded: the backend is the message truth
// (design/16 §1), so we never persist the whole chat into localStorage.
// (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.
@@ -454,26 +476,3 @@ export const useAppStore = create<AppStore>()(
}
)
);
/** Convenience selector for the last streaming assistant message id. */
export function lastStreamingMessage(s: AppStore): ChatMessage | undefined {
const last = s.messages[s.messages.length - 1];
return last?.role === "assistant" ? last : undefined;
}
/** Does an edit/delete frame scoped to `sessionId` belong to the conversation the
* UI is showing? `message_edited` / `message_deleted` carry the owning session so
* the rule matches `message_persisted`: only the active session's messages are
* rendered, so another conversation's frame must not touch this list.
*
* A frame without a session id is id-scoped (the backend could not resolve the
* conversation — see the gateway's optional message lookup). It still applies:
* message ids are globally unique, so the by-id update/removal can only ever match
* a message of its own session, and an id we do not hold is a no-op either way. */
export function messageEventApplies(
sessionId: string | undefined,
activeSessionId: string
): boolean {
if (!sessionId) return true;
return sessionId === activeSessionId;
}