feat(web): 流式帧按会话过滤,非激活会话不再串入当前气泡
- 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:
1 parent
61cc32c44b
commit
04a8ee0727
4 files changed
+171
-7
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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). */
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user