feat(web): 流式帧按会话过滤,非激活会话不再串入当前气泡
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- WsDownEvent 增 sessionId;流式分支(message/thinking/tool_call/tool_execution/stream_chunk/stream_end 及 legacy thinking/tool_used)统一过 streamFrameApplies
- 会话列表未解析(activeSessionId 为空)时保持改前行为,避免误丢主人自己的流
- 抽出 lib/streamScope.ts 承载该判断,ws.ts 回到 500 行红线内
- messageSync.test.ts 增 21 项断言(跨会话丢弃/激活追加/无 session 兼容/未解析回退)
This commit is contained in:
AskaEth committed 2026-10-03 16:08:53 +08:00
1 parent 61cc32c44b
commit 04a8ee0727
4 files changed
+171 -7

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
import { messageEventApplies } from "../store/useAppStore";
/** May a streaming frame write into the conversation the UI is showing?
*
* The hub broadcasts every session's stream to every connection (design/16 §4.2),
* while `appendChunk` / `endStream` / `setThinking` / `addTool` all target the
* *last* assistant message of the one message list — so a background
* conversation's tokens would otherwise be spliced into the bubble on screen.
* The frame's own session (`data.sessionId` when present, else the envelope's)
* decides.
*
* An unresolved active session (the session list has not loaded yet, or its fetch
* failed) keeps the pre-guard behavior rather than dropping the owner's own
* stream: the backend maps an empty request session to the main session, so those
* frames carry "web" while the store still holds "".
*
* Scope note (deliberate): a foreign frame is *dropped*, not buffered — the store
* holds the active session only, and that conversation's text arrives when it is
* switched to and its history is fetched. Buffering live deltas needs the
* per-session cache of design/16 §4.3, a separate step. */
export function streamFrameApplies(
ev: { sessionId?: string },
r: Record<string, unknown> | null,
activeSessionId: string
): boolean {
// Unresolved session list: keep the pre-guard path (see the doc comment).
if (!activeSessionId) return true;
const sessionId = typeof r?.sessionId === "string" ? r.sessionId : ev.sessionId;
return messageEventApplies(sessionId, activeSessionId);
}
+11 -2
View File
@@ -215,19 +215,28 @@ export interface ChatSyncResult {
/**
* Downstream WS frame (server → client). `data` is intentionally `unknown`;
* every consumer narrows it. The backend emits `{type, data, turnId?, seq?}`
* and types include the structured stream events message.start/delta/end,
* every consumer narrows it. The backend emits `{type, data, turnId?, seq?,
* sessionId?}` and types include the structured stream events message.start/delta/end,
* thinking.start/delta/end, tool_call.start/delta/end, tool_execution.update
* (design/04 §1.1), plus the legacy stream_chunk / stream_end / thinking / mood /
* tool_used / subagent_event / graph_update / reminder / device_update / auth /
* error / task_output, and the capacity/session events from design/14 §5 + 15 §5:
* capacity_awaken (状态切换+点亮工具), session_busy / session_pending (她是否在忙+挂起消息数).
*
* `sessionId` (design/16 §4.2) scopes the frame to one conversation. The hub
* broadcasts globally — every connection of the owner sees every session — so the
* client filters by the active session: a frame belonging to another conversation
* (a concurrent stream, an edit, a delete) must never reach the rendered bubble.
* It is absent when the event is not conversation-scoped (panel/device events) or
* when the backend could not resolve the conversation; consumers then keep the old
* permissive behavior rather than dropping the frame.
*/
export interface WsDownEvent {
type: string;
data: unknown;
turnId?: string;
seq?: number;
sessionId?: string;
}
/** Upstream WS frame (client → server). */
+19
View File
@@ -14,6 +14,7 @@ import {
toRecord,
} from "./wsParse";
import { MODES, type AwakenState, type ChatMessage, type Mode, type WsUpEvent } from "./types";
import { streamFrameApplies } from "./streamScope";
/**
* Realtime client for `/ws/chat`. Reconnects with exponential backoff and
@@ -83,10 +84,15 @@ export function dispatchFrame(ev: { type: string; sessionId?: string; data: unkn
switch (ev.type) {
case "stream_chunk": {
if (!streamFrameApplies(ev, r, s.activeSessionId)) break;
if (r && typeof r.delta === "string") s.appendChunk(r.delta);
break;
}
case "stream_end": {
// Another session's turn ending must not close the streaming bubble on
// screen or clear this conversation's busy badge (both are single-slot
// state: the last message's flag, and one shell-wide `sessionBusy`).
if (!streamFrameApplies(ev, r, s.activeSessionId)) break;
s.endStream();
// The assistant reply stopped streaming, so the immediate turn is over:
// close the busy/pending window so the badge does not stick. If the backend
@@ -104,6 +110,7 @@ export function dispatchFrame(ev: { type: string; sessionId?: string; data: unkn
break;
}
case "message.delta": {
if (!streamFrameApplies(ev, r, s.activeSessionId)) break;
if (r && typeof r.delta === "string") s.appendChunk(r.delta);
break;
}
@@ -167,10 +174,14 @@ export function dispatchFrame(ev: { type: string; sessionId?: string; data: unkn
break;
}
case "thinking.start": {
// Guarded like the deltas: only the active conversation may reset the
// accumulator (which also bounds a switched-away session's stale text).
if (!streamFrameApplies(ev, r, s.activeSessionId)) break;
pendingThinking = "";
break;
}
case "thinking.delta": {
if (!streamFrameApplies(ev, r, s.activeSessionId)) break;
if (r && typeof r.delta === "string") {
pendingThinking += r.delta;
s.setThinking(pendingThinking);
@@ -178,12 +189,14 @@ export function dispatchFrame(ev: { type: string; sessionId?: string; data: unkn
break;
}
case "thinking.end": {
if (!streamFrameApplies(ev, r, s.activeSessionId)) break;
if (pendingThinking) s.setThinking(pendingThinking);
pendingThinking = "";
break;
}
case "tool_call.start":
case "tool_call.end": {
if (!streamFrameApplies(ev, r, s.activeSessionId)) break;
const tool = parseToolCall(ev.data);
if (tool) s.addTool(tool);
break;
@@ -194,11 +207,15 @@ export function dispatchFrame(ev: { type: string; sessionId?: string; data: unkn
break;
}
case "tool_execution.update": {
// Same card as tool_call.* — same guard.
if (!streamFrameApplies(ev, r, s.activeSessionId)) break;
const tool = parseToolCall(ev.data);
if (tool) s.addTool(tool);
break;
}
case "thinking": {
// Legacy single-shot CoT frame — renders into the same bubble.
if (!streamFrameApplies(ev, r, s.activeSessionId)) break;
if (typeof ev.data === "string") {
s.setThinking(ev.data);
} else if (r && typeof r.text === "string") {
@@ -212,6 +229,8 @@ export function dispatchFrame(ev: { type: string; sessionId?: string; data: unkn
break;
}
case "tool_used": {
// Legacy tool echo — same card as tool_call.*.
if (!streamFrameApplies(ev, r, s.activeSessionId)) break;
const tool = parseTool(ev.data);
if (tool) s.addTool(tool);
break;