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
@@ -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