import { MODES, type AgentInfo, type ChatMessage, type FlowGraph, 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(), }; } 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 || !Array.isArray(r.nodes)) return null; const nodes = r.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, })); const edges = Array.isArray(r.edges) ? r.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 r.id === "string" ? r.id : "graph", nodes, edges }; } /** 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, 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(), }; }