feat(web): 前端与后端同步一批(帧壳 turnId / 并行六态与两级分桶 / documents 渲染 / System 键名 / 主人档案分组)
- 帧壳 `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 读端点
This commit is contained in:
1 parent
6a545badbd
commit
eaada3af5d
34 files changed
+2457
-223
No files matched your search
@@ -48,7 +48,17 @@ export function handleMessageEvent(ev: FrameEvent, r: FramePayload, s: StoreStat
|
||||
// dedups by id and adopts the local optimistic bubble (uid → backend id), so
|
||||
// both the originating and remote browsers converge on one message.
|
||||
const msg = parsePersistedMessage(r?.message);
|
||||
if (msg) s.appendOrUpdateMessage(msg);
|
||||
if (msg) {
|
||||
s.appendOrUpdateMessage(msg);
|
||||
// `turn_begin` 可能早于用户行落库到达(那时 messageIds 在列表里还没有落点,
|
||||
// 绑定只记在 turnBindings 里):这里补一次落地,让气泡及时进灰态。
|
||||
s.rebindSessionTurns(msg.sessionId ?? s.activeSessionId);
|
||||
// 灰态的**兜底**收口(规范草案 §1.2):该 `turnId` 的助手回复落库同样收口,
|
||||
// 它与 `turn.end` 顺序不定,任一到即收。
|
||||
if (msg.role === "assistant" && msg.turnId) {
|
||||
s.clearGrey(msg.turnId, msg.sessionId ?? s.activeSessionId);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case "message_edited": {
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
// 忙时并行帧组的逻辑测试(design/15 §2.5 / `DevDocs/_specs/忙时并行-规范草案.md`;契约 v0.65 §3.4.1–§3.4.3)。
|
||||
//
|
||||
// 为什么需要它:这组帧此前在前端 **grep 命中 0**(全部落进未知帧兜底),更根本的是
|
||||
// 帧壳 `turnId` 在 socket 层就被丢掉 ⇒ 任何「按路分桶」都不可能实现。本测试锁六件事:
|
||||
// ① `turnId` 从帧壳一路传进 handler 与按路气泡(分桶键不能在中途丢);
|
||||
// ② `(sessionId, turnId)` 两级分桶:两路增量**不会糊进同一个气泡**;
|
||||
// ③ 灰态(已读·未答)入口 = `turn_begin`/`steer_ack`,出口 = 该 `turnId` 的 `turn.end`,
|
||||
// 兜底 = 该 `turnId` 的助手 `message_persisted`;
|
||||
// ④ 六态里的 ③ 排队 / ⑥ 被拒:`pending_ack` 不是灰态,`pending_full`/`chat_busy` 说清后果;
|
||||
// ⑤ 多路忙态:`session_interrupted`(scope:"current")不能当空闲;`stream_end` 不能把
|
||||
// 另一路还在跑的徽标关掉;
|
||||
// ⑥ `chat_duplicate` 把本地乐观气泡对齐到既有 id(不重复渲染);
|
||||
// ⑦ `tool_execution.start/end` 的 `{tool, phase}` 真的落成工具卡(此前用错解析器 ⇒ 卡片永不出现)。
|
||||
//
|
||||
// 项目没有前端测试框架/jsdom,所以这是零依赖的纯 node 检查:它驱动真实的帧解码器
|
||||
// (lib/ws.ts 的 dispatchFrame)与真实的 store,并在第一个失败处抛出。
|
||||
//
|
||||
// 跑法(用 vite 自带的 esbuild,不新增依赖、不改 package.json;banner 补上 store 的
|
||||
// persist 中间件在 node 下探测的 window.localStorage):
|
||||
//
|
||||
// cd frontend/web \
|
||||
// && ./node_modules/.bin/esbuild src/lib/ws/handlers/parallel.test.ts \
|
||||
// --bundle --platform=node --format=esm \
|
||||
// --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \
|
||||
// --outfile=/tmp/cyrene-parallel.test.mjs \
|
||||
// && node /tmp/cyrene-parallel.test.mjs
|
||||
//
|
||||
// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它只从项目内导入、
|
||||
// 不 import node:*。
|
||||
|
||||
import { dispatchFrame } from "../../ws";
|
||||
import { useAppStore } from "../../../store/useAppStore";
|
||||
import type { ChatMessage } from "../../types";
|
||||
|
||||
let checks = 0;
|
||||
|
||||
function ok(cond: boolean, what: string): void {
|
||||
checks += 1;
|
||||
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||
}
|
||||
|
||||
function same<T>(got: T, want: T, what: string): void {
|
||||
ok(
|
||||
JSON.stringify(got) === JSON.stringify(want),
|
||||
`${what} — got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`
|
||||
);
|
||||
}
|
||||
|
||||
const MAIN = "web";
|
||||
const T1 = "turn-aaaa";
|
||||
const T2 = "turn-bbbb";
|
||||
|
||||
/** 每个场景前把 store 复位成「新连接、无在途路」。 */
|
||||
function reset(): void {
|
||||
useAppStore.setState({
|
||||
activeSessionId: MAIN,
|
||||
messages: [],
|
||||
sessionMessages: {},
|
||||
turnBindings: {},
|
||||
sessionBusy: false,
|
||||
pendingCount: 0,
|
||||
activeTurns: 0,
|
||||
queueNotice: null,
|
||||
deviceUpdates: [],
|
||||
});
|
||||
}
|
||||
|
||||
function msgs(): ChatMessage[] {
|
||||
return useAppStore.getState().messages;
|
||||
}
|
||||
|
||||
/** 下行帧(帧壳 turnId/seq 与真实 socket 层一致:onmessage 就是这么填的)。 */
|
||||
function down(type: string, turnId: string | undefined, data: unknown, sessionId = MAIN): void {
|
||||
dispatchFrame({ type, sessionId, turnId, data });
|
||||
}
|
||||
|
||||
/** 落库一条消息(`message_persisted`,帧里带 DTO)。 */
|
||||
function persist(m: Record<string, unknown>): void {
|
||||
down("message_persisted", undefined, { message: { sessionId: MAIN, createdAt: 1, ...m } });
|
||||
}
|
||||
|
||||
/** 起一路(`turn_begin` 的帧壳带 turnId)。 */
|
||||
function turnBegin(turnId: string, kind: string, messageIds: string[], leg: number, activeTurns: number): void {
|
||||
down("turn_begin", turnId, { kind, messageIds, leg, activeTurns });
|
||||
}
|
||||
|
||||
// ── ① 帧壳 turnId 真的传进 handler(此前在 socket 层被丢) ────────────────────────
|
||||
{
|
||||
reset();
|
||||
turnBegin(T1, "turn", ["msg-A"], 1, 1);
|
||||
const bindings = useAppStore.getState().turnBindings;
|
||||
same(Object.keys(bindings), [T1], "turn_begin 建立了以帧壳 turnId 为键的绑定");
|
||||
same(bindings[T1].messageIds, ["msg-A"], "绑定记住了本路归属的用户消息 id");
|
||||
same(bindings[T1].leg, 1, "绑定记住了路序号");
|
||||
}
|
||||
|
||||
// ── ② 两级分桶:两条回复交错到达,各写自己的气泡(不糊成一条) ──────────────────
|
||||
{
|
||||
reset();
|
||||
persist({ id: "msg-A", role: "user", content: "问题一" });
|
||||
turnBegin(T1, "turn", ["msg-A"], 1, 1);
|
||||
persist({ id: "msg-B", role: "user", content: "问题二" });
|
||||
turnBegin(T2, "parallel", ["msg-B"], 2, 2);
|
||||
|
||||
same(msgs().length, 4, "两路各自有自己的用户行与助手气泡");
|
||||
same(
|
||||
msgs().map((m) => [m.id, m.role, m.turnId]),
|
||||
[
|
||||
["msg-A", "user", T1],
|
||||
[msgs()[1].id, "assistant", T1],
|
||||
["msg-B", "user", T2],
|
||||
[msgs()[3].id, "assistant", T2],
|
||||
],
|
||||
"用户行与助手气泡各自绑到自己的 turnId(顺序 = 历史顺序)"
|
||||
);
|
||||
|
||||
// 交错到达:T1 → T2 → T1 → T2,若按「列表尾气泡」写,四个片段会全落进最后一条。
|
||||
down("message.delta", T1, { index: 0, delta: "甲一" });
|
||||
down("message.delta", T2, { index: 0, delta: "乙一" });
|
||||
down("message.delta", T1, { index: 0, delta: "甲二" });
|
||||
down("message.delta", T2, { index: 0, delta: "乙二" });
|
||||
same(msgs()[1].content, "甲一甲二", "T1 的增量只进 T1 的气泡");
|
||||
same(msgs()[3].content, "乙一乙二", "T2 的增量只进 T2 的气泡(没被糊在一起)");
|
||||
}
|
||||
|
||||
// ── ③ 灰态:入口 turn_begin/steer_ack,出口 turn.end,兜底 = 助手 message_persisted ──
|
||||
{
|
||||
reset();
|
||||
persist({ id: "msg-A", role: "user", content: "问题" });
|
||||
turnBegin(T1, "turn", ["msg-A"], 1, 1);
|
||||
same(msgs()[0].delivery, "answering", "turn_begin 把归属本路的用户气泡置灰态(已受理·回答中)");
|
||||
|
||||
down("message.delta", T1, { index: 0, delta: "答" });
|
||||
down("turn.end", T1, undefined);
|
||||
same(msgs()[0].delivery, "answered", "该 turnId 的 turn.end 收口灰态");
|
||||
same(msgs()[1].streaming, false, "该路助手气泡停止流式");
|
||||
same(useAppStore.getState().turnBindings, {}, "turn.end 丢弃该路绑定");
|
||||
|
||||
// 兜底口径:助手 message_persisted 也收口(两个事件顺序不定)。
|
||||
reset();
|
||||
persist({ id: "msg-C", role: "user", content: "问题" });
|
||||
turnBegin(T1, "turn", ["msg-C"], 1, 1);
|
||||
persist({ id: "msg-reply", role: "assistant", content: "回答", turnId: T1 });
|
||||
same(
|
||||
msgs().find((m) => m.id === "msg-C")?.delivery,
|
||||
"answered",
|
||||
"该 turnId 的助手回复落库同样收口灰态(兜底)"
|
||||
);
|
||||
|
||||
// 两路并行时:T2 收口不得把 T1 的灰态一起收掉。
|
||||
reset();
|
||||
persist({ id: "msg-A", role: "user", content: "一" });
|
||||
turnBegin(T1, "turn", ["msg-A"], 1, 1);
|
||||
persist({ id: "msg-B", role: "user", content: "二" });
|
||||
turnBegin(T2, "parallel", ["msg-B"], 2, 2);
|
||||
down("turn.end", T2, undefined);
|
||||
same(msgs()[0].delivery, "answering", "T2 收口不影响 T1 的灰态");
|
||||
same(msgs()[2].delivery, "answered", "T2 的灰态按自己的 turnId 收口");
|
||||
|
||||
// steer_ack:被在飞回合吃进 ⇒ 灰态 + 步序号(帧壳 turnId 决定与哪一路配对)。
|
||||
reset();
|
||||
persist({ id: "msg-S", role: "user", content: "忙时发的" });
|
||||
down("steer_ack", T1, { messageId: "msg-S", atStep: 2 });
|
||||
same(msgs()[0].delivery, "answering", "steer_ack 把该气泡置灰态");
|
||||
same(msgs()[0].turnId, T1, "steer_ack 的帧壳 turnId 绑到该气泡(并行下必需)");
|
||||
same(msgs()[0].steerStep, 2, "steer_ack 的 atStep 作为进度小字");
|
||||
down("turn.end", T1, undefined);
|
||||
same(msgs()[0].delivery, "answered", "steer_ack 的灰态由该 turnId 的 turn.end 收口");
|
||||
}
|
||||
|
||||
// ── ④ 六态:③ 排队(pending_ack 不是灰态)/ ④ 已受理 / ⑥ 被拒 ─────────────────────
|
||||
{
|
||||
reset();
|
||||
useAppStore.setState({ messages: [{ id: "local-1", role: "user", content: "忙时发的", delivery: "sending", createdAt: 1 }] });
|
||||
down("pending_ack", undefined, { sessionId: MAIN, mode: "executing", queueDepth: 2 });
|
||||
same(useAppStore.getState().pendingCount, 2, "pending_ack 的 queueDepth 是队列深度的真源");
|
||||
same(useAppStore.getState().sessionBusy, true, "排队 ⇒ 会话忙");
|
||||
same(msgs()[0].delivery, "queued", "本端刚发的那条标为「排队中」");
|
||||
same(typeof useAppStore.getState().queueNotice, "string", "排队给出可见的解释文案");
|
||||
|
||||
down("pending_ack", undefined, { sessionId: MAIN, mode: "executing", queueDepth: 3, reason: "parallel_limit" });
|
||||
ok(
|
||||
(useAppStore.getState().queueNotice ?? "").includes("并行额度已满"),
|
||||
"reason:parallel_limit 明说「声明并行但额度已满」"
|
||||
);
|
||||
|
||||
// 排队的消息被弹出 ⇒ 刷新深度与在途路数(随后会有该路的 turn_begin)。
|
||||
down("pending_drain", undefined, { sessionId: MAIN, mode: "thinking", queueDepth: 1, activeTurns: 2 });
|
||||
same(useAppStore.getState().pendingCount, 1, "pending_drain 刷新队列深度");
|
||||
same(useAppStore.getState().activeTurns, 2, "pending_drain 刷新在途路数");
|
||||
|
||||
// 队列满:定向 chat_busy 与广播 pending_full 都算 ⑥ 被拒。
|
||||
reset();
|
||||
useAppStore.setState({ messages: [{ id: "local-2", role: "user", content: "满了", delivery: "queued", createdAt: 1 }] });
|
||||
down("pending_full", undefined, { sessionId: MAIN, mode: "executing", max: 5 });
|
||||
same(msgs()[0].delivery, "rejected", "pending_full 把本端待处理的那条标为未送达");
|
||||
ok((msgs()[0].rejectReason ?? "").includes("排队已满"), "被拒原因写清「排队已满(上限 N 条)」");
|
||||
|
||||
reset();
|
||||
useAppStore.setState({ messages: [{ id: "local-3", role: "user", content: "满了", delivery: "queued", createdAt: 1 }] });
|
||||
down("error", undefined, { code: "chat_busy", message: "会话正忙", sessionId: MAIN, messageId: "local-3" });
|
||||
same(msgs()[0].delivery, "rejected", "定向 error{code:chat_busy} 同样标为被拒");
|
||||
|
||||
// 其它 error 码仍交给 device 组记一行日志(不被本组吞掉)。
|
||||
reset();
|
||||
down("error", undefined, { message: "voice unavailable" });
|
||||
same(useAppStore.getState().deviceUpdates.length, 1, "非 chat_busy 的 error 仍落设备日志");
|
||||
}
|
||||
|
||||
// ── ⑤ 多路忙态:interrupted(scope:current) 不能当空闲;stream_end 不能关掉别的路 ──
|
||||
{
|
||||
reset();
|
||||
down("session_busy", undefined, { sessionId: MAIN, mode: "thinking", activeTurns: 2 });
|
||||
same(useAppStore.getState().sessionBusy, true, "session_busy{activeTurns:2} 置忙");
|
||||
same(useAppStore.getState().activeTurns, 2, "在途路数被读出来(状态栏可显示「并行 2 路」)");
|
||||
|
||||
// scope:"current":只取消了当时忙碌的那条通道,另一路仍在答 ⇒ 不能显示「她闲了」。
|
||||
down("session_interrupted", undefined, { sessionId: MAIN, mode: "executing", dropped: 0, cancelledTurns: 1, activeTurns: 1 });
|
||||
same(useAppStore.getState().sessionBusy, true, "scope:current 被打断后仍忙(mode 非 idle)");
|
||||
same(useAppStore.getState().activeTurns, 1, "被打断后刷新在途路数");
|
||||
|
||||
// scope:"all":mode:"idle" ⇒ 确实空闲。
|
||||
down("session_interrupted", undefined, { sessionId: MAIN, mode: "idle", dropped: 2, cancelledTurns: 2, activeTurns: 0 });
|
||||
same(useAppStore.getState().sessionBusy, false, "scope:all(mode idle)收口为空闲");
|
||||
same(useAppStore.getState().pendingCount, 0, "scope:all 清空挂起数");
|
||||
|
||||
// stream_end:并行另一路还在跑时,先收尾的那一路不能把徽标关掉。
|
||||
reset();
|
||||
persist({ id: "msg-A", role: "user", content: "一" });
|
||||
turnBegin(T1, "turn", ["msg-A"], 1, 1);
|
||||
persist({ id: "msg-B", role: "user", content: "二" });
|
||||
turnBegin(T2, "parallel", ["msg-B"], 2, 2);
|
||||
down("session_busy", undefined, { sessionId: MAIN, mode: "thinking", activeTurns: 2 });
|
||||
down("stream_end", T1, { stopReason: "end_turn" });
|
||||
same(useAppStore.getState().sessionBusy, true, "T1 收尾时 T2 仍在跑 ⇒ 不回空闲");
|
||||
same(msgs()[1].streaming, false, "T1 的气泡确实停止流式");
|
||||
same(msgs()[3].streaming, true, "T2 的气泡继续生长");
|
||||
|
||||
down("turn.end", T1, undefined);
|
||||
same(useAppStore.getState().sessionBusy, true, "T1 的 turn.end 之后(T2 仍在途)仍忙");
|
||||
down("turn.end", T2, undefined);
|
||||
same(useAppStore.getState().sessionBusy, false, "全部在途路收尾且队列空 ⇒ 忙态置闲");
|
||||
same(useAppStore.getState().activeTurns, 0, "全部收尾后在途路数为 0");
|
||||
|
||||
// 单路(无 turnId 的老帧)逐字沿用旧行为:stream_end 直接收口。
|
||||
reset();
|
||||
down("session_busy", undefined, { sessionId: MAIN, mode: "thinking" });
|
||||
down("stream_end", undefined, { stopReason: "end_turn" });
|
||||
same(useAppStore.getState().sessionBusy, false, "无 turnId 的 stream_end 照旧收口忙态");
|
||||
}
|
||||
|
||||
// ── ⑥ chat_duplicate:本地乐观气泡对齐到既有 id(不重复渲染) ─────────────────────
|
||||
{
|
||||
reset();
|
||||
useAppStore.setState({
|
||||
messages: [
|
||||
{ id: "local-9", role: "user", content: "重发的话", delivery: "sending", clientMsgId: "c-1", createdAt: 1 },
|
||||
],
|
||||
});
|
||||
down("chat_duplicate", undefined, { sessionId: MAIN, clientMsgId: "c-1", messageId: "msg-server-1" });
|
||||
same(msgs().length, 1, "判重命中不新增气泡");
|
||||
same(msgs()[0].id, "msg-server-1", "气泡对齐到服务端既有 messageId");
|
||||
same(msgs()[0].delivery, "sent", "对齐后投递态回到「已送达」");
|
||||
|
||||
// 没有匹配的 clientMsgId 时不改任何东西(不误改别的气泡)。
|
||||
down("chat_duplicate", undefined, { sessionId: MAIN, clientMsgId: "c-unknown", messageId: "msg-x" });
|
||||
same(msgs()[0].id, "msg-server-1", "认不出的幂等键不动状态");
|
||||
}
|
||||
|
||||
// ── ⑦ 工具的**执行阶段**帧(契约 §3.4.2;键名与 tool_call.* 不同) ────────────────
|
||||
// 此前只接了 `tool_execution.update`(后端暂无生产者),真正的 start/end 落到未知帧兜底;
|
||||
// 而用 `parseToolCall` 读 `{tool, phase}` 只会得到 null(键名不同)⇒ 卡片永远不出现。
|
||||
{
|
||||
reset();
|
||||
persist({ id: "msg-A", role: "user", content: "跑个命令" });
|
||||
down("message_persisted", undefined, { message: { id: "a-T1", sessionId: MAIN, role: "assistant", content: "", turnId: T1, createdAt: 2 } });
|
||||
down("turn_begin", T1, { kind: "turn", messageIds: ["msg-A"], leg: 1, activeTurns: 1 });
|
||||
down("tool_execution.start", T1, { tool: "terminal", phase: "running" });
|
||||
const running = msgs().find((m) => m.role === "assistant")?.tools ?? [];
|
||||
same(running.map((t) => [t.name, t.ok]), [["terminal", undefined]], "tool_execution.start 落成「运行中」工具卡");
|
||||
down("tool_execution.end", T1, { tool: "terminal", phase: "error" });
|
||||
const failed = msgs().find((m) => m.role === "assistant")?.tools ?? [];
|
||||
same(failed.map((t) => [t.name, t.ok]), [["terminal", false]], "tool_execution.end 的 phase:error 落成失败(不是一次新调用)");
|
||||
same(useAppStore.getState().deviceUpdates.length, 0, "tool_execution.* 不进未知帧兜底");
|
||||
}
|
||||
|
||||
// ── ⑧ 这些帧必须被消费(不得落进未知帧兜底,往 deviceUpdates 里堆垃圾) ──────────
|
||||
{
|
||||
reset();
|
||||
turnBegin(T1, "turn", ["msg-A"], 1, 1);
|
||||
down("turn.start", T1, undefined);
|
||||
down("pending_ack", undefined, { sessionId: MAIN, mode: "thinking", queueDepth: 1 });
|
||||
down("pending_drain", undefined, { sessionId: MAIN, mode: "thinking", queueDepth: 0, activeTurns: 1 });
|
||||
down("steer_ack", T1, { messageId: "msg-A", atStep: 1 });
|
||||
down("chat_duplicate", undefined, { sessionId: MAIN, clientMsgId: "c-x", messageId: "m-x" });
|
||||
down("turn.end", T1, undefined);
|
||||
same(useAppStore.getState().deviceUpdates.length, 0, "并行帧组全部被消费,不进未知帧兜底");
|
||||
}
|
||||
|
||||
console.log(`parallel frame logic test: ${checks} checks passed`);
|
||||
@@ -0,0 +1,181 @@
|
||||
import { now } from "../../util";
|
||||
import type { TurnBinding } from "../../types";
|
||||
import {
|
||||
frameIsActive,
|
||||
frameNumber,
|
||||
frameSessionId,
|
||||
frameText,
|
||||
frameTurnId,
|
||||
type FrameEvent,
|
||||
type FramePayload,
|
||||
type StoreState,
|
||||
} from "./shared";
|
||||
|
||||
/**
|
||||
* 忙时并行(多路)帧处理组(design/15 §2.5 / `DevDocs/_specs/忙时并行-规范草案.md`):
|
||||
* `turn_begin` / `turn.end` / `turn.start` / `steer_ack` / `pending_ack` /
|
||||
* `pending_drain` / `pending_full` / `chat_duplicate`,以及 `error{code:"chat_busy"}`。
|
||||
*
|
||||
* 接入这组的三个必须(规范草案 §1.7):
|
||||
* ① 结构化流帧按 `(sessionId, turnId)` 两级分桶 —— 分桶写入在 store 的
|
||||
* `appendChunk`/`setThinking`/`addTool` 里,本组负责把 `turnId` 送进去并把
|
||||
* `turn_begin` 的「消息 ↔ 回合」绑定建起来;
|
||||
* ② 灰态(已读·未答)入口只有 `turn_begin` / `steer_ack`,出口只有该 `turnId` 的
|
||||
* `turn.end`(兜底 = 该 `turnId` 的助手 `message_persisted`,见 message.ts);
|
||||
* ③ `queued`(排队)**不是**灰态:它由 `pending_ack` 标注,收到 `turn_begin` 时
|
||||
* 直接换态,绝不把排队渲染成灰态。
|
||||
*
|
||||
* 命中(含组内主动丢弃)返回 true,未命中返回 false 交给下一组。
|
||||
*/
|
||||
export function handleParallelEvent(
|
||||
ev: FrameEvent,
|
||||
r: FramePayload,
|
||||
s: StoreState
|
||||
): boolean {
|
||||
switch (ev.type) {
|
||||
case "turn_begin":
|
||||
return onTurnBegin(ev, r, s);
|
||||
case "turn.end":
|
||||
return onTurnEnd(ev, r, s);
|
||||
case "turn.start":
|
||||
// 回合起始的边界帧:气泡与灰态已由 `turn_begin` 处理(它带 messageIds),
|
||||
// 这里消费掉即可,不再堆进未知帧兜底。
|
||||
return true;
|
||||
case "steer_ack":
|
||||
return onSteerAck(ev, r, s);
|
||||
case "pending_ack":
|
||||
return onPendingAck(ev, r, s);
|
||||
case "pending_drain":
|
||||
return onPendingDrain(ev, r, s);
|
||||
case "pending_full":
|
||||
return onPendingFull(ev, r, s);
|
||||
case "chat_duplicate":
|
||||
return onDuplicate(ev, r, s);
|
||||
case "error":
|
||||
return onUpstreamError(r, s);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** `turn_begin`:每起一路恰好一帧,是「消息 ↔ 回合」的唯一权威来源。
|
||||
* 帧壳 `turnId` 缺了就无处分桶 —— 契约要求它必带,所以这种帧只消费不猜。 */
|
||||
function onTurnBegin(ev: FrameEvent, r: FramePayload, s: StoreState): boolean {
|
||||
const turnId = frameTurnId(ev, r);
|
||||
if (!turnId) return true;
|
||||
const raw = r && Array.isArray(r.messageIds) ? r.messageIds : [];
|
||||
const binding: TurnBinding = {
|
||||
turnId,
|
||||
sessionId: frameSessionId(ev, r) ?? s.activeSessionId,
|
||||
messageIds: raw.filter((x): x is string => typeof x === "string" && x !== ""),
|
||||
kind: frameText(r?.kind) ?? "turn",
|
||||
leg: frameNumber(r?.leg) ?? 1,
|
||||
activeTurns: frameNumber(r?.activeTurns) ?? s.activeTurns,
|
||||
at: now(),
|
||||
};
|
||||
s.beginTurn(binding);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** `turn.end`:整轮结束(覆盖最终答案 / 工具循环终止 / 预算耗尽 / 出错四种出口),
|
||||
* 是灰态的**唯一**收口事件。缺 `turnId` 时无从知道收的是哪一路,交给
|
||||
* `session_idle` 收尾,绝不错关别人的灰态。 */
|
||||
function onTurnEnd(ev: FrameEvent, r: FramePayload, s: StoreState): boolean {
|
||||
const turnId = frameTurnId(ev, r);
|
||||
if (turnId) s.endTurn(turnId, frameSessionId(ev, r));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** `steer_ack`:忙时新消息被在飞回合吃进同一回合 ⇒ 该气泡进灰态;`turnId` 是把灰态
|
||||
* 与正确 `turn.end` 配对的键(并行下必需)。`atStep` 只是进度小字。 */
|
||||
function onSteerAck(ev: FrameEvent, r: FramePayload, s: StoreState): boolean {
|
||||
const messageId = frameText(r?.messageId);
|
||||
if (!messageId) return true;
|
||||
const turnId = frameTurnId(ev, r);
|
||||
const atStep = frameNumber(r?.atStep);
|
||||
s.markDelivery(
|
||||
messageId,
|
||||
{
|
||||
delivery: "answering",
|
||||
...(turnId ? { turnId } : {}),
|
||||
...(atStep === undefined ? {} : { steerStep: atStep }),
|
||||
},
|
||||
frameSessionId(ev, r)
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** `pending_ack`:忙时消息入队(唯一真源是这里的 `queueDepth`)。
|
||||
* ③ 排队 ≠ 灰态:只把**本端刚发的那条**(还没被任何一路接手)标成 queued。
|
||||
* `reason:"parallel_limit"` 时明说「声明并行但额度已满,落回排队」。 */
|
||||
function onPendingAck(ev: FrameEvent, r: FramePayload, s: StoreState): boolean {
|
||||
if (!frameIsActive(frameSessionId(ev, r), s.activeSessionId)) return true;
|
||||
const depth = frameNumber(r?.queueDepth);
|
||||
s.setSessionBusy(true, depth ?? s.pendingCount);
|
||||
if (depth !== undefined && depth > 0) s.markLocalDelivery("queued");
|
||||
if (frameText(r?.reason) === "parallel_limit") {
|
||||
s.setQueueNotice(
|
||||
depth === undefined
|
||||
? "并行额度已满:这条已转入排队"
|
||||
: `并行额度已满:这条已转入排队(前面还有 ${depth} 条)`
|
||||
);
|
||||
} else if (depth !== undefined && depth > 0) {
|
||||
s.setQueueNotice(`已排队:前面还有 ${depth} 条`);
|
||||
} else {
|
||||
s.setQueueNotice(null);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** `pending_drain`:续跑弹出队首 ⇒ 深度与在途路数都刷新(随后会有该路的 `turn_begin`)。 */
|
||||
function onPendingDrain(ev: FrameEvent, r: FramePayload, s: StoreState): boolean {
|
||||
if (!frameIsActive(frameSessionId(ev, r), s.activeSessionId)) return true;
|
||||
const depth = frameNumber(r?.queueDepth);
|
||||
s.setSessionBusy(true, depth, frameNumber(r?.activeTurns));
|
||||
if (depth === 0) s.setQueueNotice(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** `pending_full`:队列满(⑥ 被拒)。`messageId` 只在服务端已认领该消息时给出,
|
||||
* 没有就落到本端刚发的那条气泡上 —— 不新铸 id、不假装送达。 */
|
||||
function onPendingFull(ev: FrameEvent, r: FramePayload, s: StoreState): boolean {
|
||||
const sid = frameSessionId(ev, r);
|
||||
if (!frameIsActive(sid, s.activeSessionId)) return true;
|
||||
const max = frameNumber(r?.max);
|
||||
const reason =
|
||||
max === undefined
|
||||
? "会话正忙,这条未送达"
|
||||
: `会话正忙:排队已满(上限 ${max} 条),这条未送达`;
|
||||
s.setSessionBusy(true, max ?? s.pendingCount);
|
||||
s.setQueueNotice(reason);
|
||||
const messageId = frameText(r?.messageId);
|
||||
if (messageId) s.markDelivery(messageId, { delivery: "rejected", rejectReason: reason }, sid);
|
||||
else s.markLocalDelivery("rejected", { rejectReason: reason });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** `chat_duplicate`:`clientMsgId` 判重命中 —— 没有新回合、没有新内容帧,只把本地
|
||||
* 乐观气泡对齐到既有 `messageId`(否则同一句话会渲染两遍)。 */
|
||||
function onDuplicate(ev: FrameEvent, r: FramePayload, s: StoreState): boolean {
|
||||
const clientMsgId = frameText(r?.clientMsgId);
|
||||
const messageId = frameText(r?.messageId);
|
||||
if (clientMsgId && messageId) {
|
||||
s.alignDuplicateMessage(clientMsgId, messageId, frameSessionId(ev, r));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 上行 `error` 里的 `chat_busy`(定向回发送者,同时可能广播 `pending_full`):
|
||||
* 把这条气泡标成 ⑥ 被拒。**其它 error 码一律放行**给 device.ts 记一行日志。 */
|
||||
function onUpstreamError(r: FramePayload, s: StoreState): boolean {
|
||||
if (frameText(r?.code) !== "chat_busy") return false;
|
||||
const reason = frameText(r?.message) ?? "会话正忙,这条未送达";
|
||||
s.setQueueNotice(reason);
|
||||
const messageId = frameText(r?.messageId);
|
||||
if (messageId) {
|
||||
s.markDelivery(messageId, { delivery: "rejected", rejectReason: reason }, frameText(r?.sessionId));
|
||||
} else {
|
||||
s.markLocalDelivery("rejected", { rejectReason: reason });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useModeStore } from "../../../store/useModeStore";
|
||||
import { MODES, type AwakenState, type Mode } from "../../types";
|
||||
import { parseAwakenTool } from "../../wsParse";
|
||||
import type { FrameEvent, FramePayload, StoreState } from "./shared";
|
||||
|
||||
/** 角色 / 呈现状态帧处理组:capacity_awaken / mode_switch / mood。命中返回 true,
|
||||
* 未命中返回 false 交给下一组(与拆分前的单个 switch 等价)。
|
||||
*
|
||||
* 为什么从 session.ts 拆出来(AGENTS §10 函数 ≤80 行):这三帧讲的是**角色与呈现**
|
||||
* (唤醒三态 design/14 §5、呈现模式 design/01 §3、角色情绪 design/22 §4.6),
|
||||
* 而 session.ts 讲的是**会话编排**(忙闲/在途路/已读游标)。两件事的改动理由不同。 */
|
||||
export function handlePersonaEvent(ev: FrameEvent, r: FramePayload, s: StoreState): boolean {
|
||||
switch (ev.type) {
|
||||
case "capacity_awaken": {
|
||||
// design/14 §5 — 能力点亮:静息 → 唤醒 → 全能 + 本轮点亮工具。前端据此更新唤醒徽标/唤醒路径。
|
||||
const fromState =
|
||||
typeof r?.fromState === "string"
|
||||
? (r.fromState as AwakenState)
|
||||
: undefined;
|
||||
const toState =
|
||||
(typeof r?.toState === "string" ? (r.toState as AwakenState) : undefined) ??
|
||||
"quiet";
|
||||
s.setAwaken(fromState, toState, parseAwakenTool(r?.tool));
|
||||
return true;
|
||||
}
|
||||
case "mode_switch": {
|
||||
// The agent drives the behavior mode (design/01 §3). Apply it to the shell
|
||||
// so newly begun message bubbles render per that mode.
|
||||
if (r && typeof r.mode === "string") {
|
||||
const m = r.mode as Mode;
|
||||
if (MODES.includes(m)) useModeStore.getState().setMode(m);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case "mood": {
|
||||
// Role-scoped (design/22 §4.6): the frame names the persona the change
|
||||
// belongs to and carries no sessionId, so the bucket key is personaId —
|
||||
// each role's mood stays with its own conversations. A legacy string frame
|
||||
// has nowhere to put it, so it lands in the unscoped "" bucket.
|
||||
if (typeof ev.data === "string") {
|
||||
s.setMood(ev.data);
|
||||
} else if (r && typeof r.mood === "string") {
|
||||
s.setMood(r.mood, typeof r.personaId === "string" ? r.personaId : "");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,48 +1,71 @@
|
||||
import { useModeStore } from "../../../store/useModeStore";
|
||||
import { sessionReadTarget } from "../../sessionRead";
|
||||
import { MODES, type AwakenState, type Mode } from "../../types";
|
||||
import { parseAwakenTool } from "../../wsParse";
|
||||
import { frameIsActive, frameSessionId, type FrameEvent, type FramePayload, type StoreState } from "./shared";
|
||||
import {
|
||||
frameIsActive,
|
||||
frameNumber,
|
||||
frameSessionId,
|
||||
frameText,
|
||||
type FrameEvent,
|
||||
type FramePayload,
|
||||
type StoreState,
|
||||
} from "./shared";
|
||||
|
||||
/** 会话/角色状态帧处理组:capacity_awaken / 会话编排忙闲(session_busy /
|
||||
* session_pending / session_idle / session_interrupted)/ session_read /
|
||||
* mode_switch / mood。命中返回 true,未命中返回 false 交给下一组。 */
|
||||
/** 会话编排忙闲帧处理组:session_busy / session_pending / session_idle /
|
||||
* session_interrupted / session_read。命中返回 true,未命中返回 false 交给下一组。
|
||||
*
|
||||
* 角色与呈现类帧(capacity_awaken / mode_switch / mood)在 `handlers/persona.ts`
|
||||
* —— 那三帧的改动理由与本组不同(AGENTS §10 函数 ≤80 行)。
|
||||
*
|
||||
* 忙时并行(design/15 §2.5,契约 v0.65 §3.4.1):这些帧现在还带 `activeTurns` 等计数,
|
||||
* 判定必须据实读,尤其 `session_interrupted` **不能无条件当空闲**(`scope:"current"`
|
||||
* 只取消了当时忙碌的那条通道,并行的其它路仍在跑)。 */
|
||||
export function handleSessionEvent(ev: FrameEvent, r: FramePayload, s: StoreState): boolean {
|
||||
switch (ev.type) {
|
||||
case "capacity_awaken": {
|
||||
// design/14 §5 — 能力点亮:静息 → 唤醒 → 全能 + 本轮点亮工具。前端据此更新唤醒徽标/唤醒路径。
|
||||
const fromState =
|
||||
typeof r?.fromState === "string"
|
||||
? (r.fromState as AwakenState)
|
||||
: undefined;
|
||||
const toState =
|
||||
(typeof r?.toState === "string" ? (r.toState as AwakenState) : undefined) ??
|
||||
"quiet";
|
||||
s.setAwaken(fromState, toState, parseAwakenTool(r?.tool));
|
||||
return true;
|
||||
}
|
||||
case "session_busy":
|
||||
case "session_pending": {
|
||||
// design/15 §5 — 她忙/有挂起消息时的轻状态(不阻塞输入框)。两者同构:都是 {busy, pendingCount}。
|
||||
// design/15 §5 — 她忙/有挂起消息时的轻状态(不阻塞输入框)。
|
||||
// 忙态是**单槽全局**状态,只认屏幕上这个会话(见下方 session_idle 的说明)。
|
||||
// v0.65 §3.4.1 起帧里还带 `activeTurns`(在途路数):忙 = activeTurns > 0,
|
||||
// 比 `busy` 更能表达「并行 N 路」;老后端不发时回落 `busy`(缺省按忙处理)。
|
||||
if (!frameIsActive(frameSessionId(ev, r), s.activeSessionId)) return true;
|
||||
const busy = typeof r?.busy === "boolean" ? r.busy : true;
|
||||
const turns = frameNumber(r?.activeTurns);
|
||||
const busy = turns !== undefined ? turns > 0 : typeof r?.busy === "boolean" ? r.busy : true;
|
||||
const pendingCount =
|
||||
typeof r?.pendingCount === "number" ? r.pendingCount : s.pendingCount;
|
||||
s.setSessionBusy(busy, pendingCount);
|
||||
typeof r?.pendingCount === "number" ? r.pendingCount : undefined;
|
||||
s.setSessionBusy(busy, pendingCount, turns);
|
||||
return true;
|
||||
}
|
||||
case "session_idle":
|
||||
case "session_interrupted": {
|
||||
// design/15 §3/§4/§5 — 会话回到空闲:`session_idle`(队列清空)与
|
||||
// `session_interrupted`(用户 interrupt:当前任务取消、pending 丢弃)都自带
|
||||
// `{sessionId, mode}`、**没有 busy 字段**,语义就是「这个会话不再忙」。
|
||||
// 缺这两个 case 时忙态只能靠 `stream_end` 回落:回合卡住/被中断时
|
||||
// `useAppStore.sessionBusy` 一直为 true,裁决 16.4.1 的「预约切换」永不落地。
|
||||
case "session_idle": {
|
||||
// design/15 §3/§5 — 会话回到空闲:`session_idle` 只在**全部在途路收尾且 pending
|
||||
// 空**时才发(v0.65 时点收紧),所以它是可以放心置闲的权威信号。
|
||||
const sid = frameSessionId(ev, r);
|
||||
// 单槽忙态跨会话会串(design/16 §4.2 已登记的债):置忙与置闲必须用同一把尺子,
|
||||
// 否则后台会话的 bus 帧把徽标点亮、它自己的 idle 帧又被过滤掉 ⇒ 永久卡忙。
|
||||
if (frameIsActive(sid, s.activeSessionId)) s.setSessionBusy(false, 0);
|
||||
if (!frameIsActive(sid, s.activeSessionId)) return true;
|
||||
s.setSessionBusy(false, 0, 0);
|
||||
s.setQueueNotice(null);
|
||||
// 空闲 ⇒ 该会话不可能还有在途路:把(可能漏收 turn.end 的)绑定清掉,
|
||||
// 否则下一轮的「还有别的路在跑」判定会拿旧绑定当真。
|
||||
if (sid) s.dropSessionTurns(sid);
|
||||
return true;
|
||||
}
|
||||
case "session_interrupted": {
|
||||
// 打断结果(v0.65 §3.4.1):**不能无条件当空闲**。
|
||||
// scope:"all" ⇒ mode:"idle"、dropped=N、activeTurns:0 —— 这个会话不再忙;
|
||||
// scope:"current" ⇒ mode 是当时忙碌的通道(**非 idle**)、dropped:0、
|
||||
// activeTurns 可能 > 0(并行的其它路仍在跑)——置闲就会
|
||||
// 显示「她闲了」而实际还在答。
|
||||
const sid = frameSessionId(ev, r);
|
||||
if (!frameIsActive(sid, s.activeSessionId)) return true;
|
||||
const mode = frameText(r?.mode);
|
||||
const turns = frameNumber(r?.activeTurns);
|
||||
if (mode !== undefined && mode !== "idle") {
|
||||
s.setSessionBusy(turns === undefined ? true : turns > 0, undefined, turns);
|
||||
return true;
|
||||
}
|
||||
s.setSessionBusy(false, 0, 0);
|
||||
s.setQueueNotice(null);
|
||||
// 取消的路不会再发 `turn.end`,绑定必须清掉(否则永远认为「还有路在跑」)。
|
||||
if (sid) s.dropSessionTurns(sid);
|
||||
return true;
|
||||
}
|
||||
case "session_read": {
|
||||
@@ -54,27 +77,6 @@ export function handleSessionEvent(ev: FrameEvent, r: FramePayload, s: StoreStat
|
||||
if (id) s.clearUnread(id);
|
||||
return true;
|
||||
}
|
||||
case "mode_switch": {
|
||||
// The agent drives the behavior mode (design/01 §3). Apply it to the shell
|
||||
// so newly begun message bubbles render per that mode.
|
||||
if (r && typeof r.mode === "string") {
|
||||
const m = r.mode as Mode;
|
||||
if (MODES.includes(m)) useModeStore.getState().setMode(m);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case "mood": {
|
||||
// Role-scoped (design/22 §4.6): the frame names the persona the change
|
||||
// belongs to and carries no sessionId, so the bucket key is personaId —
|
||||
// each role's mood stays with its own conversations. A legacy string frame
|
||||
// has nowhere to put it, so it lands in the unscoped "" bucket.
|
||||
if (typeof ev.data === "string") {
|
||||
s.setMood(ev.data);
|
||||
} else if (r && typeof r.mood === "string") {
|
||||
s.setMood(r.mood, typeof r.personaId === "string" ? r.personaId : "");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@ import { useAppStore } from "../../../store/useAppStore";
|
||||
export interface FrameEvent {
|
||||
type: string;
|
||||
sessionId?: string;
|
||||
/** 帧壳的回合标识(契约 §3.4 帧壳 `{type,turnId?,seq?,sessionId?,data}`)。
|
||||
* 忙时并行下它是**分桶键**:同一会话两条回复靠它区分,缺了它两条会糊成一个气泡。 */
|
||||
turnId?: string;
|
||||
/** 帧壳的帧序号(同回合内的到达序;当前只透传,未参与判定)。 */
|
||||
seq?: number;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
@@ -34,3 +39,22 @@ export function frameIsActive(
|
||||
if (!sessionId || !activeSessionId) return true;
|
||||
return sessionId === activeSessionId;
|
||||
}
|
||||
|
||||
/** 一帧所属的回合 id。帧壳 `turnId` 是契约里的权威位置(结构化流帧、`turn_begin`、
|
||||
* `steer_ack` 都带);payload 里若也给了同名键(历史 DTO / graph 等)以 payload 为准
|
||||
* —— 它离内容更近。都没有时返回 undefined,调用方回落既有的「列表尾气泡」行为,
|
||||
* 因此老后端的帧逐字不变。 */
|
||||
export function frameTurnId(ev: FrameEvent, r: FramePayload): string | undefined {
|
||||
if (r && typeof r.turnId === "string" && r.turnId) return r.turnId;
|
||||
return ev.turnId || undefined;
|
||||
}
|
||||
|
||||
/** 取一个数字字段(帧字段是 `unknown`,各 handler 里重复收窄三次以上 ⇒ 抽出来)。 */
|
||||
export function frameNumber(v: unknown): number | undefined {
|
||||
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
||||
}
|
||||
|
||||
/** 取一个非空字符串字段(同上;空串按「没给」处理,避免用空值覆盖真实状态)。 */
|
||||
export function frameText(v: unknown): string | undefined {
|
||||
return typeof v === "string" && v ? v : undefined;
|
||||
}
|
||||
@@ -1,23 +1,38 @@
|
||||
import { frameIsActive, frameSessionId, type FrameEvent, type FramePayload, type StoreState } from "./shared";
|
||||
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));
|
||||
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);
|
||||
s.endStream(sid);
|
||||
const turnId = frameTurnId(ev, r);
|
||||
s.endStream(sid, turnId);
|
||||
// 忙态 / 上下文是**单槽全局**状态(design/15 §5),所以只有屏幕上这个会话的
|
||||
// 回合结束才收口;别的会话结束只能收它自己气泡的流,不能清掉这里的徽标。
|
||||
if (frameIsActive(sid, s.activeSessionId)) {
|
||||
s.setSessionBusy(false, 0);
|
||||
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 });
|
||||
}
|
||||
@@ -31,7 +46,9 @@ export function handleStreamEvent(ev: FrameEvent, r: FramePayload, s: StoreState
|
||||
return true;
|
||||
}
|
||||
case "message.delta": {
|
||||
if (r && typeof r.delta === "string") s.appendChunk(r.delta, frameSessionId(ev, r));
|
||||
if (r && typeof r.delta === "string") {
|
||||
s.appendChunk(r.delta, frameSessionId(ev, r), frameTurnId(ev, r));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case "message.end": {
|
||||
@@ -43,3 +60,12 @@ export function handleStreamEvent(ev: FrameEvent, r: FramePayload, s: StoreState
|
||||
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);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { frameSessionId, type FrameEvent, type FramePayload, type StoreState } from "./shared";
|
||||
import { frameSessionId, frameTurnId, type FrameEvent, type FramePayload, type StoreState } from "./shared";
|
||||
|
||||
/** Accumulator for a streamed thinking segment (thinking.* events), **per session**:
|
||||
* a background turn's reasoning must not be spliced into the segment being built
|
||||
@@ -6,9 +6,12 @@ import { frameSessionId, type FrameEvent, type FramePayload, type StoreState } f
|
||||
* mid-turn reconnect can rebuild the collapsed block without relying on prior state. */
|
||||
const pendingThinking: Record<string, string> = {};
|
||||
|
||||
/** 累积器与写入用的会话键:帧带的 sessionId 优先,缺席时归当前激活会话。 */
|
||||
/** 累积器与写入用的键:`sessionId|turnId` 两级 —— 忙时并行下同一会话两条回复的思维链
|
||||
* 会交错到达,只按会话累积会把两路的思考文本拼成一段(`turnId` 缺席时回落会话键,
|
||||
* 与旧行为一致)。 */
|
||||
function thinkKey(ev: FrameEvent, r: FramePayload, s: StoreState): string {
|
||||
return frameSessionId(ev, r) ?? s.activeSessionId;
|
||||
const sid = frameSessionId(ev, r) ?? s.activeSessionId;
|
||||
return `${sid}|${frameTurnId(ev, r) ?? ""}`;
|
||||
}
|
||||
|
||||
/** 思考流帧处理组:thinking.start / thinking.delta / thinking.end + 遗留单帧 thinking。
|
||||
@@ -24,22 +27,22 @@ export function handleThinkingEvent(ev: FrameEvent, r: FramePayload, s: StoreSta
|
||||
const key = thinkKey(ev, r, s);
|
||||
const text = (pendingThinking[key] ?? "") + r.delta;
|
||||
pendingThinking[key] = text;
|
||||
s.setThinking(text, frameSessionId(ev, r));
|
||||
s.setThinking(text, frameSessionId(ev, r), frameTurnId(ev, r));
|
||||
return true;
|
||||
}
|
||||
case "thinking.end": {
|
||||
const key = thinkKey(ev, r, s);
|
||||
const text = pendingThinking[key] ?? "";
|
||||
if (text) s.setThinking(text, frameSessionId(ev, r));
|
||||
if (text) s.setThinking(text, frameSessionId(ev, r), frameTurnId(ev, r));
|
||||
delete pendingThinking[key];
|
||||
return true;
|
||||
}
|
||||
case "thinking": {
|
||||
// Legacy single-shot CoT frame — renders into the same bubble.
|
||||
if (typeof ev.data === "string") {
|
||||
s.setThinking(ev.data, frameSessionId(ev, r));
|
||||
s.setThinking(ev.data, frameSessionId(ev, r), frameTurnId(ev, r));
|
||||
} else if (r && typeof r.text === "string") {
|
||||
s.setThinking(r.text, frameSessionId(ev, r));
|
||||
s.setThinking(r.text, frameSessionId(ev, r), frameTurnId(ev, r));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { frameSessionId, type FrameEvent, type FramePayload, type StoreState } from "./shared";
|
||||
import { parseTool, parseToolCall } from "../../wsParse";
|
||||
import { frameSessionId, frameTurnId, type FrameEvent, type FramePayload, type StoreState } from "./shared";
|
||||
import { parseTool, parseToolCall, parseToolExecution } from "../../wsParse";
|
||||
|
||||
/** 工具帧处理组:tool_call.start / tool_call.end / tool_call.delta /
|
||||
* tool_execution.update + 遗留 tool_used。命中(含组内主动丢弃)返回 true,
|
||||
* 未命中返回 false 交给下一组。
|
||||
* tool_execution.start / tool_execution.end / tool_execution.update + 遗留 tool_used。
|
||||
* 命中(含组内主动丢弃)返回 true,未命中返回 false 交给下一组。
|
||||
*
|
||||
* 裁决 16.4.5:工具卡按帧的 `sessionId` 落在该会话的助手气泡上(其它会话的调用
|
||||
* 卡不会跑到屏幕上的这一轮里)。 */
|
||||
* 卡不会跑到屏幕上的这一轮里)。忙时并行(design/15 §2.5):帧壳 `turnId` 决定它
|
||||
* 落在**哪一路**的气泡上 —— 两条回复的工具卡不会串到对方那里。 */
|
||||
export function handleToolEvent(ev: FrameEvent, r: FramePayload, s: StoreState): boolean {
|
||||
switch (ev.type) {
|
||||
case "tool_call.start":
|
||||
case "tool_call.end": {
|
||||
const tool = parseToolCall(ev.data);
|
||||
if (tool) s.addTool(tool, frameSessionId(ev, r));
|
||||
if (tool) s.addTool(tool, frameSessionId(ev, r), frameTurnId(ev, r));
|
||||
return true;
|
||||
}
|
||||
case "tool_call.delta": {
|
||||
@@ -20,16 +21,20 @@ export function handleToolEvent(ev: FrameEvent, r: FramePayload, s: StoreState):
|
||||
// boundary so mid-stream reconnects do not drop it.
|
||||
return true;
|
||||
}
|
||||
case "tool_execution.start":
|
||||
case "tool_execution.end":
|
||||
case "tool_execution.update": {
|
||||
// Same card as tool_call.* — same routing.
|
||||
const tool = parseToolCall(ev.data);
|
||||
if (tool) s.addTool(tool, frameSessionId(ev, r));
|
||||
// 执行阶段帧(契约 §3.4.2):`{tool, phase, id?, delta?}` —— 键名与 tool_call.*
|
||||
// 不同,必须用 parseToolExecution 读(用 parseToolCall 会一律 null ⇒ 卡片永不出现)。
|
||||
// `update` 目前后端暂无生产者(契约已注明),保留分支:一旦开报即同路。
|
||||
const tool = parseToolExecution(ev.data);
|
||||
if (tool) s.addTool(tool, frameSessionId(ev, r), frameTurnId(ev, r));
|
||||
return true;
|
||||
}
|
||||
case "tool_used": {
|
||||
// Legacy tool echo — same card as tool_call.*.
|
||||
const tool = parseTool(ev.data);
|
||||
if (tool) s.addTool(tool, frameSessionId(ev, r));
|
||||
if (tool) s.addTool(tool, frameSessionId(ev, r), frameTurnId(ev, r));
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
|
||||
Reference in new issue
Block a user