UNPKG

@juspay/neurolink

Version:

The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002

360 lines (359 loc) • 12.7 kB
/** * Data-channel event bridge. * * Connects NeuroLink's event emitter to the LiveKit room's data channel: it * forwards NeuroLink events (text deltas, tool start/result, HITL prompts, * stream lifecycle) to the browser as small versioned envelopes, and accepts * control messages (HITL responses) back, re-emitting them onto the emitter so * NeuroLink's HITL manager resolves. * * This is the WebRTC counterpart of the chat-mode SSE controller: same event * source, different transport. The browser renders the envelopes (transcript, * tool status, charts, confirmation prompts). * * `@livekit/rtc-node` is an optional dependency, imported dynamically only for * the `RoomEvent` enum value. All payloads arrive typed as `unknown` and are * narrowed with runtime guards — no type assertions. * * See docs/features/livekit-voice-agent.md. */ import { logger } from "../../utils/logger.js"; const DEFAULT_EVENTS_TOPIC = "ai-events"; const DEFAULT_CONTROL_TOPIC = "ai-control"; const DEFAULT_MAX_INLINE_BYTES = 12_000; /** NeuroLink emitter event names this bridge forwards. */ const USER_TEXT_EVENT = "voice:user-transcript"; const TEXT_EVENT = "response:chunk"; const TOOL_START_EVENT = "tool:start"; const TOOL_END_EVENT = "tool:end"; const HITL_REQUEST_EVENT = "hitl:confirmation-request"; const STREAM_START_EVENT = "stream:start"; const STREAM_COMPLETE_EVENT = "stream:complete"; const STREAM_END_EVENT = "stream:end"; const STREAM_ERROR_EVENT = "stream:error"; /** Read a property off a non-null object as `unknown` without a type assertion. */ function readProp(source, key) { return Reflect.get(source, key); } /** Narrow an unknown value to a non-null object, or `undefined`. */ function asObject(value) { return typeof value === "object" && value !== null ? value : undefined; } /** Extract the text delta from a `response:chunk` payload. */ function readTextDelta(payload) { return typeof payload === "string" && payload.length > 0 ? payload : undefined; } /** Extract tool fields from a `tool:start` / `tool:end` payload. */ function readToolFields(payload) { const obj = asObject(payload); if (!obj) { return undefined; } const toolName = readProp(obj, "toolName"); const tool = readProp(obj, "tool"); const name = typeof toolName === "string" ? toolName : typeof tool === "string" ? tool : undefined; if (name === undefined) { return undefined; } const fields = { name }; const executionId = readProp(obj, "executionId"); if (typeof executionId === "string") { fields.id = executionId; } const input = readProp(obj, "input"); if (input !== undefined) { fields.input = input; } const result = readProp(obj, "result"); if (result !== undefined) { fields.result = result; } const success = readProp(obj, "success"); if (typeof success === "boolean") { fields.success = success; } const error = readProp(obj, "error"); if (typeof error === "string") { fields.error = error; } return fields; } /** Extract the HITL prompt fields from a `hitl:confirmation-request` payload. */ function readHitlPrompt(payload) { const event = asObject(payload); if (!event) { return undefined; } const inner = asObject(readProp(event, "payload")); if (!inner) { return undefined; } const confirmationId = readProp(inner, "confirmationId"); const toolName = readProp(inner, "toolName"); if (typeof confirmationId !== "string" || typeof toolName !== "string") { return undefined; } const data = { confirmationId, toolName, }; const actionType = readProp(inner, "actionType"); if (typeof actionType === "string") { data.actionType = actionType; } const args = readProp(inner, "arguments"); if (args !== undefined) { data.arguments = args; } const timeoutMs = readProp(inner, "timeoutMs"); if (typeof timeoutMs === "number") { data.timeoutMs = timeoutMs; } const allowModification = readProp(inner, "allowModification"); if (typeof allowModification === "boolean") { data.allowModification = allowModification; } return { type: "hitl-prompt", data }; } /** Narrow a decoded inbound message to a control message, or `undefined`. */ function readControlMessage(value) { const obj = asObject(value); if (!obj) { return undefined; } const action = readProp(obj, "action"); const confirmationId = readProp(obj, "confirmationId"); if (typeof confirmationId !== "string") { return undefined; } if (action === "hitl:accept") { const message = { action: "hitl:accept", confirmationId, }; const modifiedArguments = readProp(obj, "modifiedArguments"); if (modifiedArguments !== undefined) { message.modifiedArguments = modifiedArguments; } return message; } if (action === "hitl:reject") { const message = { action: "hitl:reject", confirmationId, }; const reason = readProp(obj, "reason"); if (typeof reason === "string") { message.reason = reason; } return message; } return undefined; } /** * Attach the data-channel event bridge to a room. * * Returns a handle whose `dispose()` removes every listener and stops * publishing; it is safe to call more than once. */ export async function attachEventBridge(params) { const { room, emitter, options } = params; const eventsTopic = options?.eventsTopic ?? DEFAULT_EVENTS_TOPIC; const controlTopic = options?.controlTopic ?? DEFAULT_CONTROL_TOPIC; const maxInlineBytes = options?.maxInlineBytes ?? DEFAULT_MAX_INLINE_BYTES; const include = options?.include && options.include.length > 0 ? new Set(options.include) : undefined; const { RoomEvent } = await import("@livekit/rtc-node"); const encoder = new TextEncoder(); const decoder = new TextDecoder(); let seq = 0; let disposed = false; function publish(event) { if (disposed) { return; } if (include && !include.has(event.type)) { return; } const localParticipant = room.localParticipant; if (!localParticipant) { return; } seq += 1; const envelope = Object.assign({ seq, ts: Date.now() }, event); const json = JSON.stringify(envelope); const bytes = encoder.encode(json); const onError = (transport) => (error) => { logger.warn("voice.bridge.publishFailed", { transport, type: event.type, error: error instanceof Error ? error.message : String(error), }); }; if (bytes.byteLength <= maxInlineBytes) { void localParticipant .publishData(bytes, { reliable: true, topic: eventsTopic }) .catch(onError("publishData")); } else { // Large payloads (e.g. chart data) exceed a single reliable data packet; // the chunked text stream handles arbitrary sizes. void localParticipant .sendText(json, { topic: eventsTopic }) .catch(onError("sendText")); } } // --- Outbound: NeuroLink emitter → data channel --------------------------- const onUserText = (...args) => { const payload = asObject(args[0]); if (!payload) { return; } const text = readProp(payload, "text"); const final = readProp(payload, "final"); const replacesPrevious = readProp(payload, "replacesPrevious"); if (typeof text === "string" && text.trim().length > 0) { const data = { text, final: final === true, }; if (replacesPrevious === true) { data.replacesPrevious = true; } publish({ type: "user-text", data }); } }; const onText = (...args) => { const delta = readTextDelta(args[0]); if (delta !== undefined) { publish({ type: "text", data: { delta } }); } }; const onToolStart = (...args) => { const fields = readToolFields(args[0]); if (fields) { publish({ type: "tool-start", data: { id: fields.id, name: fields.name, input: fields.input }, }); } }; const onToolEnd = (...args) => { const fields = readToolFields(args[0]); if (fields) { publish({ type: "tool-result", data: { id: fields.id, name: fields.name, result: fields.result, success: fields.success, error: fields.error, }, }); } }; const onHitlRequest = (...args) => { const event = readHitlPrompt(args[0]); if (event) { publish(event); } }; const onStreamStart = () => { publish({ type: "status", data: { state: "thinking" } }); }; const onStreamComplete = () => { publish({ type: "done", data: {} }); }; const onStreamError = (...args) => { const obj = asObject(args[0]); const message = obj ? readProp(obj, "message") : undefined; publish({ type: "status", data: { state: "error", detail: typeof message === "string" ? message : undefined, }, }); }; emitter.on(USER_TEXT_EVENT, onUserText); emitter.on(TEXT_EVENT, onText); emitter.on(TOOL_START_EVENT, onToolStart); emitter.on(TOOL_END_EVENT, onToolEnd); emitter.on(HITL_REQUEST_EVENT, onHitlRequest); emitter.on(STREAM_START_EVENT, onStreamStart); emitter.on(STREAM_COMPLETE_EVENT, onStreamComplete); emitter.on(STREAM_END_EVENT, onStreamComplete); emitter.on(STREAM_ERROR_EVENT, onStreamError); // --- Inbound: data channel → NeuroLink emitter (HITL responses) ----------- const onData = (...args) => { const payload = args[0]; const topic = args[3]; if (topic !== controlTopic || !(payload instanceof Uint8Array)) { return; } let parsed; try { parsed = JSON.parse(decoder.decode(payload)); } catch (error) { logger.warn("voice.bridge.controlMalformed", { error: error instanceof Error ? error.message : String(error), }); return; } const message = readControlMessage(parsed); if (!message) { return; } const responsePayload = { confirmationId: message.confirmationId, approved: message.action === "hitl:accept", metadata: { timestamp: new Date().toISOString(), responseTime: 0 }, }; if (message.action === "hitl:reject" && message.reason !== undefined) { responsePayload.reason = message.reason; } if (message.action === "hitl:accept" && message.modifiedArguments !== undefined) { responsePayload.modifiedArguments = message.modifiedArguments; } emitter.emit("hitl:confirmation-response", { type: "hitl:confirmation-response", payload: responsePayload, }); }; room.on(RoomEvent.DataReceived, onData); publish({ type: "status", data: { state: "listening" } }); logger.info("voice.bridge.attached", { eventsTopic, controlTopic, filtered: include !== undefined, }); return { dispose() { if (disposed) { return; } disposed = true; emitter.off(USER_TEXT_EVENT, onUserText); emitter.off(TEXT_EVENT, onText); emitter.off(TOOL_START_EVENT, onToolStart); emitter.off(TOOL_END_EVENT, onToolEnd); emitter.off(HITL_REQUEST_EVENT, onHitlRequest); emitter.off(STREAM_START_EVENT, onStreamStart); emitter.off(STREAM_COMPLETE_EVENT, onStreamComplete); emitter.off(STREAM_END_EVENT, onStreamComplete); emitter.off(STREAM_ERROR_EVENT, onStreamError); room.off(RoomEvent.DataReceived, onData); logger.info("voice.bridge.disposed", {}); }, }; }