Files
Cyrene_v2/frontend/web/src/lib/wsParse.ts
T
AskaEth a0abf0199a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway)!: 后台思考档位精确化(+ 面板三态 + 契约 v0.69/v0.70)
- gateway:优先读落库的 rec.Urgency(精确档位,与安静时段窗口无关);仅空串才回落到既有下限推导,并打 urgencyLowerBound 标记(**只在推导值上为 true**)
- 面板三态:精确(例常/紧急(高)/紧急·安全域)|下限(一律加 ≥ 前缀)|未知;下限悬停说明写清「为什么旧行只能是下限」
- 实时帧零协议改动:Record 的 JSON 键就是 urgency ⇒ 帧自然带上;wsParse 早已防御性读取(只加一处 urgencyLowerBound 防御读)
- 契约:v0.69(directMsgPerMin 透传修复,逐字照抄附录 A)与 v0.70(精确化 + 更正 §4.10(b)「实时帧不带这两键」)一起登记
- 变异实测:旧行不标下限 ⇒ 3 条测试红(结构体对比);前端去掉 ≥ ⇒ 渲染测试红
- ⚠️ 诚实边界(§7.3 已登记):实时旧记录刷新前显示「档位未知」、刷新后显示「≥ 例常」——同一条记录两路下的已知差异
2026-10-05 14:15:55 +08:00

