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

+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;