/** * 手术室语音确认 — 浏览器端(与桌面版历史 MonitorWorker 状态机行为对齐) * 仅 WebSocket 收 voice_pending / voice_pending_empty;HTTP 仅 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; } /* 先挂采集 Promise(getUserMedia 已在此 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(); }