- 帧壳 `turnId` 在 socket 层不再被丢(`lib/ws.ts` 的 dispatchFrame 补 turnId/seq)—— 按路分桶的前提 - 并行帧组接入:`turn_begin` / `steer_ack` / `pending_ack` / `pending_drain` / `pending_full` / `chat_duplicate` ⇒ 六态 + 灰态 + `(sessionId, turnId)` 两级分桶;`appendChunk` 不再永远写尾气泡 - 多路忙态:`session_interrupted` 不再无条件当空闲(看 mode/activeTurns);`stream_end` 不再只按会话过滤 - `documents` 渲染(含 status/chars/truncated 与**逐字中文 reason**);`turnId` 历史行分组小标 - `System.tsx` 键名改正(真键 HOST/PORT/ADMIN_USERNAME/ADMIN_PASSWORD,避免照抄静默失效) - 主人档案**按平台分组** + 平台下拉 + 账号候选;**写盘仍展平数组**(后端契约不变) - 验证:tsc --noEmit ✅、npm run build ✅、22 个前端测试文件全通过(本次新增 3 文件 108 条断言) - 未做(如实):握手 history handler 与断线补拉、上行 interrupt 按钮、proactive_message/subagent_result 等帧落点、文档上传入口与图片预检、voice 管线、System 的 env 读端点
72 lines
3.1 KiB
TypeScript
72 lines
3.1 KiB
TypeScript
import { turnsInSession } from "../../../store/turnCache";
|
|
import {
|
|
frameIsActive,
|
|
frameSessionId,
|
|
frameTurnId,
|
|
type FrameEvent,
|
|
type FramePayload,
|
|
type StoreState,
|
|
} from "./shared";
|
|
|
|
/** 流式正文帧处理组:stream_chunk / stream_end / message.start / message.delta /
|
|
* message.end。命中(含组内主动丢弃)返回 true,未命中返回 false 交给下一组。
|
|
*
|
|
* 裁决 16.4.5:帧按自己的 `sessionId` 落进对应会话的消息桶,**不再**因为不属于
|
|
* 激活会话而被丢掉 —— 后台会话的正文继续累积,切过去就能看到完整的这一轮。
|
|
*
|
|
* 忙时并行(design/15 §2.5):帧壳 `turnId` 决定它属于**哪一路**,增量按
|
|
* `(sessionId, turnId)` 二级分桶写进那一路的助手气泡;两条回复交错到达时不会糊成
|
|
* 一条(此前只按「列表尾气泡」写,正是把两条回复糊在一起的成因)。 */
|
|
export function handleStreamEvent(ev: FrameEvent, r: FramePayload, s: StoreState): boolean {
|
|
switch (ev.type) {
|
|
case "stream_chunk": {
|
|
if (r && typeof r.delta === "string") {
|
|
s.appendChunk(r.delta, frameSessionId(ev, r), frameTurnId(ev, r));
|
|
}
|
|
return true;
|
|
}
|
|
case "stream_end": {
|
|
const sid = frameSessionId(ev, r);
|
|
const turnId = frameTurnId(ev, r);
|
|
s.endStream(sid, turnId);
|
|
// 忙态 / 上下文是**单槽全局**状态(design/15 §5),所以只有屏幕上这个会话的
|
|
// 回合结束才收口;别的会话结束只能收它自己气泡的流,不能清掉这里的徽标。
|
|
if (frameIsActive(sid, s.activeSessionId) && !otherLegsRunning(s, sid, turnId)) {
|
|
s.setSessionBusy(false, 0, turnId ? 0 : undefined);
|
|
if (r && typeof r.stopReason === "string") {
|
|
s.setContext({ lastAgentMode: s.context.lastAgentMode });
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
// Structured stream events (design/04 §1.1, aligned with OpenClaw). These
|
|
// arrive in place of the legacy stream_chunk when the loop streams events.
|
|
case "message.start": {
|
|
// The assistant bubble is already opened by useChat; nothing to do here.
|
|
return true;
|
|
}
|
|
case "message.delta": {
|
|
if (r && typeof r.delta === "string") {
|
|
s.appendChunk(r.delta, frameSessionId(ev, r), frameTurnId(ev, r));
|
|
}
|
|
return true;
|
|
}
|
|
case "message.end": {
|
|
// Body-text segment closed; the turn may continue with tool calls, so we
|
|
// do NOT end the stream here (stream_end owns that).
|
|
return true;
|
|
}
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** 除了刚收尾的这一路,该会话还有别的路在跑吗?有就**不能**把忙态置闲 —— 并行时
|
|
* 先答完的那一路会把「她正在忙」徽标误关掉(v0.65 §4.6 的 `session_idle` 时点收紧
|
|
* 就是为这个)。没有 `turnId` 的旧帧(单路老后端)逐字沿用旧行为:照样清忙。 */
|
|
function otherLegsRunning(s: StoreState, sessionId: string | undefined, turnId: string | undefined): boolean {
|
|
if (!turnId) return false;
|
|
const sid = sessionId ?? s.activeSessionId;
|
|
return turnsInSession(s.turnBindings, sid).some((b) => b.turnId !== turnId);
|
|
}
|