移除宿主机/conda 启动脚本与 dev 联调工具,后端仅通过 docker compose 部署并默认启用 GPU。模拟客户端与语音确认页迁入 clients/ 下自包含目录,切断对后端源码路径的依赖。 Co-authored-by: Cursor <cursoragent@cursor.com>
1481 lines
46 KiB
JavaScript
Executable File
1481 lines
46 KiB
JavaScript
Executable File
/**
|
||
* 手术室语音确认 — 浏览器端(与桌面版历史 MonitorWorker 状态机行为对齐)
|
||
* WebSocket:voice_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 || "未知错误";
|
||
}
|
||
|
||
/** 静态页常见 8080,API 常见 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 +
|
||
")。静态页一般在 8080,FastAPI 一般在 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();
|
||
}
|