Files
operating-room-monitor-server/scripts/demo_client/index.html
Kevin 0c05463617 feat: 语音确认、联调与运维增强
- 语音:序数解析(第一个/第二个等)、解析失败计数与 API detail.retry_remaining;
  百度 ASR 固定 dev_pid 为普通话;SurgeryPipelineError 支持 extra 并入 HTTP detail。
- Demo:demo 路由与假 RTSP、客户端 index 与 README;BackendResolver 与配置调整。
- 可观测:消耗 TSV 日志、语音文件日志、终端 Markdown 辅助;相关测试与依赖更新。
- 注意:.env 仍被 gitignore,本地密钥不会进入本提交。

Made-with: Cursor
2026-04-23 14:24:20 +08:00

1106 lines
44 KiB
HTML
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.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Operation Room Monitor · Demo Client</title>
<style>
:root {
--bg: #0f172a;
--panel: #111827;
--panel-2: #1f2937;
--border: #334155;
--text: #e2e8f0;
--muted: #94a3b8;
--accent: #38bdf8;
--accent-2: #22c55e;
--danger: #ef4444;
--warn: #f59e0b;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
font-size: 14px;
}
.layout {
display: grid;
grid-template-columns: minmax(0, 1.3fr) minmax(360px, 1fr);
gap: 16px;
padding: 16px;
min-height: 100vh;
}
h1 { font-size: 18px; margin: 0 0 4px; }
h2 { font-size: 15px; margin: 0 0 10px; color: var(--accent); }
h3 { font-size: 13px; margin: 14px 0 6px; color: var(--muted); }
section.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px 16px;
margin-bottom: 14px;
}
label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; }
input[type=text], input[type=url], input[type=number], select, textarea {
width: 100%;
background: var(--panel-2);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 7px 9px;
font-size: 13px;
font-family: inherit;
}
input:focus, textarea:focus, select:focus { outline: 1px solid var(--accent); }
button {
background: var(--accent);
color: #0b1220;
border: 0;
border-radius: 6px;
padding: 7px 14px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
button.secondary { background: var(--panel-2); color: var(--text); border: 1px solid var(--border); }
button.danger { background: var(--danger); color: #fff; }
button.warn { background: var(--warn); color: #0b1220; }
button:disabled { opacity: .5; cursor: not-allowed; }
.row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; }
.actions { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.kv { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; color: var(--muted); }
.pill {
display: inline-flex;
align-items: center;
gap: 4px;
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 999px;
padding: 3px 10px;
margin: 3px 4px 3px 0;
font-size: 12px;
}
.pill button {
background: transparent;
color: var(--muted);
padding: 0 0 0 4px;
font-size: 13px;
}
.pill button:hover { color: var(--danger); }
.tags-wrap {
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px;
min-height: 42px;
max-height: 160px;
overflow-y: auto;
}
.tags-wrap input {
background: transparent;
border: 0;
padding: 3px 6px;
min-width: 140px;
width: auto;
color: var(--text);
}
.log {
background: #0b1220;
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px;
height: calc(100vh - 64px);
overflow-y: auto;
position: sticky;
top: 16px;
}
.log-item { border-bottom: 1px dashed var(--border); padding: 8px 4px; }
.log-item:last-child { border-bottom: 0; }
.log-head { display: flex; justify-content: space-between; gap: 6px; align-items: baseline; }
.log-method { font-weight: 700; color: var(--accent); font-size: 11px; letter-spacing: .5px; }
.log-status { font-size: 11px; }
.log-status.ok { color: var(--accent-2); }
.log-status.err { color: var(--danger); }
.log-url { font-size: 11px; color: var(--muted); word-break: break-all; }
.log-body {
background: var(--panel-2);
border-radius: 4px;
padding: 6px 8px;
margin-top: 6px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11px;
max-height: 220px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
.log-hint {
margin-top: 6px;
padding: 6px 8px;
font-size: 11px;
line-height: 1.4;
color: #fcd34d;
background: rgba(245, 158, 11, 0.12);
border: 1px solid rgba(245, 158, 11, 0.35);
border-radius: 4px;
}
#orch-status-banner { border: 1px solid var(--border); }
.callout-ok {
background: rgba(34, 197, 94, 0.12);
border: 1px solid rgba(34, 197, 94, 0.4);
border-radius: 8px;
padding: 10px 12px;
margin: 0 0 10px;
line-height: 1.5;
}
.log-time { color: var(--muted); font-size: 11px; }
.badge {
display: inline-block;
font-size: 10px;
padding: 2px 6px;
border-radius: 3px;
margin-left: 6px;
background: var(--panel-2);
color: var(--muted);
}
.badge.on { background: #064e3b; color: #a7f3d0; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th, td { padding: 6px 8px; border-bottom: 1px solid var(--border); text-align: left; }
th { color: var(--muted); font-weight: 500; }
.record-controls { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.meter {
flex: 1;
min-width: 140px;
height: 10px;
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 5px;
overflow: hidden;
}
.meter > span {
display: block;
height: 100%;
width: 0;
background: linear-gradient(90deg, #22c55e, #f59e0b, #ef4444);
transition: width 0.06s linear;
}
.muted { color: var(--muted); }
.err { color: var(--danger); }
.ok { color: var(--accent-2); }
.warn { color: var(--warn); }
.small { font-size: 12px; }
.grow { flex: 1; }
audio { width: 100%; margin-top: 8px; }
.pending-box { background: var(--panel-2); border-radius: 6px; padding: 10px; margin-top: 8px; }
.option-row { display: flex; justify-content: space-between; padding: 2px 0; }
@media (max-width: 960px) {
.layout { grid-template-columns: 1fr; }
.log { position: static; height: auto; max-height: 50vh; }
}
pre.cmd {
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px 12px;
font-size: 11px;
line-height: 1.45;
overflow-x: auto;
margin: 8px 0 0;
white-space: pre-wrap;
word-break: break-all;
}
</style>
</head>
<body>
<div class="layout">
<main>
<section class="card">
<h1>Operation Room Monitor · Demo Client</h1>
<p id="orch-status-banner" class="small" style="display:none;margin:8px 0 0;padding:8px 10px;border-radius:6px"></p>
<p class="muted small">手动触发 <code>/client/*</code> 5 个接口;本地麦克风录音后生成 WAV 上传语音确认接口。</p>
<div class="row" style="margin-top:10px">
<div>
<label>服务端 Base URL</label>
<input id="base-url" type="url" value="http://localhost:38080" />
</div>
<div>
<label>手术号 surgery_id6 位数字)</label>
<input id="surgery-id" type="text" inputmode="numeric" pattern="\d{6}" maxlength="6" value="123456" />
</div>
</div>
<div class="actions">
<button id="btn-health" class="secondary">GET /health</button>
<button type="button" class="secondary" id="btn-orch-status" title="检查一键联调接口是否已注册">GET 联调状态</button>
<span id="health-status" class="small muted"></span>
</div>
</section>
<section class="card">
<h2>调试:两路视频(与一键联调 / 无真摄像头)</h2>
<p class="callout-ok small">
<strong>路1 / 路2</strong>选好视频、§4.1 勾选「一键联调」后点「开始手术」即可;服务端会起假 RTSP 并写 <code>VIDEO_RTSP_URLS_JSON_FILE</code>。无法使用一键时,请按 <code>scripts/demo_client/README.md</code> 在宿主机手跑
<code>fake_rtsp_from_file.py</code> 并配置环境变量。
</p>
<h3>两路视频(为 §4.1 一键选文件;两路 <code>RTSP_PATH</code> / <code>camera_id</code> 须与 API 配置一致,如 <code>demo1</code> / <code>demo2</code></h3>
<div class="row" style="margin-top:10px; align-items:stretch; grid-template-columns:1fr 1fr">
<div class="debug-stream" id="debug-stream-1" style="border:1px solid var(--border); border-radius:8px; padding:10px">
<h3 style="margin:0 0 8px; color:var(--accent)">路 1</h3>
<label>视频(一键上传优先;可选手填本地路径作备注)</label>
<input id="debug-vpath-1" type="text" placeholder="/path/a.mp4 或 ./a.mp4" />
<div class="actions" style="margin-top:6px; align-items:center">
<input type="file" id="debug-vfile-1" accept="video/*" hidden />
<button type="button" class="secondary" id="btn-dbg-pick-1">选择…</button>
<span id="debug-hint-1" class="small muted"></span>
</div>
<div class="row" style="margin-top:8px">
<div>
<label>RTSP 路径名 <code>RTSP_PATH</code>URL 最后一段,两路须不同,如 <code>demo1</code></label>
<input id="debug-rpath-1" type="text" value="demo1" />
</div>
<div>
<label>camera_id</label>
<input id="debug-cam-1" type="text" value="or-cam-01" />
</div>
</div>
</div>
<div class="debug-stream" id="debug-stream-2" style="border:1px solid var(--border); border-radius:8px; padding:10px">
<h3 style="margin:0 0 8px; color:var(--accent)">路 2</h3>
<label>视频(一键上传优先;可选手填本地路径作备注)</label>
<input id="debug-vpath-2" type="text" placeholder="/path/b.mp4 或 ./b.mp4" />
<div class="actions" style="margin-top:6px; align-items:center">
<input type="file" id="debug-vfile-2" accept="video/*" hidden />
<button type="button" class="secondary" id="btn-dbg-pick-2">选择…</button>
<span id="debug-hint-2" class="small muted"></span>
</div>
<div class="row" style="margin-top:8px">
<div>
<label>RTSP 路径名 <code>RTSP_PATH</code></label>
<input id="debug-rpath-2" type="text" value="demo2" />
</div>
<div>
<label>camera_id</label>
<input id="debug-cam-2" type="text" value="or-cam-02" />
</div>
</div>
</div>
</div>
<p id="debug-file-note" class="muted small" style="margin:8px 0 0">
一键联调会<strong>直接上传</strong>你在此为路1/路2选择的文件。选文件时会把框内填成 <code>./文件名</code>,仅作展示;真正上传以文件选择器为准,无需在框里改路径。
</p>
<div class="actions" style="margin-top:8px">
<button type="button" class="secondary" id="btn-debug-apply-cams" title="把两路 camera_id 写进 §4.1 的 camera_ids">将 camera_id 填到开始手术</button>
</div>
</section>
<section class="card">
<h2>§4.1 开始手术</h2>
<div class="row">
<div>
<label>camera_ids逗号分隔至少一个</label>
<input id="camera-ids" type="text" value="or-cam-01,or-cam-02" />
</div>
<div>
<label>candidate_consumables<span id="labels-hint" class="badge">loading…</span></label>
<div class="tags-wrap" id="tags">
<input id="tag-input" type="text" placeholder="输入后回车添加" />
</div>
</div>
</div>
<p class="small muted" style="margin:8px 0 0">
<label style="display:inline-flex;align-items:flex-start;gap:8px;cursor:pointer;max-width:52rem">
<input type="checkbox" id="orch-oneclick" style="margin-top:2px" />
<span><strong>一键联调</strong>:点下面按钮时上传 §「调试」里为<strong>路1/路2</strong>选好的两个视频,由监控服务在<strong>能执行 docker+ffmpeg 的环境</strong>里自动起假 RTSP、写 <code>VIDEO_RTSP_URLS_JSON_FILE</code> 并开录(需 <code>DEMO_ORCHESTRATOR_ENABLED=true</code> 且该文件为可写挂载;详见 README。不勾选时仍为普通 JSON 开录(需自行先起假流)。</span>
</label>
</p>
<div class="actions">
<button id="btn-start">开始手术</button>
<button id="btn-load-all-labels" class="secondary" type="button">载入全部标签</button>
<button id="btn-clear-labels" class="secondary" type="button">清空</button>
</div>
</section>
<section class="card">
<h2>§4.2 结束手术</h2>
<div class="actions">
<button id="btn-end" class="warn">POST /client/surgeries/end</button>
</div>
</section>
<section class="card">
<h2>§4.3 查询结果</h2>
<div class="actions">
<button id="btn-result" class="secondary">GET /client/surgeries/{id}/result</button>
</div>
<div id="result-render"></div>
</section>
<section class="card">
<h2>§4.4 待确认耗材</h2>
<div class="actions">
<button id="btn-pending" class="secondary">拉一条待确认</button>
<label class="small" style="display:flex;align-items:center;gap:6px;cursor:pointer">
<input id="auto-poll" type="checkbox" checked /> 自动轮询10s
</label>
<label class="small" style="display:flex;align-items:center;gap:6px;cursor:pointer" title="拉取到待确认时朗读 prompt百度 TTS 或浏览器)">
<input id="tts-pending" type="checkbox" checked /> 有待确认时 TTS
</label>
<span id="voice-status" class="small muted"></span>
</div>
<p id="voice-pipeline-hint" class="small muted" style="margin:6px 0 0">默认策略:<strong>Top1 置信度 &lt; 0.9</strong> 且达语音下沿时多会<strong>入队待确认</strong>;≥ <code>VIDEO_AUTO_CONFIRM_CONFIDENCE</code>(默认 0.9)且标签在 <code>candidate_consumables</code> 内则<strong>直接记 vision</strong>,拉取待确认为 404。可在环境变量中调整 <code>VIDEO_AUTO_CONFIRM_CONFIDENCE</code>。确认时在「语音确认(录音)」上传 WAV 即可。</p>
<div id="pending-render" class="pending-box" hidden></div>
</section>
<section class="card">
<h2>§4.5 语音确认(录音 → WAV → 上传)</h2>
<div class="row">
<div>
<label>confirmation_id从 §4.4 自动填入,也可手动修改)</label>
<input id="confirmation-id" type="text" />
</div>
</div>
<h3>录音</h3>
<div class="record-controls">
<button id="btn-rec-start">开始录音</button>
<button id="btn-rec-stop" class="danger" disabled>停止</button>
<div class="meter"><span id="meter-bar"></span></div>
<span id="rec-info" class="muted small">就绪</span>
</div>
<audio id="audio-preview" controls hidden></audio>
<div class="actions">
<button id="btn-resolve" disabled>上传并确认</button>
<a id="btn-download" class="small muted" href="#" download="voice.wav" style="display:none">下载 WAV调试</a>
</div>
</section>
</main>
<aside>
<div class="log" id="log">
<div class="small muted" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
<strong style="color:var(--text)">响应日志</strong>
<button class="secondary" id="btn-clear-log" style="padding:3px 8px;font-size:11px">清空</button>
</div>
</div>
</aside>
</div>
<script>
// ============================================================
// Utilities
// ============================================================
const $ = (id) => document.getElementById(id);
const baseUrl = () => $("base-url").value.trim().replace(/\/+$/, "");
const surgeryId = () => $("surgery-id").value.trim();
const logEl = $("log");
function addLog(method, url, status, body, { error = false, hint = "" } = {}) {
const item = document.createElement("div");
item.className = "log-item";
const time = new Date().toLocaleTimeString();
const statusClass = error || (typeof status === "number" && status >= 400) ? "err" : "ok";
const statusText = typeof status === "number" ? status : status || (error ? "ERROR" : "—");
item.innerHTML = `
<div class="log-head">
<span><span class="log-method">${method}</span> <span class="log-url">${url}</span></span>
<span><span class="log-status ${statusClass}">${statusText}</span> <span class="log-time">${time}</span></span>
</div>`;
const bodyEl = document.createElement("div");
bodyEl.className = "log-body";
if (body === undefined || body === null || body === "") {
bodyEl.textContent = "(empty)";
} else if (typeof body === "string") {
bodyEl.textContent = body;
} else {
try { bodyEl.textContent = JSON.stringify(body, null, 2); }
catch { bodyEl.textContent = String(body); }
}
item.appendChild(bodyEl);
if (hint) {
const h = document.createElement("div");
h.className = "log-hint";
h.textContent = hint;
item.appendChild(h);
}
logEl.insertBefore(item, logEl.children[1] ?? null);
}
$("btn-clear-log").onclick = () => {
[...logEl.querySelectorAll(".log-item")].forEach(n => n.remove());
};
async function apiJson(method, path, payload) {
const url = baseUrl() + path;
let res;
try {
res = await fetch(url, {
method,
headers: payload ? { "Content-Type": "application/json" } : undefined,
body: payload ? JSON.stringify(payload) : undefined,
});
} catch (e) {
addLog(method, url, "NETWORK", String(e), { error: true });
throw e;
}
const text = await res.text();
let parsed;
try { parsed = text ? JSON.parse(text) : null; } catch { parsed = text; }
addLog(method, url, res.status, parsed);
return { res, body: parsed };
}
async function apiMultipart(path, formData) {
const url = baseUrl() + path;
const bu = baseUrl();
console.info("[demo-client] orchestrate request", { baseUrl: bu, path, fullUrl: url });
let res;
try {
res = await fetch(url, { method: "POST", body: formData });
} catch (e) {
console.error("[demo-client] orchestrate network error", e);
const netHint = "无法连接 " + url + "。请确认「服务端 Base URL」指向监控 API默认 :38080且本页在 :38081 打开时勿把 Base URL 填成 demo 页自身。";
addLog("POST (orchestrate)", url, "NETWORK", String(e), { error: true, hint: netHint });
throw e;
}
const text = await res.text();
let parsed;
try { parsed = text ? JSON.parse(text) : null; } catch { parsed = text; }
const err = !res.ok;
let hint = "";
if (res.status === 404) {
hint = "HTTP 404本路径在服务端未注册。常见原因1) 未设 DEMO_ORCHESTRATOR_ENABLED=true 并重启主进程POST /internal/demo/orchestrate-and-start 未挂载2)「服务端 Base URL」填错须指向主 API 如 http://127.0.0.1:38080不是本 demo 静态站 :38081。可点「GET 联调状态」或打开浏览器控制台查看 [demo-client] 日志。";
} else if (res.status === 400 && parsed && (parsed.detail || "").toString().indexOf("VIDEO_RTSP") >= 0) {
hint = "需配置可写的 VIDEO_RTSP_URLS_JSON_FILE且 Docker 下请 bind-mount 到容器内同路径。";
} else if (res.status === 503) {
hint = "合成假 RTSP 或开录失败,请见响应体与主服务终端 logdemo orchestrate-and-start / ffmpeg / docker。";
}
if (err) {
console.error("[demo-client] orchestrate response", { status: res.status, statusText: res.statusText, body: parsed, url });
} else {
console.info("[demo-client] orchestrate ok", { status: res.status, url });
}
addLog("POST (orchestrate)", url, res.status, parsed, { error: err, hint });
return { res, body: parsed };
}
// ============================================================
// Surgery ID validation
// ============================================================
function ensureSurgeryId() {
const sid = surgeryId();
if (!/^\d{6}$/.test(sid)) {
alert("surgery_id 必须是 6 位数字");
return null;
}
return sid;
}
// ============================================================
// Tags for candidate_consumables
// ============================================================
const tagsEl = $("tags");
const tagInput = $("tag-input");
let tags = [];
function renderTags() {
[...tagsEl.querySelectorAll(".pill")].forEach(n => n.remove());
tags.forEach((t, idx) => {
const pill = document.createElement("span");
pill.className = "pill";
pill.innerHTML = `<span></span><button title="移除" aria-label="移除">×</button>`;
pill.firstChild.textContent = t;
pill.querySelector("button").onclick = () => {
tags.splice(idx, 1);
renderTags();
};
tagsEl.insertBefore(pill, tagInput);
});
}
function addTag(name) {
const v = name.trim();
if (!v) return;
if (tags.includes(v)) return;
tags.push(v);
renderTags();
}
tagInput.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
addTag(tagInput.value);
tagInput.value = "";
} else if (e.key === "Backspace" && !tagInput.value && tags.length) {
tags.pop();
renderTags();
}
});
tagsEl.addEventListener("click", (e) => {
if (e.target === tagsEl) tagInput.focus();
});
async function loadLabels() {
const hint = $("labels-hint");
try {
const res = await fetch("/labels.json");
if (!res.ok) throw new Error("HTTP " + res.status);
const data = await res.json();
const labels = Array.isArray(data.labels) ? data.labels : [];
tags = labels.slice(0, 5);
renderTags();
hint.textContent = `${labels.length} 个标签`;
hint.classList.add("on");
window.__ALL_LABELS__ = labels;
} catch (e) {
hint.textContent = "labels.json 加载失败";
console.warn(e);
}
}
$("btn-load-all-labels").onclick = () => {
const all = window.__ALL_LABELS__ || [];
tags = [...all];
renderTags();
};
$("btn-clear-labels").onclick = () => { tags = []; renderTags(); };
// ============================================================
// 联调状态(不依赖一键开关,用于诊断 404
// ============================================================
async function refreshOrchStatus() {
const b = $("orch-status-banner");
const url = baseUrl() + "/internal/demo/orchestrator-status";
try {
const res = await fetch(url);
const text = await res.text();
let data;
try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text }; }
console.info("[demo-client] GET orchestrator-status", { url, httpStatus: res.status, data });
addLog("GET (联调状态)", url, res.status, data, { error: !res.ok });
b.style.display = "block";
if (!res.ok) {
b.style.background = "rgba(239, 68, 68, 0.1)";
b.style.color = "var(--text)";
b.textContent = "无法拉取 " + url + "HTTP " + res.status + ")。请把「服务端 Base URL」设为主 API如 http://127.0.0.1:38080。";
return;
}
const on = data.orchestrator_enabled === true;
const fset = data.video_rtsp_urls_json_file_set === true;
b.style.background = on && fset ? "rgba(34, 197, 94, 0.1)" : "rgba(245, 158, 11, 0.12)";
b.style.color = "var(--text)";
const fp = data.video_rtsp_urls_json_file || "(未设)";
b.innerHTML = on
? ("一键 <code>POST " + (data.orchestrate_path || "/internal/demo/orchestrate-and-start") + "</code>" + (fset ? "已开放RTSP 映射文件 " : "未设 ") + "<code>" + fp + "</code>")
: ("一键开录 <strong>未注册</strong>:请在主服务 .env 设 <code>DEMO_ORCHESTRATOR_ENABLED=true</code> 并<strong>重启</strong>。当前 " + (data.orchestrate_path || "") + " 会 404。");
} catch (e) {
console.error("[demo-client] orchestrator-status failed", e);
b.style.display = "block";
b.style.background = "rgba(239, 68, 68, 0.1)";
b.textContent = "联调状态请求失败: " + e;
}
}
// ============================================================
// §health
// ============================================================
$("btn-health").onclick = async () => {
const { res } = await apiJson("GET", "/health");
$("health-status").textContent = `HTTP ${res.status}`;
$("health-status").className = "small " + (res.ok ? "ok" : "err");
};
$("btn-orch-status").onclick = () => { refreshOrchStatus(); };
// ============================================================
// §4.1 start
// ============================================================
$("btn-start").onclick = async () => {
const sid = ensureSurgeryId();
if (!sid) return;
if ($("orch-oneclick") && $("orch-oneclick").checked) {
const f1 = $("debug-vfile-1").files[0];
const f2 = $("debug-vfile-2").files[0];
if (!f1 || !f2) {
alert("请先在上方「调试」里为 路1 / 路2 各「选择…」一个视频文件。");
return;
}
const fd = new FormData();
fd.append("video1", f1, f1.name);
fd.append("video2", f2, f2.name);
fd.append("surgery_id", sid);
fd.append("camera_1", ($("debug-cam-1").value || "or-cam-01").trim() || "or-cam-01");
fd.append("camera_2", ($("debug-cam-2").value || "or-cam-02").trim() || "or-cam-02");
fd.append("rtsp_path_1", ($("debug-rpath-1").value || "demo1").trim() || "demo1");
fd.append("rtsp_path_2", ($("debug-rpath-2").value || "demo2").trim() || "demo2");
fd.append("candidate_consumables_json", JSON.stringify([...tags]));
const { res, body } = await apiMultipart("/internal/demo/orchestrate-and-start", fd);
if (!res.ok) {
const detail = (body && (body.detail !== undefined)) ? body.detail : body;
const errText = (typeof detail === "object" && detail !== null) ? JSON.stringify(detail, null, 2) : String(detail || body || "错误");
alert("一键开录失败 HTTP " + res.status + "\n\n" + errText);
}
return;
}
const camera_ids = $("camera-ids").value.split(",").map(s => s.trim()).filter(Boolean);
if (camera_ids.length === 0) { alert("camera_ids 至少要 1 个"); return; }
await apiJson("POST", "/client/surgeries/start", {
surgery_id: sid,
camera_ids,
candidate_consumables: [...tags],
});
};
// ============================================================
// §4.2 end
// ============================================================
$("btn-end").onclick = async () => {
const sid = ensureSurgeryId();
if (!sid) return;
await apiJson("POST", "/client/surgeries/end", { surgery_id: sid });
};
// ============================================================
// §4.3 result
// ============================================================
$("btn-result").onclick = async () => {
const sid = ensureSurgeryId();
if (!sid) return;
const { res, body } = await apiJson("GET", `/client/surgeries/${sid}/result`);
const target = $("result-render");
target.innerHTML = "";
if (!res.ok || !body || typeof body !== "object") return;
const { details = [], summary = [] } = body;
const renderTable = (title, rows, cols) => {
const h = document.createElement("h3");
h.textContent = title;
target.appendChild(h);
const t = document.createElement("table");
const thead = document.createElement("thead");
thead.innerHTML = "<tr>" + cols.map(c => `<th>${c.label}</th>`).join("") + "</tr>";
t.appendChild(thead);
const tbody = document.createElement("tbody");
if (!rows.length) {
tbody.innerHTML = `<tr><td colspan="${cols.length}" class="muted">(空)</td></tr>`;
} else {
rows.forEach(row => {
const tr = document.createElement("tr");
tr.innerHTML = cols.map(c => `<td>${row[c.key] ?? ""}</td>`).join("");
tbody.appendChild(tr);
});
}
t.appendChild(tbody);
target.appendChild(t);
};
renderTable("明细 details[]", details, [
{ key: "timestamp", label: "time" },
{ key: "item_id", label: "item_id" },
{ key: "item_name", label: "item_name" },
{ key: "quantity", label: "qty" },
{ key: "doctor_id", label: "doctor" },
{ key: "source", label: "source" },
]);
renderTable("汇总 summary[]", summary, [
{ key: "item_id", label: "item_id" },
{ key: "item_name", label: "item_name" },
{ key: "total_quantity", label: "total" },
]);
};
// ============================================================
// §4.4 pending-confirmation + 可选 TTS
// ============================================================
let pollTimer = null;
let lastTtsConfirmationId = null;
function pickZhTtsVoice() {
if (!window.speechSynthesis) return null;
const vs = window.speechSynthesis.getVoices() || [];
return (
vs.find((v) => /^zh/i.test((v.lang || "") + (v.voiceURI || ""))) ||
vs.find((v) => (v.lang || "").startsWith("zh")) ||
null
);
}
function speakTextPromise(text) {
return new Promise((resolve, reject) => {
if (!text || !window.speechSynthesis) {
resolve();
return;
}
try {
window.speechSynthesis.cancel();
const u = new SpeechSynthesisUtterance(text);
u.lang = "zh-CN";
const v = pickZhTtsVoice();
if (v) u.voice = v;
u.rate = 0.95;
u.onend = () => resolve();
u.onerror = (ev) => reject(ev.error || new Error("tts"));
window.speechSynthesis.speak(u);
} catch (e) {
reject(e);
}
});
}
/** 优先 GET /prompt-audio 播放百度 MP3失败时 speechSynthesis */
async function playPromptTts(surgeryId, confirmationId, textFallback) {
const path = `/client/surgeries/${surgeryId}/pending-confirmation/${encodeURIComponent(confirmationId)}/prompt-audio`;
const u = baseUrl() + path;
try {
const res = await fetch(u);
if (res.ok) {
const blob = await res.blob();
const o = URL.createObjectURL(blob);
return new Promise((resolve, reject) => {
const a = new Audio();
a.preload = "auto";
a.src = o;
a.onended = () => {
URL.revokeObjectURL(o);
resolve();
};
a.onerror = () => {
URL.revokeObjectURL(o);
reject(new Error("Audio 元素播放失败"));
};
const p = a.play();
if (p && typeof p.catch === "function") {
p.catch((err) => {
URL.revokeObjectURL(o);
reject(err);
});
}
});
}
} catch (e) {
console.warn("[demo-client] prompt-audio 不可用,回退浏览器 TTS", e);
}
return speakTextPromise((textFallback || "").trim());
}
if (window.speechSynthesis) {
window.speechSynthesis.addEventListener("voiceschanged", () => {});
}
$("surgery-id").addEventListener("input", () => {
lastTtsConfirmationId = null;
});
async function fetchPendingOnce() {
const sid = surgeryId();
if (!/^\d{6}$/.test(sid)) return;
const path = `/client/surgeries/${sid}/pending-confirmation`;
const url = baseUrl() + path;
let res;
try {
res = await fetch(url);
} catch (e) {
addLog("GET", url, "NETWORK", String(e), { error: true });
return;
}
const raw = await res.text();
let body;
try {
body = raw ? JSON.parse(raw) : null;
} catch {
body = raw;
}
if (res.status === 404) {
// 无待确认为常态,不写入右侧「响应日志」,减少刷屏
} else {
addLog("GET", url, res.status, body);
}
const box = $("pending-render");
if (res.status === 200 && body && body.confirmation_id) {
box.hidden = false;
$("confirmation-id").value = body.confirmation_id;
const opts = (body.options || [])
.map(o => `<div class="option-row"><span>${o.label}</span><span class="muted">${(o.confidence * 100).toFixed(1)}%</span></div>`)
.join("");
box.innerHTML = `
<div><strong>confirmation_id:</strong> <span class="kv">${body.confirmation_id}</span></div>
<div style="margin-top:4px"><strong>prompt_text:</strong> ${body.prompt_text || ""}</div>
<div style="margin-top:4px"><strong>Top1:</strong> ${body.model_top1_label} <span class="muted">(${(body.model_top1_confidence * 100).toFixed(1)}%)</span></div>
<div style="margin-top:6px"><strong>options:</strong>${opts || '<div class="muted">(无)</div>'}</div>`;
const pt = (body.prompt_text || "").trim();
const ttsOn = $("tts-pending") && $("tts-pending").checked;
if (ttsOn && pt && body.confirmation_id !== lastTtsConfirmationId) {
lastTtsConfirmationId = body.confirmation_id;
void playPromptTts(sid, body.confirmation_id, pt).catch((e) => console.warn(e));
}
} else if (res.status === 404) {
box.hidden = false;
box.innerHTML = '<span class="muted">暂无待确认项。</span>';
} else {
box.hidden = false;
box.innerHTML = `<span class="err">HTTP ${res.status}</span>`;
}
}
$("btn-pending").onclick = fetchPendingOnce;
function applyAutoPoll() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
if ($("auto-poll") && $("auto-poll").checked) {
$("voice-status").textContent = "自动轮询中…";
pollTimer = setInterval(fetchPendingOnce, 10000);
fetchPendingOnce();
} else {
$("voice-status").textContent = "";
}
}
$("auto-poll").onchange = applyAutoPoll;
if ($("auto-poll") && $("auto-poll").checked) {
applyAutoPoll();
}
// ============================================================
// §4.5 Recording (mic → WAV 16kHz mono PCM)
// ============================================================
let audioCtx = null;
let mediaStream = null;
let sourceNode = null;
let processorNode = null;
let pcmChunks = [];
let inputSampleRate = 48000;
let recStartAt = 0;
let recordingWav = null;
function floatTo16BitPCM(view, offset, input) {
for (let i = 0; i < input.length; i++, offset += 2) {
const s = Math.max(-1, Math.min(1, input[i]));
view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
}
}
function writeString(view, offset, str) {
for (let i = 0; i < str.length; i++) view.setUint8(offset + i, str.charCodeAt(i));
}
function downsampleTo16k(inputBuffer, inputRate) {
const target = 16000;
if (inputRate === target) return inputBuffer;
const ratio = inputRate / target;
const newLen = Math.round(inputBuffer.length / ratio);
const out = new Float32Array(newLen);
let offsetOut = 0;
let offsetIn = 0;
while (offsetOut < newLen) {
const nextIn = Math.round((offsetOut + 1) * ratio);
let accum = 0, count = 0;
for (let i = offsetIn; i < nextIn && i < inputBuffer.length; i++) {
accum += inputBuffer[i]; count++;
}
out[offsetOut] = count > 0 ? accum / count : 0;
offsetOut++;
offsetIn = nextIn;
}
return out;
}
function encodeWav(samples, sampleRate) {
const buffer = new ArrayBuffer(44 + samples.length * 2);
const view = new DataView(buffer);
writeString(view, 0, "RIFF");
view.setUint32(4, 36 + samples.length * 2, true);
writeString(view, 8, "WAVE");
writeString(view, 12, "fmt ");
view.setUint32(16, 16, true); // PCM subchunk size
view.setUint16(20, 1, true); // format = PCM
view.setUint16(22, 1, true); // channels = 1
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * 2, true); // byte rate
view.setUint16(32, 2, true); // block align
view.setUint16(34, 16, true); // bits per sample
writeString(view, 36, "data");
view.setUint32(40, samples.length * 2, true);
floatTo16BitPCM(view, 44, samples);
return new Blob([view], { type: "audio/wav" });
}
function concatFloat32(chunks) {
let total = 0;
chunks.forEach(c => { total += c.length; });
const out = new Float32Array(total);
let offset = 0;
chunks.forEach(c => { out.set(c, offset); offset += c.length; });
return out;
}
async function startRecording() {
try {
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } });
} catch (e) {
alert("无法获取麦克风:" + e.message + "\n请确保使用 http://localhost 或 https 访问,并授权麦克风。");
return;
}
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
inputSampleRate = audioCtx.sampleRate;
sourceNode = audioCtx.createMediaStreamSource(mediaStream);
processorNode = audioCtx.createScriptProcessor(4096, 1, 1);
pcmChunks = [];
let peak = 0;
processorNode.onaudioprocess = (ev) => {
const input = ev.inputBuffer.getChannelData(0);
pcmChunks.push(new Float32Array(input));
let localPeak = 0;
for (let i = 0; i < input.length; i++) {
const v = Math.abs(input[i]);
if (v > localPeak) localPeak = v;
}
peak = Math.max(peak * 0.85, localPeak);
$("meter-bar").style.width = Math.min(100, peak * 140) + "%";
};
sourceNode.connect(processorNode);
processorNode.connect(audioCtx.destination);
recStartAt = performance.now();
$("btn-rec-start").disabled = true;
$("btn-rec-stop").disabled = false;
$("btn-resolve").disabled = true;
$("audio-preview").hidden = true;
$("btn-download").style.display = "none";
$("rec-info").textContent = `录音中 @ ${inputSampleRate} Hz…`;
$("rec-info").className = "warn small";
}
async function stopRecording() {
if (processorNode) { processorNode.disconnect(); processorNode.onaudioprocess = null; }
if (sourceNode) sourceNode.disconnect();
if (mediaStream) mediaStream.getTracks().forEach(t => t.stop());
if (audioCtx) await audioCtx.close();
const durationMs = performance.now() - recStartAt;
const samples = concatFloat32(pcmChunks);
const downsampled = downsampleTo16k(samples, inputSampleRate);
recordingWav = encodeWav(downsampled, 16000);
const url = URL.createObjectURL(recordingWav);
const audio = $("audio-preview");
audio.src = url;
audio.hidden = false;
const dl = $("btn-download");
dl.href = url;
dl.style.display = "inline-block";
$("btn-rec-start").disabled = false;
$("btn-rec-stop").disabled = true;
$("btn-resolve").disabled = false;
$("rec-info").textContent = `录音完成:${(durationMs / 1000).toFixed(1)}s · ${(recordingWav.size / 1024).toFixed(1)} KB · 16 kHz mono`;
$("rec-info").className = "ok small";
$("meter-bar").style.width = "0";
pcmChunks = [];
}
$("btn-rec-start").onclick = startRecording;
$("btn-rec-stop").onclick = stopRecording;
$("btn-resolve").onclick = async () => {
const sid = ensureSurgeryId();
if (!sid) return;
const cid = $("confirmation-id").value.trim();
if (!cid) { alert("请先获取 confirmation_id"); return; }
if (!recordingWav) { alert("请先录制音频"); return; }
const url = baseUrl() + `/client/surgeries/${sid}/pending-confirmation/${encodeURIComponent(cid)}/resolve`;
const fd = new FormData();
fd.append("audio", recordingWav, "voice.wav");
let res;
try {
res = await fetch(url, { method: "POST", body: fd });
} catch (e) {
addLog("POST", url, "NETWORK", String(e), { error: true });
return;
}
const text = await res.text();
let parsed;
try { parsed = text ? JSON.parse(text) : null; } catch { parsed = text; }
addLog("POST (multipart)", url, res.status, parsed);
if (res.ok) {
recordingWav = null;
$("btn-resolve").disabled = true;
$("audio-preview").hidden = true;
$("btn-download").style.display = "none";
lastTtsConfirmationId = null;
$("rec-info").textContent = "已提交,正在拉取下一条待确认…";
$("rec-info").className = "ok small";
await fetchPendingOnce();
if ($("auto-poll") && $("auto-poll").checked) {
$("voice-status").textContent = "自动轮询中…";
}
} else if (res.status === 422 && parsed && parsed.detail && typeof parsed.detail === "object") {
const d = parsed.detail;
if (d.message) {
let line = "解析未通过:" + d.message;
if (typeof d.retry_remaining === "number") {
line += "retry_remaining=" + d.retry_remaining + "";
}
$("rec-info").textContent = line;
$("rec-info").className = "warn small";
}
}
};
// ============================================================
// Debug: two streams for one-click upload (路1/路2)
// ============================================================
$("btn-dbg-pick-1").onclick = () => $("debug-vfile-1").click();
$("debug-vfile-1").addEventListener("change", (e) => {
const f = e.target.files && e.target.files[0];
if (!f) return;
$("debug-vpath-1").value = "./" + f.name;
$("debug-hint-1").textContent = "已选: " + f.name;
});
$("btn-dbg-pick-2").onclick = () => $("debug-vfile-2").click();
$("debug-vfile-2").addEventListener("change", (e) => {
const f = e.target.files && e.target.files[0];
if (!f) return;
$("debug-vpath-2").value = "./" + f.name;
$("debug-hint-2").textContent = "已选: " + f.name;
});
$("btn-debug-apply-cams").onclick = () => {
const a = ($("debug-cam-1").value || "or-cam-01").trim() || "or-cam-01";
const b = ($("debug-cam-2").value || "or-cam-02").trim() || "or-cam-02";
$("camera-ids").value = a + "," + b;
};
(function setupDebugVideoDrop() {
function bindStreamCard(el, vpathId, hintId) {
if (!el) return;
el.addEventListener("dragover", (ev) => {
ev.preventDefault();
el.style.outline = "1px dashed var(--accent)";
});
el.addEventListener("dragleave", () => {
el.style.outline = "";
});
el.addEventListener("drop", (ev) => {
ev.preventDefault();
el.style.outline = "";
const f = ev.dataTransfer && ev.dataTransfer.files && ev.dataTransfer.files[0];
const looksVideo =
f &&
(/^video\//.test(f.type || "") ||
/\.(mp4|mov|mkv|avi|webm|m4v|mpeg|mpg)$/i.test(f.name || ""));
if (!looksVideo) {
$(hintId).textContent = "请拖入视频文件";
return;
}
$(vpathId).value = "./" + f.name;
$(hintId).textContent = "已选: " + f.name + "(拖放)";
});
}
bindStreamCard($("debug-stream-1"), "debug-vpath-1", "debug-hint-1");
bindStreamCard($("debug-stream-2"), "debug-vpath-2", "debug-hint-2");
})();
// ============================================================
// Boot
// ============================================================
loadLabels();
$("base-url").addEventListener("change", () => { refreshOrchStatus(); });
refreshOrchStatus();
</script>
</body>
</html>