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

+199
View File
@@ -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;
}