+72
-1
@@ -34,6 +34,32 @@ type CreatedConversation = {
|
||||
};
|
||||
};
|
||||
|
||||
type CreatedHardwareAgentSession = {
|
||||
conversation_id: string;
|
||||
status: "SESSION_READY";
|
||||
runtime_type: "volc_hardware_agent";
|
||||
runtime: {
|
||||
provider: "volcengine";
|
||||
bot_id: string;
|
||||
transport: "high_quality" | "low_load";
|
||||
provider_session_ref: string;
|
||||
session_nonce: string;
|
||||
config_digest: string;
|
||||
wait_for_session_update: boolean;
|
||||
};
|
||||
session_config: {
|
||||
dialog_id: string;
|
||||
system_prompt?: string;
|
||||
persona_tone?: string;
|
||||
authorized_context: string;
|
||||
prompt_bundle_version: string;
|
||||
tool_url?: string;
|
||||
tool_scope_token?: string;
|
||||
};
|
||||
privacy: CreatedConversation["privacy"];
|
||||
session: CreatedConversation["session"];
|
||||
};
|
||||
|
||||
type DeviceFinalTurn = {
|
||||
turn_key: string;
|
||||
sequence_no: number;
|
||||
@@ -50,15 +76,60 @@ interface Window {
|
||||
configure(input: SimulatorConfiguration): Promise<{ apiURL: string; deviceID: string }>;
|
||||
probe(): Promise<Record<string, unknown>>;
|
||||
probeRTCNetwork(): Promise<{ endpoints: Array<{ hostname: string; ok: boolean; error?: string }> }>;
|
||||
probeHardwareNetwork(): Promise<{ endpoints: Array<{ hostname: string; ok: boolean; error?: string }> }>;
|
||||
getHardwareRuntimeDefaults(): Promise<{
|
||||
instanceID: string;
|
||||
productKey: string;
|
||||
deviceName: string;
|
||||
hardwareID: string;
|
||||
modelMode: "llm" | "s2s";
|
||||
deviceCredentialConfigured: boolean;
|
||||
}>;
|
||||
configureHardware(input: {
|
||||
hardwareID: string;
|
||||
}): Promise<{
|
||||
instanceID: string;
|
||||
productKey: string;
|
||||
deviceName: string;
|
||||
hardwareID: string;
|
||||
registered: boolean;
|
||||
persisted: boolean;
|
||||
credentialSource: string;
|
||||
credentialVersion: number;
|
||||
}>;
|
||||
getAutomationAudio(): Promise<{ data_url: string; source_name: string } | undefined>;
|
||||
createConversation(): Promise<CreatedConversation>;
|
||||
createHardwareAgentSession(): Promise<CreatedHardwareAgentSession>;
|
||||
connectHardwareAgent(input: { platformSession: CreatedHardwareAgentSession; modelMode: "llm" | "s2s" }): Promise<{
|
||||
providerConnectionRef: string;
|
||||
session: Record<string, unknown>;
|
||||
}>;
|
||||
sendHardwareAgentAudio(bytes: ArrayBuffer): void;
|
||||
sendHardwareAgentText(input: { text: string; interruptMode?: number }): Promise<{ accepted: boolean }>;
|
||||
closeHardwareAgent(input?: { code?: number; reason?: string }): Promise<{ closed: boolean }>;
|
||||
onHardwareProviderEvent(callback: (event: Record<string, unknown>) => void): () => void;
|
||||
saveHardwareReport(report: Record<string, unknown>): Promise<{ saved: boolean; path?: string }>;
|
||||
hardwareAgentEvent(input: {
|
||||
conversationID: string;
|
||||
eventID?: string;
|
||||
sequenceNo: number;
|
||||
eventType: "session_started" | "session_updated" | "session_ended" | "session_failed";
|
||||
providerSessionRef: string;
|
||||
sessionNonce: string;
|
||||
providerConnectionRef?: string;
|
||||
botID?: string;
|
||||
transport?: "high_quality" | "low_load";
|
||||
configDigest?: string;
|
||||
dynamicConfigApplied?: boolean;
|
||||
errorCode?: string;
|
||||
}): Promise<{ conversation_id: string; status: string; action: "continue" | "stop_session"; duplicate: boolean }>;
|
||||
activateConversation(input: { conversationID: string; roomID: string; userID: string }): Promise<Record<string, unknown>>;
|
||||
getConversation(conversationID: string): Promise<{ status: string; action: string; failure_code?: string }>;
|
||||
heartbeat(input: { conversationID: string; sequenceNo: number; credentialExpiresAt: string }): Promise<{
|
||||
actions?: Array<{ type: string; rtc?: RTCGrant }>;
|
||||
desired_config_revision?: number;
|
||||
}>;
|
||||
submitTranscriptBatch(input: { conversationID: string; turns: DeviceFinalTurn[] }): Promise<{
|
||||
submitTranscriptBatch(input: { conversationID: string; turns: DeviceFinalTurn[]; hardwareAgent?: boolean }): Promise<{
|
||||
conversation_id: string;
|
||||
accepted_count: number;
|
||||
duplicate_count: number;
|
||||
|
||||
@@ -0,0 +1,778 @@
|
||||
import "./style.css";
|
||||
|
||||
type HardwarePhase = "idle" | "checking" | "ready" | "connecting" | "active" | "stopping" | "completed" | "error";
|
||||
type ReportCheck = { id: string; label: string; status: "pending" | "passed" | "failed"; detail?: string; at?: string };
|
||||
type ReportEvent = { at: string; source: "app" | "platform" | "volcengine"; type: string; detail?: Record<string, unknown> };
|
||||
|
||||
const checkDefinitions: Array<[string, string]> = [
|
||||
["platform_authenticated", "平台设备 HMAC 鉴权"],
|
||||
["credential_resolved", "火山一机一密凭据"],
|
||||
["context_frozen", "人设、背景与授权上下文冻结"],
|
||||
["provider_connected", "火山硬件智能体 WebSocket 建连"],
|
||||
["dynamic_config_applied", "session.updated 动态配置确认"],
|
||||
["platform_active", "平台确认会话 ACTIVE"],
|
||||
["microphone_streaming", "16kHz PCM 麦克风实时上行"],
|
||||
["audio_playback_buffered", "16kHz PCM 连续缓冲播放"],
|
||||
["heartbeat_ack", "平台 session_updated 存活回执"],
|
||||
["user_transcript", "用户最终字幕"],
|
||||
["agent_transcript", "智能体最终字幕"],
|
||||
["transcript_uploaded", "最终话轮可靠上传"],
|
||||
["provider_closed", "火山连接正常关闭"],
|
||||
["platform_completed", "平台确认会话 COMPLETED"],
|
||||
];
|
||||
|
||||
const state: {
|
||||
phase: HardwarePhase;
|
||||
platformSession?: CreatedHardwareAgentSession;
|
||||
providerConnectionRef?: string;
|
||||
eventSequence: number;
|
||||
transcriptSequence: number;
|
||||
pendingTurns: DeviceFinalTurn[];
|
||||
transcriptUpload?: Promise<void>;
|
||||
heartbeatTimer?: number;
|
||||
durationTimer?: number;
|
||||
startedAt?: number;
|
||||
stopping: boolean;
|
||||
intentionalProviderClose: boolean;
|
||||
microphoneStream?: MediaStream;
|
||||
captureContext?: AudioContext;
|
||||
captureNode?: AudioWorkletNode;
|
||||
playbackContext?: AudioContext;
|
||||
playbackNode?: AudioWorkletNode;
|
||||
playbackReady?: Promise<AudioWorkletNode>;
|
||||
playbackPendingByte?: number;
|
||||
playbackChunks: number;
|
||||
playbackSamples: number;
|
||||
playbackUnderflows: number;
|
||||
responseTranscripts: Map<string, string>;
|
||||
speechStartedAt?: number;
|
||||
checks: Map<string, ReportCheck>;
|
||||
events: ReportEvent[];
|
||||
turns: DeviceFinalTurn[];
|
||||
failure?: string;
|
||||
failureTask?: Promise<void>;
|
||||
} = {
|
||||
phase: "idle",
|
||||
eventSequence: 0,
|
||||
transcriptSequence: 0,
|
||||
pendingTurns: [],
|
||||
stopping: false,
|
||||
intentionalProviderClose: false,
|
||||
playbackChunks: 0,
|
||||
playbackSamples: 0,
|
||||
playbackUnderflows: 0,
|
||||
responseTranscripts: new Map(),
|
||||
checks: new Map(checkDefinitions.map(([id, label]) => [id, { id, label, status: "pending" }])),
|
||||
events: [],
|
||||
turns: [],
|
||||
};
|
||||
|
||||
const app = document.querySelector<HTMLElement>("#app")!;
|
||||
app.innerHTML = `
|
||||
<section class="shell hardware-shell">
|
||||
<header class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">WINDOWS · VOLC HARDWARE AGENT · LOW LOAD</p>
|
||||
<h1>硬件对话智能体测试程序</h1>
|
||||
<p class="subtitle">真实连接平台设备 v3 控制面和火山硬件对话智能体 WebSocket,验证动态人设、PCM 双向语音、字幕、记忆工具、平台停止与结束闭环。</p>
|
||||
</div>
|
||||
<div class="hero-actions"><a class="legacy-link" href="./legacy.html">传统 RTC 测试页</a><div class="overall" id="overallStatus"><strong>尚未检测</strong></div></div>
|
||||
</header>
|
||||
|
||||
<section class="status-grid" aria-label="链路状态">
|
||||
<article class="status-card"><span>平台 v3</span><strong id="platformStatus">未检测</strong><small id="platformDetail">等待设备鉴权</small></article>
|
||||
<article class="status-card"><span>火山设备凭据</span><strong id="credentialStatus">未配置</strong><small id="credentialDetail">支持动态注册</small></article>
|
||||
<article class="status-card"><span>动态会话配置</span><strong id="configStatus">未应用</strong><small id="configDetail">麦克风保持关闭</small></article>
|
||||
<article class="status-card"><span>通话时长</span><strong id="duration">00:00</strong><small id="providerStatus">网关未连接</small></article>
|
||||
</section>
|
||||
|
||||
<section class="content-grid hardware-content-grid">
|
||||
<article class="panel setup-panel">
|
||||
<div class="panel-heading"><div><p class="eyebrow">SECURE SETUP</p><h2>平台与火山设备</h2></div><span class="safe">密钥不写入报告</span></div>
|
||||
<h3>平台设备身份</h3>
|
||||
<label>平台地址<input id="apiURL" value="https://121.41.46.238" autocomplete="off" /></label>
|
||||
<label>平台设备 ID<input id="deviceID" autocomplete="off" /></label>
|
||||
<label>平台设备签名密钥<input id="deviceSecret" type="password" autocomplete="off" /></label>
|
||||
<h3>火山硬件身份</h3>
|
||||
<div class="row"><label>实例 ID(平台下发)<input id="instanceID" readonly /></label><label>Product Key(平台下发)<input id="productKey" readonly /></label></div>
|
||||
<div class="row"><label>设备名称(平台分配)<input id="volcDeviceName" readonly /></label><label>Hardware ID<input id="hardwareID" autocomplete="off" /></label></div>
|
||||
<label>智能体模型类型<select id="modelMode"><option value="llm">通用 LLM(LLMConfig.SystemMessages)</option><option value="s2s">豆包端到端语音(S2S dialog)</option></select></label>
|
||||
<label>麦克风<select id="microphone"><option value="">默认麦克风</option></select></label>
|
||||
<button class="secondary" id="checkButton">检测、鉴权并准备设备</button>
|
||||
<p class="hint">Product Secret 只保存在平台密钥服务中。平台代设备完成动态注册,设备仅接收自己的一机一密并使用 Windows Safe Storage 加密保存;默认 Bot 由平台产品配置自动分配,设备不能覆盖。</p>
|
||||
</article>
|
||||
|
||||
<article class="panel call-panel hardware-call-panel">
|
||||
<div class="panel-heading"><div><p class="eyebrow">REAL END-TO-END FLOW</p><h2>硬件智能体通话</h2></div><span class="phase-badge" id="phaseBadge">待机</span></div>
|
||||
<div class="conversation-orb" id="conversationOrb"><div class="orb-core"><span id="orbLabel">准备检测</span></div></div>
|
||||
<div class="meters">
|
||||
<div><div class="meter-label"><span>16k PCM 上行</span><strong id="micValue">0</strong></div><div class="meter"><i id="micMeter"></i></div></div>
|
||||
<div><div class="meter-label"><span>16k PCM 下行</span><strong id="agentValue">0</strong></div><div class="meter agent"><i id="agentMeter"></i></div></div>
|
||||
</div>
|
||||
<div class="call-actions"><button class="primary" id="startButton" disabled>开始硬件智能体通话</button><button class="danger" id="stopButton" disabled>结束通话</button></div>
|
||||
<div class="text-question"><input id="textQuestion" placeholder="可选:输入文字验证智能体回复" /><button class="secondary" id="sendTextButton" disabled>发送文本问题</button></div>
|
||||
<dl class="session-info hardware-session-info">
|
||||
<div><dt>平台通话编号</dt><dd id="conversationID">—</dd></div>
|
||||
<div><dt>产品默认 Bot</dt><dd id="botID">—</dd></div>
|
||||
<div><dt>传输方案</dt><dd id="transport">low_load</dd></div>
|
||||
<div><dt>火山会话编号</dt><dd id="providerConnectionRef">—</dd></div>
|
||||
<div><dt>配置摘要</dt><dd id="configDigest">—</dd></div>
|
||||
<div><dt>冻结提示版本</dt><dd id="promptVersion">—</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="panel flow-panel">
|
||||
<div class="panel-heading"><div><p class="eyebrow">ACCEPTANCE GATES</p><h2>完整流程验收</h2></div><button class="text-button" id="exportReport">导出测试报告</button></div>
|
||||
<ol id="flowChecks" class="flow-checks"></ol>
|
||||
</section>
|
||||
|
||||
<section class="panel transcript-panel">
|
||||
<div class="panel-heading"><div><p class="eyebrow">FINAL TRANSCRIPTS</p><h2>双方最终字幕与平台上传</h2></div><span class="phase-badge" id="transcriptStatus">0 条</span></div>
|
||||
<ol id="liveTranscript"><li class="transcript-empty">通话开始后显示火山返回的用户和智能体最终字幕。</li></ol>
|
||||
</section>
|
||||
|
||||
<section class="panel log-panel">
|
||||
<div class="panel-heading"><div><p class="eyebrow">AUDITABLE EVENT STREAM</p><h2>平台与火山事件</h2></div><button class="text-button" id="clearLog">清空显示</button></div>
|
||||
<ol id="eventLog"><li><time>系统</time><span>测试程序已启动,等待环境检测。</span></li></ol>
|
||||
</section>
|
||||
</section>
|
||||
`;
|
||||
|
||||
const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
|
||||
const controls = {
|
||||
apiURL: $<HTMLInputElement>("apiURL"), deviceID: $<HTMLInputElement>("deviceID"), deviceSecret: $<HTMLInputElement>("deviceSecret"),
|
||||
instanceID: $<HTMLInputElement>("instanceID"), productKey: $<HTMLInputElement>("productKey"),
|
||||
volcDeviceName: $<HTMLInputElement>("volcDeviceName"), hardwareID: $<HTMLInputElement>("hardwareID"),
|
||||
modelMode: $<HTMLSelectElement>("modelMode"),
|
||||
microphone: $<HTMLSelectElement>("microphone"), check: $<HTMLButtonElement>("checkButton"), start: $<HTMLButtonElement>("startButton"),
|
||||
stop: $<HTMLButtonElement>("stopButton"), textQuestion: $<HTMLInputElement>("textQuestion"), sendText: $<HTMLButtonElement>("sendTextButton"),
|
||||
};
|
||||
|
||||
function setText(id: string, value: string) { $(id).textContent = value; }
|
||||
function errorMessage(error: unknown) { return error instanceof Error ? error.message : String(error); }
|
||||
|
||||
function recordEvent(source: ReportEvent["source"], type: string, detail?: Record<string, unknown>) {
|
||||
state.events.push({ at: new Date().toISOString(), source, type, detail });
|
||||
if (state.events.length > 1000) state.events.splice(0, state.events.length - 1000);
|
||||
}
|
||||
|
||||
function log(message: string, level: "info" | "success" | "error" = "info", source: ReportEvent["source"] = "app", type = "log") {
|
||||
const item = document.createElement("li");
|
||||
item.className = level;
|
||||
const time = document.createElement("time");
|
||||
time.textContent = new Date().toLocaleTimeString("zh-CN", { hour12: false });
|
||||
const content = document.createElement("span");
|
||||
content.textContent = message;
|
||||
item.append(time, content);
|
||||
$("eventLog").prepend(item);
|
||||
recordEvent(source, type, { level, message });
|
||||
}
|
||||
|
||||
function setPhase(phase: HardwarePhase, detail: string) {
|
||||
state.phase = phase;
|
||||
const labels: Record<HardwarePhase, string> = { idle: "待机", checking: "检测中", ready: "已就绪", connecting: "连接中", active: "通话中", stopping: "结束中", completed: "已完成", error: "异常" };
|
||||
setText("phaseBadge", labels[phase]);
|
||||
setText("orbLabel", detail);
|
||||
setText("overallStatus", detail);
|
||||
$("overallStatus").className = `overall phase-${phase}`;
|
||||
$("conversationOrb").className = `conversation-orb phase-${phase}`;
|
||||
controls.check.disabled = ["connecting", "active", "stopping"].includes(phase);
|
||||
controls.start.disabled = phase !== "ready" && phase !== "completed";
|
||||
controls.stop.disabled = !["active", "error"].includes(phase) || !state.platformSession || !!state.failureTask;
|
||||
controls.sendText.disabled = phase !== "active";
|
||||
}
|
||||
|
||||
function setCheck(id: string, status: ReportCheck["status"], detail?: string) {
|
||||
const check = state.checks.get(id);
|
||||
if (!check) return;
|
||||
check.status = status;
|
||||
check.detail = detail;
|
||||
check.at = new Date().toISOString();
|
||||
renderChecks();
|
||||
}
|
||||
|
||||
function resetChecks() {
|
||||
state.checks = new Map(checkDefinitions.map(([id, label]) => [id, { id, label, status: "pending" }]));
|
||||
renderChecks();
|
||||
}
|
||||
|
||||
function renderChecks() {
|
||||
const list = $("flowChecks");
|
||||
list.replaceChildren(...[...state.checks.values()].map((check) => {
|
||||
const item = document.createElement("li");
|
||||
item.className = `flow-check flow-check--${check.status}`;
|
||||
const mark = document.createElement("i");
|
||||
mark.textContent = check.status === "passed" ? "✓" : check.status === "failed" ? "!" : "·";
|
||||
const content = document.createElement("span");
|
||||
content.textContent = check.label;
|
||||
const detail = document.createElement("small");
|
||||
detail.textContent = check.detail || (check.status === "pending" ? "等待验证" : check.status === "passed" ? "已通过" : "未通过");
|
||||
item.append(mark, content, detail);
|
||||
return item;
|
||||
}));
|
||||
}
|
||||
|
||||
function updateMeter(kind: "mic" | "agent", value: number) {
|
||||
const normalized = Math.max(0, Math.min(100, Math.round(value)));
|
||||
$(`${kind}Meter`).style.width = `${normalized}%`;
|
||||
setText(`${kind}Value`, String(normalized));
|
||||
}
|
||||
|
||||
async function loadDefaults() {
|
||||
const [platform, hardware] = await Promise.all([
|
||||
window.deviceSimulator.getRuntimeDefaults(),
|
||||
window.deviceSimulator.getHardwareRuntimeDefaults(),
|
||||
]);
|
||||
controls.apiURL.value = platform.apiURL;
|
||||
controls.deviceID.value = platform.deviceID;
|
||||
if (platform.deviceSecretConfigured) controls.deviceSecret.placeholder = "已从 Windows 安全凭据文件加载";
|
||||
controls.instanceID.value = hardware.instanceID;
|
||||
controls.productKey.value = hardware.productKey;
|
||||
controls.volcDeviceName.value = hardware.deviceName;
|
||||
controls.hardwareID.value = hardware.hardwareID;
|
||||
controls.modelMode.value = hardware.modelMode;
|
||||
if (hardware.deviceCredentialConfigured) setText("credentialDetail", "本机已有平台签发的一机一密安全缓存");
|
||||
}
|
||||
|
||||
async function enumerateMicrophones() {
|
||||
const permission = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
permission.getTracks().forEach((track) => track.stop());
|
||||
const devices = (await navigator.mediaDevices.enumerateDevices()).filter((device) => device.kind === "audioinput");
|
||||
controls.microphone.replaceChildren(new Option("默认麦克风", ""));
|
||||
devices.forEach((device, index) => controls.microphone.add(new Option(device.label || `麦克风 ${index + 1}`, device.deviceId)));
|
||||
}
|
||||
|
||||
async function configureAndProbe() {
|
||||
if (state.failureTask) await state.failureTask;
|
||||
setPhase("checking", "正在鉴权和检测");
|
||||
resetChecks();
|
||||
state.events = [];
|
||||
state.turns = [];
|
||||
state.failure = undefined;
|
||||
try {
|
||||
await window.deviceSimulator.configure({ apiURL: controls.apiURL.value, deviceID: controls.deviceID.value, deviceSecret: controls.deviceSecret.value });
|
||||
await window.deviceSimulator.probe();
|
||||
setCheck("platform_authenticated", "passed", "设备 bootstrap/config HMAC 请求成功");
|
||||
setText("platformStatus", "鉴权成功");
|
||||
setText("platformDetail", controls.apiURL.value);
|
||||
const hardware = await window.deviceSimulator.configureHardware({ hardwareID: controls.hardwareID.value });
|
||||
controls.instanceID.value = hardware.instanceID;
|
||||
controls.productKey.value = hardware.productKey;
|
||||
controls.volcDeviceName.value = hardware.deviceName;
|
||||
controls.hardwareID.value = hardware.hardwareID;
|
||||
setCheck("credential_resolved", "passed", `${hardware.credentialSource}${hardware.persisted ? ",Device Secret 已写入 Windows 安全存储" : ",仅本次内存"}`);
|
||||
setText("credentialStatus", hardware.registered ? "平台注册成功" : "平台凭据可用");
|
||||
setText("credentialDetail", `${hardware.deviceName} · ${hardware.credentialSource} · 火山 License 额度已在动态注册时由服务端消费`);
|
||||
const network = await window.deviceSimulator.probeHardwareNetwork();
|
||||
const failures = network.endpoints.filter((endpoint) => !endpoint.ok);
|
||||
if (failures.length) throw new Error(`火山网络不可达:${failures.map((item) => `${item.hostname} ${item.error || "失败"}`).join(";")}`);
|
||||
await enumerateMicrophones();
|
||||
setPhase("ready", "可以开始真实通话");
|
||||
log("平台 v3、平台代理注册、火山网关和麦克风检测通过。", "success");
|
||||
} catch (error) {
|
||||
state.failure = errorMessage(error);
|
||||
setPhase("error", "环境检测失败");
|
||||
setText("platformStatus", "检测失败");
|
||||
setText("platformDetail", state.failure);
|
||||
log(`环境检测失败:${state.failure}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function renderPlatformSession(created: CreatedHardwareAgentSession) {
|
||||
setText("conversationID", created.conversation_id);
|
||||
setText("botID", created.runtime.bot_id);
|
||||
setText("transport", created.runtime.transport);
|
||||
setText("configDigest", created.runtime.config_digest);
|
||||
setText("promptVersion", created.session_config.prompt_bundle_version);
|
||||
setText("providerConnectionRef", "等待火山连接");
|
||||
}
|
||||
|
||||
async function startHardwareConversation() {
|
||||
if (!["ready", "completed"].includes(state.phase)) return;
|
||||
if (state.failureTask) await state.failureTask;
|
||||
setPhase("connecting", "正在冻结本次上下文");
|
||||
resetSessionState();
|
||||
try {
|
||||
const created = await window.deviceSimulator.createHardwareAgentSession();
|
||||
state.platformSession = created;
|
||||
renderPlatformSession(created);
|
||||
if (created.runtime.transport !== "low_load") throw new Error(`设备绑定传输模式为 ${created.runtime.transport},Windows 标准协议测试要求 low_load`);
|
||||
if (!created.session_config.authorized_context || !created.runtime.config_digest || !created.runtime.session_nonce) throw new Error("平台未返回完整冻结上下文或会话凭证");
|
||||
setCheck("context_frozen", "passed", `${created.session_config.prompt_bundle_version} · ${created.runtime.config_digest.slice(0, 12)}…`);
|
||||
recordEvent("platform", "hardware.session.ready", { conversation_id: created.conversation_id, bot_id: created.runtime.bot_id, transport: created.runtime.transport });
|
||||
setText("configStatus", "等待 session.updated");
|
||||
setText("configDetail", "麦克风仍保持关闭");
|
||||
const connected = await window.deviceSimulator.connectHardwareAgent({ platformSession: created, modelMode: controls.modelMode.value as "llm" | "s2s" });
|
||||
if (state.failureTask) { await state.failureTask; return; }
|
||||
state.providerConnectionRef = connected.providerConnectionRef;
|
||||
setText("providerConnectionRef", connected.providerConnectionRef);
|
||||
setText("providerStatus", "WebSocket 已连接");
|
||||
setCheck("provider_connected", "passed", connected.providerConnectionRef);
|
||||
setCheck("dynamic_config_applied", "passed", "火山已返回 session.updated");
|
||||
setText("configStatus", "已应用");
|
||||
setText("configDetail", "允许激活麦克风");
|
||||
state.eventSequence = 1;
|
||||
const activated = await window.deviceSimulator.hardwareAgentEvent({
|
||||
conversationID: created.conversation_id, sequenceNo: state.eventSequence, eventType: "session_started",
|
||||
providerSessionRef: created.runtime.provider_session_ref, sessionNonce: created.runtime.session_nonce,
|
||||
providerConnectionRef: connected.providerConnectionRef, botID: created.runtime.bot_id, transport: created.runtime.transport,
|
||||
configDigest: created.runtime.config_digest, dynamicConfigApplied: true,
|
||||
});
|
||||
if (activated.status !== "ACTIVE" || activated.action !== "continue") throw new Error(`平台未激活硬件会话:${activated.status}/${activated.action}`);
|
||||
if (state.failureTask) { await state.failureTask; return; }
|
||||
setCheck("platform_active", "passed", "session_started 已进入 ACTIVE");
|
||||
await ensurePlaybackNode();
|
||||
await startMicrophone();
|
||||
if (state.failureTask) { await state.failureTask; await stopAudio(); return; }
|
||||
setCheck("microphone_streaming", "passed", "PCM16/16kHz/单声道/20ms 分片");
|
||||
state.startedAt = Date.now();
|
||||
setPhase("active", "请开始说话");
|
||||
startTimers(created.session.heartbeat_interval_sec || 15);
|
||||
log("硬件对话智能体已激活:动态配置确认后才开放麦克风。", "success", "platform", "hardware.session.active");
|
||||
} catch (error) {
|
||||
state.failure ||= errorMessage(error);
|
||||
log(`启动硬件智能体失败:${state.failure}`, "error");
|
||||
await failHardwareConversation("WINDOWS_HARDWARE_START_FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
function resetSessionState() {
|
||||
stopTimers();
|
||||
state.platformSession = undefined;
|
||||
state.providerConnectionRef = undefined;
|
||||
state.eventSequence = 0;
|
||||
state.transcriptSequence = 0;
|
||||
state.pendingTurns = [];
|
||||
state.transcriptUpload = undefined;
|
||||
state.startedAt = undefined;
|
||||
state.stopping = false;
|
||||
state.intentionalProviderClose = false;
|
||||
state.failureTask = undefined;
|
||||
state.failure = undefined;
|
||||
state.responseTranscripts.clear();
|
||||
state.speechStartedAt = undefined;
|
||||
state.turns = [];
|
||||
state.playbackPendingByte = undefined;
|
||||
state.playbackChunks = 0;
|
||||
state.playbackSamples = 0;
|
||||
state.playbackUnderflows = 0;
|
||||
// Device preparation remains valid, but every call must earn its own checks.
|
||||
for (const [id, label] of checkDefinitions) {
|
||||
if (!["platform_authenticated", "credential_resolved"].includes(id)) {
|
||||
state.checks.set(id, { id, label, status: "pending" });
|
||||
}
|
||||
}
|
||||
renderChecks();
|
||||
setText("duration", "00:00");
|
||||
setText("providerStatus", "网关未连接");
|
||||
setText("providerConnectionRef", "—");
|
||||
setText("conversationID", "—");
|
||||
setText("configStatus", "未应用");
|
||||
setText("configDetail", "麦克风保持关闭");
|
||||
$("liveTranscript").innerHTML = '<li class="transcript-empty">通话开始后显示火山返回的用户和智能体最终字幕。</li>';
|
||||
setText("transcriptStatus", "0 条");
|
||||
updateMeter("mic", 0);
|
||||
updateMeter("agent", 0);
|
||||
}
|
||||
|
||||
async function startMicrophone() {
|
||||
const constraints: MediaTrackConstraints = { channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true };
|
||||
if (controls.microphone.value) constraints.deviceId = { exact: controls.microphone.value };
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: constraints });
|
||||
const context = new AudioContext({ latencyHint: "interactive" });
|
||||
await context.audioWorklet.addModule(new URL("./pcm-capture.worklet.js", window.location.href).toString());
|
||||
const source = context.createMediaStreamSource(stream);
|
||||
const node = new AudioWorkletNode(context, "pcm16-capture", { processorOptions: { targetSampleRate: 16000, frameSamples: 320 } });
|
||||
const silent = context.createGain();
|
||||
silent.gain.value = 0;
|
||||
node.port.onmessage = (message) => {
|
||||
if (state.phase !== "active" && state.phase !== "connecting") return;
|
||||
if (message.data?.type !== "pcm" || !(message.data.bytes instanceof ArrayBuffer)) return;
|
||||
updateMeter("mic", Number(message.data.volume || 0));
|
||||
window.deviceSimulator.sendHardwareAgentAudio(message.data.bytes);
|
||||
};
|
||||
source.connect(node).connect(silent).connect(context.destination);
|
||||
await context.resume();
|
||||
state.microphoneStream = stream;
|
||||
state.captureContext = context;
|
||||
state.captureNode = node;
|
||||
}
|
||||
|
||||
async function stopAudio() {
|
||||
state.microphoneStream?.getTracks().forEach((track) => track.stop());
|
||||
state.captureNode?.disconnect();
|
||||
state.playbackNode?.port.postMessage({ type: "reset" });
|
||||
state.playbackNode?.disconnect();
|
||||
if (state.captureContext) try { await state.captureContext.close(); } catch { /* no-op */ }
|
||||
if (state.playbackContext) try { await state.playbackContext.close(); } catch { /* no-op */ }
|
||||
state.microphoneStream = undefined;
|
||||
state.captureNode = undefined;
|
||||
state.captureContext = undefined;
|
||||
state.playbackNode = undefined;
|
||||
state.playbackReady = undefined;
|
||||
state.playbackContext = undefined;
|
||||
state.playbackPendingByte = undefined;
|
||||
updateMeter("mic", 0);
|
||||
updateMeter("agent", 0);
|
||||
}
|
||||
|
||||
function ensurePlaybackNode(): Promise<AudioWorkletNode> {
|
||||
if (state.playbackNode) return Promise.resolve(state.playbackNode);
|
||||
if (state.playbackReady) return state.playbackReady;
|
||||
state.playbackReady = (async () => {
|
||||
const context = new AudioContext({ latencyHint: "interactive" });
|
||||
state.playbackContext = context;
|
||||
await context.audioWorklet.addModule(new URL("./pcm-playback.worklet.js", window.location.href).toString());
|
||||
const node = new AudioWorkletNode(context, "pcm16-playback", {
|
||||
processorOptions: { sourceSampleRate: 16000, prebufferMs: 120 },
|
||||
outputChannelCount: [1],
|
||||
});
|
||||
node.port.onmessage = (message) => {
|
||||
if (message.data?.type === "underflow") {
|
||||
state.playbackUnderflows = Number(message.data.count || state.playbackUnderflows + 1);
|
||||
recordEvent("app", "audio.playback.underflow", { count: state.playbackUnderflows });
|
||||
}
|
||||
if (message.data?.type === "drained") {
|
||||
updateMeter("agent", 0);
|
||||
setCheck("audio_playback_buffered", "passed", `${state.playbackChunks} 个分片 · ${state.playbackSamples} 个采样 · 欠载 ${state.playbackUnderflows} 次`);
|
||||
}
|
||||
};
|
||||
node.connect(context.destination);
|
||||
await context.resume();
|
||||
state.playbackNode = node;
|
||||
return node;
|
||||
})().catch(async (error) => {
|
||||
const context = state.playbackContext;
|
||||
state.playbackReady = undefined;
|
||||
state.playbackContext = undefined;
|
||||
if (context) try { await context.close(); } catch { /* no-op */ }
|
||||
throw error;
|
||||
});
|
||||
return state.playbackReady;
|
||||
}
|
||||
|
||||
async function playProviderAudio(base64: string) {
|
||||
if (!base64) return;
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
||||
const totalBytes = bytes.byteLength + (state.playbackPendingByte === undefined ? 0 : 1);
|
||||
const sampleCount = Math.floor(totalBytes / 2);
|
||||
if (sampleCount === 0) {
|
||||
if (bytes.byteLength === 1) state.playbackPendingByte = bytes[0];
|
||||
return;
|
||||
}
|
||||
const samples = new Float32Array(sampleCount);
|
||||
let energy = 0;
|
||||
let byteIndex = 0;
|
||||
let sampleIndex = 0;
|
||||
if (state.playbackPendingByte !== undefined) {
|
||||
const value = new DataView(new Uint8Array([state.playbackPendingByte, bytes[0]]).buffer).getInt16(0, true) / 32768;
|
||||
samples[sampleIndex] = value;
|
||||
energy += value * value;
|
||||
sampleIndex += 1;
|
||||
byteIndex = 1;
|
||||
state.playbackPendingByte = undefined;
|
||||
}
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
while (sampleIndex < sampleCount) {
|
||||
const value = view.getInt16(byteIndex, true) / 32768;
|
||||
samples[sampleIndex] = value;
|
||||
energy += value * value;
|
||||
sampleIndex += 1;
|
||||
byteIndex += 2;
|
||||
}
|
||||
if (byteIndex < bytes.byteLength) state.playbackPendingByte = bytes[byteIndex];
|
||||
updateMeter("agent", Math.sqrt(energy / Math.max(1, sampleCount)) * 280);
|
||||
state.playbackChunks += 1;
|
||||
state.playbackSamples += sampleCount;
|
||||
const node = await ensurePlaybackNode();
|
||||
node.port.postMessage({ type: "push", samples: samples.buffer }, [samples.buffer]);
|
||||
setCheck("audio_playback_buffered", "passed", `已接收并缓冲 ${state.playbackChunks} 个火山音频分片`);
|
||||
}
|
||||
|
||||
function sanitizeProviderEvent(event: Record<string, unknown>) {
|
||||
const redact = (value: unknown, key = ""): unknown => {
|
||||
if (/systemmessages|system_role|system_prompt|instructions|authorized_context|speaking_style/i.test(key)) return "[REDACTED]";
|
||||
if (Array.isArray(value)) return value.map((item) => redact(item));
|
||||
if (!value || typeof value !== "object") return value;
|
||||
return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([itemKey, item]) => [itemKey, redact(item, itemKey)]));
|
||||
};
|
||||
const copy = redact(event) as Record<string, unknown>;
|
||||
if (typeof copy.delta === "string" && event.type === "response.audio.delta") copy.delta = `<pcm-base64:${copy.delta.length} chars>`;
|
||||
return copy;
|
||||
}
|
||||
|
||||
async function handleProviderEvent(event: Record<string, unknown>) {
|
||||
const type = String(event.type || "unknown");
|
||||
recordEvent("volcengine", type, sanitizeProviderEvent(event));
|
||||
if (type !== "response.audio.delta") log(`火山事件:${type}`, type === "error" ? "error" : "info", "volcengine", type);
|
||||
// Teardown can still deliver queued provider events. Do not revive a failed call.
|
||||
if (state.failureTask && type !== "transport.close") return;
|
||||
switch (type) {
|
||||
case "session.created": setText("providerStatus", "会话已创建"); break;
|
||||
case "session.updated": setText("configStatus", "已确认"); break;
|
||||
case "input_audio_buffer.speech_started": state.speechStartedAt = Date.now(); setPhase("active", "检测到用户说话"); break;
|
||||
case "input_audio_buffer.speech_stopped": setPhase("active", "智能体正在思考"); break;
|
||||
case "response.created": setPhase("active", "智能体正在生成"); break;
|
||||
case "response.audio.delta": await playProviderAudio(String(event.delta || "")); break;
|
||||
case "response.audio.done": state.playbackNode?.port.postMessage({ type: "end" }); break;
|
||||
case "conversation.item.input_audio_transcription.completed":
|
||||
await appendFinalTurn("subject", String(event.transcript || ""), String(event.event_id || crypto.randomUUID()), state.speechStartedAt); state.speechStartedAt = undefined; break;
|
||||
case "response.audio_transcript.delta": {
|
||||
const responseID = String(event.response_id || "unknown");
|
||||
state.responseTranscripts.set(responseID, `${state.responseTranscripts.get(responseID) || ""}${String(event.delta || "")}`);
|
||||
break;
|
||||
}
|
||||
case "response.audio_transcript.done": {
|
||||
const responseID = String(event.response_id || "unknown");
|
||||
const transcript = String(event.transcript || state.responseTranscripts.get(responseID) || "");
|
||||
state.responseTranscripts.delete(responseID);
|
||||
await appendFinalTurn("agent", transcript, responseID); break;
|
||||
}
|
||||
case "response.done": setPhase("active", "继续聆听"); break;
|
||||
case "platform.memory_search.completed": log(`平台记忆工具返回 ${Number(event.result_count || 0)} 条已授权记忆。`, "success", "platform", type); break;
|
||||
case "platform.memory_search.failed": log(`平台记忆工具降级:${String(event.message || "未知错误")}`, "error", "platform", type); break;
|
||||
case "error": {
|
||||
const providerError = event.error as Record<string, unknown> | undefined;
|
||||
state.failure ||= `${String(providerError?.code || providerError?.type || "PROVIDER_ERROR")}: ${String(providerError?.message || "火山智能体错误")}`;
|
||||
log(`火山错误:${state.failure}`, "error", "volcengine", "provider.error");
|
||||
await failHardwareConversation("VOLC_HARDWARE_AGENT_ERROR");
|
||||
break;
|
||||
}
|
||||
case "transport.close":
|
||||
setText("providerStatus", "连接已关闭");
|
||||
if (state.failureTask) setCheck("provider_closed", "failed", `失败清理关闭:WebSocket ${Number(event.code || 1000)}`);
|
||||
else if (state.intentionalProviderClose) setCheck("provider_closed", "passed", `WebSocket ${Number(event.code || 1000)}`);
|
||||
else if (["connecting", "active"].includes(state.phase)) { state.failure = `火山连接意外关闭:${String(event.code || "")}`; await failHardwareConversation("VOLC_CONNECTION_CLOSED"); }
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function appendFinalTurn(speaker: "subject" | "agent", rawText: string, sourceID: string, startedAt?: number) {
|
||||
const text = rawText.trim();
|
||||
if (!text || !state.platformSession) return;
|
||||
state.transcriptSequence += 1;
|
||||
const now = Date.now();
|
||||
const turn: DeviceFinalTurn = {
|
||||
turn_key: `hardware-${speaker}-${sourceID}`.slice(0, 240), sequence_no: state.transcriptSequence, speaker,
|
||||
start_at: new Date(startedAt || now).toISOString(), end_at: new Date(now).toISOString(), final_text: text, interrupted: false,
|
||||
};
|
||||
state.pendingTurns.push(turn);
|
||||
state.turns.push(turn);
|
||||
renderTurn(turn);
|
||||
setCheck(speaker === "subject" ? "user_transcript" : "agent_transcript", "passed", text.slice(0, 50));
|
||||
await flushTurns();
|
||||
}
|
||||
|
||||
function renderTurn(turn: DeviceFinalTurn) {
|
||||
const list = $("liveTranscript");
|
||||
list.querySelector(".transcript-empty")?.remove();
|
||||
const item = document.createElement("li");
|
||||
item.className = `transcript-turn transcript-turn--${turn.speaker}`;
|
||||
const speaker = document.createElement("strong"); speaker.textContent = turn.speaker === "subject" ? "用户" : "智能体";
|
||||
const text = document.createElement("span"); text.textContent = turn.final_text;
|
||||
item.append(speaker, text); list.append(item);
|
||||
setText("transcriptStatus", `${state.turns.length} 条`);
|
||||
}
|
||||
|
||||
async function flushTurns() {
|
||||
if (state.transcriptUpload) return state.transcriptUpload;
|
||||
if (!state.platformSession || state.pendingTurns.length === 0) return;
|
||||
const batch = state.pendingTurns.slice(0, 500);
|
||||
state.transcriptUpload = (async () => {
|
||||
try {
|
||||
const result = await window.deviceSimulator.submitTranscriptBatch({ conversationID: state.platformSession!.conversation_id, turns: batch, hardwareAgent: true });
|
||||
state.pendingTurns.splice(0, batch.length);
|
||||
setCheck("transcript_uploaded", "passed", `新增 ${result.accepted_count},重复 ${result.duplicate_count}`);
|
||||
log(`最终字幕已上传平台:新增 ${result.accepted_count},重复 ${result.duplicate_count}。`, "success", "platform", "hardware.turns.uploaded");
|
||||
} catch (error) { log(`字幕上传失败,保留队列待重试:${errorMessage(error)}`, "error"); }
|
||||
finally { state.transcriptUpload = undefined; }
|
||||
})();
|
||||
await state.transcriptUpload;
|
||||
}
|
||||
|
||||
async function flushTurnsWithRetry(attempts: number) {
|
||||
for (let attempt = 1; attempt <= attempts && state.pendingTurns.length > 0; attempt += 1) {
|
||||
await flushTurns();
|
||||
if (state.pendingTurns.length && attempt < attempts) await new Promise((resolve) => window.setTimeout(resolve, attempt * 500));
|
||||
}
|
||||
if (state.pendingTurns.length) throw new Error(`仍有 ${state.pendingTurns.length} 条最终字幕未上传`);
|
||||
}
|
||||
|
||||
function startTimers(intervalSeconds: number) {
|
||||
stopTimers();
|
||||
state.durationTimer = window.setInterval(() => {
|
||||
if (!state.startedAt) return;
|
||||
const seconds = Math.floor((Date.now() - state.startedAt) / 1000);
|
||||
setText("duration", `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`);
|
||||
}, 1000);
|
||||
state.heartbeatTimer = window.setInterval(() => void sendHardwareHeartbeat(), Math.max(5, intervalSeconds) * 1000);
|
||||
void sendHardwareHeartbeat();
|
||||
}
|
||||
|
||||
function stopTimers() {
|
||||
if (state.durationTimer) window.clearInterval(state.durationTimer);
|
||||
if (state.heartbeatTimer) window.clearInterval(state.heartbeatTimer);
|
||||
state.durationTimer = undefined;
|
||||
state.heartbeatTimer = undefined;
|
||||
}
|
||||
|
||||
async function sendHardwareHeartbeat() {
|
||||
const created = state.platformSession;
|
||||
if (!created || state.phase !== "active" || state.stopping) return;
|
||||
try {
|
||||
state.eventSequence += 1;
|
||||
const result = await window.deviceSimulator.hardwareAgentEvent({
|
||||
conversationID: created.conversation_id, sequenceNo: state.eventSequence, eventType: "session_updated",
|
||||
providerSessionRef: created.runtime.provider_session_ref, sessionNonce: created.runtime.session_nonce,
|
||||
});
|
||||
setCheck("heartbeat_ack", "passed", `序号 ${state.eventSequence} · ${result.action}`);
|
||||
if (result.action === "stop_session") await stopHardwareConversation("platform_stop_session");
|
||||
await flushTurns();
|
||||
} catch (error) { log(`硬件会话存活上报失败:${errorMessage(error)}`, "error"); }
|
||||
}
|
||||
|
||||
async function stopHardwareConversation(reason = "windows_user_hangup") {
|
||||
if (state.failureTask) return state.failureTask;
|
||||
if (!state.platformSession || state.stopping) return;
|
||||
state.stopping = true;
|
||||
setPhase("stopping", "正在可靠结束");
|
||||
stopTimers();
|
||||
const created = state.platformSession;
|
||||
try {
|
||||
await stopAudio();
|
||||
await flushTurnsWithRetry(3);
|
||||
state.intentionalProviderClose = true;
|
||||
await window.deviceSimulator.closeHardwareAgent({ code: 1000, reason });
|
||||
setCheck("provider_closed", "passed", "设备主动关闭 WebSocket");
|
||||
state.eventSequence += 1;
|
||||
const ended = await window.deviceSimulator.hardwareAgentEvent({
|
||||
conversationID: created.conversation_id, sequenceNo: state.eventSequence, eventType: "session_ended",
|
||||
providerSessionRef: created.runtime.provider_session_ref, sessionNonce: created.runtime.session_nonce,
|
||||
});
|
||||
if (ended.status !== "COMPLETED") throw new Error(`平台结束状态不是 COMPLETED:${ended.status}`);
|
||||
setCheck("platform_completed", "passed", "session_ended 已进入 COMPLETED");
|
||||
state.stopping = false;
|
||||
setPhase("completed", allRequiredChecksPassed() ? "完整流程通过" : "通话完成,部分验收项待覆盖");
|
||||
log("硬件对话智能体会话已正常结束,平台状态为 COMPLETED。", "success", "platform", "hardware.session.completed");
|
||||
} catch (error) {
|
||||
state.failure = errorMessage(error);
|
||||
state.stopping = false;
|
||||
setPhase("error", "结束流程失败");
|
||||
log(`结束硬件会话失败:${state.failure}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function failHardwareConversation(errorCode: string): Promise<void> {
|
||||
// The provider error event and connect() rejection describe the same failure.
|
||||
// Install a shared task before any asynchronous cleanup, and retain it until
|
||||
// the next call so later errors/hangup cannot submit another terminal event.
|
||||
if (state.failureTask) return state.failureTask;
|
||||
const created = state.platformSession;
|
||||
state.failureTask = Promise.resolve().then(async () => {
|
||||
try {
|
||||
await stopAudio();
|
||||
try { await window.deviceSimulator.closeHardwareAgent({ code: 1011, reason: errorCode }); } catch { /* no-op */ }
|
||||
if (created) {
|
||||
try {
|
||||
state.eventSequence += 1;
|
||||
await window.deviceSimulator.hardwareAgentEvent({
|
||||
conversationID: created.conversation_id, sequenceNo: state.eventSequence, eventType: "session_failed",
|
||||
providerSessionRef: created.runtime.provider_session_ref, sessionNonce: created.runtime.session_nonce,
|
||||
errorCode,
|
||||
});
|
||||
} catch (error) { log(`平台失败事件回报未成功:${errorMessage(error)}`, "error"); }
|
||||
}
|
||||
} finally {
|
||||
state.stopping = false;
|
||||
setPhase("error", state.failure || "硬件会话失败");
|
||||
}
|
||||
});
|
||||
stopTimers();
|
||||
state.stopping = true;
|
||||
state.intentionalProviderClose = true;
|
||||
setPhase("stopping", "正在清理失败会话");
|
||||
setCheck("platform_completed", "failed", state.failure || errorCode);
|
||||
if (!state.providerConnectionRef) {
|
||||
setText("configStatus", "未完成");
|
||||
setCheck("dynamic_config_applied", "failed", state.failure || errorCode);
|
||||
}
|
||||
return state.failureTask;
|
||||
}
|
||||
|
||||
function allRequiredChecksPassed() {
|
||||
const required = ["platform_authenticated", "credential_resolved", "context_frozen", "provider_connected", "dynamic_config_applied", "platform_active", "microphone_streaming", "audio_playback_buffered", "heartbeat_ack", "transcript_uploaded", "provider_closed", "platform_completed"];
|
||||
return required.every((id) => state.checks.get(id)?.status === "passed");
|
||||
}
|
||||
|
||||
async function sendTextQuestion() {
|
||||
if (state.phase !== "active" || state.failureTask) return;
|
||||
const text = controls.textQuestion.value.trim();
|
||||
if (!text) return;
|
||||
try {
|
||||
await window.deviceSimulator.sendHardwareAgentText({ text, interruptMode: 1 });
|
||||
// 文本输入没有 ASR final 事件,设备本地将已被火山接受的最终输入作为用户话轮上报,
|
||||
// 这样文本验收与真实语音验收都会进入同一套平台聊天记录。
|
||||
await appendFinalTurn("subject", text, `text-${crypto.randomUUID()}`);
|
||||
controls.textQuestion.value = "";
|
||||
log(`已通过硬件标准协议发送文本问题:${text}`, "success", "app", "hardware.text.sent");
|
||||
} catch (error) { log(`发送文本问题失败:${errorMessage(error)}`, "error"); }
|
||||
}
|
||||
|
||||
function buildReport(): Record<string, unknown> {
|
||||
return {
|
||||
schema_version: "hardware-agent-windows-e2e-report-v1",
|
||||
generated_at: new Date().toISOString(),
|
||||
result: state.phase === "completed" && allRequiredChecksPassed() ? "PASS" : state.failure ? "FAIL" : "INCOMPLETE",
|
||||
platform_api: controls.apiURL.value,
|
||||
platform_device_id: controls.deviceID.value,
|
||||
volc_identity: { instance_id: controls.instanceID.value, product_key: controls.productKey.value, device_name: controls.volcDeviceName.value, hardware_id: controls.hardwareID.value },
|
||||
conversation_id: state.platformSession?.conversation_id,
|
||||
bot_id: state.platformSession?.runtime.bot_id,
|
||||
model_mode: controls.modelMode.value,
|
||||
transport: state.platformSession?.runtime.transport,
|
||||
audio_playback: {
|
||||
source_format: "pcm_s16le_16000_mono",
|
||||
mode: "audio_worklet_ring_buffer",
|
||||
prebuffer_ms: 120,
|
||||
chunks: state.playbackChunks,
|
||||
samples: state.playbackSamples,
|
||||
underflows: state.playbackUnderflows,
|
||||
},
|
||||
provider_connection_ref: state.providerConnectionRef,
|
||||
config_digest: state.platformSession?.runtime.config_digest,
|
||||
prompt_bundle_version: state.platformSession?.session_config.prompt_bundle_version,
|
||||
failure: state.failure,
|
||||
checks: [...state.checks.values()],
|
||||
turns: state.turns,
|
||||
events: state.events,
|
||||
};
|
||||
}
|
||||
|
||||
async function exportReport() {
|
||||
try {
|
||||
const result = await window.deviceSimulator.saveHardwareReport(buildReport());
|
||||
if (result.saved) log(`测试报告已保存:${result.path}`, "success");
|
||||
} catch (error) { log(`导出测试报告失败:${errorMessage(error)}`, "error"); }
|
||||
}
|
||||
|
||||
const unsubscribeProvider = window.deviceSimulator.onHardwareProviderEvent((event) => void handleProviderEvent(event));
|
||||
controls.check.addEventListener("click", () => void configureAndProbe());
|
||||
controls.start.addEventListener("click", () => void startHardwareConversation());
|
||||
controls.stop.addEventListener("click", () => void stopHardwareConversation());
|
||||
controls.sendText.addEventListener("click", () => void sendTextQuestion());
|
||||
controls.textQuestion.addEventListener("keydown", (event) => { if (event.key === "Enter") void sendTextQuestion(); });
|
||||
$("exportReport").addEventListener("click", () => void exportReport());
|
||||
$("clearLog").addEventListener("click", () => $("eventLog").replaceChildren());
|
||||
window.addEventListener("beforeunload", () => {
|
||||
unsubscribeProvider();
|
||||
stopTimers();
|
||||
void stopAudio();
|
||||
void window.deviceSimulator.closeHardwareAgent({ code: 1001, reason: "window closed" });
|
||||
});
|
||||
|
||||
renderChecks();
|
||||
setPhase("idle", "尚未检测");
|
||||
void loadDefaults().catch((error) => log(`读取默认配置失败:${errorMessage(error)}`, "error"));
|
||||
@@ -16,10 +16,15 @@ button:disabled { cursor: not-allowed; opacity: .45; }
|
||||
.eyebrow { color: #c8652a; font-size: 12px; font-weight: 800; letter-spacing: .16em; margin: 0 0 9px; }
|
||||
h1 { margin: 0; font-family: Georgia, "Microsoft YaHei UI", serif; font-size: 44px; letter-spacing: -.035em; }
|
||||
h2 { margin: 0; font-size: 23px; }
|
||||
h3 { margin: 24px 0 14px; color: #315966; font-size: 14px; }
|
||||
.subtitle { max-width: 760px; color: #66808e; line-height: 1.8; margin: 12px 0 0; }
|
||||
.overall { display: flex; align-items: center; min-width: 150px; justify-content: center; padding: 13px 18px; border-radius: 999px; background: rgba(255,255,255,.82); box-shadow: 0 12px 30px rgba(36,64,69,.1); color: #647a82; }
|
||||
.overall.phase-ready, .overall.phase-active { color: #1a7d57; }
|
||||
.overall.phase-error { color: #b63d2e; }
|
||||
.overall.phase-completed { color: #1a7d57; }
|
||||
.hero-actions { display: grid; justify-items: end; gap: 10px; }
|
||||
.legacy-link { color: #617985; font-size: 12px; text-decoration: none; border-bottom: 1px solid #b9c8c7; padding-bottom: 2px; }
|
||||
.legacy-link:hover { color: #c8652a; }
|
||||
|
||||
.status-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 16px; }
|
||||
.status-card, .panel { background: rgba(255,255,255,.9); border: 1px solid rgba(23,61,69,.08); box-shadow: 0 14px 40px rgba(34,61,63,.08); }
|
||||
@@ -77,7 +82,27 @@ input:focus, select:focus { border-color: #d6783f; box-shadow: 0 0 0 3px rgba(21
|
||||
.transcript-turn strong { color: #315966; font-size: 12px; }
|
||||
.transcript-turn span { color: #263f49; font-size: 14px; line-height: 1.55; }
|
||||
|
||||
.hardware-shell { width: min(1260px, calc(100vw - 48px)); }
|
||||
.hardware-content-grid { grid-template-columns: 1.05fr 1.2fr; }
|
||||
.hardware-call-panel { min-height: 720px; }
|
||||
.hardware-session-info { grid-template-columns: 1fr 1fr; }
|
||||
.text-question { display: grid; grid-template-columns: 1fr 150px; gap: 10px; margin-bottom: 22px; }
|
||||
.text-question input { margin-top: 0; }
|
||||
.text-question .secondary { padding: 10px; }
|
||||
.flow-panel { margin-top: 16px; }
|
||||
.flow-panel .panel-heading { margin-bottom: 14px; }
|
||||
.flow-checks { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; list-style: none; padding: 0; margin: 0; }
|
||||
.flow-check { display: grid; grid-template-columns: 28px 1fr; grid-template-rows: auto auto; column-gap: 10px; align-items: center; padding: 13px; border: 1px solid #e3ebe8; border-radius: 14px; background: #fafcfb; }
|
||||
.flow-check i { grid-row: 1 / span 2; display: grid; place-items: center; width: 26px; height: 26px; border-radius: 50%; font-style: normal; font-weight: 800; color: #809399; background: #e8efed; }
|
||||
.flow-check span { min-width: 0; color: #31505e; font-size: 13px; font-weight: 700; }
|
||||
.flow-check small { min-width: 0; margin-top: 4px; color: #8b9ba1; font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.flow-check--passed { border-color: #cce5d7; background: #f4faf6; }
|
||||
.flow-check--passed i { color: white; background: #2f8d67; }
|
||||
.flow-check--failed { border-color: #edcbc5; background: #fff7f5; }
|
||||
.flow-check--failed i { color: white; background: #b94c3d; }
|
||||
|
||||
@media (max-width: 1050px) {
|
||||
.status-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.content-grid { grid-template-columns: 1fr; }
|
||||
.flow-checks { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user