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
@@ -32,7 +32,11 @@ export {
|
||||
|
||||
export { health, login } from "./api/auth";
|
||||
|
||||
export { fetchEnums } from "./api/meta";
|
||||
export type { MetaEnums } from "./api/meta";
|
||||
|
||||
export { chatStream, chatSync } from "./api/chat";
|
||||
export type { ChatTriggerResult } from "./api/chat";
|
||||
|
||||
export {
|
||||
createSubSession,
|
||||
|
||||
@@ -15,19 +15,42 @@ export function chatSync(
|
||||
});
|
||||
}
|
||||
|
||||
/** `POST /api/v1/chat`(`stream:true`)的响应面(契约 §4.6)。202/200 都属成功,
|
||||
* 载荷必须**如实读出**:`queued` 表示被排队(不是流式),`parallel`/`turnId` 给出
|
||||
* 这一路的权威回合标识(`turn_begin` 帧也会带同一个值)。 */
|
||||
export interface ChatTriggerResult {
|
||||
stream?: true;
|
||||
/** 显式声明 `parallel:true` 且服务端照准 ⇒ 独立一路并行回合。 */
|
||||
parallel?: boolean;
|
||||
/** 会话忙且落回排队(HTTP 202):`queueDepth` = 仍在 pending 的条数。 */
|
||||
queued?: boolean;
|
||||
queueDepth?: number;
|
||||
/** 本回合的权威标识(`turn_begin` 帧的帧壳 `turnId` 同值)。 */
|
||||
turnId?: string;
|
||||
/** `clientMsgId` 判重命中(HTTP 200):本条没有新回合,`messageId` 是既有消息的
|
||||
* 权威 id(与 WS 的 `chat_duplicate` 帧同一条事实)。 */
|
||||
duplicate?: boolean;
|
||||
messageId?: string;
|
||||
}
|
||||
|
||||
/** POST /api/v1/chat with stream:true — triggers streaming over the WS channel.
|
||||
* `image` (a base64 data: URI or URL) is attached for the vision path; the
|
||||
* gateway's chat body currently has no image field, so an unconfigured backend
|
||||
* simply ignores it. `deviceId` mirrors the client's per-browser id onto the
|
||||
* persisted message (design/16 §4.5). `mode` is the behavior mode this turn is
|
||||
* sent in (design/01 §3); omitted when empty so the backend falls back to chat. */
|
||||
* sent in (design/01 §3); omitted when empty so the backend falls back to chat.
|
||||
* `clientMsgId` 是本条的幂等键(契约 §4.3):服务端判重命中时回
|
||||
* `chat_duplicate{clientMsgId,messageId}`,本端据此把乐观气泡对齐到既有 id。
|
||||
* `parallel` 显式请求独立一路(忙时并行规范草案 §2.2);省略 = 行为与今天逐字相同。 */
|
||||
export function chatStream(
|
||||
sessionId: string,
|
||||
text: string,
|
||||
image?: string,
|
||||
mode?: Mode
|
||||
): Promise<{ stream: true }> {
|
||||
return request<{ stream: true }>("/chat", {
|
||||
mode?: Mode,
|
||||
clientMsgId?: string,
|
||||
parallel?: boolean
|
||||
): Promise<ChatTriggerResult> {
|
||||
return request<ChatTriggerResult>("/chat", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
sessionId,
|
||||
@@ -36,6 +59,8 @@ export function chatStream(
|
||||
stream: true,
|
||||
deviceId: getClientId(),
|
||||
...(mode ? { mode } : {}),
|
||||
...(clientMsgId ? { clientMsgId } : {}),
|
||||
...(parallel ? { parallel: true } : {}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { EnumItem } from "../types";
|
||||
import { request } from "./core";
|
||||
|
||||
/** GET /api/v1/meta/enums 的载荷(design/07 §6,Android 协作 B4):后端发布的权威
|
||||
* 枚举表 + 未列出取值的回退策略(`unknown: "display-raw"`)。
|
||||
*
|
||||
* 客户端优先读这里而不是硬编码映射:表由后端一处维护,来源徽标/情绪标签才不会逐
|
||||
* 客户端漂移。取不到(未接线 / 离线)时调用方回落到本仓镜像的 `lib/enums.ts`
|
||||
* 标签表 —— 回落也只影响标签文字,不影响取值的原样呈现。 */
|
||||
export interface MetaEnums {
|
||||
platforms?: EnumItem[];
|
||||
moods?: EnumItem[];
|
||||
modes?: EnumItem[];
|
||||
unknown?: string;
|
||||
}
|
||||
|
||||
export function fetchEnums(): Promise<MetaEnums> {
|
||||
return request<MetaEnums>("/meta/enums");
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ChatMessage, Mode, SessionMeta } from "../types";
|
||||
import type { ChatMessage, MessageDocument, Mode, SessionMeta } from "../types";
|
||||
import { request } from "./core";
|
||||
|
||||
/** One chat message as returned by GET /api/v1/sessions/{id}/messages. Mirrors
|
||||
@@ -11,6 +11,11 @@ export interface MessageDto {
|
||||
mode?: Mode;
|
||||
mood?: string;
|
||||
image?: string;
|
||||
/** 文档附件(契约 §4.4):`{name,mime,size,chars,truncated,status,reason?}`,
|
||||
* ≤5 条/回合,0 个附件时键缺席。**不含正文**;`status:"unreadable"` 时带逐字
|
||||
* 中文 `reason`。`chars`/`truncated` 是**注入侧**语义(注入上下文的字符数与是否
|
||||
* 被预算截断),不是文件字数/截断。 */
|
||||
documents?: MessageDocument[];
|
||||
thinking?: string;
|
||||
/** Source-platform tag (design/16 §4.4): "web" or an IM channel platform. */
|
||||
platform?: string;
|
||||
@@ -18,6 +23,13 @@ export interface MessageDto {
|
||||
deviceId?: string;
|
||||
/** Sender display name of a third-party message (design/16 §4.6). Blank for the owner. */
|
||||
senderName?: string;
|
||||
/** IM 来源适配器(`platform:account`);Web 来源为空。 */
|
||||
adapterId?: string;
|
||||
/** 本回合的权威回合标识(`chat_messages.turn_id`):用户行 = 吃进/起它的那一路,
|
||||
* 助手行 = 产出它的那一路。旧行为空缺席。 */
|
||||
turnId?: string;
|
||||
/** 幂等键(契约 §4.3);空则省略。 */
|
||||
clientMsgId?: string;
|
||||
/** The role that spoke this message (design/22 §7-5, backing column
|
||||
* `chat_messages.persona_id`); `''` means "inherit the current default role". */
|
||||
personaId?: string;
|
||||
@@ -34,10 +46,13 @@ function toChatMessage(m: MessageDto): ChatMessage {
|
||||
mode: m.mode,
|
||||
mood: m.mood,
|
||||
image: m.image,
|
||||
documents: m.documents,
|
||||
thinking: m.thinking,
|
||||
platform: m.platform,
|
||||
deviceId: m.deviceId,
|
||||
senderName: m.senderName,
|
||||
turnId: m.turnId,
|
||||
clientMsgId: m.clientMsgId,
|
||||
personaId: m.personaId,
|
||||
createdAt: m.createdAt,
|
||||
};
|
||||
|
||||
@@ -14,3 +14,12 @@ export interface ApiErrorShape {
|
||||
code?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/** 一条客户端枚举项(`GET /api/v1/meta/enums` 的 `platforms`/`moods`/`modes` 元素):
|
||||
* `id` 是客户端拿来比对的机器可读值,`label` 是可直接显示的中文默认标签
|
||||
* (有 i18n 的客户端可覆盖)。契约保证「表外取值原样展示」,所以消费方绝不能把
|
||||
* 未列出的 id 当错误丢掉。 */
|
||||
export interface EnumItem {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
@@ -30,6 +30,40 @@ export interface ToolUsed {
|
||||
at: number;
|
||||
}
|
||||
|
||||
/** 一条用户消息的投递态(忙时并行规范草案 §1.1 的六态)。缺省 = 旧行为
|
||||
* (已送达 / 已答),因此老后端下渲染逐字不变。
|
||||
*
|
||||
* sending ①本地乐观气泡 → sent ②已落库 → queued ③排队中(pending_ack)→
|
||||
* answering ④已受理·回答中(灰态:turn_begin / steer_ack)→ answered ⑤已答
|
||||
* (该 turnId 的 turn.end,兜底 = 该 turnId 的助手 message_persisted);
|
||||
* rejected ⑥被拒(chat_busy / pending_full)。 */
|
||||
export type MessageDelivery =
|
||||
| "sending"
|
||||
| "sent"
|
||||
| "queued"
|
||||
| "answering"
|
||||
| "answered"
|
||||
| "rejected";
|
||||
|
||||
/** 一条消息携带的文档附件(契约 §4.4)。**不含正文**:只给名字与「注入侧」的
|
||||
* 统计。`chars`/`truncated` 讲的是**本回合注入模型上下文的字符数 / 是否被预算
|
||||
* 截断**,不是文件的真实字数与截断 —— 渲染时不能写成「文件 N 字」。 */
|
||||
export interface MessageDocument {
|
||||
name: string;
|
||||
mime?: string;
|
||||
size?: number;
|
||||
/** 本回合注入上下文的字符数(截断时为截断后的值)。 */
|
||||
chars?: number;
|
||||
/** 注入是否被预算截断。 */
|
||||
truncated?: boolean;
|
||||
/** extracted(正文已进上下文)/ listed(只给了清单)/ unreadable(读不到正文)。
|
||||
* 契约允许后端新增取值,未知值按 display-raw 原样呈现,绝不当作错误。 */
|
||||
status?: string;
|
||||
/** **只在 `status:"unreadable"` 时下发**的逐字中文原因(服务端常量),客户端
|
||||
* 原样渲染、不自己拼。 */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
/** The owning session (backend `session_id`). Set when loaded via the sessions
|
||||
@@ -49,6 +83,23 @@ export interface ChatMessage {
|
||||
* as an image card. Absent for legacy persisted messages, so renders fall back
|
||||
* to plain text. */
|
||||
image?: string;
|
||||
/** 文档附件列表(契约 §4.4;0 个附件时键缺席,不是 `[]`)。渲染在用户气泡里;
|
||||
* `status:"unreadable"` 时同屏显示后端给的逐字中文 `reason`。 */
|
||||
documents?: MessageDocument[];
|
||||
/** 这条消息所属的回合(`chat_messages.turn_id` / 帧壳 `turnId`)。用户行 = 吃进
|
||||
* 或起它的那一路;助手行 = 产出它的那一路。忙时并行下靠它把「回复 ↔ 提问」配对、
|
||||
* 把两条交错到达的回复分进两个气泡(规范草案 §1.4/§1.7)。旧行为空缺席。 */
|
||||
turnId?: string;
|
||||
/** 本端乐观气泡的幂等键(契约 §4.3;随 `chat.clientMsgId` 上行)。服务端判重命中
|
||||
* 时回 `chat_duplicate{clientMsgId,messageId}`,本端据此把气泡对齐到既有 id。 */
|
||||
clientMsgId?: string;
|
||||
/** 六态投递态(规范草案 §1.1);缺省 = 已送达/已答。 */
|
||||
delivery?: MessageDelivery;
|
||||
/** steer_ack 的 `atStep`:这条消息被在飞回合吃进时的模型步序号(1 基)。
|
||||
* 只作灰态小字用,不参与判定。 */
|
||||
steerStep?: number;
|
||||
/** ⑥ 被拒的原因小字(队列满 / `chat_busy`),后端文案或码,原样展示。 */
|
||||
rejectReason?: string;
|
||||
/** Source-platform tag of the message (design/16 §4.4): "web" for messages that
|
||||
* originate on the Web main session, or an IM channel (e.g. "telegram",
|
||||
* "weixin-ilink", "qq-obv11") for messages mirrored in from an IM adapter. The
|
||||
|
||||
@@ -17,10 +17,64 @@ export interface AwakenPayload {
|
||||
tool?: string | { name: string };
|
||||
}
|
||||
|
||||
/** session_busy / session_pending 下行事件 payload(design/15 §5):她是否在忙 + 挂起消息数。 */
|
||||
/** session_busy / session_pending 下行事件 payload(design/15 §5):她是否在忙 + 挂起消息数。
|
||||
*
|
||||
* 忙时并行(design/15 §2.5,契约 v0.65 §3.4.1)后这些帧还带 `activeTurns`(在途路数)、
|
||||
* `mode`(忙碌通道)、`queueDepth` / `reason` / `dropped` / `cancelledTurns` 等计数键。
|
||||
* 全部可选:老后端不发则逐字沿用旧行为。`reason` 目前唯一取值 `"parallel_limit"`
|
||||
* (声明并行却落回排队)。 */
|
||||
export interface SessionStatePayload {
|
||||
busy?: boolean;
|
||||
pendingCount?: number;
|
||||
/** 该帧发出时的在途路数(含正在收尾的路)。忙 = activeTurns > 0。 */
|
||||
activeTurns?: number;
|
||||
/** 忙碌通道:thinking / executing / subagent;`session_idle` 为 "idle"。 */
|
||||
mode?: string;
|
||||
/** 仍在 pending、尚未被任何一路接手的条数(已 steer / 已升并行路的不计)。 */
|
||||
queueDepth?: number;
|
||||
/** `pending_ack` 的落回原因,目前仅 `"parallel_limit"`。其它情况键缺席。 */
|
||||
reason?: string;
|
||||
/** `session_interrupted`:scope:"all" 时清空的 pending 数;scope:"current" 恒 0。 */
|
||||
dropped?: number;
|
||||
/** `session_interrupted`:本次真的取消了几路。 */
|
||||
cancelledTurns?: number;
|
||||
/** `pending_full`:队列上限(队列满 ⇒ 深度 = max)。 */
|
||||
max?: number;
|
||||
/** `pending_full`:被拒消息此前已落库/已认领时的权威 id(服务端绝不在此新铸 id)。 */
|
||||
messageId?: string;
|
||||
/** `pending_full`:被拒消息的幂等键(未携带则键缺席)。 */
|
||||
clientMsgId?: string;
|
||||
}
|
||||
|
||||
/** `turn_begin` 下行帧 payload(契约 §3.4.3 / 规范草案 §2.4):每起一路(含续跑路)
|
||||
* 恰好一帧,把「服务端刚起的这一路 ↔ 它归属的用户消息 id」钉死。帧壳带 `turnId`。 */
|
||||
export interface TurnBeginPayload {
|
||||
/** 服务端**实际**如何起这条路:新回合/续跑 = "turn";声明并行且照准 = "parallel"。
|
||||
* 仅供展示,客户端状态机一律用 `messageIds` 是否含本气泡的 id 判定。 */
|
||||
kind?: string;
|
||||
/** 本次启动归属本路的用户消息 id 列表(可为空数组,恒为数组不为 null)。 */
|
||||
messageIds?: string[];
|
||||
/** 该路的会话内序号(1 基;1 = 唯一/第一路)。 */
|
||||
leg?: number;
|
||||
/** 本次启动后该会话在途路数。 */
|
||||
activeTurns?: number;
|
||||
}
|
||||
|
||||
/** 一路在途回合的客户端绑定记录(由 `turn_begin` 建立,`turn.end` 收口时丢弃)。
|
||||
* 它是「消息 ↔ 回合」的唯一真源:灰态靠它把 `messageIds` 置 answering,待落库的
|
||||
* 用户行靠它在 `message_persisted` 到达时补上绑定。只存内存,不持久化。 */
|
||||
export interface TurnBinding {
|
||||
turnId: string;
|
||||
sessionId: string;
|
||||
/** 该路归属的用户消息 id(`turn_begin.messageIds`,可为空数组)。 */
|
||||
messageIds: string[];
|
||||
/** "turn"(新回合/续跑)或 "parallel"(并行照准);仅供标注。 */
|
||||
kind: string;
|
||||
/** 会话内路序号(1 基)。 */
|
||||
leg: number;
|
||||
/** 建立时的在途路数。 */
|
||||
activeTurns: number;
|
||||
at: number;
|
||||
}
|
||||
|
||||
/** approval_notice 下行事件 payload(design/11 §4 主代理代审,裁决 13.4.3):
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { chatStream } from "./api";
|
||||
import { ApiError, chatStream } from "./api";
|
||||
import { connectWs, isWsOpen } from "./ws";
|
||||
import { useAppStore } from "../store/useAppStore";
|
||||
import { useModeStore } from "../store/useModeStore";
|
||||
import { getClientId } from "./client";
|
||||
import { now } from "./util";
|
||||
import { now, uid } from "./util";
|
||||
|
||||
/** Drives one user turn: push the user bubble, blanket a streaming assistant
|
||||
* bubble, trigger the backend loop over REST (`stream:true`), and let the WS
|
||||
@@ -16,7 +16,7 @@ import { now } from "./util";
|
||||
* renders per that message's mode. We read the current UI mode here and pass it
|
||||
* on the turn so the backend persists it alongside the message (A3). */
|
||||
export function useSendChat(): {
|
||||
send: (text: string, image?: string) => Promise<void>;
|
||||
send: (text: string, image?: string, opts?: { parallel?: boolean }) => Promise<void>;
|
||||
sending: boolean;
|
||||
} {
|
||||
const [sending, setSending] = useState(false);
|
||||
@@ -27,10 +27,12 @@ export function useSendChat(): {
|
||||
const appendChunk = useAppStore((s) => s.appendChunk);
|
||||
const addTool = useAppStore((s) => s.addTool);
|
||||
const appendTaskLog = useAppStore((s) => s.appendTaskLog);
|
||||
const markLocalDelivery = useAppStore((s) => s.markLocalDelivery);
|
||||
const alignDuplicateMessage = useAppStore((s) => s.alignDuplicateMessage);
|
||||
const mode = useModeStore((s) => s.mode);
|
||||
|
||||
const send = useCallback(
|
||||
async (text: string, image?: string) => {
|
||||
async (text: string, image?: string, opts: { parallel?: boolean } = {}) => {
|
||||
const trimmed = text.trim();
|
||||
if ((!trimmed && !image) || sending) return;
|
||||
setSending(true);
|
||||
@@ -42,15 +44,41 @@ export function useSendChat(): {
|
||||
// Tag this browser's persistent client id onto the optimistic bubbles so the
|
||||
// source label shows immediately and matches the persisted turn (design/16 §4.5).
|
||||
const clientId = getClientId();
|
||||
addUserMessage(trimmed, undefined, image, clientId);
|
||||
// 幂等键(契约 §4.3):随上行一起发,服务端判重命中会回 `chat_duplicate` 帧或
|
||||
// `{duplicate:true,messageId}`(REST 面),本端据此把本地气泡对齐到既有 id ——
|
||||
// 重发/双发不会多出一条气泡。
|
||||
const clientMsgId = uid();
|
||||
addUserMessage(trimmed, undefined, image, clientId, clientMsgId);
|
||||
beginAssistant(mode, clientId);
|
||||
|
||||
try {
|
||||
await chatStream(sessionId, prompt, image, mode);
|
||||
const res = await chatStream(
|
||||
sessionId,
|
||||
prompt,
|
||||
image,
|
||||
mode,
|
||||
clientMsgId,
|
||||
opts.parallel
|
||||
);
|
||||
// 响应面得如实消费(契约 §4.6):202 排队不是错误。
|
||||
// `pending_ack` 帧是队列深度的真源,这里只做「万一那条帧没到」的兜底标注;
|
||||
// `duplicate` 同理(同一条事实的 REST 面)。
|
||||
if (res.duplicate && res.messageId) {
|
||||
alignDuplicateMessage(clientMsgId, res.messageId, sessionId);
|
||||
} else if (res.queued) {
|
||||
markLocalDelivery("queued");
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
endStream();
|
||||
// ⑥ 被拒:`409 chat_busy`(队列满)—— 这不是「触发失败」,气泡要明确说
|
||||
// 「未送达」,让人知道这句话没进去(而不是只看到一行错误日志)。
|
||||
if (err instanceof ApiError && err.code === "chat_busy") {
|
||||
markLocalDelivery("rejected", { rejectReason: "会话正忙:排队已满,这条未送达" });
|
||||
appendTaskLog("main", "[chat_busy] 排队已满,本条未送达\n");
|
||||
return;
|
||||
}
|
||||
// Backend loop not wired or gateway unreachable: show the failure inline
|
||||
// rather than leaving a permanently-streaming bubble.
|
||||
endStream();
|
||||
const msg = err instanceof Error ? err.message : "触发失败";
|
||||
appendChunk(`[stream 触发失败] ${msg}`);
|
||||
appendTaskLog("main", `[error] ${msg}\n`);
|
||||
@@ -59,7 +87,7 @@ export function useSendChat(): {
|
||||
setSending(false);
|
||||
}
|
||||
},
|
||||
[sending, sessionId, mode, addUserMessage, beginAssistant, endStream, appendChunk, addTool, appendTaskLog]
|
||||
[sending, sessionId, mode, addUserMessage, beginAssistant, endStream, appendChunk, addTool, appendTaskLog, markLocalDelivery, alignDuplicateMessage]
|
||||
);
|
||||
|
||||
return { send, sending };
|
||||
|
||||
@@ -9,6 +9,8 @@ import { handleToolEvent } from "./ws/handlers/tool";
|
||||
import { handleMessageEvent } from "./ws/handlers/message";
|
||||
import { handleAgentEvent } from "./ws/handlers/agent";
|
||||
import { handleSessionEvent } from "./ws/handlers/session";
|
||||
import { handlePersonaEvent } from "./ws/handlers/persona";
|
||||
import { handleParallelEvent } from "./ws/handlers/parallel";
|
||||
import { handleDeviceEvent } from "./ws/handlers/device";
|
||||
import { handleApprovalEvent } from "./ws/handlers/approval";
|
||||
|
||||
@@ -72,8 +74,18 @@ function notifyObservability(type: string, data: unknown): void {
|
||||
* logic test without a browser: this project has no jsdom/test runner, so the
|
||||
* socket lifecycle around it stays untested while the mapping does not have to.
|
||||
* 帧按类型分组在 lib/ws/handlers/* 中处理:任一组命中(含组内主动丢弃)即结束,
|
||||
* 全部未命中走下方未知帧兜底 —— 与拆分前的单个 switch 等价。 */
|
||||
export function dispatchFrame(ev: { type: string; sessionId?: string; data: unknown }): void {
|
||||
* 全部未命中走下方未知帧兜底 —— 与拆分前的单个 switch 等价。
|
||||
*
|
||||
* `turnId`/`seq` 是帧壳字段,**必须原样传进 handler**:忙时并行下同一会话的两条
|
||||
* 回复靠 `(sessionId, turnId)` 两级分桶区分,此前签名只收 `{type,sessionId,data}`,
|
||||
* `turnId` 在 socket 层就被丢掉 ⇒ 所有按路分桶都不可能实现。 */
|
||||
export function dispatchFrame(ev: {
|
||||
type: string;
|
||||
sessionId?: string;
|
||||
turnId?: string;
|
||||
seq?: number;
|
||||
data: unknown;
|
||||
}): void {
|
||||
const s = useAppStore.getState();
|
||||
const r = toRecord(ev.data);
|
||||
|
||||
@@ -91,6 +103,10 @@ export function dispatchFrame(ev: { type: string; sessionId?: string; data: unkn
|
||||
if (handleMessageEvent(ev, r, s)) return;
|
||||
if (handleAgentEvent(ev, r, s)) return;
|
||||
if (handleSessionEvent(ev, r, s)) return;
|
||||
if (handlePersonaEvent(ev, r, s)) return;
|
||||
// 忙时并行帧组在 device 组之前:`error` 除 `chat_busy` 外一律放行,仍由 device.ts
|
||||
// 记一行日志,所以顺序只影响谁先处理 chat_busy,不影响原有日志行为。
|
||||
if (handleParallelEvent(ev, r, s)) return;
|
||||
if (handleDeviceEvent(ev, r, s)) return;
|
||||
if (handleApprovalEvent(ev, r, s)) return;
|
||||
|
||||
@@ -161,6 +177,9 @@ function openSocket(): void {
|
||||
// The frame envelope's sessionId is what scopes a payload that does not
|
||||
// carry its own (see the `message_deleted` case in dispatch).
|
||||
sessionId: typeof r.sessionId === "string" ? r.sessionId : undefined,
|
||||
// 帧壳的 turnId/seq(契约 §3.4):分桶键必须传下去,不能在这里丢。
|
||||
turnId: typeof r.turnId === "string" && r.turnId ? r.turnId : undefined,
|
||||
seq: typeof r.seq === "number" ? r.seq : undefined,
|
||||
data: r.data,
|
||||
});
|
||||
} catch {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MODES, type AdminChannelMessage, type AgentInfo, type BackthinkItem, type ChatMessage, type FlowGraph, type Mode, type ToolUsed } from "./types";
|
||||
import { MODES, type AdminChannelMessage, type AgentInfo, type BackthinkItem, type ChatMessage, type FlowGraph, type MessageDocument, type Mode, type ToolUsed } from "./types";
|
||||
import { now, uid } from "./util";
|
||||
|
||||
/**
|
||||
@@ -83,6 +83,24 @@ export function parseToolCall(v: unknown): ToolUsed | null {
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a tool card from a `tool_execution.*` payload (契约 §3.4.2):
|
||||
* `{tool, phase, id?, delta?}`,`phase` ∈ `running/done/error`(**phase 绝不塞进
|
||||
* delta**)。注意这里的键名与 `tool_call.*` 不同:工具名在 `tool` 上(不是 `name`),
|
||||
* 成败由 `phase` 决定 —— 用 `parseToolCall` 读它只会得到 null,卡片就永远不会出现。 */
|
||||
export function parseToolExecution(v: unknown): ToolUsed | null {
|
||||
const r = toRecord(v);
|
||||
if (!r) return null;
|
||||
const name = typeof r.tool === "string" ? r.tool : "";
|
||||
if (!name) return null;
|
||||
const phase = typeof r.phase === "string" ? r.phase : "";
|
||||
return {
|
||||
name,
|
||||
summary: typeof r.detail === "string" ? r.detail : undefined,
|
||||
ok: phase === "done" ? true : phase === "error" ? false : undefined,
|
||||
at: now(),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseAgent(payload: unknown): AgentInfo | null {
|
||||
const r = toRecord(payload);
|
||||
if (!r) return null;
|
||||
@@ -159,6 +177,28 @@ export function parseGraph(payload: unknown): FlowGraph | null {
|
||||
};
|
||||
}
|
||||
|
||||
/** 解析消息 DTO 的 `documents` 数组(契约 §4.4)。**不含正文**,只在有附件时出现
|
||||
* (0 个附件时键缺席)。`status` 允许后端新增取值(display-raw:未知值原样带下去,
|
||||
* 渲染层按未知处理,绝不丢掉附件、也绝不把未知当错误)。`reason` 只在
|
||||
* `status:"unreadable"` 时下发,是服务端产出的逐字中文原因,客户端原样展示。 */
|
||||
export function parseDocuments(v: unknown): MessageDocument[] | undefined {
|
||||
if (!Array.isArray(v)) return undefined;
|
||||
const docs = v
|
||||
.map((d) => toRecord(d))
|
||||
.filter((d): d is Record<string, unknown> => d !== null)
|
||||
.map((d) => ({
|
||||
name: typeof d.name === "string" ? d.name : "",
|
||||
mime: typeof d.mime === "string" && d.mime ? d.mime : undefined,
|
||||
size: typeof d.size === "number" ? d.size : undefined,
|
||||
chars: typeof d.chars === "number" ? d.chars : undefined,
|
||||
truncated: typeof d.truncated === "boolean" ? d.truncated : undefined,
|
||||
status: typeof d.status === "string" && d.status ? d.status : undefined,
|
||||
reason: typeof d.reason === "string" && d.reason ? d.reason : undefined,
|
||||
}))
|
||||
.filter((d) => d.name !== "");
|
||||
return docs.length ? docs : undefined;
|
||||
}
|
||||
|
||||
/** Build a ChatMessage from a `message_persisted` payload (design/16 §3.3/§4.2):
|
||||
* the backend broadcast a fully-persisted message carrying the backend-generated
|
||||
* id. mode is validated against the known modes so a bad value never reaches the
|
||||
@@ -177,6 +217,11 @@ export function parsePersistedMessage(v: unknown): ChatMessage | null {
|
||||
mode: typeof r.mode === "string" && MODES.includes(r.mode as Mode) ? (r.mode as Mode) : undefined,
|
||||
mood: typeof r.mood === "string" ? r.mood : undefined,
|
||||
image: typeof r.image === "string" ? r.image : undefined,
|
||||
documents: parseDocuments(r.documents),
|
||||
// `chat_messages.turn_id`(契约 §4.4):忙时并行下靠它把回复与提问配对、把两条
|
||||
// 交错到达的回复分进两个气泡;旧行该键省略 ⇒ 回落按顺序渲染。
|
||||
turnId: typeof r.turnId === "string" && r.turnId ? r.turnId : undefined,
|
||||
clientMsgId: typeof r.clientMsgId === "string" && r.clientMsgId ? r.clientMsgId : undefined,
|
||||
platform: typeof r.platform === "string" && r.platform ? r.platform : undefined,
|
||||
deviceId: typeof r.deviceId === "string" && r.deviceId ? r.deviceId : undefined,
|
||||
senderName: typeof r.senderName === "string" && r.senderName ? r.senderName : undefined,
|
||||
|
||||
Reference in new issue
Block a user