Files
operating-room-monitor-server/clients/voice-confirmation/voice_app.js
Kevin 6bc6801df9 统一 Docker Compose 部署,并将客户端拆分为独立子项目。
移除宿主机/conda 启动脚本与 dev 联调工具,后端仅通过 docker compose 部署并默认启用 GPU。模拟客户端与语音确认页迁入 clients/ 下自包含目录,切断对后端源码路径的依赖。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 15:56:53 +08:00

1481 lines
46 KiB
JavaScript
Executable File
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 状态机行为对齐)
* WebSocketvoice_assignment / voice_pending / voice_pending_empty
* HTTP可选 GET 轮询队首、POST resolve 上传 WAV。
*/
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";
const LS_MANUAL_SID = "vc_manual_surgery_id";
const LS_ADVANCED = "vc_advanced_open";
/** 工程师可见的原始状态 -> 术间平板展示文案 */
const CLINICAL_STATUS = {
待机: "等待下一个确认",
"待机(无待确认)": "当前没有待确认项目",
"待机(等待 WebSocket 推送)": "等待系统分配任务",
"播放话术(手动重播)…": "正在重播提示…",
"手术号无效(需 6 位数字)": "手术号应为 6 位数字,请在高级设置中核对",
"请重试录音或检查麦克风": "未听清或录音异常,请检查麦克风后点「重试」",
"播报提示中…": "正在播放提示,播报结束后将自动开始录音",
"播报已结束,正在打开麦克风…": "提示音已结束,正在准备录音,请稍候",
"录音中…": "提示已结束,正在录制您的回答,请对着麦克风清楚说出确认内容",
"无 TTS 音频(可重试)": "没有可用的提示音,请点「重试」或联系信息科",
"播放或录音失败(可重试)": "播放或录音失败,请点「重试」",
"待机dry-run": "调试模式:已录音但未上传",
"上传识别…": "正在提交语音,请稍候…",
"解析/上传被拒(可重试)": "未能完成确认,请点「重试」",
"上传失败(可重试)": "提交失败,请检查网络后点「重试」",
"监控中(手动手术号)": "已连接,等待确认任务",
"监控中(手动手术号,已自动启用)": "已连接,等待确认任务",
"请把 Base URL 指向 API常见为 :38080不要填本页 :8080":
"接口地址可能填错,请通知信息科在「高级设置」中核对服务端地址",
"已停止(服务端结束)": "本场手术已在系统内结束",
"已关闭自动指派": "已关闭自动指派(可在高级设置重新打开)",
"已停止(本地)": "本机已停止接收任务",
};
function toClinicalStatus(raw) {
if (raw == null || raw === "") return "等待下一个确认";
const s = String(raw);
if (Object.prototype.hasOwnProperty.call(CLINICAL_STATUS, s)) return CLINICAL_STATUS[s];
return s;
}
function updateSurgeryDisplay() {
const line = document.getElementById("surgeryIdLine");
const valEl = document.getElementById("surgeryIdValue");
if (!line || !valEl) return;
if (assignedSurgeryId && /^\d{6}$/.test(assignedSurgeryId)) {
valEl.textContent = assignedSurgeryId;
line.classList.remove("hidden");
} else {
line.classList.add("hidden");
}
}
function wireAdvancedPanel() {
const panel = document.getElementById("advancedPanel");
if (!panel) return;
try {
const params = new URLSearchParams(location.search);
if (params.get("debug") === "1") {
panel.open = true;
localStorage.setItem(LS_ADVANCED, "1");
} else if (localStorage.getItem(LS_ADVANCED) === "1") {
panel.open = true;
}
} catch {
/* */
}
panel.addEventListener("toggle", () => {
try {
if (panel.open) localStorage.setItem(LS_ADVANCED, "1");
else localStorage.removeItem(LS_ADVANCED);
} catch {
/* */
}
});
}
/** 极短静音 WAV用于在用户首次手势内解锁 HTMLAudioElement.play()(否则异步推送触发的播报会被浏览器静默拦截)。 */
const SILENT_UNLOCK_DATA_URL =
"data:audio/wav;base64,UklGRigAAABXQVZFZm10IBIAAAABAAEARKwAAIhYAQACABAAAABkYXRhAgAAAAE=";
let htmlAudioPlaybackUnlocked = false;
/**
* 在用户已与页面交互的前提下调用:播放静音再暂停,满足 Chrome/Safari 自动播放策略。
* @returns {Promise<boolean>}
*/
async function unlockHtmlAudioPlayback() {
if (htmlAudioPlaybackUnlocked) return true;
try {
const a = new Audio();
a.src = SILENT_UNLOCK_DATA_URL;
await a.play();
a.pause();
a.currentTime = 0;
htmlAudioPlaybackUnlocked = true;
return true;
} catch (e) {
console.warn("[voice-confirmation] 音频解锁失败(需用户手势)", e);
return false;
}
}
/** 首次 pointerdown / keydown 时尝试解锁(捕获阶段,尽早命中)。 */
function wireAudioPlaybackUnlock() {
const tryOnce = async () => {
const ok = await unlockHtmlAudioPlayback();
if (ok) {
document.removeEventListener("pointerdown", tryOnce, true);
document.removeEventListener("keydown", tryOnce, true);
}
};
document.addEventListener("pointerdown", tryOnce, { capture: true, passive: true });
document.addEventListener("keydown", tryOnce, { capture: true });
}
function formatAudioPlayError(err) {
const name = err && err.name ? String(err.name) : "";
const msg = err && err.message ? String(err.message) : String(err);
if (name === "NotAllowedError" || /not allowed|user didn'?t interact/i.test(msg)) {
return "浏览器拒绝了自动播放(需先在页面内点击或按键解锁音频)。若已点击仍失败,请检查标签页是否静音。";
}
return msg || name || "未知错误";
}
/** 静态页常见 8080API 常见 38080避免把 Base URL 填成页面自己的端口。 */
function inferDefaultApiBase() {
try {
const h = location.hostname || "127.0.0.1";
const scheme = location.protocol === "https:" ? "https" : "http";
return `${scheme}://${h}:38080`;
} catch {
return "http://127.0.0.1:38080";
}
}
function isLoopbackOrWildcardHost(hostname) {
const h = String(hostname || "").trim().toLowerCase();
return h === "localhost" || h === "127.0.0.1" || h === "::1" || h === "0.0.0.0";
}
function parseHttpBaseUrl(raw) {
const s = String(raw || "").trim();
if (!s) return null;
try {
const u = new URL(s);
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
return u;
} catch {
return null;
}
}
function apiBaseFromQuery() {
try {
const params = new URLSearchParams(location.search);
for (const key of ["baseUrl", "base_url", "apiBaseUrl", "api_base_url"]) {
const val = params.get(key);
if (parseHttpBaseUrl(val)) return String(val).trim();
}
} catch {
/* ignore */
}
return "";
}
function resolveInitialApiBase(savedBase) {
const queryBase = apiBaseFromQuery();
if (queryBase) {
return {
base: queryBase,
note: "[提示] 已使用 URL 参数中的服务端 Base URL: " + queryBase,
};
}
const saved = String(savedBase || "").trim();
const fallback = inferDefaultApiBase();
const pageHost = location.hostname || "";
if (saved) {
const savedUrl = parseHttpBaseUrl(saved);
if (
savedUrl &&
!isLoopbackOrWildcardHost(pageHost) &&
isLoopbackOrWildcardHost(savedUrl.hostname)
) {
return {
base: fallback,
note:
"[提示] 当前语音页来自局域网地址,已将旧的本机 Base URL " +
saved +
" 自动改为 " +
fallback,
};
}
return { base: saved, note: "" };
}
return { base: fallback, note: "" };
}
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, onStarted) {
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();
}
if (typeof onStarted === "function") {
onStarted();
}
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, onStarted) {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
return await recordWav16kFromStream(stream, durationSec, onStarted);
}
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" });
}
/** 用于日志:去掉超长 base64 */
function summarizePendingForLog(obj) {
if (!obj || typeof obj !== "object") return String(obj);
const o = { ...obj };
const b64 = o.prompt_audio_mp3_base64;
if (typeof b64 === "string" && b64.length > 72) {
o.prompt_audio_mp3_base64 = `[base64 ${b64.length} chars]`;
}
try {
return JSON.stringify(o);
} catch {
return "[object]";
}
}
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((err) => {
URL.revokeObjectURL(url);
reject(new Error(formatAudioPlayError(err)));
});
});
}
async function postResolve(baseUrl, surgeryId, confirmationId, wavBytes, onLog) {
const log = typeof onLog === "function" ? onLog : () => {};
const enc = encodeURIComponent(confirmationId);
const u = new URL(
`client/surgeries/${surgeryId}/pending-confirmation/${enc}/resolve`,
baseUrl.endsWith("/") ? baseUrl : baseUrl + "/"
);
log("POST resolve → " + u.toString() + " WAV " + wavBytes.byteLength + " 字节)");
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 */
}
const preview =
typeof body === "string"
? body.length > 600
? body.slice(0, 600) + "…"
: body
: JSON.stringify(body);
log("POST resolve ← HTTP " + res.status + " " + preview);
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: inferDefaultApiBase(),
surgery_id: "",
record_seconds: 8,
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);
}
/** 轮询队首时若正在播报/录音/上传,勿注入 empty 以免打乱状态机。 */
isBusy() {
return !!this._state.busy;
}
isMonitoring() {
return !!this._monitoring;
}
_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;
const log = (msg) => this._log(msg);
try {
if (!htmlAudioPlaybackUnlocked) {
log(
"尚未检测到页面音频解锁:请先在本页任意处点击或按键一次,否则浏览器通常会静默拦截话术播报。",
);
}
const sec = Math.max(0.5, Number(cfg.record_seconds) || 0);
log("流程:先完整播报 TTS再录制 " + sec + " 秒回答音频");
this._emitState("播报提示中…");
await playMp3FromBase64(String(body.prompt_audio_mp3_base64 || ""));
if (this._state.generation !== genRun) return;
this._emitState("播报已结束,正在打开麦克风…");
wav = await recordWav16kMono(sec, () => {
this._emitState("录音中…");
});
} 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,
(msg) => this._log(msg),
);
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 = "";
/** 避免同一待机文案刷屏 */
let lastLoggedState = "";
/** HTTP 轮询队首:与 WebSocket 并存,避免仅连上 WS 但无指派时永远是「待机」。 */
let pollTimer = null;
let lastPollCid = null;
const MAX_LOG_LINES = 800;
function appendLog(msg) {
const el = document.getElementById("log");
if (!el) return;
const t = new Date().toLocaleTimeString();
const line = `[${t}] ${msg}`;
const cur = el.textContent ? el.textContent + "\n" + line : line;
const lines = cur.split("\n");
const trimmed =
lines.length > MAX_LOG_LINES ? lines.slice(-MAX_LOG_LINES).join("\n") + "\n…(更早日志已截断)" : cur;
el.textContent = trimmed;
el.scrollTop = el.scrollHeight;
}
function clearLog() {
const el = document.getElementById("log");
if (!el) return;
el.textContent = "";
}
function setStatus(s) {
const raw = s == null ? "" : String(s);
const rawEl = document.getElementById("statusRaw");
if (rawEl) rawEl.textContent = raw;
const clinicalEl = document.getElementById("statusClinical");
if (clinicalEl) clinicalEl.textContent = toClinicalStatus(raw);
}
function updateVoiceActivityBanner(state) {
const banner = document.getElementById("recBanner");
if (!banner) return;
const t = String(state || "");
const playingPrompt =
t.includes("播报提示") ||
t.includes("播放话术") ||
t.includes("播报中") ||
t.includes("TTS");
const preparingRecord = t.includes("打开麦克风") || t.includes("准备录音");
const recording = t.includes("录音中");
const visible = playingPrompt || preparingRecord || recording;
banner.classList.toggle("hidden", !visible);
banner.classList.toggle("activity-banner-active", visible);
if (!visible) return;
const title = document.getElementById("activityTitle");
const desc = document.getElementById("activityDesc");
const dot = document.getElementById("activityDot");
const bars = Array.from(document.querySelectorAll("#recWave .activity-wave-bar"));
let barClasses = ["bg-blue-400/90", "bg-blue-400", "bg-blue-300", "bg-blue-200", "bg-blue-300", "bg-blue-400", "bg-blue-400/90"];
if (preparingRecord) {
barClasses = [
"bg-amber-400/90",
"bg-amber-400",
"bg-amber-300",
"bg-amber-200",
"bg-amber-300",
"bg-amber-400",
"bg-amber-400/90",
];
}
if (recording) {
barClasses = [
"bg-red-400/90",
"bg-red-400",
"bg-red-300",
"bg-red-200",
"bg-red-300",
"bg-red-400",
"bg-red-400/90",
];
}
bars.forEach((bar, idx) => {
bar.className = "activity-wave-bar " + (barClasses[idx] || barClasses[0]);
});
if (recording) {
banner.className =
"activity-banner activity-banner-active mb-3 overflow-hidden rounded-xl border border-red-200 bg-red-50 px-4 py-4 text-center";
banner.setAttribute("aria-label", "正在录音");
if (dot) dot.className = "activity-dot mr-1 inline-block text-red-600";
if (title) {
title.className = "text-sm font-semibold text-red-900";
title.textContent = "正在采集语音";
if (dot) title.prepend(dot);
}
if (desc) {
desc.className = "mt-1 text-xs leading-relaxed text-red-800";
desc.textContent = "提示音已结束,请对着麦克风清楚回答";
}
return;
}
if (preparingRecord) {
banner.className =
"activity-banner activity-banner-active mb-3 overflow-hidden rounded-xl border border-amber-200 bg-amber-50 px-4 py-4 text-center";
banner.setAttribute("aria-label", "正在准备录音");
if (dot) dot.className = "activity-dot mr-1 inline-block text-amber-600";
if (title) {
title.className = "text-sm font-semibold text-amber-900";
title.textContent = "正在打开麦克风";
if (dot) title.prepend(dot);
}
if (desc) {
desc.className = "mt-1 text-xs leading-relaxed text-amber-800";
desc.textContent = "提示音已结束,系统正在准备录音,请稍候";
}
return;
}
banner.className =
"activity-banner activity-banner-active mb-3 overflow-hidden rounded-xl border border-blue-200 bg-blue-50 px-4 py-4 text-center";
banner.setAttribute("aria-label", "正在播报提示");
if (dot) dot.className = "activity-dot mr-1 inline-block text-blue-600";
if (title) {
title.className = "text-sm font-semibold text-blue-900";
title.textContent = "正在播报提示";
if (dot) title.prepend(dot);
}
if (desc) {
desc.className = "mt-1 text-xs leading-relaxed text-blue-800";
desc.textContent = "请先听完提示音,播报结束后系统才会开始录音";
}
}
function updateQueueHint(p) {
const posEl = document.getElementById("queuePositionHint");
const cumEl = document.getElementById("cumulativeHint");
const qc = document.getElementById("queueClinical");
if (p == null) {
if (posEl) posEl.textContent = "无待确认";
if (cumEl) cumEl.textContent = "无待确认";
if (qc) qc.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";
}
if (qc) {
let line = "";
if (posOk && nOk) {
line = `当前排队:第 ${pos} 条,共 ${n}`;
} else if (nOk) {
line = `当前共有 ${n} 条待确认`;
} else {
line = "有待确认项目";
}
if (cumOk) {
line += `;本场累计第 ${cum}`;
}
qc.textContent = line;
}
}
const RESULT_CLINICAL_BASE =
"rounded-xl border px-4 py-3 text-sm leading-relaxed shadow-sm";
function setResolveResultDisplay(payload) {
const el = document.getElementById("resolveResult");
const clinical = document.getElementById("resultClinical");
const emptyPayload =
payload == null || (typeof payload === "object" && Object.keys(payload).length === 0);
if (emptyPayload) {
if (el) el.textContent = "—";
if (clinical) {
clinical.textContent = "尚无确认结果";
clinical.className =
RESULT_CLINICAL_BASE + " border-slate-200 bg-white text-slate-700";
}
return;
}
if (el) el.textContent = JSON.stringify(payload, null, 2);
if (!clinical) return;
if (payload.error && payload.body == null) {
clinical.textContent = "提交失败:" + String(payload.error);
clinical.className = RESULT_CLINICAL_BASE + " border-amber-200 bg-amber-50 text-amber-950";
return;
}
const body = payload.body;
if (body && typeof body === "object" && body.note === "未请求服务端dry-run") {
clinical.textContent = "调试:已录音但未上传";
clinical.className = RESULT_CLINICAL_BASE + " border-slate-300 bg-slate-100 text-slate-800";
return;
}
const st = body && typeof body === "object" ? body.status : null;
const msg =
body && typeof body === "object" && body.message != null ? String(body.message) : "";
const http = payload.httpStatus;
if (st === "accepted") {
clinical.textContent = msg ? "成功:" + msg : "成功:已确认";
clinical.className = RESULT_CLINICAL_BASE + " border-emerald-200 bg-emerald-50 text-emerald-950";
} else if (st === "failed") {
clinical.textContent = msg ? "未通过:" + msg : "未通过:请按提示重试";
clinical.className = RESULT_CLINICAL_BASE + " border-amber-200 bg-amber-50 text-amber-950";
} else if (http != null && http !== 200) {
clinical.textContent =
"服务器返回异常HTTP " + http + "),请展开「高级设置」查看详情或联系信息科";
clinical.className = RESULT_CLINICAL_BASE + " border-amber-200 bg-amber-50 text-amber-950";
} else {
clinical.textContent = msg || "结果已返回,详情请展开高级设置";
clinical.className = RESULT_CLINICAL_BASE + " border-slate-200 bg-white text-slate-800";
}
}
function stopPendingPoll() {
if (pollTimer != null) {
clearInterval(pollTimer);
pollTimer = null;
}
lastPollCid = null;
}
async function tickPendingPoll() {
const base = document.getElementById("baseUrl")?.value?.trim() || "";
const sid = assignedSurgeryId;
if (!base || !sid || !/^\d{6}$/.test(sid) || !engine.isMonitoring()) {
return;
}
if (engine.isBusy()) {
return;
}
const u = new URL(
`client/surgeries/${sid}/pending-confirmation`,
base.endsWith("/") ? base : base + "/",
);
let res;
try {
res = await fetch(u.toString(), { method: "GET", credentials: "omit" });
} catch (e) {
appendLog("GET 队首失败(网络): " + e);
return;
}
if (res.status === 200) {
let data;
try {
data = await res.json();
} catch {
appendLog("GET 队首:响应非 JSON");
return;
}
const cid = data && typeof data === "object" ? String(data.confirmation_id || "") : "";
if (!cid) {
return;
}
if (cid === lastPollCid) {
return;
}
lastPollCid = cid;
engine.injectWsPending({ type: "voice_pending", surgery_id: sid, ...data });
appendLog("[HTTP 轮询] 队首 confirmation_id=" + JSON.stringify(cid));
return;
}
if (res.status === 404) {
if (lastPollCid == null) {
return;
}
lastPollCid = null;
engine.injectWsPendingEmpty(sid);
appendLog("[HTTP 轮询] 当前无队首待确认404");
return;
}
appendLog("GET 队首 HTTP " + res.status);
}
function startPendingPoll() {
stopPendingPoll();
const tick = async () => {
try {
await tickPendingPoll();
} catch (e) {
appendLog("轮询队首异常: " + e);
}
};
pollTimer = setInterval(tick, 2500);
tick();
}
function beginMonitoringWithSurgeryId(sid, extraLog) {
if (!/^\d{6}$/.test(sid)) {
return false;
}
assignedSurgeryId = sid;
updateTitle();
applySettings();
engine.setMonitoring(true);
document.getElementById("btnStop")?.removeAttribute("disabled");
startPendingPoll();
if (extraLog) {
appendLog(extraLog);
}
return true;
}
function startMonitoringManual() {
const raw = document.getElementById("manualSurgeryId")?.value?.trim() || "";
if (!/^\d{6}$/.test(raw)) {
appendLog("手动开始:请输入 6 位数字手术号");
setStatus("手术号需为 6 位数字");
return;
}
saveForm();
const ok = beginMonitoringWithSurgeryId(
raw,
"已手动开始监控手术 " + raw + "HTTP 轮询队首,可与 WebSocket 同时使用)",
);
if (ok) {
setStatus("监控中(手动手术号)");
}
}
/** Demo 默认 camera 绑定在 desktop-1若仅连上 WS 未点开始监控,且已填手术号,则自动拉队首避免「只有连线、没有队列」。 */
function tryAutoStartMonitoringFromManualField() {
const raw = document.getElementById("manualSurgeryId")?.value?.trim() || "";
if (!/^\d{6}$/.test(raw)) {
return;
}
if (engine.isMonitoring()) {
return;
}
saveForm();
beginMonitoringWithSurgeryId(
raw,
"[提示] WebSocket 已连接:已用手动手术号 " +
raw +
" 自动开始监控并拉队首。若仍无待确认,请核对与 Demo「开始手术」的手术号一致且 OR_SITE_CONFIG 中本机终端 ID 与开录 camera 为同一绑定组。",
);
setStatus("监控中(手动手术号,已自动启用)");
}
function warnIfApiBaseLooksLikeStaticPage() {
const base = document.getElementById("baseUrl")?.value?.trim() || "";
try {
const bu = new URL(base);
if (bu.origin === location.origin) {
appendLog(
"[提示] 「服务端 Base URL」与当前页面同源都是 " +
bu.origin +
")。静态页一般在 8080FastAPI 一般在 38080请把 Base URL 改成 API 地址,否则无法录音与拉取待确认。",
);
setStatus("请把 Base URL 指向 API常见为 :38080不要填本页 :8080");
}
} catch {
/* ignore */
}
}
const engine = new VoiceMonitorEngine({
onLog: (m) => appendLog(m),
onState: (s) => {
if (s !== lastLoggedState) {
lastLoggedState = s;
appendLog("[状态] " + s);
}
setStatus(s);
updateVoiceActivityBanner(s);
},
onPending: (p) => {
const el = document.getElementById("pendingJson");
if (el) {
if (p == null) el.textContent = "—";
else el.textContent = JSON.stringify(p, null, 2);
}
updateQueueHint(p);
if (p && typeof p === "object") {
appendLog(
"[队首队列字段] pending_queue_position=" +
JSON.stringify(p.pending_queue_position) +
" pending_queue_length=" +
JSON.stringify(p.pending_queue_length) +
" pending_cumulative_ordinal=" +
JSON.stringify(p.pending_cumulative_ordinal),
);
}
},
onResolveResult: (payload) => setResolveResultDisplay(payload),
});
function loadForm() {
const resolvedBase = resolveInitialApiBase(localStorage.getItem(LS_BASE));
const base = resolvedBase.base;
const tid = localStorage.getItem(LS_TID) || "";
const sec = localStorage.getItem(LS_SEC) || "8";
const auto = localStorage.getItem(LS_AUTO) !== "0";
const dry = localStorage.getItem(LS_DRY) === "1";
const manualSid = localStorage.getItem(LS_MANUAL_SID) || "";
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");
const manualEl = document.getElementById("manualSurgeryId");
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;
if (manualEl) manualEl.value = manualSid;
applySettings();
if (resolvedBase.note) {
appendLog(resolvedBase.note);
}
}
function saveForm() {
const base = document.getElementById("baseUrl")?.value?.trim() || "";
const tid = document.getElementById("terminalId")?.value?.trim() || "";
const sec = document.getElementById("recordSec")?.value || "8";
const auto = document.getElementById("autoAssign")?.checked ?? true;
const dry = document.getElementById("dryRun")?.checked ?? false;
const manualSid = document.getElementById("manualSurgeryId")?.value?.trim() || "";
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");
localStorage.setItem(LS_MANUAL_SID, manualSid);
}
function applySettings() {
saveForm();
const base = document.getElementById("baseUrl")?.value?.trim() || "";
const rawSec = document.getElementById("recordSec")?.value ?? "8";
const parsedSec = parseFloat(rawSec);
const recordSec = Number.isFinite(parsedSec) ? Math.max(0, parsedSec) : 8;
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) {
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));
tryAutoStartMonitoringFromManualField();
};
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 {
appendLog("[WS] 非 JSON 帧已忽略");
return;
}
if (!data || typeof data !== "object") return;
const t = data.type;
if (t === "voice_pending") {
const pcid = String(data.confirmation_id || "");
if (pcid) lastPollCid = pcid;
appendLog("[WS] voice_pending " + summarizePendingForLog(data));
engine.injectWsPending(data);
return;
}
if (t === "voice_pending_empty") {
lastPollCid = null;
appendLog(
"[WS] voice_pending_empty surgery_id=" + JSON.stringify(data.surgery_id || ""),
);
engine.injectWsPendingEmpty(String(data.surgery_id || ""));
return;
}
if (t === "voice_assignment") {
appendLog("[WS] voice_assignment " + JSON.stringify({ action: data.action, surgery_id: data.surgery_id }));
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");
const mid = document.getElementById("manualSurgeryId");
if (mid) mid.value = sid;
saveForm();
startPendingPoll();
appendLog("服务端已指派手术 " + sid + ",已自动开始监控");
return;
}
if (action === "end") {
stopPendingPoll();
engine.setMonitoring(false);
assignedSurgeryId = "";
updateTitle();
applySettings();
setStatus("已停止(服务端结束)");
const btn = document.getElementById("btnStop");
if (btn) btn.setAttribute("disabled", "disabled");
appendLog("服务端已结束手术 " + sid + ",已自动停止监控");
}
return;
}
appendLog("[WS] 未知类型 " + JSON.stringify(data));
};
}
function updateTitle() {
const t = document.getElementById("pageTitle");
if (t) t.textContent = assignedSurgeryId ? "语音确认 — 手术 " + assignedSurgeryId : "语音确认";
updateSurgeryDisplay();
}
function onAutoAssignChange() {
if (!document.getElementById("autoAssign")?.checked) {
stopPendingPoll();
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() {
stopPendingPoll();
engine.setMonitoring(false);
assignedSurgeryId = "";
updateTitle();
document.getElementById("btnStop")?.setAttribute("disabled", "disabled");
applySettings();
appendLog("—— 本地已停止监控;服务端结束手术或再次开录后将自动恢复指派 ——");
setStatus("已停止(本地)");
}
function init() {
loadForm();
lastLoggedState = "";
updateQueueHint(null);
setResolveResultDisplay(null);
setStatus("待机");
wireAdvancedPanel();
engine
.runLoop()
.catch((e) => appendLog("runLoop: " + e));
warnIfApiBaseLooksLikeStaticPage();
if (document.getElementById("autoAssign")?.checked) {
connectWs();
} else {
applySettings();
}
const base = document.getElementById("baseUrl");
const tid = document.getElementById("terminalId");
const rec = document.getElementById("recordSec");
const manual = document.getElementById("manualSurgeryId");
const auto = document.getElementById("autoAssign");
[base, tid, rec, manual].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("btnStartManual")?.addEventListener("click", startMonitoringManual);
document.getElementById("btnRetry")?.addEventListener("click", () => engine.retryFailed());
document.getElementById("btnReplay")?.addEventListener("click", () => engine.replayPromptOnly());
document.getElementById("btnClearLog")?.addEventListener("click", clearLog);
wireAudioPlaybackUnlock();
window.addEventListener("beforeunload", () => {
engine.stop();
stopPendingPoll();
wsManualClose = true;
if (ws) try { ws.close(); } catch { /* */ }
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}