- gateway:优先读落库的 rec.Urgency(精确档位,与安静时段窗口无关);仅空串才回落到既有下限推导,并打 urgencyLowerBound 标记(**只在推导值上为 true**)
- 面板三态:精确(例常/紧急(高)/紧急·安全域)|下限(一律加 ≥ 前缀)|未知;下限悬停说明写清「为什么旧行只能是下限」
- 实时帧零协议改动:Record 的 JSON 键就是 urgency ⇒ 帧自然带上;wsParse 早已防御性读取(只加一处 urgencyLowerBound 防御读)
- 契约:v0.69(directMsgPerMin 透传修复,逐字照抄附录 A)与 v0.70(精确化 + 更正 §4.10(b)「实时帧不带这两键」)一起登记
- 变异实测:旧行不标下限 ⇒ 3 条测试红(结构体对比);前端去掉 ≥ ⇒ 渲染测试红
- ⚠️ 诚实边界(§7.3 已登记):实时旧记录刷新前显示「档位未知」、刷新后显示「≥ 例常」——同一条记录两路下的已知差异
337 lines
15 KiB
TypeScript
337 lines
15 KiB
TypeScript
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;
|
||
}
|