Files
operating-room-monitor-server/web/voice-confirmation/voice_app.js
2026-04-28 10:41:48 +08:00

953 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 手术室语音确认 — 浏览器端(与桌面版历史 MonitorWorker 状态机行为对齐)
* 仅 WebSocket 收 voice_pending / voice_pending_emptyHTTP 仅 POST resolve。
*/
const LS_BASE = "vc_http_base_url";
const LS_TID = "vc_terminal_id";
const LS_SEC = "vc_record_sec";
const LS_AUTO = "vc_auto_assign";
const LS_DRY = "vc_dry_run";
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
function httpBaseToWsRoot(httpBase) {
let u;
try {
u = new URL(httpBase.trim());
} catch {
return null;
}
const scheme = u.protocol === "https:" ? "wss:" : "ws:";
return `${scheme}//${u.host}`;
}
function buildVoiceWsUrl(httpBase, terminalId) {
const root = httpBaseToWsRoot(httpBase);
if (!root) return null;
const path = `/client/voice-terminals/ws?terminal_id=${encodeURIComponent(terminalId.trim())}`;
return root + path;
}
// --- WAV ---
function resampleFloat32(input, inRate, outRate) {
if (inRate === outRate) return input;
const outLen = Math.max(1, Math.floor((input.length * outRate) / inRate));
const out = new Float32Array(outLen);
for (let i = 0; i < outLen; i++) {
const pos = (i * inRate) / outRate;
const i0 = Math.floor(pos);
const f = pos - i0;
out[i] = (1 - f) * (input[i0] ?? 0) + f * (input[i0 + 1] ?? 0);
}
return out;
}
function floatToPcmS16le(floats) {
const n = floats.length;
const buf = new Int16Array(n);
for (let i = 0; i < n; i++) {
const s = Math.max(-1, Math.min(1, floats[i]));
buf[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
}
return buf;
}
function pcmS16leToWav(pcm, sampleRate) {
const n = pcm.byteLength;
const ab = new ArrayBuffer(44 + n);
const dv = new DataView(ab);
const enc = (o, s) => {
for (let i = 0; i < s.length; i++) dv.setUint8(o + i, s.charCodeAt(i));
};
enc(0, "RIFF");
dv.setUint32(4, 36 + n, true);
enc(8, "WAVE");
enc(12, "fmt ");
dv.setUint32(16, 16, true);
dv.setUint16(20, 1, true);
dv.setUint16(22, 1, true);
dv.setUint32(24, sampleRate, true);
dv.setUint32(28, sampleRate * 2, true);
dv.setUint16(32, 2, true);
dv.setUint16(34, 16, true);
enc(36, "data");
dv.setUint32(40, n, true);
new Uint8Array(ab, 44).set(new Uint8Array(pcm.buffer, pcm.byteOffset, pcm.byteLength));
return new Uint8Array(ab);
}
/**
* 从已有 MediaStream 采集(须先 getUserMedia。必须 resume AudioContext否则在多数浏览器
* 中 ScriptProcessor 不跑WAV 全为或接近静音。
*/
async function recordWav16kFromStream(stream, durationSec) {
const targetRate = 16000;
const chunks = [];
let ac;
let source;
let proc;
try {
try {
ac = new AudioContext({ sampleRate: targetRate });
} catch {
ac = new AudioContext();
}
const inRate = ac.sampleRate;
source = ac.createMediaStreamSource(stream);
const bufferSize = 4096;
proc = ac.createScriptProcessor(bufferSize, 1, 1);
proc.onaudioprocess = (e) => {
const ch0 = e.inputBuffer.getChannelData(0);
chunks.push(new Float32Array(ch0));
};
source.connect(proc);
proc.connect(ac.destination);
if (ac.state === "suspended") {
await ac.resume();
}
await sleep(Math.max(0, durationSec) * 1000);
let total = 0;
for (const c of chunks) total += c.length;
const merged = new Float32Array(total);
let o = 0;
for (const c of chunks) {
merged.set(c, o);
o += c.length;
}
const atTarget = resampleFloat32(merged, inRate, targetRate);
const pcm = floatToPcmS16le(atTarget);
return pcmS16leToWav(pcm, targetRate);
} finally {
try {
proc && proc.disconnect();
} catch {
/* */
}
try {
source && source.disconnect();
} catch {
/* */
}
try {
stream.getTracks().forEach((t) => t.stop());
} catch {
/* */
}
if (ac) {
try {
await ac.close();
} catch {
/* */
}
}
}
}
/**
* 录制指定秒数 16kHz mono 16-bit PCM WAV优先 AudioContext 16k否则重采样
*/
async function recordWav16kMono(durationSec) {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
return await recordWav16kFromStream(stream, durationSec);
}
function mp3BlobFromBase64(b64) {
const raw = (b64 || "").replace(/\s/g, "");
if (!raw) throw new Error("empty prompt_audio_mp3_base64");
const binary = atob(raw);
const u8 = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) u8[i] = binary.charCodeAt(i);
return new Blob([u8], { type: "audio/mpeg" });
}
function getMp3DurationSecFromObjectUrl(objectUrl) {
return new Promise((resolve, reject) => {
const probe = new Audio();
probe.preload = "metadata";
const fail = (msg) => {
try {
probe.onloadedmetadata = null;
probe.onerror = null;
probe.src = "";
probe.remove?.();
} catch {
/* */
}
reject(new Error(msg));
};
probe.onloadedmetadata = () => {
const d = probe.duration;
try {
probe.onloadedmetadata = null;
probe.onerror = null;
probe.src = "";
probe.remove?.();
} catch {
/* */
}
if (!Number.isFinite(d) || d <= 0) {
fail("invalid TTS duration");
return;
}
resolve(d);
};
probe.onerror = () => fail("TTS metadata load failed");
probe.src = objectUrl;
});
}
/**
* 与 TTS 同步采音:先 await 麦克风与图,再并行 play 与采集。
* 总采集时长 = TTS 音轨时长 + postAfterTtsSec播报结束后再多采几秒
* 元数据读失败时退化为先播后采(仅采 postAfterTtsSec 秒)。
*/
async function playTtsParallelRecord(b64, postAfterTtsSec) {
const post = Math.max(0, Number(postAfterTtsSec) || 0);
const blob = mp3BlobFromBase64(b64);
const url = URL.createObjectURL(blob);
let ttsSec;
try {
ttsSec = await getMp3DurationSecFromObjectUrl(url);
} catch {
URL.revokeObjectURL(url);
/* 元数据失败时无法与 TTS 对齐总时长,退化为先播后采 */
await playMp3FromBase64(b64);
return await recordWav16kMono(Math.max(2, post));
}
const totalSec = Math.max(2, ttsSec + post);
let stream;
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (e) {
URL.revokeObjectURL(url);
throw e;
}
/* 先挂采集 PromisegetUserMedia 已在此 await内部 await ac.resume 在 microtask。
play() 用 setTimeout(0) 放到宏任务,避免在 resume 前执行,否则 onaudioprocess 不跑、WAV 全静音。 */
const recP = recordWav16kFromStream(stream, totalSec);
const playP = new Promise((resolve, reject) => {
const audio = new Audio();
audio.preload = "auto";
audio.onended = () => {
URL.revokeObjectURL(url);
resolve();
};
audio.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error("audio play failed"));
};
audio.src = url;
setTimeout(() => {
audio.play().catch((err) => {
URL.revokeObjectURL(url);
reject(err);
});
}, 0);
});
const settled = await Promise.allSettled([playP, recP]);
if (settled[0].status === "rejected") throw settled[0].reason;
if (settled[1].status === "rejected") throw settled[1].reason;
return settled[1].value;
}
function playMp3FromBase64(b64) {
let blob;
try {
blob = mp3BlobFromBase64(b64);
} catch (e) {
return Promise.reject(e);
}
const url = URL.createObjectURL(blob);
return new Promise((resolve, reject) => {
const audio = new Audio();
audio.preload = "auto";
audio.onended = () => {
URL.revokeObjectURL(url);
resolve();
};
audio.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error("audio play failed"));
};
audio.src = url;
audio.play().catch((e) => {
URL.revokeObjectURL(url);
reject(e);
});
});
}
async function postResolve(baseUrl, surgeryId, confirmationId, wavBytes) {
const enc = encodeURIComponent(confirmationId);
const u = new URL(
`client/surgeries/${surgeryId}/pending-confirmation/${enc}/resolve`,
baseUrl.endsWith("/") ? baseUrl : baseUrl + "/"
);
const form = new FormData();
form.append("audio", new Blob([wavBytes], { type: "audio/wav" }), "voice.wav");
const res = await fetch(u.toString(), { method: "POST", body: form });
const text = await res.text();
let body = text;
try {
body = text ? JSON.parse(text) : {};
} catch {
/* keep string */
}
return { status: res.status, body };
}
// --- 状态机(对齐 monitor_worker.MonitorWorker---
class VoiceMonitorEngine {
constructor(hooks) {
this.onLog = hooks.onLog;
this.onState = hooks.onState;
this.onPending = hooks.onPending;
this.onResolveResult = hooks.onResolveResult;
this.settings = {
base_url: "http://127.0.0.1:38080",
surgery_id: "",
record_seconds: 5,
dry_run: false,
};
this._state = {
generation: 0,
busy: false,
spoken_cid: null,
failed_resolve_cid: null,
force_retry: false,
last_payload: null,
};
this._stop = false;
this._monitoring = false;
this._wsEventQueue = [];
this._wakeResolvers = [];
}
injectWsPending(payload) {
this._wsEventQueue.push(["pending", { ...payload }]);
this._wake();
}
injectWsPendingEmpty(surgeryId) {
this._wsEventQueue.push(["empty", (surgeryId || "").trim()]);
this._wake();
}
_drainWsEvents(surgeryId) {
const requeue = [];
let lastKind = "none";
let lastBody = null;
for (const [kind, data] of this._wsEventQueue) {
if (kind === "pending") {
const row = { ...data };
if (String(row.surgery_id || "") !== surgeryId) {
requeue.push([kind, data]);
continue;
}
delete row.type;
lastKind = "payload";
lastBody = row;
} else if (kind === "empty") {
if (String(data) !== surgeryId) {
requeue.push([kind, data]);
continue;
}
lastKind = "empty";
lastBody = null;
}
}
this._wsEventQueue = requeue;
return [lastKind, lastBody];
}
setSettings(updates) {
const oldSid = this.settings.surgery_id;
for (const [k, v] of Object.entries(updates)) {
if (k in this.settings) this.settings[k] = v;
}
const sidChanged = Object.prototype.hasOwnProperty.call(updates, "surgery_id") && this.settings.surgery_id !== oldSid;
this._state.generation += 1;
if (sidChanged) {
this._state.spoken_cid = null;
this._state.failed_resolve_cid = null;
this._state.last_payload = null;
this._state.force_retry = false;
this._emitPending(null);
}
this._wake();
}
setMonitoring(active) {
this._monitoring = !!active;
if (active) {
this._log("监控已开启 surgery_id=" + JSON.stringify(this.settings.surgery_id));
} else {
this._log("监控已关闭");
this._state.generation += 1;
}
this._wake();
}
stop() {
this._stop = true;
this._wake();
}
retryFailed() {
this._state.force_retry = true;
this._wake();
}
_log(msg) {
if (this.onLog) this.onLog(msg);
}
_emitState(s) {
if (this.onState) this.onState(s);
}
_onPending(p) {
if (this.onPending) this.onPending(p);
}
_wake() {
const rs = this._wakeResolvers.splice(0);
rs.forEach((r) => r());
}
async _waitForWake() {
await new Promise((resolve) => {
this._wakeResolvers.push(resolve);
});
}
_emitPending(p) {
this._onPending(p);
}
_emitResolveResult(payload) {
if (this.onResolveResult) this.onResolveResult(payload);
}
async _replayPromptJob() {
const p = this._state.last_payload;
if (!p) {
this._log("没有可重播的待确认数据");
return;
}
const b64 = p.prompt_audio_mp3_base64 || "";
if (!b64) {
this._log("当前任务无 MP3 数据");
return;
}
this._emitState("播放话术(手动重播)…");
try {
await playMp3FromBase64(b64);
} catch (e) {
this._log("重播失败: " + e);
} finally {
this._emitState("待机");
}
}
replayPromptOnly() {
this._replayPromptJob();
}
async runLoop() {
while (!this._stop) {
if (!this._monitoring) {
await sleep(150);
continue;
}
const cfg = { ...this.settings };
if (!/^\d{6}$/.test(cfg.surgery_id || "")) {
this._emitState("手术号无效(需 6 位数字)");
await new Promise((r) => setTimeout(r, 1000));
continue;
}
if (this._state.busy) {
await sleep(500);
continue;
}
const genBefore = this._state.generation;
const [wsKind, wsBody] = this._drainWsEvents(cfg.surgery_id);
if (wsKind === "empty") {
if (this._state.generation !== genBefore) continue;
if (this._state.busy) continue;
this._state.last_payload = null;
this._state.spoken_cid = null;
this._state.failed_resolve_cid = null;
this._emitPending(null);
this._emitState("待机(无待确认)");
await this._waitForWake();
continue;
}
let body = null;
if (wsKind === "payload" && wsBody) body = wsBody;
if (body == null) {
const lp = this._state.last_payload;
const failed = this._state.failed_resolve_cid;
const force = this._state.force_retry;
const cidLp = typeof lp === "object" && lp ? String(lp.confirmation_id || "") : "";
if (lp && typeof lp === "object" && force && failed === cidLp) {
body = { ...lp };
} else if (lp && typeof lp === "object" && failed === cidLp && !force) {
this._emitPending(lp);
this._emitState("请重试录音或检查麦克风");
await this._waitForWake();
continue;
} else if (lp && typeof lp === "object" && failed == null && !force && this._state.spoken_cid === cidLp && cidLp) {
this._emitPending(lp);
this._emitState("待机(等待 WebSocket 推送)");
await this._waitForWake();
continue;
}
}
if (body == null) {
this._emitState("待机(等待 WebSocket 推送)");
await this._waitForWake();
continue;
}
if (this._state.generation !== genBefore) continue;
if (this._state.busy) continue;
const cid = String(body.confirmation_id || "");
if (!cid) {
await this._waitForWake();
continue;
}
this._state.last_payload = body;
let failed = this._state.failed_resolve_cid;
const force = this._state.force_retry;
const spoken = this._state.spoken_cid;
if (failed != null && failed !== cid) {
this._state.failed_resolve_cid = null;
this._state.force_retry = false;
failed = null;
}
if (failed === cid && !force) {
this._emitPending(body);
await this._waitForWake();
continue;
}
if (spoken === cid && failed == null && !force) {
this._emitPending(body);
await this._waitForWake();
continue;
}
this._state.force_retry = false;
this._state.busy = true;
this._state.spoken_cid = cid;
const qn = body.pending_queue_length;
if (typeof qn === "number" && qn > 1) {
this._log("待确认队列共 " + qn + " 条(按 FIFO 队首依次处理)");
}
this._emitPending(body);
let genRun;
try {
genRun = this._state.generation;
await this._pipelinePlayRecordResolve(cfg, body, cid, genRun);
} finally {
this._state.busy = false;
}
}
}
async _pipelinePlayRecordResolve(cfg, body, cid, genRun) {
let wav;
try {
this._emitState("TTS 播报中,同时录音中…");
wav = await playTtsParallelRecord(
String(body.prompt_audio_mp3_base64 || ""),
cfg.record_seconds,
);
} catch (e) {
this._log("播报/录音失败: " + e);
this._state.failed_resolve_cid = cid;
const msg = String(e || "");
this._emitState(
msg.includes("empty") || msg.includes("empty prompt")
? "无 TTS 音频(可重试)"
: "播放或录音失败(可重试)",
);
return;
}
if (this._state.generation !== genRun) return;
if (cfg.dry_run) {
this._log("[dry-run] 已录音 " + wav.length + " 字节,跳过上传");
this._state.failed_resolve_cid = null;
this._state.spoken_cid = null;
this._state.generation += 1;
this._emitState("待机dry-run");
this._emitResolveResult({
httpStatus: null,
surgery_id: cfg.surgery_id,
confirmation_id: cid,
body: { note: "未请求服务端dry-run", recorded_bytes: wav.length },
});
return;
}
try {
this._emitState("上传识别…");
const { status: st, body: res } = await postResolve(cfg.base_url, cfg.surgery_id, cid, wav);
const baseMeta = { httpStatus: st, surgery_id: cfg.surgery_id, confirmation_id: cid };
if (st === 200 && typeof res === "object" && res) {
const rstatus = res.status;
if (rstatus === "accepted") {
this._emitResolveResult({ ...baseMeta, body: res });
this._log("已确认: " + (res.message || "") + " (resolved_label=" + JSON.stringify(res.resolved_label) + ")");
this._state.failed_resolve_cid = null;
this._state.spoken_cid = null;
this._state.last_payload = null;
this._state.generation += 1;
this._emitPending(null);
this._emitState("待机");
return;
}
if (rstatus === "failed") {
this._emitResolveResult({ ...baseMeta, body: res });
const code = res.error_code || "";
const msg = String(res.message || "");
this._log("语音未通过(可重试)" + (code ? "[" + code + "] " : "") + msg);
this._state.failed_resolve_cid = cid;
this._emitState("请重试录音或检查麦克风");
return;
}
}
if (st === 422 && typeof res === "object" && res && res.detail) {
const d = res.detail;
if (typeof d === "object" && d.code) {
const c = d.code;
if (c === "VOICE_ASR_FAILED" || c === "VOICE_TEXT_EMPTY" || c === "VOICE_PARSE_FAILED") {
this._emitResolveResult({ ...baseMeta, body: res });
this._log("语音未通过(可重试,旧接口)[" + c + "]: " + (d.message || ""));
this._state.failed_resolve_cid = cid;
this._emitState("请重试录音或检查麦克风");
return;
}
}
}
this._emitResolveResult({ ...baseMeta, body: res });
this._log("resolve 未接受 HTTP " + st + ": " + JSON.stringify(res));
this._state.failed_resolve_cid = cid;
this._emitState("解析/上传被拒(可重试)");
} catch (e) {
this._emitResolveResult({
httpStatus: null,
surgery_id: cfg.surgery_id,
confirmation_id: cid,
body: null,
error: String(e),
});
this._log("POST resolve 失败: " + e);
this._state.failed_resolve_cid = cid;
this._emitState("上传失败(可重试)");
}
}
}
// --- 全局 wiring ---
let ws = null;
let wsManualClose = false;
let assignedSurgeryId = "";
function appendLog(msg) {
const el = document.getElementById("log");
if (!el) return;
const t = new Date().toLocaleTimeString();
el.textContent += (el.textContent ? "\n" : "") + `[${t}] ${msg}`;
el.scrollTop = el.scrollHeight;
}
function setStatus(s) {
const el = document.getElementById("status");
if (el) el.textContent = s;
}
function updateQueueHint(p) {
const posEl = document.getElementById("queuePositionHint");
const cumEl = document.getElementById("cumulativeHint");
if (p == null) {
if (posEl) posEl.textContent = "无待确认";
if (cumEl) cumEl.textContent = "无待确认";
return;
}
const n = p.pending_queue_length;
const pos = p.pending_queue_position;
const cum = p.pending_cumulative_ordinal;
const nOk = typeof n === "number" && n >= 1;
const posOk = typeof pos === "number" && pos >= 1;
const cumOk = typeof cum === "number" && cum >= 1;
if (posEl) {
if (posOk && nOk) {
posEl.textContent = `${pos} / 共 ${n}FIFO 内排队)`;
} else if (nOk) {
posEl.textContent = `${n} 条待确认(服务端未给 pending_queue_position 时仅显示条数)`;
} else {
posEl.textContent = "待确认";
}
}
if (cumEl) {
cumEl.textContent = cumOk
? `${cum} 条(本场手术累计入队序号)`
: "待确认(服务端未返回 pending_cumulative_ordinal";
}
}
function setResolveResultDisplay(payload) {
const el = document.getElementById("resolveResult");
if (!el) return;
el.textContent = JSON.stringify(payload, null, 2);
}
const engine = new VoiceMonitorEngine({
onLog: (m) => appendLog(m),
onState: (s) => {
setStatus(s);
const banner = document.getElementById("recBanner");
if (banner) {
const recording = String(s).includes("录音中");
banner.classList.toggle("hidden", !recording);
banner.classList.toggle("rec-banner-active", recording);
}
},
onPending: (p) => {
const el = document.getElementById("pendingJson");
if (el) el.textContent = p == null ? "" : JSON.stringify(p, null, 2);
updateQueueHint(p);
},
onResolveResult: (payload) => setResolveResultDisplay(payload),
});
function loadForm() {
const base = localStorage.getItem(LS_BASE) || "http://127.0.0.1:38080";
const tid = localStorage.getItem(LS_TID) || "";
const sec = localStorage.getItem(LS_SEC) || "5";
const auto = localStorage.getItem(LS_AUTO) !== "0";
const dry = localStorage.getItem(LS_DRY) === "1";
const baseEl = document.getElementById("baseUrl");
const tidEl = document.getElementById("terminalId");
const secEl = document.getElementById("recordSec");
const autoEl = document.getElementById("autoAssign");
const dryEl = document.getElementById("dryRun");
if (baseEl) baseEl.value = base;
if (tidEl) tidEl.value = tid;
if (secEl) secEl.value = sec;
if (autoEl) autoEl.checked = auto;
if (dryEl) dryEl.checked = dry;
applySettings();
}
function saveForm() {
const base = document.getElementById("baseUrl")?.value?.trim() || "";
const tid = document.getElementById("terminalId")?.value?.trim() || "";
const sec = document.getElementById("recordSec")?.value || "5";
const auto = document.getElementById("autoAssign")?.checked ?? true;
const dry = document.getElementById("dryRun")?.checked ?? false;
localStorage.setItem(LS_BASE, base);
localStorage.setItem(LS_TID, tid);
localStorage.setItem(LS_SEC, sec);
localStorage.setItem(LS_AUTO, auto ? "1" : "0");
localStorage.setItem(LS_DRY, dry ? "1" : "0");
}
function applySettings() {
saveForm();
const base = document.getElementById("baseUrl")?.value?.trim() || "";
const recordSec = parseFloat(document.getElementById("recordSec")?.value || "5") || 5;
const dry = document.getElementById("dryRun")?.checked ?? false;
engine.setSettings({
base_url: base,
surgery_id: assignedSurgeryId,
record_seconds: recordSec,
dry_run: dry,
});
}
function connectWs() {
if (ws) {
wsManualClose = true;
try {
ws.close();
} catch {
/* ignore */
}
ws = null;
}
const auto = document.getElementById("autoAssign")?.checked ?? true;
if (!auto) {
engine.setMonitoring(false);
assignedSurgeryId = "";
updateTitle();
document.getElementById("btnStop")?.setAttribute("disabled", "disabled");
return;
}
const base = document.getElementById("baseUrl")?.value?.trim() || "";
const tid = document.getElementById("terminalId")?.value?.trim() || "";
if (!tid || !base) {
appendLog("未配置 Base URL 或终端 ID跳过 WebSocket");
return;
}
const url = buildVoiceWsUrl(base, tid);
if (!url) {
appendLog("无效的 Base URL");
return;
}
wsManualClose = false;
appendLog("正在连接 " + url);
try {
ws = new WebSocket(url);
} catch (e) {
appendLog("WebSocket 构造失败: " + e);
return;
}
ws.onopen = () => appendLog("WebSocket 已连接 terminal_id=" + JSON.stringify(tid));
ws.onclose = (ev) => {
appendLog("WebSocket 断开 code=" + ev.code);
ws = null;
if (!wsManualClose && document.getElementById("autoAssign")?.checked) {
setTimeout(connectWs, 2000);
}
};
ws.onerror = () => appendLog("WebSocket 错误");
ws.onmessage = (ev) => {
let data;
try {
data = JSON.parse(ev.data);
} catch {
return;
}
if (!data || typeof data !== "object") return;
const t = data.type;
if (t === "voice_pending") {
engine.injectWsPending(data);
return;
}
if (t === "voice_pending_empty") {
engine.injectWsPendingEmpty(String(data.surgery_id || ""));
return;
}
if (t === "voice_assignment") {
const action = data.action;
const sid = String(data.surgery_id || "");
if (action === "start") {
if (sid.length !== 6 || !/^\d{6}$/.test(sid)) {
appendLog("服务端指派无效手术号: " + JSON.stringify(sid));
return;
}
assignedSurgeryId = sid;
updateTitle();
applySettings();
engine.setMonitoring(true);
const btn = document.getElementById("btnStop");
if (btn) btn.removeAttribute("disabled");
appendLog("服务端已指派手术 " + sid + ",已自动开始监控");
return;
}
if (action === "end") {
engine.setMonitoring(false);
assignedSurgeryId = "";
updateTitle();
applySettings();
setStatus("已停止(服务端结束)");
const btn = document.getElementById("btnStop");
if (btn) btn.setAttribute("disabled", "disabled");
appendLog("服务端已结束手术 " + sid + ",已自动停止监控");
}
}
};
}
function updateTitle() {
const t = document.getElementById("pageTitle");
if (t) t.textContent = assignedSurgeryId ? "语音确认 — 手术 " + assignedSurgeryId : "语音确认";
}
function onAutoAssignChange() {
if (!document.getElementById("autoAssign")?.checked) {
engine.setMonitoring(false);
assignedSurgeryId = "";
updateTitle();
document.getElementById("btnStop")?.setAttribute("disabled", "disabled");
applySettings();
setStatus("已关闭自动指派");
if (ws) {
wsManualClose = true;
try {
ws.close();
} catch {
/* ignore */
}
ws = null;
}
} else {
connectWs();
}
}
function stopMonitoringLocal() {
engine.setMonitoring(false);
assignedSurgeryId = "";
updateTitle();
document.getElementById("btnStop")?.setAttribute("disabled", "disabled");
applySettings();
appendLog("—— 本地已停止监控;服务端结束手术或再次开录后将自动恢复指派 ——");
setStatus("已停止(本地)");
}
function init() {
loadForm();
updateQueueHint(null);
engine
.runLoop()
.catch((e) => appendLog("runLoop: " + e));
if (document.getElementById("autoAssign")?.checked) {
connectWs();
} else {
applySettings();
}
const base = document.getElementById("baseUrl");
const tid = document.getElementById("terminalId");
const rec = document.getElementById("recordSec");
const auto = document.getElementById("autoAssign");
[base, tid, rec].forEach((el) => {
if (el) {
el.addEventListener("change", () => {
saveForm();
applySettings();
if (auto?.checked) connectWs();
});
}
});
if (auto) auto.addEventListener("change", onAutoAssignChange);
document.getElementById("dryRun")?.addEventListener("change", () => {
saveForm();
applySettings();
});
document.getElementById("btnStop")?.addEventListener("click", stopMonitoringLocal);
document.getElementById("btnRetry")?.addEventListener("click", () => engine.retryFailed());
document.getElementById("btnReplay")?.addEventListener("click", () => engine.replayPromptOnly());
window.addEventListener("beforeunload", () => {
engine.stop();
wsManualClose = true;
if (ws) try { ws.close(); } catch { /* */ }
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}