337 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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";
/**
* Pure frame decoders for the `/ws/chat` downstream stream (design/04 §1.1 +
* design/16 §4). Each takes an `unknown` `data` payload, narrows it, and returns a
* typed model (or `null` to drop the frame). None of these touch the store, so the
* socket client (`ws.ts`) stays focused on connection lifecycle.
*/
export function toRecord(v: unknown): Record<string, unknown> | null {
if (v !== null && typeof v === "object" && !Array.isArray(v)) {
return v as Record<string, unknown>;
}
return null;
}
/** Decode a base64 string into bytes (used for the voice audio reply). */
function decodeBase64(b64: string): Uint8Array {
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return bytes;
}
/** Best-effort playback of a voice_output audio payload (blocked by autoplay
* policy, so sound may be silent until the user interacts). */
export function playVoiceAudio(payload: unknown): void {
const r = toRecord(payload);
if (!r || typeof r.audio !== "string" || !r.audio) return;
try {
const bytes = decodeBase64(r.audio);
const format = typeof r.format === "string" && r.format ? r.format : "wav";
const buf = new ArrayBuffer(bytes.byteLength);
new Uint8Array(buf).set(bytes);
const blob = new Blob([buf], { type: `audio/${format}` });
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
void audio.play().catch(() => {});
audio.onended = () => URL.revokeObjectURL(url);
} catch {
// Ignore malformed audio payloads.
}
}
export function parseTool(v: unknown): ToolUsed | null {
const r = toRecord(v);
if (!r) return null;
if (typeof r.name !== "string") return null;
return {
name: r.name,
summary: typeof r.summary === "string" ? r.summary : undefined,
detail: typeof r.detail === "string" ? r.detail : undefined,
ok: typeof r.ok === "boolean" ? r.ok : undefined,
at: now(),
};
}
/** Normalize a capacity_awaken `tool` field into a tool-name string. The backend
* may send a bare name or a `{name}` object; both reduce to the name. */
export function parseAwakenTool(v: unknown): string | undefined {
if (typeof v === "string") return v || undefined;
const r = toRecord(v);
if (r && typeof r.name === "string" && r.name) return r.name;
return undefined;
}
/** Build a tool card from a tool_call.* payload. Unlike the legacy tool_used
* frame these carry `id` (provider block id) rather than a guaranteed `name`,
* so we label the card by id when no name is present. */
export function parseToolCall(v: unknown): ToolUsed | null {
const r = toRecord(v);
if (!r) return null;
const name = typeof r.name === "string" ? r.name : "";
const id = typeof r.id === "string" ? r.id : "";
if (!name && !id) return null;
return {
name: name || `tool_${id.slice(0, 4)}`,
summary: typeof r.summary === "string" ? r.summary : undefined,
detail: typeof r.detail === "string" ? r.detail : undefined,
ok: typeof r.ok === "boolean" ? r.ok : undefined,
at: now(),
};
}
/** 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;
const id = typeof r.id === "string" ? r.id : uid();
const status =
typeof r.status === "string"
? (r.status as AgentInfo["status"])
: typeof r.state === "string"
? (r.state as AgentInfo["status"])
: "running";
const tool = parseTool(r.tool);
return {
id,
name: typeof r.name === "string" ? r.name : `子代理 ${id.slice(0, 4)}`,
role: typeof r.role === "string" ? r.role : undefined,
mode: typeof r.mode === "string" ? r.mode : undefined,
status,
progress: typeof r.progress === "number" ? r.progress : undefined,
thinking: typeof r.thinking === "string" ? r.thinking : undefined,
message: typeof r.message === "string" ? r.message : undefined,
tools: tool ? [tool] : undefined,
};
}
export function parseGraph(payload: unknown): FlowGraph | null {
const r = toRecord(payload);
if (!r) return null;
// Support both a bare FlowGraph and a {sessionId, graph} envelope.
const graphRec = toRecord(r.graph) ?? r;
if (!Array.isArray(graphRec.nodes)) return null;
const nodes = graphRec.nodes
.map((n) => toRecord(n))
.filter((n): n is Record<string, unknown> => n !== null)
.map((n) => ({
id: typeof n.id === "string" ? n.id : uid(),
label: typeof n.label === "string" ? n.label : "节点",
kind: typeof n.kind === "string" ? n.kind : undefined,
status:
typeof n.status === "string"
? (n.status as FlowGraph["nodes"][number]["status"])
: undefined,
startedAt: typeof n.startedAt === "string" ? n.startedAt : undefined,
endedAt: typeof n.endedAt === "string" ? n.endedAt : undefined,
props: toRecord(n.props) ?? undefined,
log: Array.isArray(n.log)
? n.log
.map((e) => toRecord(e))
.filter((e): e is Record<string, unknown> => e !== null)
.map((e) => ({
time: typeof e.time === "string" ? e.time : "",
type: typeof e.type === "string" ? e.type : "",
props: toRecord(e.props) ?? undefined,
}))
: undefined,
}));
const edges = Array.isArray(graphRec.edges)
? graphRec.edges
.map((e) => toRecord(e))
.filter((e): e is Record<string, unknown> => e !== null)
.map((e) => ({
from: typeof e.from === "string" ? e.from : "",
to: typeof e.to === "string" ? e.to : "",
}))
: [];
return {
id: typeof graphRec.id === "string" ? graphRec.id : "graph",
nodes,
edges,
sessionId:
(typeof r.sessionId === "string" ? r.sessionId : undefined) ??
(typeof graphRec.sessionId === "string" ? graphRec.sessionId : undefined),
startedAt: typeof graphRec.startedAt === "string" ? graphRec.startedAt : undefined,
endedAt: typeof graphRec.endedAt === "string" ? graphRec.endedAt : undefined,
};
}
/** 解析消息 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
* UI; createdAt falls back to now when the wire omits it. */
export function parsePersistedMessage(v: unknown): ChatMessage | null {
const r = toRecord(v);
if (!r) return null;
const id = typeof r.id === "string" && r.id ? r.id : "";
const role = r.role;
if (!id || (role !== "user" && role !== "assistant" && role !== "system")) return null;
return {
id,
sessionId: typeof r.sessionId === "string" && r.sessionId ? r.sessionId : undefined,
role,
content: typeof r.content === "string" ? r.content : "",
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,
createdAt: typeof r.createdAt === "number" ? r.createdAt : now(),
};
}
/** 解析握手 `history` 回放帧的 `data.messages` 数组(契约 §3.2):服务端在 owner/admin
* 连接注册后**定向**发一帧,内含主会话(`web`)最近 50 条(`wsReplayLimit`)。每条就是
* 同一个 `historyMessage` DTO(§4.4),因此复用 `parsePersistedMessage` 收窄;坏行走
* `null` 被丢弃,绝不把一条畸形行塞进列表。 */
export function parseHistoryMessages(v: unknown): ChatMessage[] {
if (!Array.isArray(v)) return [];
const out: ChatMessage[] = [];
for (const item of v) {
const msg = parsePersistedMessage(item);
if (msg) out.push(msg);
}
return out;
}
/** Build an AdminChannelMessage from a `channel_persisted` payload (design/16
* §4.6 §4): the backend broadcast a fully-persisted isolated (group/stranger)
* channel message carrying the backend id + adapter id, for the IM adapter log
* page to append in realtime. Requires id + sessionId; role/content fall back to
* defaults so a malformed frame is dropped rather than rendered. */
export function parseAdminChannelMessage(v: unknown): AdminChannelMessage | null {
const r = toRecord(v);
if (!r) return null;
const id = typeof r.id === "string" && r.id ? r.id : "";
const sessionId = typeof r.sessionId === "string" && r.sessionId ? r.sessionId : "";
if (!id || !sessionId) return null;
return {
id,
sessionId,
role: typeof r.role === "string" ? r.role : "user",
content: typeof r.content === "string" ? r.content : "",
mode: typeof r.mode === "string" ? r.mode : undefined,
platform: typeof r.platform === "string" && r.platform ? r.platform : undefined,
deviceId: typeof r.deviceId === "string" && r.deviceId ? r.deviceId : undefined,
adapterId: typeof r.adapterId === "string" && r.adapterId ? r.adapterId : undefined,
senderName: typeof r.senderName === "string" && r.senderName ? r.senderName : undefined,
createdAt: typeof r.createdAt === "number" ? r.createdAt : now(),
};
}
/** Decode a background-think `backthink` WS payload into a BackthinkItem. The bus
* publishes the raw `backthink.Record` (Go default field names ID/Trigger/At/
* Conclusion/ShouldPush/PushResult,后者内含 Pushed/Route/Bypassed/Detail);
* the admin wire uses camelCase, so this reads both defensively and returns
* null when the id cannot be resolved.
*
* route / detail / bypassed 与历史路径(`GET /admin/backthink` →
* AdminBackthinkItem)的同名字段**同义同源**(都来自后端已存的 PushResult),
* 因此实时行与刷新后的行渲染一致;线上没报告时保持缺席,绝不编值。 */
export function parseBackthinkItem(v: unknown): BackthinkItem | null {
const r = toRecord(v);
if (!r) return null;
const idRaw = r.ID ?? r.id ?? r.record_id ?? r.recordId;
if (idRaw === undefined || idRaw === null) return null;
const atRaw = r.At ?? r.at;
let at = 0;
if (typeof atRaw === "number") at = atRaw;
else if (typeof atRaw === "string") {
const t = Date.parse(atRaw);
if (!Number.isNaN(t)) at = t;
}
const trigger = typeof r.Trigger === "string" ? r.Trigger : typeof r.trigger === "string" ? r.trigger : "";
const conclusion = typeof r.Conclusion === "string" ? r.Conclusion : typeof r.conclusion === "string" ? r.conclusion : "";
const pr = toRecord(r.PushResult) ?? {};
const shouldNotify = (r.ShouldPush ?? r.shouldNotify ?? r.should_push) === true;
const pushed = (pr.Pushed ?? pr.pushed ?? r.pushed ?? r.push_pushed) === true;
// 与历史路径同源字段(后端已存的 PushResult 的如实投影):Route/Detail 为空时
// 留 undefined(等同 AdminBackthinkItem 的 omitempty),Bypassed 只在线上给出
// 布尔时才带值,绝不把「没报告」改写成 false。
const route = firstPushText(pr.Route, pr.route, r.route, r.push_route);
const detail = firstPushText(pr.Detail, pr.detail, r.detail, r.push_detail);
const bypassedRaw = pr.Bypassed ?? pr.bypassed ?? r.bypassed ?? r.push_bypassed;
const bypassed = typeof bypassedRaw === "boolean" ? bypassedRaw : undefined;
// v0.68 的档位/穿透:**防御性读取**(兼容 camelCase 与 Go 字段名)。X2a 起 bus 上发布的
// 原始 `backthink.Record` 已带 `urgency`(该轮实际生效的**精确**档位)⇒ 实时行同样显示档位;
// `urgencyLowerBound` / `quietHoursBypassed` 不在记录里(由网关按安静时段窗口推导),
// 这里照旧防御性读取,一旦帧里带上就无需改动地显示出来 —— 但绝不因为「帧里没有」就编一个值。
const urgencyRaw = r.urgency ?? r.Urgency ?? r.urgency_level ?? r.urgencyLevel;
const urgency = typeof urgencyRaw === "string" && urgencyRaw ? urgencyRaw : undefined;
const lbRaw = r.urgencyLowerBound ?? r.UrgencyLowerBound;
const urgencyLowerBound = typeof lbRaw === "boolean" ? lbRaw : undefined;
const qhRaw = r.quietHoursBypassed ?? r.QuietHoursBypassed;
const quietHoursBypassed = typeof qhRaw === "boolean" ? qhRaw : undefined;
return {
id: String(idRaw),
trigger,
conclusion,
shouldNotify,
pushed,
route,
detail,
bypassed,
urgency,
urgencyLowerBound,
quietHoursBypassed,
at,
};
}
/** 取 PushResult 文本字段的第一个非空值(先 Go 字段名,再 camelCase)。空串按
* 「没有值」处理,与历史 DTO 的 omitempty 一致 —— 实时行与历史行对「没有值」
* 的判定因此相同。 */
function firstPushText(...vals: unknown[]): string | undefined {
for (const v of vals) if (typeof v === "string" && v) return v;
return undefined;
}