253 lines
10 KiB
JavaScript
253 lines
10 KiB
JavaScript
const crypto = require("node:crypto");
|
|
const WebSocket = require("ws");
|
|
|
|
const DEFAULT_GATEWAY_URL = "wss://ai-gateway.vei.volces.com/v1/realtime";
|
|
|
|
function requiredText(value, name, maximum = 512) {
|
|
const normalized = String(value || "").trim();
|
|
if (!normalized || normalized.length > maximum) throw new Error(`${name}不能为空或过长`);
|
|
return normalized;
|
|
}
|
|
|
|
function validateDeviceName(value) {
|
|
const name = requiredText(value, "火山设备名称", 128);
|
|
if (!/^[a-zA-Z0-9_\-.:@]{1,128}$/.test(name)) throw new Error("火山设备名称格式不正确");
|
|
return name;
|
|
}
|
|
|
|
function hmacBase64(secret, content) {
|
|
return crypto.createHmac("sha256", secret).update(content).digest("base64");
|
|
}
|
|
|
|
function websocketSignature({ authType = 1, deviceName, randomNumber, productKey, timestamp, instanceID }, deviceSecret) {
|
|
const content = `auth_type=${authType}&device_name=${deviceName}&random_num=${randomNumber}&product_key=${productKey}×tamp=${timestamp}&instance_id=${instanceID}`;
|
|
return hmacBase64(deviceSecret, content);
|
|
}
|
|
|
|
function buildSessionPrompt(sessionConfig) {
|
|
const sections = [
|
|
requiredText(sessionConfig.authorized_context, "授权上下文", 4000),
|
|
String(sessionConfig.system_prompt || "").trim(),
|
|
String(sessionConfig.persona_tone || "").trim() ? `对话语气:${String(sessionConfig.persona_tone).trim()}` : "",
|
|
`本次对话标识:${requiredText(sessionConfig.dialog_id, "对话标识", 200)}`,
|
|
].filter(Boolean);
|
|
return sections.join("\n\n");
|
|
}
|
|
|
|
function buildSessionUpdate(sessionConfig, eventID, modelMode = "llm") {
|
|
const normalizedMode = String(modelMode || "llm").trim().toLowerCase();
|
|
if (!new Set(["llm", "s2s"]).has(normalizedMode)) throw new Error("智能体模型类型必须为 llm 或 s2s");
|
|
const dialogID = requiredText(sessionConfig.dialog_id, "对话标识", 200);
|
|
const systemPrompt = buildSessionPrompt(sessionConfig);
|
|
const config = {
|
|
ASRConfig: { TurnDetectionMode: 0 },
|
|
};
|
|
if (normalizedMode === "s2s") {
|
|
config.S2SConfig = {
|
|
ProviderParams: {
|
|
dialog: {
|
|
system_role: systemPrompt,
|
|
speaking_style: String(sessionConfig.persona_tone || "").trim(),
|
|
dialog_id: dialogID,
|
|
},
|
|
},
|
|
};
|
|
} else {
|
|
config.LLMConfig = { SystemMessages: [systemPrompt] };
|
|
}
|
|
return {
|
|
event_id: eventID,
|
|
type: "session.update",
|
|
session: {
|
|
object: "realtime.session",
|
|
model: "",
|
|
input_audio_format: "pcm16",
|
|
output_audio_format: "pcm16",
|
|
config,
|
|
agent_config: { EnableConversationStateCallback: false },
|
|
},
|
|
};
|
|
}
|
|
|
|
function normalizeProviderError(event) {
|
|
const code = String(event?.error?.code || event?.error?.type || "PROVIDER_ERROR");
|
|
const message = String(event?.error?.message || "火山硬件智能体返回错误");
|
|
return new Error(`${code}: ${message}`);
|
|
}
|
|
|
|
class HardwareAgentClient {
|
|
constructor(options = {}) {
|
|
this.WebSocketImpl = options.WebSocketImpl || WebSocket;
|
|
this.gatewayURL = options.gatewayURL || DEFAULT_GATEWAY_URL;
|
|
this.emit = typeof options.emit === "function" ? options.emit : () => {};
|
|
this.socket = undefined;
|
|
this.sessionCreated = undefined;
|
|
this.providerConnectionRef = "";
|
|
this.pendingCalls = new Map();
|
|
this.pingTimer = undefined;
|
|
this.connectionPromise = undefined;
|
|
this.sessionUpdateSent = false;
|
|
}
|
|
|
|
async connect(input) {
|
|
if (this.socket) throw new Error("火山硬件智能体连接已经存在");
|
|
const instanceID = requiredText(input.instanceID, "实例 ID", 200);
|
|
const productKey = requiredText(input.productKey, "Product Key", 200);
|
|
const deviceName = validateDeviceName(input.deviceName);
|
|
const deviceSecret = requiredText(input.deviceSecret, "Device Secret", 4096);
|
|
const hardwareID = requiredText(input.hardwareID, "Hardware ID", 200);
|
|
const botID = requiredText(input.botID, "Bot ID", 200);
|
|
const randomNumber = crypto.randomInt(1, 2_147_483_647);
|
|
const timestamp = Math.floor(Date.now() / 1000);
|
|
const signature = websocketSignature({ deviceName, randomNumber, productKey, timestamp, instanceID }, deviceSecret);
|
|
const target = new URL(this.gatewayURL);
|
|
target.searchParams.set("bot", botID);
|
|
target.searchParams.set("wait_for_session_update", "true");
|
|
const headers = {
|
|
"X-Auth-Type": "1",
|
|
"X-Product-Key": productKey,
|
|
"X-Device-Name": deviceName,
|
|
"X-Random-Num": String(randomNumber),
|
|
"X-Timestamp": String(timestamp),
|
|
"X-Instance-Id": instanceID,
|
|
"X-Signature": signature,
|
|
"X-Hardware-Id": hardwareID,
|
|
};
|
|
const socket = new this.WebSocketImpl(target.toString(), { headers, handshakeTimeout: 10_000, maxPayload: 8 * 1024 * 1024 });
|
|
this.socket = socket;
|
|
this.connectionPromise = new Promise((resolve, reject) => {
|
|
const timeout = setTimeout(() => reject(new Error("等待火山 session.updated 超时")), 15_000);
|
|
const finish = (callback, value) => {
|
|
clearTimeout(timeout);
|
|
callback(value);
|
|
};
|
|
socket.once("open", () => {
|
|
this.emit({ type: "transport.open" });
|
|
// wait_for_session_update=true makes the gateway wait for the client
|
|
// configuration before it publishes the usable session. Sending only
|
|
// after session.created deadlocks until the provider-side timeout.
|
|
this.sendJSON(buildSessionUpdate(input.sessionConfig, `windows-session-update-${crypto.randomUUID()}`, input.modelMode));
|
|
this.sessionUpdateSent = true;
|
|
});
|
|
socket.on("message", (data) => {
|
|
let event;
|
|
try { event = JSON.parse(Buffer.isBuffer(data) ? data.toString("utf8") : String(data)); }
|
|
catch { this.emit({ type: "transport.invalid_message" }); return; }
|
|
this.handleProviderEvent(event, input.sessionConfig, input.modelMode);
|
|
// In the hardware gateway's wait_for_session_update mode, the initial
|
|
// session.created event is published only after the pre-creation
|
|
// session.update has been accepted. Some Bot versions do not emit an
|
|
// additional session.updated event for that initial configuration.
|
|
if (event.type === "session.updated" || (event.type === "session.created" && this.sessionUpdateSent)) {
|
|
const connectionRef = String(event?.session?.id || this.providerConnectionRef || event.event_id || "").trim();
|
|
if (!connectionRef) return finish(reject, new Error("火山会话确认未提供可关联的会话编号"));
|
|
this.providerConnectionRef = connectionRef;
|
|
finish(resolve, { providerConnectionRef: connectionRef, session: event.session || {} });
|
|
} else if (event.type === "error" && !this.providerConnectionRef) {
|
|
finish(reject, normalizeProviderError(event));
|
|
}
|
|
});
|
|
socket.once("unexpected-response", (_request, response) => {
|
|
finish(reject, new Error(`火山 WebSocket 鉴权失败:HTTP ${response.statusCode}`));
|
|
});
|
|
socket.once("error", (error) => {
|
|
this.emit({ type: "transport.error", message: error.message });
|
|
if (!this.providerConnectionRef) finish(reject, error);
|
|
});
|
|
socket.once("close", (code, reason) => {
|
|
this.stopPing();
|
|
this.socket = undefined;
|
|
this.emit({ type: "transport.close", code, reason: Buffer.from(reason || "").toString("utf8") });
|
|
if (!this.providerConnectionRef) finish(reject, new Error(`火山连接提前关闭:${code}`));
|
|
});
|
|
});
|
|
try {
|
|
const result = await this.connectionPromise;
|
|
this.startPing();
|
|
return result;
|
|
} catch (error) {
|
|
this.close(1011, "connection setup failed");
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
handleProviderEvent(event, sessionConfig, modelMode) {
|
|
if (event.type === "session.created") {
|
|
this.sessionCreated = event.session || {};
|
|
this.providerConnectionRef = String(event?.session?.id || event.event_id || "").trim();
|
|
}
|
|
if (event.type === "conversation.item.created" && event?.item?.type === "function_call" && event.item.call_id) {
|
|
this.pendingCalls.set(String(event.item.call_id), String(event.item.name || ""));
|
|
}
|
|
this.emit(event);
|
|
}
|
|
|
|
sendAudio(bytes) {
|
|
const buffer = Buffer.from(bytes);
|
|
if (!buffer.length) return;
|
|
if (buffer.length > 64 * 1024) throw new Error("单个音频分片不能超过64KiB");
|
|
this.sendJSON({
|
|
event_id: `windows-audio-${crypto.randomUUID()}`,
|
|
type: "input_audio_buffer.append",
|
|
audio: buffer.toString("base64"),
|
|
});
|
|
}
|
|
|
|
sendText(text, interruptMode = 1) {
|
|
const normalized = requiredText(text, "文本问题", 2000);
|
|
this.sendJSON({
|
|
event_id: `windows-text-${crypto.randomUUID()}`,
|
|
type: "conversation.item.create",
|
|
item: { type: "message", role: "user", content: [{ type: "input_text", text: normalized }], interrupt_mode: interruptMode },
|
|
});
|
|
}
|
|
|
|
completeFunctionCall(callID, output) {
|
|
const normalizedCallID = requiredText(callID, "函数调用编号", 200);
|
|
this.sendJSON({
|
|
event_id: `windows-function-${crypto.randomUUID()}`,
|
|
type: "conversation.item.create",
|
|
item: { type: "function_call_output", call_id: normalizedCallID, output: JSON.stringify(output) },
|
|
});
|
|
this.sendJSON({ event_id: `windows-response-${crypto.randomUUID()}`, type: "response.create" });
|
|
this.pendingCalls.delete(normalizedCallID);
|
|
}
|
|
|
|
sendJSON(value) {
|
|
if (!this.socket || this.socket.readyState !== this.WebSocketImpl.OPEN) throw new Error("火山硬件智能体连接未就绪");
|
|
this.socket.send(JSON.stringify(value));
|
|
}
|
|
|
|
startPing() {
|
|
this.stopPing();
|
|
this.pingTimer = setInterval(() => {
|
|
try { if (this.socket?.readyState === this.WebSocketImpl.OPEN && typeof this.socket.ping === "function") this.socket.ping(); }
|
|
catch { /* close/error callbacks own recovery */ }
|
|
}, 20_000);
|
|
}
|
|
|
|
stopPing() {
|
|
if (this.pingTimer) clearInterval(this.pingTimer);
|
|
this.pingTimer = undefined;
|
|
}
|
|
|
|
close(code = 1000, reason = "device session ended") {
|
|
this.stopPing();
|
|
const socket = this.socket;
|
|
this.socket = undefined;
|
|
this.providerConnectionRef = "";
|
|
this.sessionUpdateSent = false;
|
|
this.pendingCalls.clear();
|
|
if (!socket) return;
|
|
try { socket.close(code, reason); } catch { try { socket.terminate?.(); } catch { /* no-op */ } }
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
DEFAULT_GATEWAY_URL,
|
|
HardwareAgentClient,
|
|
buildSessionPrompt,
|
|
buildSessionUpdate,
|
|
websocketSignature,
|
|
};
|