796 lines
37 KiB
JavaScript
796 lines
37 KiB
JavaScript
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({
|
|
width: 1220,
|
|
height: 820,
|
|
minWidth: 980,
|
|
minHeight: 700,
|
|
backgroundColor: "#eef3f0",
|
|
autoHideMenuBar: true,
|
|
title: "语音终端模拟器",
|
|
webPreferences: {
|
|
preload: path.join(__dirname, "preload.cjs"),
|
|
contextIsolation: true,
|
|
nodeIntegration: false,
|
|
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) : "";
|
|
window.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL) => {
|
|
console.error("renderer load failed", { errorCode, errorDescription, validatedURL });
|
|
});
|
|
window.webContents.on("render-process-gone", (_event, details) => {
|
|
console.error("renderer process exited", details);
|
|
});
|
|
window.webContents.on("console-message", (_event, ...args) => {
|
|
const details = args[0];
|
|
const message = typeof details === "object" && details ? details.message : String(args[1] ?? details ?? "");
|
|
console.log(`[renderer] ${message}`);
|
|
});
|
|
window.webContents.once("did-finish-load", async () => {
|
|
try {
|
|
const diagnostics = await window.webContents.executeJavaScript(`JSON.stringify({readyState: document.readyState, bodyLength: document.body.innerText.length, appChildren: document.querySelector('#app')?.childElementCount || 0})`);
|
|
console.log(`[renderer-ready] ${diagnostics}`);
|
|
} catch (error) {
|
|
console.error("renderer diagnostics failed", error);
|
|
}
|
|
if (automatedUI) {
|
|
void runAutomatedSmoke(window);
|
|
}
|
|
});
|
|
|
|
if (devServerURL) {
|
|
window.loadURL(devServerURL);
|
|
} else {
|
|
window.loadFile(path.join(__dirname, "..", "dist", "index.html"));
|
|
}
|
|
|
|
const screenshotPath = process.env.SIMULATOR_SCREENSHOT_PATH;
|
|
if (screenshotPath) {
|
|
window.webContents.once("did-finish-load", () => {
|
|
setTimeout(async () => {
|
|
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);
|
|
});
|
|
}
|
|
}
|
|
|
|
async function runAutomatedSmoke(window) {
|
|
const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
await delay(800);
|
|
await window.webContents.executeJavaScript(`document.querySelector('#checkButton')?.click()`);
|
|
for (let attempt = 0; attempt < 30; attempt += 1) {
|
|
await delay(500);
|
|
const state = await window.webContents.executeJavaScript(`JSON.stringify({phase: document.querySelector('#phaseBadge')?.textContent, canStart: !document.querySelector('#startButton')?.disabled, detail: document.querySelector('#apiDetail')?.textContent})`);
|
|
console.log(`[auto-test] ${state}`);
|
|
const parsed = JSON.parse(state);
|
|
if (parsed.canStart) {
|
|
await window.webContents.executeJavaScript(`document.querySelector('#startButton')?.click()`);
|
|
console.log("[auto-test] start requested");
|
|
await collectAutomatedConversationResult(window);
|
|
return;
|
|
}
|
|
if (parsed.phase === "异常") {
|
|
return;
|
|
}
|
|
}
|
|
console.error("[auto-test] environment check did not become ready");
|
|
}
|
|
|
|
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 || ''),
|
|
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) {
|
|
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 === "异常") {
|
|
snapshot.result = "ERROR";
|
|
break;
|
|
}
|
|
}
|
|
if (!snapshot.result) snapshot.result = "TIMEOUT";
|
|
const resultPath = process.env.SIMULATOR_AUTO_RESULT_PATH;
|
|
if (resultPath) {
|
|
fs.writeFileSync(path.resolve(resultPath), JSON.stringify(snapshot, null, 2), "utf8");
|
|
}
|
|
await delay(4000);
|
|
app.quit();
|
|
}
|
|
|
|
function ensureAllowedAPIURL(value) {
|
|
const parsed = new URL(String(value || ""));
|
|
const loopback = parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost" || parsed.hostname === "[::1]";
|
|
const deployedTestServer = parsed.protocol === "https:" && parsed.hostname === "121.41.46.238" && parsed.port === "";
|
|
if ((!loopback || (parsed.protocol !== "http:" && parsed.protocol !== "https:")) && !deployedTestServer) {
|
|
throw new Error("测试设备只允许连接本机平台或已部署的测试服务器");
|
|
}
|
|
return parsed.origin;
|
|
}
|
|
|
|
function configure(input) {
|
|
const apiURL = ensureAllowedAPIURL(input?.apiURL);
|
|
const deviceID = String(input?.deviceID || "").trim();
|
|
const deviceSecret = String(input?.deviceSecret || "") || loadDefaultDeviceSecret();
|
|
if (!/^[0-9a-f-]{36}$/i.test(deviceID)) {
|
|
throw new Error("设备 ID 必须是 UUID");
|
|
}
|
|
if (deviceSecret.length < 32) {
|
|
throw new Error("未找到有效的设备密钥。请使用云端设备启动脚本,或在设备签名密钥中输入至少32个字符");
|
|
}
|
|
deviceConfig = { apiURL, deviceID, deviceSecret };
|
|
return { apiURL, deviceID };
|
|
}
|
|
|
|
function loadDefaultDeviceSecret() {
|
|
const environmentSecret = String(process.env.SIMULATOR_DEFAULT_DEVICE_SECRET || "").trim();
|
|
if (environmentSecret) return environmentSecret;
|
|
const candidates = [];
|
|
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, "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");
|
|
if (home) candidates.push(path.join(home, "AppData", "Local", "DataTerminalVoiceDevice", "server-device-secret"));
|
|
} catch { /* app path is unavailable before ready */ }
|
|
if (portableDirectory) candidates.push(path.join(portableDirectory, ".server-device-secret"));
|
|
candidates.push(path.join(path.dirname(process.execPath), ".server-device-secret"));
|
|
|
|
for (const secretPath of new Set(candidates)) {
|
|
try {
|
|
const stats = fs.statSync(secretPath);
|
|
if (!stats.isFile() || stats.size < 32 || stats.size > 4096) continue;
|
|
const value = fs.readFileSync(secretPath, "utf8").trim();
|
|
if (value.length >= 32) return value;
|
|
} catch { /* try the next fixed credential location */ }
|
|
}
|
|
return "";
|
|
}
|
|
|
|
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-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");
|
|
}
|
|
|
|
function signedHeaders(method, requestURI, body) {
|
|
if (!deviceConfig) throw new Error("请先连接本地平台");
|
|
const timestamp = new Date().toISOString();
|
|
const requestNonce = nonce();
|
|
const digest = crypto.createHash("sha256").update(body).digest("hex");
|
|
const canonical = [method.toUpperCase(), requestURI, timestamp, requestNonce, digest].join("\n");
|
|
const signature = crypto.createHmac("sha256", deviceConfig.deviceSecret).update(canonical).digest("base64url");
|
|
return {
|
|
"X-Device-Id": deviceConfig.deviceID,
|
|
"X-Device-Timestamp": timestamp,
|
|
"X-Device-Nonce": requestNonce,
|
|
"X-Device-Content-SHA256": digest,
|
|
"X-Device-Signature": signature,
|
|
};
|
|
}
|
|
|
|
async function deviceRequest(method, requestPath, payload, idempotencyKey) {
|
|
if (!deviceConfig) throw new Error("请先连接本地平台");
|
|
const target = new URL(requestPath, `${deviceConfig.apiURL}/`);
|
|
const body = payload === undefined ? "" : JSON.stringify(payload);
|
|
const headers = signedHeaders(method, `${target.pathname}${target.search}`, body);
|
|
headers.Accept = "application/json";
|
|
if (body) headers["Content-Type"] = "application/json";
|
|
if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
|
|
const response = await fetch(target, { method, headers, body: body || undefined });
|
|
const text = await response.text();
|
|
let parsed;
|
|
try {
|
|
parsed = text ? JSON.parse(text) : {};
|
|
} catch {
|
|
parsed = { message: text };
|
|
}
|
|
if (!response.ok) {
|
|
const code = parsed?.error?.code || parsed?.code || `HTTP_${response.status}`;
|
|
const message = parsed?.error?.message || parsed?.message || "本地平台请求失败";
|
|
throw new Error(`${code}: ${message}`);
|
|
}
|
|
return parsed?.data ?? parsed;
|
|
}
|
|
|
|
ipcMain.handle("sim:configure", (_event, input) => configure(input));
|
|
|
|
ipcMain.handle("sim:probe", async () => {
|
|
const query = new URLSearchParams({
|
|
firmware_version: "0.3.2-windows-hardware-agent",
|
|
board_rev: "windows-x64",
|
|
rtc_sdk_version: "web-4.69.0-electron",
|
|
codec: "opus",
|
|
sample_rate: "48000",
|
|
network: "windows-local",
|
|
device_time: new Date().toISOString(),
|
|
});
|
|
const bootstrap = await deviceRequest("GET", `/device/v2/bootstrap?${query.toString()}`);
|
|
const configuration = await deviceRequest("GET", "/device/v2/config");
|
|
desiredConfigRevision = Number(configuration.revision || bootstrap?.device?.config_revision || 1);
|
|
return { bootstrap, configuration, desiredConfigRevision };
|
|
});
|
|
|
|
const rtcWebEndpoints = [
|
|
"access.rtc.volcvideo.com",
|
|
"access2-hl.rtc.volcvideo.com",
|
|
"common.rtc.volcvideo.com",
|
|
"rtcg-access.volcvideos.com",
|
|
"rtcg.volcvideos.com",
|
|
"web-log-report.rtc.volcvideo.com",
|
|
"web-log-report.volcvideos.com",
|
|
];
|
|
|
|
function probeTLSEndpoint(hostname) {
|
|
return new Promise((resolve) => {
|
|
const socket = tls.connect({ host: hostname, port: 443, servername: hostname, rejectUnauthorized: true });
|
|
let finished = false;
|
|
const finish = (result) => {
|
|
if (finished) return;
|
|
finished = true;
|
|
socket.destroy();
|
|
resolve(result);
|
|
};
|
|
socket.setTimeout(5_000, () => finish({ hostname, ok: false, error: "连接超时" }));
|
|
socket.once("secureConnect", () => finish({ hostname, ok: true }));
|
|
socket.once("error", (error) => finish({ hostname, ok: false, error: String(error?.code || error?.message || "连接失败") }));
|
|
});
|
|
}
|
|
|
|
ipcMain.handle("sim:probe-rtc-network", async () => {
|
|
const endpoints = await Promise.all(rtcWebEndpoints.map(probeTLSEndpoint));
|
|
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;
|
|
const resolvedPath = path.resolve(configuredPath);
|
|
const stats = fs.statSync(resolvedPath);
|
|
if (!stats.isFile() || stats.size < 1 || stats.size > 10 * 1024 * 1024) {
|
|
throw new Error("自动验证音频必须是 1 字节到 10MB 的文件");
|
|
}
|
|
const extension = path.extname(resolvedPath).toLowerCase();
|
|
const mime = extension === ".mp3" ? "audio/mpeg" : extension === ".ogg" ? "audio/ogg" : "audio/wav";
|
|
return {
|
|
data_url: `data:${mime};base64,${fs.readFileSync(resolvedPath).toString("base64")}`,
|
|
source_name: path.basename(resolvedPath),
|
|
};
|
|
});
|
|
|
|
ipcMain.handle("sim:create-conversation", async () => deviceRequest("POST", "/device/v2/conversations", {
|
|
device_time: new Date().toISOString(),
|
|
monotonic_ms: Math.max(0, Math.round(performance.now())),
|
|
capabilities: {
|
|
rtc: true,
|
|
audio_tracks: ["near", "far"],
|
|
upload_resume: true,
|
|
rtc_credential_renewal: true,
|
|
},
|
|
network: { type: "windows-local" },
|
|
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`,
|
|
{
|
|
joined_at: new Date().toISOString(),
|
|
room_id: input.roomID,
|
|
rtc_user_id: input.userID,
|
|
rtc_join_result: "success",
|
|
config_revision: desiredConfigRevision,
|
|
connection_reference: "windows-electron-web-rtc",
|
|
},
|
|
`windows-activate-${nonce()}`,
|
|
));
|
|
|
|
ipcMain.handle("sim:get-conversation", async (_event, conversationID) => deviceRequest(
|
|
"GET",
|
|
`/device/v2/conversations/${encodeURIComponent(conversationID)}`,
|
|
));
|
|
|
|
ipcMain.handle("sim:heartbeat", async (_event, input) => deviceRequest(
|
|
"POST",
|
|
`/device/v2/conversations/${encodeURIComponent(input.conversationID)}/heartbeat`,
|
|
{
|
|
sequence_no: input.sequenceNo,
|
|
device_time: new Date().toISOString(),
|
|
monotonic_ms: Math.max(0, Math.round(performance.now())),
|
|
conversation_status: "ACTIVE",
|
|
rtc: { joined: true, uplink: true, downlink: true },
|
|
network: { type: "windows-local" },
|
|
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 },
|
|
command_results: [],
|
|
},
|
|
));
|
|
|
|
ipcMain.handle("sim:submit-transcript-batch", async (_event, input) => {
|
|
const conversationID = String(input?.conversationID || "");
|
|
const turns = Array.isArray(input?.turns) ? input.turns : [];
|
|
if (!conversationID || turns.length === 0) {
|
|
throw new Error("通话编号和最终话轮不能为空");
|
|
}
|
|
const payload = { turns };
|
|
const digest = crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex").slice(0, 32);
|
|
return deviceRequest(
|
|
"POST",
|
|
`/${input?.hardwareAgent ? "device/v3/agent-sessions" : "device/v2/conversations"}/${encodeURIComponent(conversationID)}/turns:batch`,
|
|
payload,
|
|
`windows-turns-${digest}`,
|
|
);
|
|
});
|
|
|
|
ipcMain.handle("sim:stop-conversation", async (_event, input) => deviceRequest(
|
|
"POST",
|
|
`/device/v2/conversations/${encodeURIComponent(input.conversationID)}/stop`,
|
|
{
|
|
reason: input.reason || "windows_user_hangup",
|
|
device_time: new Date().toISOString(),
|
|
last_heartbeat_sequence: input.lastHeartbeatSequence || 0,
|
|
rtc_left: true,
|
|
recording: { near_segments: Number(input.nearSegments || 0), far_segments: Number(input.farSegments || 0) },
|
|
},
|
|
`windows-stop-${nonce()}`,
|
|
));
|
|
|
|
ipcMain.handle("sim:upload-conversation-recording", async (_event, input) => {
|
|
const conversationID = String(input?.conversationID || "");
|
|
const supplied = Array.isArray(input?.segments) ? input.segments : [];
|
|
if (!conversationID || supplied.length === 0) throw new Error("通话编号和录音分片不能为空");
|
|
|
|
const segments = supplied.map((segment) => {
|
|
const bytes = Buffer.from(segment.bytes);
|
|
if (!bytes.length || bytes.length > 64 * 1024 * 1024) throw new Error("单个录音分片必须在 1 字节到 64MiB 之间");
|
|
return {
|
|
track: segment.track,
|
|
sequence_no: Number(segment.sequence_no),
|
|
codec: segment.codec,
|
|
content_type: segment.content_type,
|
|
duration_ms: Number(segment.duration_ms),
|
|
expected_size: bytes.length,
|
|
expected_digest: crypto.createHash("sha256").update(bytes).digest("hex"),
|
|
bytes,
|
|
};
|
|
});
|
|
const manifest = { segments: segments.map(({ bytes: _bytes, ...segment }) => segment) };
|
|
const manifestDigest = crypto.createHash("sha256").update(JSON.stringify(manifest)).digest("hex").slice(0, 40);
|
|
const batch = await deviceRequest(
|
|
"POST",
|
|
`/device/v2/conversations/${encodeURIComponent(conversationID)}/upload-batches`,
|
|
manifest,
|
|
`windows-media-${manifestDigest}`,
|
|
);
|
|
|
|
for (const local of segments) {
|
|
const remote = batch.segments?.find((segment) => segment.track === local.track && Number(segment.sequence_no) === local.sequence_no);
|
|
if (!remote) throw new Error(`平台未返回${local.track}轨道第${local.sequence_no}段录音`);
|
|
if (remote.status === "VERIFIED" || remote.status === "VERIFYING") continue;
|
|
if (!remote.upload_url) throw new Error(`平台未签发${local.track}轨道第${local.sequence_no}段上传地址`);
|
|
const response = await fetch(remote.upload_url, { method: "PUT", headers: { "Content-Type": local.content_type }, body: local.bytes });
|
|
if (!response.ok) throw new Error(`录音对象上传失败:HTTP ${response.status}`);
|
|
}
|
|
|
|
await deviceRequest("POST", `/device/v2/upload-batches/${encodeURIComponent(batch.id)}/complete`, {});
|
|
const deadline = Date.now() + 90_000;
|
|
while (Date.now() < deadline) {
|
|
const current = await deviceRequest("GET", `/device/v2/upload-batches/${encodeURIComponent(batch.id)}`);
|
|
if (current.status === "COMPLETED") return current;
|
|
if (current.status === "FAILED" || current.status === "EXPIRED") {
|
|
const rejected = current.segments?.find((segment) => segment.status === "REJECTED");
|
|
throw new Error(rejected?.last_error_code || current.last_error_code || `录音批次${current.status}`);
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 750));
|
|
}
|
|
throw new Error("录音已上传,但等待服务器校验超时");
|
|
});
|
|
|
|
app.whenReady().then(() => {
|
|
session.defaultSession.setPermissionCheckHandler((_webContents, permission) => permission === "media");
|
|
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => callback(permission === "media"));
|
|
createWindow();
|
|
});
|
|
|
|
app.on("window-all-closed", () => {
|
|
hardwareClient?.close(1001, "application closed");
|
|
hardwareClient = null;
|
|
activeHardwarePlatformSession = null;
|
|
if (process.platform !== "darwin") app.quit();
|
|
});
|
|
|
|
app.on("activate", () => {
|
|
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
|
});
|