feat(web): 前端与后端同步一批(帧壳 turnId / 并行六态与两级分桶 / documents 渲染 / System 键名 / 主人档案分组)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 帧壳 `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:
AskaEth committed 2026-10-05 11:09:24 +08:00
1 parent 6a545badbd
commit eaada3af5d
34 files changed
+2457 -223

No files matched your search

+46 -1
View File
@@ -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,