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:
1 parent
f454273c5a
commit
b480b04dd0
15 files changed
+733
-300
No files matched your search
@@ -1,9 +1,11 @@
|
||||
// Logic test for the realtime-sync frames: the D3 edit/delete pair
|
||||
// (`message_edited` / `message_deleted`), the streaming session guard
|
||||
// (design/16 §4.2 — a stream frame for another conversation must not land in the
|
||||
// bubble on screen), and the session read cursor (Android 协同 B3/D4 — the
|
||||
// `session_read` frame clears exactly the conversation it names, and opening a
|
||||
// session pushes its forward-only cursor). The project has no frontend test runner
|
||||
// (`message_edited` / `message_deleted`), the per-session message cache
|
||||
// (裁决 16.4.5 — a frame is written into the bucket its own `sessionId` names, and
|
||||
// switching sessions keeps the list instead of clearing it), the per-session flow
|
||||
// graphs (裁决 16.4.2 — a backthink graph never overwrites the active session's),
|
||||
// and the session read cursor (Android 协同 B3/D4 — the `session_read` frame clears
|
||||
// exactly the conversation it names, and opening a session pushes its forward-only
|
||||
// cursor). The project has no frontend test runner
|
||||
// and no jsdom, so this is a dependency-free plain-node check: it drives the real
|
||||
// frame decoder (`dispatchFrame` in lib/ws.ts), the real read path
|
||||
// (`markSessionOpenedRead` in lib/sessionRead.ts, via lib/api) and the real store,
|
||||
@@ -25,7 +27,9 @@
|
||||
|
||||
import { dispatchFrame } from "../lib/ws";
|
||||
import { markSessionOpenedRead } from "../lib/sessionRead";
|
||||
import { messageEventApplies, useAppStore } from "./useAppStore";
|
||||
import { frameIsActive } from "../lib/ws/handlers/shared";
|
||||
import { useAppStore } from "./useAppStore";
|
||||
import { BACKTHINK_SESSION_ID } from "./sessionCache";
|
||||
import type { ChatMessage, SessionMeta } from "../lib/types";
|
||||
|
||||
let checks = 0;
|
||||
@@ -42,15 +46,21 @@ function same<T>(got: T, want: T, what: string): void {
|
||||
const MAIN = "web";
|
||||
const OTHER = "web:sub-1";
|
||||
|
||||
/** Reset the store to a focused session holding a fixed message list. */
|
||||
/** Reset the store to a focused session holding a fixed message list (and no
|
||||
* buckets, so each case starts from a known cache state). */
|
||||
function seed(messages: ChatMessage[], activeSessionId: string): void {
|
||||
useAppStore.setState({ activeSessionId, messages });
|
||||
useAppStore.setState({ activeSessionId, messages, sessionMessages: {} });
|
||||
}
|
||||
|
||||
function messages(): ChatMessage[] {
|
||||
return useAppStore.getState().messages;
|
||||
}
|
||||
|
||||
/** The cached message bucket of a session that is not on screen. */
|
||||
function bucket(sessionId: string): ChatMessage[] {
|
||||
return useAppStore.getState().sessionMessages[sessionId] ?? [];
|
||||
}
|
||||
|
||||
function base(): ChatMessage[] {
|
||||
return [
|
||||
{ id: "m1", sessionId: MAIN, role: "user", content: "old", mode: "chat", createdAt: 1000 },
|
||||
@@ -59,9 +69,13 @@ function base(): ChatMessage[] {
|
||||
}
|
||||
|
||||
// ① Session filter: a frame scoped to another conversation never touches this one.
|
||||
ok(messageEventApplies(MAIN, MAIN), "same session applies");
|
||||
ok(!messageEventApplies(OTHER, MAIN), "another session does not apply");
|
||||
ok(messageEventApplies(undefined, MAIN), "an unscoped (id-only) frame applies");
|
||||
// 这是单槽全局状态(忙态 / 上下文 / 代审小字 / 回合结束的收口)共用的判据,
|
||||
// 实现在 lib/ws/handlers/shared.ts。
|
||||
ok(frameIsActive(MAIN, MAIN), "same session applies");
|
||||
ok(!frameIsActive(OTHER, MAIN), "another session does not apply");
|
||||
ok(frameIsActive(undefined, MAIN), "an unscoped (id-only) frame applies");
|
||||
// 激活会话尚未解析(会话列表没回来)时沿用旧行为,不把主人正看着的流丢掉。
|
||||
ok(frameIsActive(MAIN, ""), "an unresolved active session keeps the legacy behavior");
|
||||
|
||||
// ② message_edited patches the addressed bubble in place, by id, in the active
|
||||
// session — and leaves its siblings alone.
|
||||
@@ -117,10 +131,10 @@ useAppStore.getState().updateMessageById("m-ghost", { content: "x" });
|
||||
useAppStore.getState().removeMessageById("m-ghost");
|
||||
same(messages().length, 2, "store id-actions no-op on an unknown id");
|
||||
|
||||
// ⑤ Streaming session guard (design/16 §4.2). The hub broadcasts every session's
|
||||
// stream to every connection, and `appendChunk` writes into the last assistant
|
||||
// message of the single active list — so a delta carrying another sessionId
|
||||
// would otherwise splice that conversation's tokens into this bubble.
|
||||
// ⑤ 会话级消息缓存(裁决 16.4.5 / design/16 §4.3)。hub 把每个会话的帧都广播给每条
|
||||
// 连接,因此一帧要落到**它自己那个会话**:属于屏幕上这个会话就写当前列表,属于别的
|
||||
// 会话就写它的消息桶(切过去即见),而不是被丢掉。只有单槽全局状态(忙态 / 上下文)
|
||||
// 仍只认「屏幕上这个会话」。
|
||||
function streaming(): ChatMessage[] {
|
||||
return [
|
||||
{ id: "s1", sessionId: MAIN, role: "user", content: "hi", createdAt: 3000 },
|
||||
@@ -136,10 +150,36 @@ function streaming(): ChatMessage[] {
|
||||
];
|
||||
}
|
||||
|
||||
// Another session's delta never touches this bubble …
|
||||
// A foreign delta never touches this bubble, and it does not invent a bubble in the
|
||||
// other session's bucket either (a delta can only extend an assistant bubble that
|
||||
// already exists there — the persisted row covers the rest).
|
||||
seed(streaming(), MAIN);
|
||||
dispatchFrame({ type: "message.delta", sessionId: OTHER, data: { delta: "LEAK", index: 0 } });
|
||||
same(messages().map((m) => m.content), ["hi", "partial"], "delta for another session is dropped");
|
||||
same(messages().map((m) => m.content), ["hi", "partial"], "delta for another session does not touch the active bubble");
|
||||
same(bucket(OTHER), [], "a delta without a bubble in that session invents nothing");
|
||||
|
||||
// … but when the bucket already holds that turn's bubble — the common case, because
|
||||
// switching away stashes the optimistic bubbles into it — the delta lands there and
|
||||
// is on screen the moment the session is switched to.
|
||||
seed(streaming(), MAIN);
|
||||
useAppStore.setState({ sessionMessages: { [OTHER]: streaming() } });
|
||||
dispatchFrame({ type: "message.delta", sessionId: OTHER, data: { delta: "+bg", index: 0 } });
|
||||
same(bucket(OTHER)[1].content, "partial+bg", "a background turn keeps streaming into its own bucket");
|
||||
same(messages()[1].content, "partial", "…and the active bubble stays untouched");
|
||||
useAppStore.getState().setActiveSession(OTHER);
|
||||
same(messages()[1].content, "partial+bg", "switching to the background session shows its live text");
|
||||
useAppStore.getState().setActiveSession(MAIN);
|
||||
same(messages().map((m) => m.content), ["hi", "partial"], "switching back restores the first session (nothing cleared)");
|
||||
|
||||
// A persisted message for another session is cached as well (never dropped).
|
||||
seed(base(), MAIN);
|
||||
dispatchFrame({
|
||||
type: "message_persisted",
|
||||
sessionId: OTHER,
|
||||
data: { message: { id: "m9", sessionId: OTHER, role: "assistant", content: "bg", createdAt: 5000 } },
|
||||
});
|
||||
same(bucket(OTHER).map((m) => m.content), ["bg"], "message_persisted for another session is cached in its bucket");
|
||||
same(messages().length, 2, "…and leaves the active list alone");
|
||||
|
||||
// … the active session's delta is appended as before …
|
||||
seed(streaming(), MAIN);
|
||||
@@ -156,31 +196,30 @@ seed(streaming(), MAIN);
|
||||
dispatchFrame({ type: "message.delta", data: { delta: " old", index: 0 } });
|
||||
same(messages()[1].content, "partial old", "an unscoped delta keeps the legacy behavior");
|
||||
|
||||
// … as does the owner's own stream before the session list has resolved the active
|
||||
// session: the store still holds "" while the frames carry the main session, so
|
||||
// dropping them would hide a reply the user is watching.
|
||||
// Before the session list has resolved the active session the store still holds "",
|
||||
// while the frames carry the main session: the text is filed under that session (not
|
||||
// spliced into the unresolved list) and appears as soon as the list resolves.
|
||||
seed(streaming(), "");
|
||||
useAppStore.setState({ sessionMessages: { [MAIN]: streaming() } });
|
||||
dispatchFrame({ type: "message.delta", sessionId: MAIN, data: { delta: " early", index: 0 } });
|
||||
same(
|
||||
messages()[1].content,
|
||||
"partial early",
|
||||
"a delta before the active session resolves keeps the legacy behavior"
|
||||
);
|
||||
same(bucket(MAIN)[1].content, "partial early", "a delta before the active session resolves lands in the named session's bucket");
|
||||
useAppStore.getState().setActiveSession(MAIN);
|
||||
same(messages()[1].content, "partial early", "…and is on screen once the active session resolves");
|
||||
|
||||
// The legacy text channel (stream_chunk) is filtered the same way.
|
||||
// The legacy text channel (stream_chunk) is routed the same way.
|
||||
seed(streaming(), MAIN);
|
||||
dispatchFrame({ type: "stream_chunk", sessionId: OTHER, data: { delta: "LEAK" } });
|
||||
same(messages()[1].content, "partial", "legacy stream_chunk for another session is dropped");
|
||||
same(messages()[1].content, "partial", "legacy stream_chunk for another session does not touch the active bubble");
|
||||
seed(streaming(), MAIN);
|
||||
dispatchFrame({ type: "stream_chunk", data: { delta: " tail" } });
|
||||
same(messages()[1].content, "partial tail", "an unscoped stream_chunk keeps the legacy behavior");
|
||||
|
||||
// Thinking segments: the accumulator and the rendered block belong to the active
|
||||
// session only.
|
||||
// Thinking segments: the accumulator and the rendered block are per session, so a
|
||||
// background turn's reasoning never lands in the block on screen.
|
||||
seed(streaming(), MAIN);
|
||||
dispatchFrame({ type: "thinking.start", sessionId: MAIN, data: { index: 0 } });
|
||||
dispatchFrame({ type: "thinking.delta", sessionId: OTHER, data: { delta: "other-thought", index: 0 } });
|
||||
ok(messages()[1].thinking === undefined, "thinking.delta for another session is dropped");
|
||||
ok(messages()[1].thinking === undefined, "thinking.delta for another session does not touch this bubble");
|
||||
dispatchFrame({ type: "thinking.delta", sessionId: MAIN, data: { delta: "mine", index: 0 } });
|
||||
same(messages()[1].thinking, "mine", "thinking.delta for the active session accumulates");
|
||||
dispatchFrame({ type: "thinking.end", sessionId: OTHER, data: { index: 0 } });
|
||||
@@ -188,10 +227,11 @@ same(messages()[1].thinking, "mine", "thinking.end for another session leaves th
|
||||
dispatchFrame({ type: "thinking.end", sessionId: MAIN, data: { index: 0 } });
|
||||
same(messages()[1].thinking, "mine", "thinking.end for the active session keeps the text");
|
||||
|
||||
// Tool cards land on the last assistant message too.
|
||||
// Tool cards land on the last assistant message of their own session too.
|
||||
seed(streaming(), MAIN);
|
||||
dispatchFrame({ type: "tool_call.start", sessionId: OTHER, data: { id: "toolu_1", name: "web_search" } });
|
||||
same(messages()[1].tools, [], "tool_call.start for another session adds no card");
|
||||
same(messages()[1].tools, [], "tool_call.start for another session adds no card here");
|
||||
same(bucket(OTHER), [], "…and invents no bucket for it");
|
||||
dispatchFrame({ type: "tool_call.start", sessionId: MAIN, data: { id: "toolu_1", name: "web_search" } });
|
||||
same(
|
||||
(messages()[1].tools ?? []).map((t) => t.name),
|
||||
@@ -200,17 +240,21 @@ same(
|
||||
);
|
||||
seed(streaming(), MAIN);
|
||||
dispatchFrame({ type: "tool_used", sessionId: OTHER, data: { name: "shell" } });
|
||||
same(messages()[1].tools, [], "legacy tool_used for another session adds no card");
|
||||
same(messages()[1].tools, [], "legacy tool_used for another session adds no card here");
|
||||
|
||||
// stream_end carries single-slot state (the streaming flag + the busy badge), so
|
||||
// another session finishing its turn must not close this one.
|
||||
// another session finishing its turn must not close this one — it only closes its
|
||||
// own bubble, in its own bucket.
|
||||
seed(streaming(), MAIN);
|
||||
useAppStore.setState({ sessionBusy: true, pendingCount: 2 });
|
||||
useAppStore.setState({ sessionBusy: true, pendingCount: 2, sessionMessages: { [OTHER]: streaming() } });
|
||||
dispatchFrame({ type: "stream_end", sessionId: OTHER, data: { stopReason: "end_turn" } });
|
||||
ok(messages()[1].streaming === true, "a foreign stream_end does not close the active bubble");
|
||||
ok(bucket(OTHER)[1].streaming === false, "…it closes that turn's bubble in its own bucket");
|
||||
ok(useAppStore.getState().sessionBusy === true, "a foreign stream_end does not clear the busy badge");
|
||||
same(useAppStore.getState().pendingCount, 2, "a foreign stream_end does not clear the pending count");
|
||||
|
||||
seed(streaming(), MAIN);
|
||||
useAppStore.setState({ sessionBusy: true, pendingCount: 2 });
|
||||
dispatchFrame({ type: "stream_end", sessionId: MAIN, data: { stopReason: "end_turn" } });
|
||||
ok(messages()[1].streaming === false, "the active session's stream_end closes the bubble");
|
||||
ok(useAppStore.getState().sessionBusy === false, "the active session's stream_end clears busy");
|
||||
@@ -328,4 +372,41 @@ same(calls.length, 1, "a missing session metadata entry sends no read call");
|
||||
globalThis.fetch = originalFetch;
|
||||
globalThis.localStorage = originalLocalStorage;
|
||||
|
||||
// ⑦ 会话级链路图(裁决 16.4.2)。`graph_update` 快照按它自己的 sessionId 入桶:
|
||||
// 后台思考的图不会顶掉管理端 Flow / Agents 页正在看的「当前会话」图,管理后台
|
||||
// 因此能同时拿到每一路(用户会话 / 后台思考)的完整链路。
|
||||
seed([], MAIN);
|
||||
dispatchFrame({
|
||||
type: "graph_update",
|
||||
data: { id: "web-1", nodes: [], edges: [], sessionId: MAIN },
|
||||
});
|
||||
dispatchFrame({
|
||||
type: "graph_update",
|
||||
data: { id: "sess-backthink-1", nodes: [], edges: [], sessionId: BACKTHINK_SESSION_ID },
|
||||
});
|
||||
same(
|
||||
useAppStore.getState().flowGraphs[MAIN]?.id,
|
||||
"web-1",
|
||||
"a graph is stored under its own sessionId"
|
||||
);
|
||||
same(
|
||||
useAppStore.getState().flowGraphs[BACKTHINK_SESSION_ID]?.id,
|
||||
"sess-backthink-1",
|
||||
"the backthink graph lives in its own bucket"
|
||||
);
|
||||
same(
|
||||
useAppStore.getState().flowGraph?.id,
|
||||
"web-1",
|
||||
"the compatibility alias stays on the active session (a backthink graph never takes it over)"
|
||||
);
|
||||
|
||||
// A graph without a sessionId (legacy/unscoped frame) falls back to the active session.
|
||||
seed([], MAIN);
|
||||
dispatchFrame({ type: "graph_update", data: { id: "bare-1", nodes: [], edges: [] } });
|
||||
same(
|
||||
useAppStore.getState().flowGraphs[MAIN]?.id,
|
||||
"bare-1",
|
||||
"an unscoped graph falls back to the active session"
|
||||
);
|
||||
|
||||
console.log(`message sync logic test: ${checks} checks passed`);
|
||||
@@ -0,0 +1,199 @@
|
||||
import type { ChatMessage, FlowGraph } from "../lib/types";
|
||||
import type { AppStore } from "./useAppStore";
|
||||
|
||||
/**
|
||||
* 会话级缓存助手(裁决 16.4.5 / 16.4.2)——纯函数,不碰 store,便于逐条做逻辑测试。
|
||||
*
|
||||
* 两件事:
|
||||
* ① **消息桶**(design/16 §4.3):一个会话一份消息列表。store 把**激活会话**的列表
|
||||
* 放在 `messages`,把**非激活会话**的列表放在 `sessionMessages`;这样任何一帧都能
|
||||
* 按自己的 `sessionId` 落进对应的桶(而不是被丢弃),切会话时把离开的会话存回它的
|
||||
* 桶、把切到的会话取出,于是「切会话不再清空」。
|
||||
* ② **链路图桶**(裁决 16.4.2):一个会话最近一张 `graph_update` 快照。管理后台按
|
||||
* `sessionId` 取图,而不是所有页面共用一张全局图——后台思考的完整链路图与
|
||||
* 「用户侧前端发不发」无关,也绝不顶掉管理员看的「当前会话」图。
|
||||
*
|
||||
* 从 `useAppStore.ts` 拆出来(AGENTS §10 文件 ≤500 行 / 三次复制必须抽取):下面每条
|
||||
* 规则都被「激活会话」与「非激活会话」两条写入路径共用,放在一处才不会走样。
|
||||
*/
|
||||
|
||||
/** 后端固定主会话 id(`internal/runtime/history` 的 `MainSessionID`,design/16 §2.1)。 */
|
||||
export const MAIN_SESSION_ID = "web";
|
||||
|
||||
/** 后台思考会话 id(`internal/services/backthink` 的 `DefaultSessionID`;design/19 §1 的
|
||||
* 发布会话范围、design/20 §2)。后台思考的完整链路图发在这个会话上,管理后台的
|
||||
* Backthink 页据此取图(裁决 16.4.2)。 */
|
||||
export const BACKTHINK_SESSION_ID = "sess-backthink";
|
||||
|
||||
/** 一个会话的消息列表;键是会话 id。 */
|
||||
export type SessionMessages = Record<string, ChatMessage[]>;
|
||||
|
||||
/** 把一条后端消息并入消息列表:按 id upsert;没有同 id 时采纳本端同 role 的本地
|
||||
* 乐观气泡(本地 `uid` → 后端 `id`),使同一句话不会渲染两遍(design/16 §4.3 去重)。
|
||||
* 后端消息是内容的真源,本地气泡保留流式/工具/思考等本端状态。 */
|
||||
export function mergeMessage(list: ChatMessage[], msg: ChatMessage): ChatMessage[] {
|
||||
const idx = list.findIndex((m) => m.id === msg.id);
|
||||
if (idx !== -1) {
|
||||
const next = list.slice();
|
||||
next[idx] = { ...next[idx], ...msg };
|
||||
return next;
|
||||
}
|
||||
// 没有同 id:找一个本端尚未落库的同 role 气泡(无 sessionId)来归一化。
|
||||
// 优先内容完全相同的那条,否则取 createdAt 最近的;从新到旧扫描。
|
||||
let best = -1;
|
||||
let bestDist = Number.MAX_SAFE_INTEGER;
|
||||
for (let i = list.length - 1; i >= 0; i--) {
|
||||
const m = list[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) return [...list, msg];
|
||||
const m = list[best];
|
||||
const next = list.slice();
|
||||
next[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 next;
|
||||
}
|
||||
|
||||
/** 按 id 就地打补丁。目标不在列表里返回 `null` —— 调用方据此不动状态,另一个会话
|
||||
* 的编辑帧绝不会在这里凭空插一行。 */
|
||||
export function patchMessage(
|
||||
list: ChatMessage[],
|
||||
id: string,
|
||||
patch: Partial<ChatMessage>
|
||||
): ChatMessage[] | null {
|
||||
const idx = list.findIndex((m) => m.id === id);
|
||||
if (idx === -1) return null;
|
||||
const next = list.slice();
|
||||
next[idx] = { ...next[idx], ...patch };
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 按 id 删除。不在列表里返回 `null`(重复帧、或那条消息本就属于别的会话)。 */
|
||||
export function dropMessage(list: ChatMessage[], id: string): ChatMessage[] | null {
|
||||
const next = list.filter((m) => m.id !== id);
|
||||
return next.length === list.length ? null : next;
|
||||
}
|
||||
|
||||
/** 对**最后一条助手气泡**做一次变换:流式增量、思考文本、工具卡、流结束都落在它上面。
|
||||
* 列表为空或最后一条不是助手气泡时返回 `null` —— 帧不能改到用户气泡或更早的历史上。 */
|
||||
export function patchAssistantTail(
|
||||
list: ChatMessage[],
|
||||
fn: (m: ChatMessage) => ChatMessage
|
||||
): ChatMessage[] | null {
|
||||
const last = list[list.length - 1];
|
||||
if (!last || last.role !== "assistant") return null;
|
||||
const next = list.slice();
|
||||
next[next.length - 1] = fn(last);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 把一个会话的消息列表写回桶:空列表就删键(省内存,也代表「该会话没有缓存」)。 */
|
||||
export function withBucket(
|
||||
buckets: SessionMessages,
|
||||
sessionId: string,
|
||||
list: ChatMessage[]
|
||||
): SessionMessages {
|
||||
if (list.length) return { ...buckets, [sessionId]: list };
|
||||
if (!(sessionId in buckets)) return buckets;
|
||||
const next = { ...buckets };
|
||||
delete next[sessionId];
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 用后端的一页**替换**当前列表,但保留本端尚未落库的流式助手气泡:切走再切回时它
|
||||
* 已随会话桶回来,后端要等这一轮结束才落库,直接丢掉会让用户正看着的回复凭空消失
|
||||
* (design/16 §4.3)。该气泡若已出现在后端这一页里(同 id)则不重复追加。 */
|
||||
export function replacePage(list: ChatMessage[], page: ChatMessage[]): ChatMessage[] {
|
||||
const tail = list[list.length - 1];
|
||||
if (!tail?.streaming || tail.role !== "assistant") return page;
|
||||
if (page.some((m) => m.id === tail.id)) return page;
|
||||
return [...page, tail];
|
||||
}
|
||||
|
||||
/** 最多保留多少个会话的链路图。web 子会话可能很多,桶不能无限长(图随每次 span
|
||||
* 迁移更新,是高频写入);超限时丢弃最久没更新的那个会话。 */
|
||||
const MAX_GRAPHS = 12;
|
||||
|
||||
/** 存一张会话图:键按最近更新重排(先删后插),超出上限时丢掉最久没更新的会话。 */
|
||||
export function putGraph(
|
||||
graphs: Record<string, FlowGraph>,
|
||||
sessionId: string,
|
||||
graph: FlowGraph
|
||||
): Record<string, FlowGraph> {
|
||||
const next: Record<string, FlowGraph> = {};
|
||||
for (const [k, v] of Object.entries(graphs)) if (k !== sessionId) next[k] = v;
|
||||
next[sessionId] = graph;
|
||||
const keys = Object.keys(next);
|
||||
if (keys.length <= MAX_GRAPHS) return next;
|
||||
const drop = new Set(keys.slice(0, keys.length - MAX_GRAPHS));
|
||||
const trimmed: Record<string, FlowGraph> = {};
|
||||
for (const k of keys) if (!drop.has(k)) trimmed[k] = next[k];
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/** 兼容字段 `flowGraph` 的口径(管理端 Flow / Agents 页读它):**激活会话**的图;
|
||||
* 激活会话还没有图时回落主会话的最近一张,使页面不至于空着。
|
||||
* 后台思考会话的图不会顶掉它 —— 这正是裁决 16.4.2 要修的:每条思考看自己的图,
|
||||
* 「当前会话」的图只随当前会话变化。 */
|
||||
export function currentGraph(
|
||||
graphs: Record<string, FlowGraph>,
|
||||
activeSessionId: string
|
||||
): FlowGraph | null {
|
||||
return graphs[activeSessionId] ?? graphs[MAIN_SESSION_ID] ?? null;
|
||||
}
|
||||
|
||||
/** 一次会话级写入:`fn` 返回新列表就写回该会话(激活会话写 `messages`,其它写它的
|
||||
* 桶),返回 `null` 表示这次不改动(帧与我们持有的消息对不上)。所有会话级写入
|
||||
* 都走这里,两条路径的规则因此不会走样(裁决 16.4.5)。
|
||||
*
|
||||
* 放在本模块而非 store 里:写入的落点就是缓存策略,且 `AppStore` 只作类型引入
|
||||
* (编译后被擦除,不构成运行时循环依赖);`useAppStore.ts` 也要守住 ≤500 行。 */
|
||||
export function writeSession(
|
||||
s: AppStore,
|
||||
sessionId: string,
|
||||
fn: (list: ChatMessage[]) => ChatMessage[] | null
|
||||
): Partial<AppStore> | AppStore {
|
||||
if (sessionId === s.activeSessionId) {
|
||||
const next = fn(s.messages);
|
||||
return next === null ? s : { messages: next };
|
||||
}
|
||||
const next = fn(s.sessionMessages[sessionId] ?? []);
|
||||
if (next === null) return s;
|
||||
return { sessionMessages: withBucket(s.sessionMessages, sessionId, next) };
|
||||
}
|
||||
|
||||
/** 按 id 的会话级写入(编辑/删除帧)。帧带 `sessionId` 时才在它能命中的会话里找;
|
||||
* 没带则说明后端解析不出会话(id 全局唯一)—— 先看屏幕上这个会话,再逐桶找,
|
||||
* 命中的那一桶只改它自己(裁决 16.4.5)。 */
|
||||
export function writeSessionById(
|
||||
s: AppStore,
|
||||
sessionId: string | undefined,
|
||||
fn: (list: ChatMessage[]) => ChatMessage[] | null
|
||||
): Partial<AppStore> | AppStore {
|
||||
if (sessionId) return writeSession(s, sessionId, fn);
|
||||
const active = fn(s.messages);
|
||||
if (active !== null) return { messages: active };
|
||||
for (const [sid, list] of Object.entries(s.sessionMessages)) {
|
||||
const next = fn(list);
|
||||
if (next !== null) {
|
||||
return { sessionMessages: withBucket(s.sessionMessages, sid, next) };
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user