Files
story/device-simulator-windows/electron/hardware-agent-client.cjs
T

277 lines
11 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}&timestamp=${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) {
const normalizedMode = String(modelMode || sessionConfig.model_mode || "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,
},
},
};
}
config.LLMConfig = { SystemMessages: [systemPrompt] };
const memory = sessionConfig.memory;
if (memory && typeof memory === "object") {
const collectionName = requiredText(memory.collection_name, "Viking 记忆库名称", 200);
const userID = requiredText(memory.user_id, "Viking 用户标识", 200);
const memoryTypes = Array.isArray(memory.memory_types)
? memory.memory_types.map((value) => requiredText(value, "Viking 记忆类型", 100))
: [];
if (!memoryTypes.length) throw new Error("Viking 记忆类型不能为空");
const filter = { user_id: [userID], memory_type: memoryTypes };
if (String(memory.assistant_id || "").trim()) {
filter.assistant_id = [requiredText(memory.assistant_id, "Viking 助手标识", 200)];
}
config.MemoryConfig = {
Enable: true,
Provider: "volc",
ProviderParams: {
collection_name: collectionName,
filter,
limit: Number.isInteger(memory.limit) && memory.limit > 0 ? Math.min(memory.limit, 5) : 5,
...(String(memory.transition_words || "").trim()
? { transition_words: String(memory.transition_words).trim() }
: {}),
},
};
}
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,
};