Files
operating-room-monitor-server/web/voice-confirmation/voice_app.js

953 lines
28 KiB
JavaScript
Raw Normal View History

2026-04-28 10:41:48 +08:00
/**
* 手术室语音确认 浏览器端与桌面版历史 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();
}