@@ -0,0 +1,252 @@
|
||||
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,
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const test = require("node:test");
|
||||
const {
|
||||
HardwareAgentClient,
|
||||
buildSessionUpdate,
|
||||
websocketSignature,
|
||||
} = require("./hardware-agent-client.cjs");
|
||||
|
||||
test("WebSocket signatures use the documented canonical field order", () => {
|
||||
const connection = websocketSignature({ authType: 1, deviceName: "device-1", randomNumber: 42, productKey: "product-1", timestamp: 1700000000, instanceID: "instance-1" }, "device-secret");
|
||||
assert.equal(connection, "6OFE1DUZzRgNy0o5IzZSmqKw3SUP4s3GxK2YHe8phfo=");
|
||||
});
|
||||
|
||||
test("LLM session update uses the official Config/AgentConfig schema", () => {
|
||||
const update = buildSessionUpdate({ dialog_id: "conversation-1", system_prompt: "你是陪伴助手", persona_tone: "亲切", authorized_context: "只可使用已授权背景" }, "event-1");
|
||||
assert.equal(update.type, "session.update");
|
||||
assert.equal(update.session.object, "realtime.session");
|
||||
assert.equal(update.session.input_audio_format, "pcm16");
|
||||
assert.equal(update.session.output_audio_format, "pcm16");
|
||||
assert.equal(update.session.config.ASRConfig.TurnDetectionMode, 0);
|
||||
assert.match(update.session.config.LLMConfig.SystemMessages[0], /只可使用已授权背景/);
|
||||
assert.match(update.session.config.LLMConfig.SystemMessages[0], /你是陪伴助手/);
|
||||
assert.match(update.session.config.LLMConfig.SystemMessages[0], /亲切/);
|
||||
assert.equal(update.session.agent_config.EnableConversationStateCallback, false);
|
||||
assert.equal("system_prompt" in update.session, false);
|
||||
});
|
||||
|
||||
test("S2S session update maps persona and dialog id to provider dialog config", () => {
|
||||
const update = buildSessionUpdate({ dialog_id: "conversation-2", system_prompt: "你是陪伴助手", persona_tone: "亲切自然", authorized_context: "只使用授权信息" }, "event-2", "s2s");
|
||||
const dialog = update.session.config.S2SConfig.ProviderParams.dialog;
|
||||
assert.equal(dialog.dialog_id, "conversation-2");
|
||||
assert.equal(dialog.speaking_style, "亲切自然");
|
||||
assert.match(dialog.system_role, /只使用授权信息/);
|
||||
assert.match(dialog.system_role, /你是陪伴助手/);
|
||||
assert.equal("LLMConfig" in update.session.config, false);
|
||||
});
|
||||
|
||||
class FakeWebSocket extends EventEmitter {
|
||||
static OPEN = 1;
|
||||
constructor(url, options) {
|
||||
super();
|
||||
this.url = url;
|
||||
this.options = options;
|
||||
this.readyState = 0;
|
||||
this.sent = [];
|
||||
FakeWebSocket.last = this;
|
||||
setImmediate(() => {
|
||||
this.readyState = FakeWebSocket.OPEN;
|
||||
this.emit("open");
|
||||
this.emit("message", Buffer.from(JSON.stringify({ type: "session.created", event_id: "created-event", session: { id: "provider-session-1" } })));
|
||||
});
|
||||
}
|
||||
send(body) {
|
||||
const value = JSON.parse(body);
|
||||
this.sent.push(value);
|
||||
if (value.type === "session.update") {
|
||||
setImmediate(() => this.emit("message", Buffer.from(JSON.stringify({ type: "session.updated", event_id: "updated-event", session: { id: "provider-session-1" } }))));
|
||||
}
|
||||
}
|
||||
ping() {}
|
||||
close(code, reason) {
|
||||
this.readyState = 3;
|
||||
this.emit("close", code, Buffer.from(reason));
|
||||
}
|
||||
}
|
||||
|
||||
test("hardware client waits for dynamic config acknowledgement before becoming ready", async () => {
|
||||
const events = [];
|
||||
const client = new HardwareAgentClient({ WebSocketImpl: FakeWebSocket, gatewayURL: "ws://127.0.0.1:19090/v1/realtime", emit: (event) => events.push(event) });
|
||||
const connected = await client.connect({
|
||||
instanceID: "instance-1", productKey: "product-1", deviceName: "device-1",
|
||||
deviceSecret: "device-secret-with-at-least-32-characters", hardwareID: "windows-test", botID: "bot-1",
|
||||
sessionConfig: { dialog_id: "conversation-1", authorized_context: "authorized" },
|
||||
});
|
||||
assert.equal(connected.providerConnectionRef, "provider-session-1");
|
||||
assert.match(FakeWebSocket.last.url, /bot=bot-1/);
|
||||
assert.equal(FakeWebSocket.last.options.headers["X-Product-Key"], "product-1");
|
||||
assert.equal(FakeWebSocket.last.sent[0].type, "session.update");
|
||||
client.sendAudio(Buffer.from([1, 2, 3, 4]));
|
||||
client.sendText("你好");
|
||||
assert.equal(FakeWebSocket.last.sent[1].type, "input_audio_buffer.append");
|
||||
assert.equal(FakeWebSocket.last.sent[2].type, "conversation.item.create");
|
||||
assert.ok(events.some((event) => event.type === "session.created"));
|
||||
client.close();
|
||||
});
|
||||
|
||||
test("hardware client sends session.update immediately when the WebSocket opens", async () => {
|
||||
const client = new HardwareAgentClient({ WebSocketImpl: FakeWebSocket, gatewayURL: "ws://127.0.0.1:19090/v1/realtime" });
|
||||
await client.connect({
|
||||
instanceID: "instance-1", productKey: "product-1", deviceName: "device-1",
|
||||
deviceSecret: "device-secret-with-at-least-32-characters", hardwareID: "windows-test", botID: "bot-1",
|
||||
sessionConfig: { dialog_id: "conversation-1", authorized_context: "authorized" },
|
||||
});
|
||||
assert.equal(FakeWebSocket.last.sent.filter((event) => event.type === "session.update").length, 1);
|
||||
client.close();
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vm = require("node:vm");
|
||||
const ts = require("typescript");
|
||||
|
||||
// Execute the actual renderer with a small DOM/IPC fixture; no network or audio.
|
||||
const source = fs.readFileSync(path.join(__dirname, "../src/hardware.ts"), "utf8");
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS },
|
||||
}).outputText;
|
||||
|
||||
function fixture(overrides = {}) {
|
||||
const elements = new Map();
|
||||
const element = () => ({
|
||||
style: {}, value: "", textContent: "", disabled: false, children: [],
|
||||
append(...items) { this.children.push(...items); },
|
||||
prepend(...items) { this.children.unshift(...items); },
|
||||
replaceChildren(...items) { this.children = items; },
|
||||
querySelector() { return null; }, addEventListener() {},
|
||||
});
|
||||
const get = (id) => {
|
||||
if (!elements.has(id)) elements.set(id, element());
|
||||
return elements.get(id);
|
||||
};
|
||||
const events = [];
|
||||
const closes = [];
|
||||
const texts = [];
|
||||
const created = {
|
||||
conversation_id: "test-conversation", session: {},
|
||||
runtime: { transport: "low_load", config_digest: "digest", session_nonce: "nonce", provider_session_ref: "ref", bot_id: "test-bot" },
|
||||
session_config: { authorized_context: "test", prompt_bundle_version: "test-v1" },
|
||||
};
|
||||
const ipc = {
|
||||
getRuntimeDefaults: async () => ({}), getHardwareRuntimeDefaults: async () => ({}),
|
||||
onHardwareProviderEvent: () => () => {},
|
||||
createHardwareAgentSession: async () => created,
|
||||
closeHardwareAgent: async (input) => { closes.push(input); },
|
||||
hardwareAgentEvent: async (input) => { events.push(input); return { status: "FAILED" }; },
|
||||
sendHardwareAgentText: async (input) => { texts.push(input); },
|
||||
...overrides,
|
||||
};
|
||||
const context = vm.createContext({
|
||||
exports: {}, require: () => ({}), console, URL,
|
||||
document: { querySelector: () => get("app"), getElementById: get, createElement: element },
|
||||
window: { deviceSimulator: ipc, addEventListener() {}, clearInterval() {}, setTimeout, setInterval() {} },
|
||||
});
|
||||
vm.runInContext(compiled + `\nglobalThis.renderer = { state, controls, setPhase, setCheck, resetSessionState, failHardwareConversation, stopHardwareConversation, startHardwareConversation, handleProviderEvent, sendTextQuestion, buildReport };`, context);
|
||||
return { r: context.renderer, events, closes, texts, created, get, ipc };
|
||||
}
|
||||
|
||||
test("provider error plus connect rejection sends one failure, including late duplicates", async () => {
|
||||
const f = fixture();
|
||||
const r = f.r;
|
||||
f.ipc.connectHardwareAgent = async () => {
|
||||
void r.handleProviderEvent({ type: "error", error: { code: "internal_error", message: "StartVoiceChat failed" } });
|
||||
throw new Error("wrapped connect rejection");
|
||||
};
|
||||
r.setPhase("ready", "ready");
|
||||
await r.startHardwareConversation();
|
||||
await r.failHardwareConversation("SECOND_FAILURE");
|
||||
await r.stopHardwareConversation();
|
||||
await r.handleProviderEvent({ type: "error", error: { message: "late error" } });
|
||||
assert.equal(f.events.length, 1);
|
||||
assert.equal(f.events[0].eventType, "session_failed");
|
||||
assert.equal(f.events[0].errorCode, "VOLC_HARDWARE_AGENT_ERROR");
|
||||
assert.equal(f.closes.length, 1);
|
||||
assert.equal(r.state.failure, "internal_error: StartVoiceChat failed");
|
||||
assert.equal(r.state.phase, "error");
|
||||
assert.equal(r.controls.stop.disabled, true);
|
||||
assert.equal(r.controls.check.disabled, false);
|
||||
});
|
||||
|
||||
test("concurrent failure callers share pending cleanup; late events cannot reactivate", async () => {
|
||||
let release;
|
||||
const gate = new Promise((resolve) => { release = resolve; });
|
||||
const f = fixture({ closeHardwareAgent: () => gate });
|
||||
f.r.state.platformSession = f.created;
|
||||
f.r.state.failure = "first cause";
|
||||
f.r.setPhase("active", "active");
|
||||
const first = f.r.failHardwareConversation("FIRST");
|
||||
const second = f.r.failHardwareConversation("SECOND");
|
||||
assert.equal(first, second);
|
||||
assert.equal(f.r.state.phase, "stopping");
|
||||
await f.r.handleProviderEvent({ type: "response.done" });
|
||||
assert.equal(f.r.state.phase, "stopping");
|
||||
release();
|
||||
await first;
|
||||
assert.equal(f.events.length, 1);
|
||||
assert.equal(f.events[0].errorCode, "FIRST");
|
||||
await f.r.handleProviderEvent({ type: "transport.close", code: 1011 });
|
||||
assert.equal(f.r.state.checks.get("provider_closed").status, "failed");
|
||||
f.r.controls.textQuestion.value = "should not send";
|
||||
await f.r.sendTextQuestion();
|
||||
assert.equal(f.texts.length, 0);
|
||||
});
|
||||
|
||||
test("new call clears stale checks, duration and failure while retaining device preparation", async () => {
|
||||
const f = fixture();
|
||||
f.r.state.platformSession = f.created;
|
||||
f.r.state.failure = "old failure";
|
||||
await f.r.failHardwareConversation("OLD");
|
||||
for (const id of f.r.state.checks.keys()) f.r.setCheck(id, "passed", "old");
|
||||
f.get("duration").textContent = "48:15";
|
||||
f.r.resetSessionState();
|
||||
assert.equal(f.r.state.failure, undefined);
|
||||
assert.equal(f.r.state.failureTask, undefined);
|
||||
assert.equal(f.get("duration").textContent, "00:00");
|
||||
assert.equal(f.r.state.checks.get("platform_authenticated").status, "passed");
|
||||
assert.equal(f.r.state.checks.get("credential_resolved").status, "passed");
|
||||
assert.equal(f.r.state.checks.get("platform_completed").status, "pending");
|
||||
f.r.state.platformSession = { ...f.created, conversation_id: "next-conversation" };
|
||||
await f.r.failHardwareConversation("NEW");
|
||||
assert.equal(f.events.length, 2);
|
||||
assert.equal(f.events[1].conversationID, "next-conversation");
|
||||
assert.equal(f.events[1].sequenceNo, 1);
|
||||
});
|
||||
|
||||
test("failed platform receipt does not trigger a new terminal event on hangup", async () => {
|
||||
let attempts = 0;
|
||||
const f = fixture({ hardwareAgentEvent: async () => { attempts += 1; throw new Error("network unavailable"); } });
|
||||
f.r.state.platformSession = f.created;
|
||||
await f.r.failHardwareConversation("FIRST");
|
||||
await f.r.stopHardwareConversation();
|
||||
assert.equal(attempts, 1);
|
||||
assert.equal(f.r.state.phase, "error");
|
||||
assert.equal(f.r.buildReport().checks.find((c) => c.id === "platform_completed").status, "failed");
|
||||
});
|
||||
|
||||
test("connection rejection without a provider event still reports failure once", async () => {
|
||||
const f = fixture({ connectHardwareAgent: async () => { throw new Error("connect timeout"); } });
|
||||
f.r.setPhase("ready", "ready");
|
||||
await f.r.startHardwareConversation();
|
||||
assert.equal(f.events.length, 1);
|
||||
assert.equal(f.events[0].errorCode, "WINDOWS_HARDWARE_START_FAILED");
|
||||
assert.match(f.r.state.failure, /connect timeout/);
|
||||
assert.equal(f.r.state.phase, "error");
|
||||
});
|
||||
|
||||
test("normal hangup still reports completed and closes normally", async () => {
|
||||
const f = fixture();
|
||||
f.ipc.hardwareAgentEvent = async (input) => { f.events.push(input); return { status: "COMPLETED" }; };
|
||||
f.r.state.platformSession = f.created;
|
||||
f.r.setPhase("active", "active");
|
||||
await f.r.stopHardwareConversation();
|
||||
assert.equal(f.events.length, 1);
|
||||
assert.equal(f.events[0].eventType, "session_ended");
|
||||
assert.equal(f.closes[0].code, 1000);
|
||||
assert.equal(f.r.state.phase, "completed");
|
||||
assert.equal(f.r.state.failureTask, undefined);
|
||||
assert.equal(f.r.controls.start.disabled, false);
|
||||
});
|
||||
@@ -1,14 +1,24 @@
|
||||
const { app, BrowserWindow, ipcMain, session } = require("electron");
|
||||
const { app, BrowserWindow, dialog, ipcMain, safeStorage, session } = require("electron");
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const tls = require("node:tls");
|
||||
const { HardwareAgentClient } = require("./hardware-agent-client.cjs");
|
||||
|
||||
app.commandLine.appendSwitch("autoplay-policy", "no-user-gesture-required");
|
||||
const automatedUI = process.env.SIMULATOR_AUTO_TEST === "1" || process.env.SIMULATOR_REAL_E2E === "1";
|
||||
if (automatedUI) {
|
||||
app.commandLine.appendSwitch("use-fake-device-for-media-stream");
|
||||
app.commandLine.appendSwitch("use-fake-ui-for-media-stream");
|
||||
}
|
||||
app.disableHardwareAcceleration();
|
||||
|
||||
let deviceConfig = null;
|
||||
let desiredConfigRevision = 1;
|
||||
let mainWindow = null;
|
||||
let hardwareConfig = null;
|
||||
let hardwareClient = null;
|
||||
let activeHardwarePlatformSession = null;
|
||||
|
||||
function createWindow() {
|
||||
const window = new BrowserWindow({
|
||||
@@ -26,6 +36,10 @@ function createWindow() {
|
||||
sandbox: true,
|
||||
},
|
||||
});
|
||||
mainWindow = window;
|
||||
window.once("closed", () => {
|
||||
if (mainWindow === window) mainWindow = null;
|
||||
});
|
||||
|
||||
const devArgument = process.argv.find((argument) => argument.startsWith("--dev-server-url="));
|
||||
const devServerURL = devArgument ? devArgument.slice("--dev-server-url=".length) : "";
|
||||
@@ -47,7 +61,7 @@ function createWindow() {
|
||||
} catch (error) {
|
||||
console.error("renderer diagnostics failed", error);
|
||||
}
|
||||
if (process.env.SIMULATOR_AUTO_TEST === "1") {
|
||||
if (automatedUI) {
|
||||
void runAutomatedSmoke(window);
|
||||
}
|
||||
});
|
||||
@@ -62,9 +76,15 @@ function createWindow() {
|
||||
if (screenshotPath) {
|
||||
window.webContents.once("did-finish-load", () => {
|
||||
setTimeout(async () => {
|
||||
const image = await window.webContents.capturePage();
|
||||
fs.writeFileSync(screenshotPath, image.toPNG());
|
||||
app.quit();
|
||||
try {
|
||||
const image = await window.webContents.capturePage();
|
||||
fs.writeFileSync(screenshotPath, image.toPNG());
|
||||
} catch (error) {
|
||||
console.error("simulator screenshot failed", error);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
app.quit();
|
||||
}
|
||||
}, 1200);
|
||||
});
|
||||
}
|
||||
@@ -95,24 +115,54 @@ async function runAutomatedSmoke(window) {
|
||||
async function collectAutomatedConversationResult(window) {
|
||||
const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
const deadline = Date.now() + 120_000;
|
||||
let realQuestionSent = false;
|
||||
let snapshot = {};
|
||||
while (Date.now() < deadline) {
|
||||
await delay(1000);
|
||||
const encoded = await window.webContents.executeJavaScript(`JSON.stringify({
|
||||
phase: document.querySelector('#phaseBadge')?.textContent || '',
|
||||
conversation_id: document.querySelector('#conversationID')?.textContent || '',
|
||||
model_mode: document.querySelector('#modelMode')?.value || '',
|
||||
subject: [...document.querySelectorAll('.transcript-turn--subject span')].map((item) => item.textContent || ''),
|
||||
agent: [...document.querySelectorAll('.transcript-turn--agent span')].map((item) => item.textContent || ''),
|
||||
turns: [...document.querySelectorAll('.transcript-turn')].map((item) => ({speaker: item.classList.contains('transcript-turn--subject') ? 'subject' : 'agent', text: item.querySelector('span')?.textContent || ''})),
|
||||
events: [...document.querySelectorAll('#eventLog li span')].map((item) => item.textContent || '')
|
||||
events: [...document.querySelectorAll('#eventLog li span')].map((item) => item.textContent || ''),
|
||||
checks: [...document.querySelectorAll('#flowChecks .flow-check')].map((item) => ({label: item.querySelector('span')?.textContent || '', detail: item.querySelector('small')?.textContent || '', status: item.classList.contains('flow-check--passed') ? 'passed' : item.classList.contains('flow-check--failed') ? 'failed' : 'pending'}))
|
||||
})`);
|
||||
snapshot = JSON.parse(encoded);
|
||||
console.log(`[auto-test] conversation ${encoded}`);
|
||||
if (process.env.SIMULATOR_REAL_E2E === "1" && snapshot.phase === "通话中" && !realQuestionSent) {
|
||||
const question = String(process.env.SIMULATOR_REAL_E2E_QUESTION || "请用一句简短的话介绍你自己。 ").trim();
|
||||
await window.webContents.executeJavaScript(`(() => { const input = document.querySelector('#textQuestion'); if (input) input.value = ${JSON.stringify(question)}; document.querySelector('#sendTextButton')?.click(); })()`);
|
||||
realQuestionSent = true;
|
||||
console.log("[real-e2e] text question sent");
|
||||
}
|
||||
const subjectIndex = snapshot.turns?.findIndex((turn) => turn.speaker === "subject") ?? -1;
|
||||
const hasAgentReply = subjectIndex >= 0 && snapshot.turns.slice(subjectIndex + 1).some((turn) => turn.speaker === "agent");
|
||||
if (hasAgentReply) {
|
||||
await window.webContents.executeJavaScript(`document.querySelector('#stopButton')?.click()`);
|
||||
snapshot.result = "PASS";
|
||||
const hardwarePage = await window.webContents.executeJavaScript(`Boolean(document.querySelector('#flowChecks'))`);
|
||||
const waitForPlatformStop = hardwarePage && process.env.SIMULATOR_AUTO_WAIT_FOR_PLATFORM_STOP === "1";
|
||||
if (!waitForPlatformStop) await window.webContents.executeJavaScript(`document.querySelector('#stopButton')?.click()`);
|
||||
if (hardwarePage) {
|
||||
const stopDeadline = Date.now() + 30_000;
|
||||
while (Date.now() < stopDeadline) {
|
||||
await delay(500);
|
||||
const phase = await window.webContents.executeJavaScript(`document.querySelector('#phaseBadge')?.textContent || ''`);
|
||||
if (phase === "已完成" || phase === "异常") break;
|
||||
}
|
||||
const finalEncoded = await window.webContents.executeJavaScript(`JSON.stringify({
|
||||
phase: document.querySelector('#phaseBadge')?.textContent || '',
|
||||
conversation_id: document.querySelector('#conversationID')?.textContent || '',
|
||||
model_mode: document.querySelector('#modelMode')?.value || '',
|
||||
turns: [...document.querySelectorAll('.transcript-turn')].map((item) => ({speaker: item.classList.contains('transcript-turn--subject') ? 'subject' : 'agent', text: item.querySelector('span')?.textContent || ''})),
|
||||
events: [...document.querySelectorAll('#eventLog li span')].map((item) => item.textContent || ''),
|
||||
checks: [...document.querySelectorAll('#flowChecks .flow-check')].map((item) => ({label: item.querySelector('span')?.textContent || '', detail: item.querySelector('small')?.textContent || '', status: item.classList.contains('flow-check--passed') ? 'passed' : item.classList.contains('flow-check--failed') ? 'failed' : 'pending'}))
|
||||
})`);
|
||||
snapshot = JSON.parse(finalEncoded);
|
||||
snapshot.result = snapshot.phase === "已完成" ? "PASS" : "ERROR";
|
||||
} else {
|
||||
snapshot.result = "PASS";
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (snapshot.phase === "异常") {
|
||||
@@ -160,7 +210,10 @@ function loadDefaultDeviceSecret() {
|
||||
const localAppData = String(process.env.LOCALAPPDATA || "").trim();
|
||||
const userProfile = String(process.env.USERPROFILE || "").trim();
|
||||
const portableDirectory = String(process.env.PORTABLE_EXECUTABLE_DIR || "").trim();
|
||||
if (localAppData) candidates.push(path.join(localAppData, "DataTerminalVoiceDevice", "server-device-secret"));
|
||||
if (localAppData) {
|
||||
candidates.push(path.join(localAppData, "DataTerminalHardwareAgent", "server-device-secret"));
|
||||
candidates.push(path.join(localAppData, "DataTerminalVoiceDevice", "server-device-secret"));
|
||||
}
|
||||
if (userProfile) candidates.push(path.join(userProfile, "AppData", "Local", "DataTerminalVoiceDevice", "server-device-secret"));
|
||||
try {
|
||||
const home = app.getPath("home");
|
||||
@@ -182,11 +235,134 @@ function loadDefaultDeviceSecret() {
|
||||
|
||||
ipcMain.handle("sim:get-runtime-defaults", () => ({
|
||||
apiURL: process.env.SIMULATOR_DEFAULT_API_URL || "https://121.41.46.238",
|
||||
deviceID: process.env.SIMULATOR_DEFAULT_DEVICE_ID || "10000000-0000-0000-0000-000000000511",
|
||||
deviceID: process.env.SIMULATOR_DEFAULT_DEVICE_ID || "10000000-0000-0000-0000-000000000512",
|
||||
deviceSecret: "",
|
||||
deviceSecretConfigured: loadDefaultDeviceSecret().length >= 32,
|
||||
}));
|
||||
|
||||
function hardwareCredentialPath() {
|
||||
return path.join(app.getPath("userData"), "volc-hardware-device-credential.json");
|
||||
}
|
||||
|
||||
function readSavedHardwareMetadata() {
|
||||
try {
|
||||
const stored = JSON.parse(fs.readFileSync(hardwareCredentialPath(), "utf8"));
|
||||
if (!stored.instanceID || !stored.productKey || !stored.deviceName || !stored.encryptedDeviceSecret) return undefined;
|
||||
return stored;
|
||||
} catch { return undefined; }
|
||||
}
|
||||
|
||||
function loadSavedHardwareCredential(identity) {
|
||||
if (!safeStorage.isEncryptionAvailable()) return undefined;
|
||||
try {
|
||||
const stored = JSON.parse(fs.readFileSync(hardwareCredentialPath(), "utf8"));
|
||||
if (stored.schemaVersion !== 3 || stored.instanceID !== identity.instanceID || stored.productKey !== identity.productKey || stored.deviceName !== identity.deviceName) return undefined;
|
||||
const secret = safeStorage.decryptString(Buffer.from(stored.encryptedDeviceSecret, "base64"));
|
||||
if (secret.length < 16) return undefined;
|
||||
return { deviceSecret: secret, hardwareID: String(stored.hardwareID || identity.hardwareID || "") };
|
||||
} catch { return undefined; }
|
||||
}
|
||||
|
||||
function saveHardwareCredential(identity, deviceSecret, metadata = {}) {
|
||||
if (!safeStorage.isEncryptionAvailable()) return false;
|
||||
const target = hardwareCredentialPath();
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
const payload = {
|
||||
schemaVersion: 3,
|
||||
instanceID: identity.instanceID,
|
||||
productKey: identity.productKey,
|
||||
deviceName: identity.deviceName,
|
||||
hardwareID: identity.hardwareID,
|
||||
platformDeviceID: String(deviceConfig?.deviceID || ""),
|
||||
credentialRef: String(metadata.credentialRef || ""),
|
||||
credentialVersion: Number(metadata.credentialVersion || 1),
|
||||
encryptedDeviceSecret: safeStorage.encryptString(deviceSecret).toString("base64"),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
const temporary = `${target}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(temporary, `${JSON.stringify(payload, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
||||
fs.renameSync(temporary, target);
|
||||
return true;
|
||||
}
|
||||
|
||||
function hardwareIdentity(input) {
|
||||
const instanceID = String(input?.instanceID || "").trim();
|
||||
const productKey = String(input?.productKey || "").trim();
|
||||
const deviceName = String(input?.deviceName || "").trim();
|
||||
const hardwareID = String(input?.hardwareID || "").trim();
|
||||
if (!instanceID || instanceID.length > 200) throw new Error("实例 ID 不能为空或过长");
|
||||
if (!productKey || productKey.length > 200) throw new Error("Product Key 不能为空或过长");
|
||||
if (!/^[a-zA-Z0-9_\-.:@]{1,128}$/.test(deviceName)) throw new Error("火山设备名称格式不正确");
|
||||
if (!hardwareID || hardwareID.length > 200) throw new Error("Hardware ID 不能为空或过长");
|
||||
return { instanceID, productKey, deviceName, hardwareID };
|
||||
}
|
||||
|
||||
ipcMain.handle("sim:get-hardware-runtime-defaults", () => {
|
||||
const candidateMetadata = readSavedHardwareMetadata();
|
||||
const legacyMock = candidateMetadata && [candidateMetadata.instanceID, candidateMetadata.productKey, candidateMetadata.deviceName, candidateMetadata.hardwareID]
|
||||
.some((value) => String(value || "").toLowerCase().includes("mock") || String(value || "").toLowerCase().includes("e2e"));
|
||||
const savedMetadata = automatedUI || !legacyMock ? candidateMetadata : undefined;
|
||||
const instanceID = String(process.env.SIMULATOR_VOLC_INSTANCE_ID || savedMetadata?.instanceID || "").trim();
|
||||
const productKey = String(process.env.SIMULATOR_VOLC_PRODUCT_KEY || savedMetadata?.productKey || "").trim();
|
||||
const deviceName = String(process.env.SIMULATOR_VOLC_DEVICE_NAME || savedMetadata?.deviceName || "windows-hardware-agent-test").trim();
|
||||
const hardwareID = String(process.env.SIMULATOR_VOLC_HARDWARE_ID || savedMetadata?.hardwareID || `windows-${crypto.createHash("sha256").update(app.getPath("userData")).digest("hex").slice(0, 24)}`).trim();
|
||||
const modelMode = String(process.env.SIMULATOR_VOLC_MODEL_MODE || "llm").trim().toLowerCase() === "s2s" ? "s2s" : "llm";
|
||||
const saved = instanceID && productKey ? loadSavedHardwareCredential({ instanceID, productKey, deviceName, hardwareID }) : undefined;
|
||||
return { instanceID, productKey, deviceName, hardwareID, modelMode, deviceCredentialConfigured: Boolean(saved) };
|
||||
});
|
||||
|
||||
ipcMain.handle("sim:configure-hardware", async (_event, input) => {
|
||||
if (!deviceConfig) throw new Error("请先连接平台");
|
||||
const requestedHardwareID = String(input?.hardwareID || "").trim();
|
||||
if (!requestedHardwareID || requestedHardwareID.length > 200) throw new Error("Hardware ID 不能为空或过长");
|
||||
const automatedTest = process.env.SIMULATOR_AUTO_TEST === "1";
|
||||
let provisioned;
|
||||
if (automatedTest) {
|
||||
provisioned = {
|
||||
instance_id: String(process.env.SIMULATOR_VOLC_INSTANCE_ID || "").trim(),
|
||||
product_key: String(process.env.SIMULATOR_VOLC_PRODUCT_KEY || "").trim(),
|
||||
device_name: String(process.env.SIMULATOR_VOLC_DEVICE_NAME || "").trim(),
|
||||
hardware_id: String(process.env.SIMULATOR_VOLC_HARDWARE_ID || requestedHardwareID).trim(),
|
||||
device_secret: String(process.env.SIMULATOR_VOLC_DEVICE_SECRET || "").trim(),
|
||||
credential_source: "isolated_test_override",
|
||||
credential_version: 1,
|
||||
credential_ref: "test-only",
|
||||
};
|
||||
} else {
|
||||
provisioned = await deviceRequest("POST", "/device/v3/hardware-agent-credentials:provision", {
|
||||
hardware_id: requestedHardwareID,
|
||||
}, `windows-hardware-provision-${nonce()}`);
|
||||
}
|
||||
const identity = hardwareIdentity({
|
||||
instanceID: provisioned?.instance_id,
|
||||
productKey: provisioned?.product_key,
|
||||
deviceName: provisioned?.device_name,
|
||||
hardwareID: provisioned?.hardware_id,
|
||||
});
|
||||
const deviceSecret = String(provisioned?.device_secret || "").trim();
|
||||
if (deviceSecret.length < 16 || deviceSecret.length > 4096) throw new Error("Device Secret 长度不正确");
|
||||
const persisted = automatedTest ? false : saveHardwareCredential(identity, deviceSecret, {
|
||||
credentialRef: provisioned?.credential_ref,
|
||||
credentialVersion: provisioned?.credential_version,
|
||||
});
|
||||
if (!automatedTest && !persisted) throw new Error("Windows 安全存储不可用,拒绝保存 Device Secret");
|
||||
hardwareConfig = { ...identity, deviceSecret };
|
||||
// 首次预配会由平台发布产品级默认 Bot/传输配置,并可能推进配置版本。
|
||||
// 设备必须重新拉取版本,后续创建会话时不能继续上报预配前的旧 revision。
|
||||
if (!automatedTest) {
|
||||
const configuration = await deviceRequest("GET", "/device/v2/config");
|
||||
desiredConfigRevision = Number(configuration?.revision || desiredConfigRevision || 1);
|
||||
}
|
||||
return {
|
||||
...identity,
|
||||
registered: String(provisioned?.credential_source || "").includes("dynamic_registration"),
|
||||
persisted,
|
||||
credentialSource: String(provisioned?.credential_source || "platform_provisioning"),
|
||||
credentialVersion: Number(provisioned?.credential_version || 1),
|
||||
configRevision: desiredConfigRevision,
|
||||
};
|
||||
});
|
||||
|
||||
function nonce() {
|
||||
return crypto.randomBytes(16).toString("hex");
|
||||
}
|
||||
@@ -235,7 +411,7 @@ ipcMain.handle("sim:configure", (_event, input) => configure(input));
|
||||
|
||||
ipcMain.handle("sim:probe", async () => {
|
||||
const query = new URLSearchParams({
|
||||
firmware_version: "0.3.0-windows-rtc",
|
||||
firmware_version: "0.3.2-windows-hardware-agent",
|
||||
board_rev: "windows-x64",
|
||||
rtc_sdk_version: "web-4.69.0-electron",
|
||||
codec: "opus",
|
||||
@@ -280,6 +456,14 @@ ipcMain.handle("sim:probe-rtc-network", async () => {
|
||||
return { endpoints };
|
||||
});
|
||||
|
||||
ipcMain.handle("sim:probe-hardware-network", async () => {
|
||||
const endpoints = await Promise.all([
|
||||
probeTLSEndpoint("ai-gateway.vei.volces.com"),
|
||||
probeTLSEndpoint("iot-cn-shanghai.iot.volces.com"),
|
||||
]);
|
||||
return { endpoints };
|
||||
});
|
||||
|
||||
ipcMain.handle("sim:get-automation-audio", async () => {
|
||||
const configuredPath = String(process.env.SIMULATOR_AUTOMATED_AUDIO_PATH || "").trim();
|
||||
if (!configuredPath) return undefined;
|
||||
@@ -309,6 +493,173 @@ ipcMain.handle("sim:create-conversation", async () => deviceRequest("POST", "/de
|
||||
requested_goal: "open_conversation",
|
||||
}, `windows-create-${nonce()}`));
|
||||
|
||||
ipcMain.handle("sim:create-hardware-agent-session", async () => deviceRequest("POST", "/device/v3/agent-sessions", {
|
||||
device_time: new Date().toISOString(),
|
||||
monotonic_ms: Math.max(0, Math.round(performance.now())),
|
||||
capabilities: {
|
||||
hardware_agent: true,
|
||||
sdk_version: "1.1.0-windows-standard-protocol",
|
||||
transports: ["low_load"],
|
||||
input_audio_format: "pcm_s16le_16000_mono",
|
||||
output_audio_format: "pcm_s16le_16000_mono",
|
||||
server_event_callback: false,
|
||||
transcript_upload: true,
|
||||
dynamic_session_config: true,
|
||||
credential_rotation: true,
|
||||
near_pcm_recording: false,
|
||||
far_pcm_recording: false,
|
||||
},
|
||||
network: { type: "windows-local" },
|
||||
requested_goal: "open_conversation",
|
||||
config_revision: desiredConfigRevision,
|
||||
}, `windows-hardware-create-${nonce()}`));
|
||||
|
||||
function emitHardwareEvent(event) {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
mainWindow.webContents.send("sim:hardware-provider-event", event);
|
||||
}
|
||||
|
||||
function allowedHardwareGatewayURL() {
|
||||
const configured = String(process.env.SIMULATOR_HARDWARE_GATEWAY_URL || "wss://ai-gateway.vei.volces.com/v1/realtime").trim();
|
||||
const parsed = new URL(configured);
|
||||
const official = parsed.protocol === "wss:" && parsed.hostname === "ai-gateway.vei.volces.com";
|
||||
const local = (parsed.protocol === "ws:" || parsed.protocol === "wss:") && ["127.0.0.1", "localhost", "[::1]"].includes(parsed.hostname);
|
||||
if (!official && !local) throw new Error("硬件智能体网关只允许火山官方地址或本机测试地址");
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
async function invokeHardwareMemoryTool(event) {
|
||||
const client = hardwareClient;
|
||||
const platformSession = activeHardwarePlatformSession;
|
||||
if (!client || !platformSession) return;
|
||||
const callID = String(event.call_id || "");
|
||||
const functionName = String(client.pendingCalls.get(callID) || "").toLowerCase();
|
||||
if (!["memory_search", "search_memory", "retrieve_memory"].includes(functionName)) return;
|
||||
let argumentsValue = {};
|
||||
try { argumentsValue = JSON.parse(String(event.arguments || "{}")); } catch { /* invalid input is handled below */ }
|
||||
const query = String(argumentsValue.query || argumentsValue.keyword || argumentsValue.text || "").trim();
|
||||
const limit = Math.max(1, Math.min(5, Number(argumentsValue.limit || 3)));
|
||||
try {
|
||||
if (!query) throw new Error("记忆工具缺少 query 参数");
|
||||
const baseURL = new URL(deviceConfig.apiURL);
|
||||
const target = new URL(platformSession.session_config.tool_url, `${baseURL.origin}/`);
|
||||
if (target.origin !== baseURL.origin) throw new Error("记忆工具地址必须与平台同源");
|
||||
const response = await fetch(target, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${platformSession.session_config.tool_scope_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ query, limit }),
|
||||
});
|
||||
const raw = await response.text();
|
||||
let body;
|
||||
try { body = raw ? JSON.parse(raw) : {}; } catch { body = { message: raw }; }
|
||||
if (!response.ok) throw new Error(body?.error?.message || body?.message || `HTTP ${response.status}`);
|
||||
const result = body?.data ?? body;
|
||||
client.completeFunctionCall(callID, result);
|
||||
emitHardwareEvent({ type: "platform.memory_search.completed", call_id: callID, query, result_count: result?.items?.length || 0, degraded: Boolean(result?.degraded) });
|
||||
} catch (error) {
|
||||
client.completeFunctionCall(callID, { items: [], degraded: true, error: error.message });
|
||||
emitHardwareEvent({ type: "platform.memory_search.failed", call_id: callID, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle("sim:connect-hardware-agent", async (_event, input) => {
|
||||
if (!deviceConfig) throw new Error("请先连接平台");
|
||||
if (!hardwareConfig) throw new Error("请先完成火山设备鉴权配置");
|
||||
if (hardwareClient) throw new Error("已有硬件智能体连接,请先结束");
|
||||
const platformSession = input?.platformSession;
|
||||
if (!platformSession?.runtime?.bot_id || !platformSession?.session_config?.dialog_id) throw new Error("平台硬件会话配置不完整");
|
||||
if (platformSession.runtime.transport !== "low_load") throw new Error("Windows 标准协议测试程序只接受设备绑定为 low_load 的会话");
|
||||
const modelMode = String(input?.modelMode || "llm").trim().toLowerCase();
|
||||
if (!new Set(["llm", "s2s"]).has(modelMode)) throw new Error("智能体模型类型必须为 llm 或 s2s");
|
||||
activeHardwarePlatformSession = platformSession;
|
||||
const client = new HardwareAgentClient({
|
||||
gatewayURL: allowedHardwareGatewayURL(),
|
||||
emit: (event) => {
|
||||
emitHardwareEvent(event);
|
||||
if (event.type === "response.function_call_arguments.done") void invokeHardwareMemoryTool(event);
|
||||
},
|
||||
});
|
||||
hardwareClient = client;
|
||||
try {
|
||||
return await client.connect({
|
||||
...hardwareConfig,
|
||||
botID: platformSession.runtime.bot_id,
|
||||
sessionConfig: platformSession.session_config,
|
||||
modelMode,
|
||||
});
|
||||
} catch (error) {
|
||||
hardwareClient = null;
|
||||
activeHardwarePlatformSession = null;
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on("sim:hardware-agent-audio", (_event, bytes) => {
|
||||
try { hardwareClient?.sendAudio(Buffer.from(bytes)); }
|
||||
catch (error) { emitHardwareEvent({ type: "transport.audio_error", message: error.message }); }
|
||||
});
|
||||
|
||||
ipcMain.handle("sim:hardware-agent-text", (_event, input) => {
|
||||
if (!hardwareClient) throw new Error("火山硬件智能体连接未就绪");
|
||||
hardwareClient.sendText(input?.text, Number(input?.interruptMode || 1));
|
||||
return { accepted: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("sim:close-hardware-agent", (_event, input) => {
|
||||
const client = hardwareClient;
|
||||
hardwareClient = null;
|
||||
activeHardwarePlatformSession = null;
|
||||
client?.close(Number(input?.code || 1000), String(input?.reason || "device session ended").slice(0, 100));
|
||||
return { closed: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("sim:save-hardware-report", async (_event, report) => {
|
||||
const result = await dialog.showSaveDialog(mainWindow, {
|
||||
title: "导出硬件对话智能体测试报告",
|
||||
defaultPath: `hardware-agent-test-${new Date().toISOString().replace(/[:.]/g, "-")}.json`,
|
||||
filters: [{ name: "JSON 测试报告", extensions: ["json"] }],
|
||||
});
|
||||
if (result.canceled || !result.filePath) return { saved: false };
|
||||
const redact = (value) => {
|
||||
if (Array.isArray(value)) return value.map(redact);
|
||||
if (!value || typeof value !== "object") return value;
|
||||
const output = {};
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (/secret|signature|authorization|tool_scope_token|systemmessages|system_role|system_prompt|authorized_context|speaking_style|instructions/i.test(key)) output[key] = "[REDACTED]";
|
||||
else output[key] = redact(item);
|
||||
}
|
||||
return output;
|
||||
};
|
||||
const safeReport = redact(JSON.parse(JSON.stringify(report)));
|
||||
fs.writeFileSync(result.filePath, `${JSON.stringify(safeReport, null, 2)}\n`, "utf8");
|
||||
return { saved: true, path: result.filePath };
|
||||
});
|
||||
|
||||
ipcMain.handle("sim:hardware-agent-event", async (_event, input) => deviceRequest(
|
||||
"POST",
|
||||
`/device/v3/agent-sessions/${encodeURIComponent(input.conversationID)}/events`,
|
||||
{
|
||||
event_id: input.eventID || `windows-hardware-event-${nonce()}`,
|
||||
sequence_no: Number(input.sequenceNo),
|
||||
event_type: input.eventType,
|
||||
occurred_at: new Date().toISOString(),
|
||||
provider_session_ref: input.providerSessionRef,
|
||||
session_nonce: input.sessionNonce,
|
||||
...(input.eventType === "session_started" ? {
|
||||
provider_connection_ref: input.providerConnectionRef,
|
||||
bot_id: input.botID,
|
||||
transport: input.transport,
|
||||
config_digest: input.configDigest,
|
||||
dynamic_config_applied: input.dynamicConfigApplied,
|
||||
} : {}),
|
||||
...(input.errorCode ? { error_code: input.errorCode } : {}),
|
||||
},
|
||||
));
|
||||
|
||||
ipcMain.handle("sim:activate-conversation", async (_event, input) => deviceRequest(
|
||||
"POST",
|
||||
`/device/v2/conversations/${encodeURIComponent(input.conversationID)}/activate`,
|
||||
@@ -338,7 +689,7 @@ ipcMain.handle("sim:heartbeat", async (_event, input) => deviceRequest(
|
||||
conversation_status: "ACTIVE",
|
||||
rtc: { joined: true, uplink: true, downlink: true },
|
||||
network: { type: "windows-local" },
|
||||
firmware_version: "0.3.0-windows-rtc",
|
||||
firmware_version: "0.3.2-windows-hardware-agent",
|
||||
reported_config_revision: desiredConfigRevision,
|
||||
rtc_credential_expires_at: input.credentialExpiresAt,
|
||||
recording_queue: { pending_segments: 0, failed_segments: 0 },
|
||||
@@ -356,7 +707,7 @@ ipcMain.handle("sim:submit-transcript-batch", async (_event, input) => {
|
||||
const digest = crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex").slice(0, 32);
|
||||
return deviceRequest(
|
||||
"POST",
|
||||
`/device/v2/conversations/${encodeURIComponent(conversationID)}/turns:batch`,
|
||||
`/${input?.hardwareAgent ? "device/v3/agent-sessions" : "device/v2/conversations"}/${encodeURIComponent(conversationID)}/turns:batch`,
|
||||
payload,
|
||||
`windows-turns-${digest}`,
|
||||
);
|
||||
@@ -433,6 +784,9 @@ app.whenReady().then(() => {
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
hardwareClient?.close(1001, "application closed");
|
||||
hardwareClient = null;
|
||||
activeHardwarePlatformSession = null;
|
||||
if (process.platform !== "darwin") app.quit();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vm = require("node:vm");
|
||||
|
||||
const source = fs.readFileSync(path.join(__dirname, "../public/pcm-playback.worklet.js"), "utf8");
|
||||
|
||||
function fixture({ outputRate = 48000, prebufferMs = 120 } = {}) {
|
||||
const messages = [];
|
||||
let Processor;
|
||||
class AudioWorkletProcessor {
|
||||
constructor() {
|
||||
this.port = { onmessage: null, postMessage: (message) => messages.push(message) };
|
||||
}
|
||||
}
|
||||
const context = vm.createContext({
|
||||
AudioWorkletProcessor,
|
||||
Float32Array,
|
||||
ArrayBuffer,
|
||||
Math,
|
||||
sampleRate: outputRate,
|
||||
registerProcessor: (name, implementation) => {
|
||||
assert.equal(name, "pcm16-playback");
|
||||
Processor = implementation;
|
||||
},
|
||||
});
|
||||
vm.runInContext(source, context);
|
||||
const processor = new Processor({ processorOptions: { sourceSampleRate: 16000, prebufferMs } });
|
||||
const push = (values) => {
|
||||
const samples = new Float32Array(values);
|
||||
processor.port.onmessage({ data: { type: "push", samples: samples.buffer } });
|
||||
};
|
||||
const process = () => {
|
||||
const output = new Float32Array(128);
|
||||
assert.equal(processor.process([], [[output]]), true);
|
||||
return output;
|
||||
};
|
||||
return { messages, processor, push, process };
|
||||
}
|
||||
|
||||
test("waits for prebuffer then resamples continuous 16k PCM without fragment gaps", () => {
|
||||
const f = fixture({ outputRate: 48000, prebufferMs: 10 });
|
||||
f.push(new Array(120).fill(0.25));
|
||||
assert.ok(f.process().every((sample) => sample === 0), "must remain silent below the prebuffer threshold");
|
||||
f.push(new Array(400).fill(0.25));
|
||||
const first = f.process();
|
||||
const second = f.process();
|
||||
assert.ok(first.some((sample) => sample > 0.05), "buffered playback should start with a short anti-pop fade");
|
||||
assert.ok(second.every((sample) => sample > 0.12), "playback should remain continuous across source fragments");
|
||||
assert.equal(f.messages.filter((message) => message.type === "underflow").length, 0);
|
||||
});
|
||||
|
||||
test("plays 16k source at real-time duration instead of the former 1.5x speed", () => {
|
||||
const f = fixture({ outputRate: 48000, prebufferMs: 1 });
|
||||
f.push(new Array(1000).fill(0.25));
|
||||
f.process();
|
||||
assert.ok(Math.abs(f.processor.readPosition - 128 / 3) < 0.001, `consumed ${f.processor.readPosition} source samples`);
|
||||
});
|
||||
|
||||
test("reports network starvation and re-enters prebuffering instead of scheduling tiny buffers", () => {
|
||||
const f = fixture({ outputRate: 48000, prebufferMs: 0.01 });
|
||||
f.push(new Array(8).fill(0.5));
|
||||
f.process();
|
||||
assert.equal(f.messages.filter((message) => message.type === "underflow").length, 1);
|
||||
assert.ok(f.process().every((sample) => sample === 0), "starved playback should wait for a new prebuffer");
|
||||
});
|
||||
|
||||
test("drains the final response cleanly and emits a completion signal", () => {
|
||||
const f = fixture({ outputRate: 24000 });
|
||||
f.push(new Array(64).fill(-0.25));
|
||||
f.processor.port.onmessage({ data: { type: "end" } });
|
||||
f.process();
|
||||
assert.equal(f.messages.filter((message) => message.type === "drained").length, 1);
|
||||
assert.equal(f.messages.filter((message) => message.type === "underflow").length, 0);
|
||||
});
|
||||
@@ -5,8 +5,23 @@ contextBridge.exposeInMainWorld("deviceSimulator", {
|
||||
configure: (input) => ipcRenderer.invoke("sim:configure", input),
|
||||
probe: () => ipcRenderer.invoke("sim:probe"),
|
||||
probeRTCNetwork: () => ipcRenderer.invoke("sim:probe-rtc-network"),
|
||||
probeHardwareNetwork: () => ipcRenderer.invoke("sim:probe-hardware-network"),
|
||||
getHardwareRuntimeDefaults: () => ipcRenderer.invoke("sim:get-hardware-runtime-defaults"),
|
||||
configureHardware: (input) => ipcRenderer.invoke("sim:configure-hardware", input),
|
||||
getAutomationAudio: () => ipcRenderer.invoke("sim:get-automation-audio"),
|
||||
createConversation: () => ipcRenderer.invoke("sim:create-conversation"),
|
||||
createHardwareAgentSession: () => ipcRenderer.invoke("sim:create-hardware-agent-session"),
|
||||
connectHardwareAgent: (input) => ipcRenderer.invoke("sim:connect-hardware-agent", input),
|
||||
sendHardwareAgentAudio: (bytes) => ipcRenderer.send("sim:hardware-agent-audio", bytes),
|
||||
sendHardwareAgentText: (input) => ipcRenderer.invoke("sim:hardware-agent-text", input),
|
||||
closeHardwareAgent: (input) => ipcRenderer.invoke("sim:close-hardware-agent", input),
|
||||
onHardwareProviderEvent: (callback) => {
|
||||
const listener = (_event, value) => callback(value);
|
||||
ipcRenderer.on("sim:hardware-provider-event", listener);
|
||||
return () => ipcRenderer.removeListener("sim:hardware-provider-event", listener);
|
||||
},
|
||||
saveHardwareReport: (report) => ipcRenderer.invoke("sim:save-hardware-report", report),
|
||||
hardwareAgentEvent: (input) => ipcRenderer.invoke("sim:hardware-agent-event", input),
|
||||
activateConversation: (input) => ipcRenderer.invoke("sim:activate-conversation", input),
|
||||
getConversation: (conversationID) => ipcRenderer.invoke("sim:get-conversation", conversationID),
|
||||
heartbeat: (input) => ipcRenderer.invoke("sim:heartbeat", input),
|
||||
|
||||
Reference in New Issue
Block a user