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 | null { if (v !== null && typeof v === "object" && !Array.isArray(v)) { return v as Record; } 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 => 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 => 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 => 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 => 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 字段名)。当前 bus 上发布的 // 是原始 `backthink.Record`,它**不带**这两个字段 ⇒ 今天恒为 undefined(本次会话新到 // 的行显示「档位未知」,重开页面由历史拉取补真值)。一旦后端把这两个事实放进帧里, // 这里无需改动就会显示出来 —— 但绝不因为「帧里没有」就编一个值。 const urgencyRaw = r.urgency ?? r.Urgency ?? r.urgency_level ?? r.urgencyLevel; const urgency = typeof urgencyRaw === "string" && urgencyRaw ? urgencyRaw : 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, 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; }