109 lines
6.8 KiB
JavaScript
109 lines
6.8 KiB
JavaScript
const crypto = require("node:crypto");
|
|
const { WebSocketServer } = require("ws");
|
|
const { websocketSignature } = require("../electron/hardware-agent-client.cjs");
|
|
|
|
const port = Number(process.env.MOCK_HARDWARE_GATEWAY_PORT || 19090);
|
|
const expectedSecret = process.env.MOCK_VOLC_DEVICE_SECRET || "mock-volc-device-secret-at-least-32-characters";
|
|
const server = new WebSocketServer({ host: "127.0.0.1", port, maxPayload: 1024 * 1024 });
|
|
|
|
function send(socket, value) { socket.send(JSON.stringify(value)); }
|
|
async function requestPlatformStop(conversationID) {
|
|
const baseURL = String(process.env.MOCK_PLATFORM_API_URL || "").replace(/\/$/, "");
|
|
const deviceID = String(process.env.MOCK_PLATFORM_DEVICE_ID || "");
|
|
const secret = String(process.env.MOCK_PLATFORM_DEVICE_SECRET || "");
|
|
if (!baseURL || !deviceID || !secret || !conversationID) return;
|
|
const requestPath = `/device/v2/conversations/${encodeURIComponent(conversationID)}/stop`;
|
|
const body = JSON.stringify({ reason: "windows_e2e_platform_stop", device_time: new Date().toISOString(), last_heartbeat_sequence: 1, rtc_left: true, recording: { near_segments: 0, far_segments: 0 } });
|
|
const timestamp = new Date().toISOString();
|
|
const nonce = crypto.randomBytes(16).toString("hex");
|
|
const digest = crypto.createHash("sha256").update(body).digest("hex");
|
|
const canonical = ["POST", requestPath, timestamp, nonce, digest].join("\n");
|
|
const signature = crypto.createHmac("sha256", secret).update(canonical).digest("base64url");
|
|
const response = await fetch(`${baseURL}${requestPath}`, {
|
|
method: "POST",
|
|
headers: {
|
|
Accept: "application/json", "Content-Type": "application/json", "Idempotency-Key": `windows-platform-stop-${crypto.randomUUID()}`,
|
|
"X-Device-Id": deviceID, "X-Device-Timestamp": timestamp, "X-Device-Nonce": nonce,
|
|
"X-Device-Content-SHA256": digest, "X-Device-Signature": signature,
|
|
},
|
|
body,
|
|
});
|
|
if (!response.ok) throw new Error(`platform stop HTTP ${response.status}: ${await response.text()}`);
|
|
process.stdout.write(`${JSON.stringify({ type: "platform_stop_requested", conversation_id: conversationID })}\n`);
|
|
}
|
|
function tonePCMBase64() {
|
|
const samples = 2400;
|
|
const bytes = Buffer.alloc(samples * 2);
|
|
for (let index = 0; index < samples; index += 1) {
|
|
const sample = Math.round(Math.sin((index / 16000) * Math.PI * 2 * 440) * 1800);
|
|
bytes.writeInt16LE(sample, index * 2);
|
|
}
|
|
return bytes.toString("base64");
|
|
}
|
|
|
|
server.on("listening", () => process.stdout.write(`${JSON.stringify({ type: "listening", port })}\n`));
|
|
server.on("connection", (socket, request) => {
|
|
const header = (name) => String(request.headers[name.toLowerCase()] || "");
|
|
const expectedSignature = websocketSignature({
|
|
authType: 1,
|
|
deviceName: header("x-device-name"),
|
|
randomNumber: Number(header("x-random-num")),
|
|
productKey: header("x-product-key"),
|
|
timestamp: Number(header("x-timestamp")),
|
|
instanceID: header("x-instance-id"),
|
|
}, expectedSecret);
|
|
if (!crypto.timingSafeEqual(Buffer.from(header("x-signature")), Buffer.from(expectedSignature))) {
|
|
socket.close(1008, "invalid signature");
|
|
return;
|
|
}
|
|
const target = new URL(request.url, `ws://127.0.0.1:${port}`);
|
|
if (!target.searchParams.get("bot") || target.searchParams.get("wait_for_session_update") !== "true") {
|
|
socket.close(1008, "invalid query");
|
|
return;
|
|
}
|
|
process.stdout.write(`${JSON.stringify({ type: "authenticated", device: header("x-device-name"), bot: target.searchParams.get("bot") })}\n`);
|
|
send(socket, { type: "session.created", event_id: "mock-created", session: { id: "mock-provider-session-windows", model: "doubao", input_audio_format: "pcm16", output_audio_format: "pcm16", turn_detection: { type: "server_vad" } } });
|
|
let replied = false;
|
|
let dialogID = "";
|
|
socket.on("message", (raw) => {
|
|
const event = JSON.parse(raw.toString("utf8"));
|
|
if (event.type === "session.update") {
|
|
const llmPrompt = event.session?.config?.LLMConfig?.SystemMessages?.[0];
|
|
const s2sDialog = event.session?.config?.S2SConfig?.ProviderParams?.dialog;
|
|
const sessionDialogID = String(s2sDialog?.dialog_id || (llmPrompt ? llmPrompt.match(/本次对话标识:([^\n]+)/)?.[1] : "") || "").trim();
|
|
const frozenPrompt = String(llmPrompt || s2sDialog?.system_role || "").trim();
|
|
if (event.session?.object !== "realtime.session" || event.session?.config?.ASRConfig?.TurnDetectionMode !== 0 || !sessionDialogID || !frozenPrompt) {
|
|
send(socket, { type: "error", event_id: "mock-error", error: { code: "INVALID_SESSION_CONFIG", message: "frozen context missing" } });
|
|
return;
|
|
}
|
|
dialogID = sessionDialogID;
|
|
send(socket, { type: "session.updated", event_id: "mock-updated", session: { ...event.session, id: "mock-provider-session-windows" } });
|
|
process.stdout.write(`${JSON.stringify({ type: "dynamic_config_applied", dialog_id: dialogID })}\n`);
|
|
return;
|
|
}
|
|
if (event.type === "input_audio_buffer.append" && !replied) {
|
|
replied = true;
|
|
send(socket, { type: "input_audio_buffer.speech_started", event_id: "mock-speech-start", audio_start_ms: 0 });
|
|
send(socket, { type: "input_audio_buffer.speech_stopped", event_id: "mock-speech-stop", audio_end_ms: 800 });
|
|
send(socket, { type: "conversation.item.input_audio_transcription.completed", event_id: "mock-user-final", item_id: "mock-user-item", transcript: "你好,请介绍一下今天的安排。" });
|
|
send(socket, { type: "response.created", event_id: "mock-response-created", response: { id: "mock-response-1", status: "in_progress" } });
|
|
send(socket, { type: "response.audio_transcript.delta", event_id: "mock-agent-delta", response_id: "mock-response-1", delta: "您好,今天可以先聊聊您最想做的事情。" });
|
|
send(socket, { type: "response.audio.delta", event_id: "mock-audio-delta", response_id: "mock-response-1", delta: tonePCMBase64() });
|
|
send(socket, { type: "response.audio_transcript.done", event_id: "mock-agent-final", response_id: "mock-response-1", transcript: "您好,今天可以先聊聊您最想做的事情。" });
|
|
send(socket, { type: "response.audio.done", event_id: "mock-audio-done", response_id: "mock-response-1" });
|
|
send(socket, { type: "response.done", event_id: "mock-response-done", response: { id: "mock-response-1", status: "completed", output: [] } });
|
|
process.stdout.write(`${JSON.stringify({ type: "round_completed" })}\n`);
|
|
if (process.env.MOCK_REQUEST_PLATFORM_STOP === "1") {
|
|
setTimeout(() => void requestPlatformStop(dialogID).catch((error) => {
|
|
process.stderr.write(`${error.stack || error.message}\n`);
|
|
}), 250);
|
|
}
|
|
}
|
|
});
|
|
socket.on("close", (code) => process.stdout.write(`${JSON.stringify({ type: "closed", code })}\n`));
|
|
});
|
|
|
|
function shutdown() { server.close(() => process.exit(0)); }
|
|
process.on("SIGINT", shutdown);
|
|
process.on("SIGTERM", shutdown);
|