- 用 OR_SITE_CONFIG_JSON_FILE 统一术间配置(video_rtsp_urls + voice_or_room_bindings) - VoiceTerminalHub:assignment、WS 推送与 HTTP 查询;开录/停录后 notify - 一键联调 orchestrate-and-start 与 /client/surgeries/start 共用指派逻辑,修复 demo 路径不发 WS - 语音桌面端:SIGINT 退出、shutdown 清理、仅 WS 指派、固定 pending 轮询间隔、界面仅保留录音时长 - 新增/调整契约与绑定测试,文档与示例配置同步 Made-with: Cursor
1374 lines
55 KiB
HTML
1374 lines
55 KiB
HTML
<!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_id(6 位数字)</label>
|
||
<input id="surgery-id" type="text" inputmode="numeric" pattern="\d{6}" maxlength="6" value="123456" />
|
||
</div>
|
||
</div>
|
||
<p class="small muted" style="margin-top:10px">
|
||
<label style="display:inline-flex;align-items:flex-start;gap:8px;cursor:pointer;max-width:52rem">
|
||
<input type="checkbox" id="toggle-browser-voice-ui" style="margin-top:2px" />
|
||
<span>显示<strong> §4.4 / §4.5</strong>(浏览器待确认与录音上传;默认关闭,主流程请用桌面语音客户端)</span>
|
||
</label>
|
||
</p>
|
||
<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>调试:多路视频 1–4 路(与一键联调 / 无真摄像头)</h2>
|
||
<p class="callout-ok small">
|
||
在下方选好各路视频、第 4.1 节勾选「一键联调」后点「开始手术」即可;服务端会起假 RTSP 并合并写入 <code>OR_SITE_CONFIG_JSON_FILE</code> 的 <code>video_rtsp_urls</code>。无法使用一键时,请按 <code>scripts/demo_client/README.md</code> 在宿主机手跑
|
||
<code>fake_rtsp_from_file.py</code> 并配置环境变量。
|
||
</p>
|
||
<div class="row" style="margin-top:8px; max-width:28rem">
|
||
<div>
|
||
<label>模拟路数(1–4)</label>
|
||
<select id="debug-stream-count">
|
||
<option value="1">1 路</option>
|
||
<option value="2" selected>2 路</option>
|
||
<option value="3">3 路</option>
|
||
<option value="4">4 路</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<h3>各路视频(为第 4.1 节一键选文件;每路 <code>RTSP_PATH</code> 须不同,<code>camera_id</code> 须与开录时一致)</h3>
|
||
<div id="debug-streams-grid" class="row" style="margin-top:10px; align-items:stretch; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr))">
|
||
<div class="debug-stream" id="debug-stream-1" data-stream-slot="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></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" data-stream-slot="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 class="debug-stream" id="debug-stream-3" data-stream-slot="3" style="border:1px solid var(--border); border-radius:8px; padding:10px; display:none">
|
||
<h3 style="margin:0 0 8px; color:var(--accent)">路 3</h3>
|
||
<label>视频(一键上传优先;可选手填本地路径作备注)</label>
|
||
<input id="debug-vpath-3" type="text" placeholder="/path/c.mp4 或 ./c.mp4" />
|
||
<div class="actions" style="margin-top:6px; align-items:center">
|
||
<input type="file" id="debug-vfile-3" accept="video/*" hidden />
|
||
<button type="button" class="secondary" id="btn-dbg-pick-3">选择…</button>
|
||
<span id="debug-hint-3" class="small muted"></span>
|
||
</div>
|
||
<div class="row" style="margin-top:8px">
|
||
<div>
|
||
<label>RTSP 路径名 <code>RTSP_PATH</code></label>
|
||
<input id="debug-rpath-3" type="text" value="demo3" />
|
||
</div>
|
||
<div>
|
||
<label>camera_id</label>
|
||
<input id="debug-cam-3" type="text" value="or-cam-03" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="debug-stream" id="debug-stream-4" data-stream-slot="4" style="border:1px solid var(--border); border-radius:8px; padding:10px; display:none">
|
||
<h3 style="margin:0 0 8px; color:var(--accent)">路 4</h3>
|
||
<label>视频(一键上传优先;可选手填本地路径作备注)</label>
|
||
<input id="debug-vpath-4" type="text" placeholder="/path/d.mp4 或 ./d.mp4" />
|
||
<div class="actions" style="margin-top:6px; align-items:center">
|
||
<input type="file" id="debug-vfile-4" accept="video/*" hidden />
|
||
<button type="button" class="secondary" id="btn-dbg-pick-4">选择…</button>
|
||
<span id="debug-hint-4" class="small muted"></span>
|
||
</div>
|
||
<div class="row" style="margin-top:8px">
|
||
<div>
|
||
<label>RTSP 路径名 <code>RTSP_PATH</code></label>
|
||
<input id="debug-rpath-4" type="text" value="demo4" />
|
||
</div>
|
||
<div>
|
||
<label>camera_id</label>
|
||
<input id="debug-cam-4" type="text" value="or-cam-04" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<p id="debug-file-note" class="muted small" style="margin:8px 0 0">
|
||
一键联调会<strong>直接上传</strong>你在此为各路选择的文件。选文件时会把框内填成 <code>./文件名</code>,仅作展示;真正上传以文件选择器为准,无需在框里改路径。
|
||
</p>
|
||
<div class="actions" style="margin-top:8px">
|
||
<button type="button" class="secondary" id="btn-debug-apply-cams" title="把当前已启用的各路 camera_id 写进开录的 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…N</strong>选好的视频(1–4 路),由监控服务在<strong>能执行 docker+ffmpeg 的环境</strong>里自动起假 RTSP、更新 <code>OR_SITE_CONFIG_JSON_FILE</code> 并开录(需 <code>DEMO_ORCHESTRATOR_ENABLED=true</code> 且该文件为可写挂载;详见 README)。不勾选时仍为普通开录(需自行先起假流并保证站点 JSON 中 RTSP 映射正确)。</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>
|
||
|
||
<div id="browser-voice-sections" hidden>
|
||
<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 置信度 < 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>。<strong>§4.1 开录返回 200 后本页会自动排入一次 §4.4 拉取</strong>;§4.5 上传成功后也会串行拉取下一条,多条待确认按服务端 FIFO 逐条处理。若在轮询 GET 尚未返回时已提交 §4.5,本页会丢弃过期响应并自动再拉一次,避免旧 <code>confirmation_id</code> 盖住新队首。</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>
|
||
</div>
|
||
</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());
|
||
};
|
||
|
||
const browserVoiceSections = $("browser-voice-sections");
|
||
const toggleBrowserVoiceUi = $("toggle-browser-voice-ui");
|
||
function syncBrowserVoiceUiVisibility() {
|
||
if (!browserVoiceSections || !toggleBrowserVoiceUi) return;
|
||
browserVoiceSections.hidden = !toggleBrowserVoiceUi.checked;
|
||
}
|
||
if (toggleBrowserVoiceUi) {
|
||
toggleBrowserVoiceUi.addEventListener("change", syncBrowserVoiceUiVisibility);
|
||
syncBrowserVoiceUiVisibility();
|
||
}
|
||
|
||
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("OR_SITE_CONFIG") >= 0) {
|
||
hint = "需配置可写的 OR_SITE_CONFIG_JSON_FILE(严格站点 JSON),且 Docker 下请 bind-mount 到容器内同路径。";
|
||
} else if (res.status === 503) {
|
||
hint = "合成假 RTSP 或开录失败,请见响应体与主服务终端 log(demo 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.or_site_config_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.or_site_config_json_file || "(未设)";
|
||
b.innerHTML = on
|
||
? ("一键 <code>POST " + (data.orchestrate_path || "/internal/demo/orchestrate-and-start") + "</code>:" + (fset ? "已开放;站点配置 " : "未设 ") + "<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 n = getDebugStreamCount();
|
||
const files = [];
|
||
for (let i = 1; i <= n; i++) {
|
||
const f = $("debug-vfile-" + i).files && $("debug-vfile-" + i).files[0];
|
||
files.push(f);
|
||
}
|
||
const missing = files.findIndex((x) => !x);
|
||
if (missing >= 0) {
|
||
alert("请先在上方「调试」里为 路 " + (missing + 1) + " 「选择…」一个视频文件(当前为 " + n + " 路)。");
|
||
return;
|
||
}
|
||
const fd = new FormData();
|
||
fd.append("surgery_id", sid);
|
||
fd.append("video1", files[0], files[0].name);
|
||
if (n >= 2) fd.append("video2", files[1], files[1].name);
|
||
if (n >= 3) fd.append("video3", files[2], files[2].name);
|
||
if (n >= 4) fd.append("video4", files[3], files[3].name);
|
||
const defCams = ["or-cam-01", "or-cam-02", "or-cam-03", "or-cam-04"];
|
||
const defRp = ["demo1", "demo2", "demo3", "demo4"];
|
||
for (let i = 1; i <= 4; i++) {
|
||
fd.append(
|
||
"camera_" + i,
|
||
($("debug-cam-" + i).value || defCams[i - 1]).trim() || defCams[i - 1],
|
||
);
|
||
fd.append(
|
||
"rtsp_path_" + i,
|
||
($("debug-rpath-" + i).value || defRp[i - 1]).trim() || defRp[i - 1],
|
||
);
|
||
}
|
||
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;
|
||
}
|
||
// 开录成功后立即排入 §4.4;并使此前进行中的 pending GET 失效(避免旧 id 覆盖)
|
||
_pendingSyncSeq++;
|
||
fetchPendingOnce();
|
||
return;
|
||
}
|
||
const camera_ids = $("camera-ids").value.split(",").map(s => s.trim()).filter(Boolean);
|
||
if (camera_ids.length === 0) { alert("camera_ids 至少要 1 个"); return; }
|
||
const { res } = await apiJson("POST", "/client/surgeries/start", {
|
||
surgery_id: sid,
|
||
camera_ids,
|
||
candidate_consumables: [...tags],
|
||
});
|
||
if (res.ok) {
|
||
_pendingSyncSeq++;
|
||
fetchPendingOnce();
|
||
}
|
||
};
|
||
|
||
// ============================================================
|
||
// §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: "qty", label: "qty" },
|
||
{ key: "doctor_id", label: "doctor" },
|
||
]);
|
||
renderTable("汇总 summary[]", summary, [
|
||
{ key: "item_id", label: "item_id" },
|
||
{ key: "item_name", label: "item_name" },
|
||
{ key: "total_quantity", label: "total" },
|
||
]);
|
||
};
|
||
|
||
// ============================================================
|
||
// §4.4 pending-confirmation(响应内带 Base64 MP3)+ 可选自动播报
|
||
// §4.5 依赖本节的 confirmation_id;避免并发拉取竞态与「已无待确认仍保留旧 id」
|
||
// ============================================================
|
||
let pollTimer = null;
|
||
/**
|
||
* 自动播报去重:在「开始排程」时同步写入当前 confirmation_id,避免串行 GET 在首段尚未播完时
|
||
* 再次命中 !== 判断而启动第二遍播报(_pendingSyncSeq 重拉、开录后立即拉取等会紧挨着第二次 fetch)。
|
||
* 仅当播放失败时在 catch 中清除,便于轮询/手动重试。
|
||
*/
|
||
let lastSpokenConfirmationId = null;
|
||
let lastPendingPayload = null;
|
||
/**
|
||
* 与「进行中的 GET pending」比对:§4.5 resolve 成功、手术 id 变更、重新开录时递增。
|
||
* 解决竞态:GET 已发出后用户才提交 resolve,晚到的响应会带着旧 confirmation_id,不得写回 UI。
|
||
*/
|
||
let _pendingSyncSeq = 0;
|
||
/** 串行化 GET pending:链式 Promise,支持 await fetchPendingOnce()(§4.5 成功后拉取下一条) */
|
||
let _pendingFetchChain = Promise.resolve();
|
||
|
||
/** 方案1:首次用户手势内播放极短静音,解锁自动播放;之后待确认 MP3 复用同一 Audio */
|
||
const SILENT_UNLOCK_DATA_URL =
|
||
"data:audio/wav;base64,UklGRigAAABXQVZFZm10IBIAAAABAAEARKwAAIhYAQACABAAAABkYXRhAgAAAAE=";
|
||
let sharedPromptAudio = null;
|
||
let audioPlaybackUnlocked = false;
|
||
let lastPromptBlobUrl = null;
|
||
|
||
function getSharedPromptAudio() {
|
||
if (!sharedPromptAudio) {
|
||
sharedPromptAudio = new Audio();
|
||
sharedPromptAudio.preload = "auto";
|
||
sharedPromptAudio.volume = 1;
|
||
}
|
||
return sharedPromptAudio;
|
||
}
|
||
|
||
document.addEventListener(
|
||
"pointerdown",
|
||
async () => {
|
||
if (audioPlaybackUnlocked) return;
|
||
try {
|
||
const a = getSharedPromptAudio();
|
||
if (lastPromptBlobUrl) {
|
||
URL.revokeObjectURL(lastPromptBlobUrl);
|
||
lastPromptBlobUrl = null;
|
||
}
|
||
a.src = SILENT_UNLOCK_DATA_URL;
|
||
await a.play();
|
||
a.pause();
|
||
a.currentTime = 0;
|
||
audioPlaybackUnlocked = true;
|
||
} catch (e) {
|
||
console.warn("[demo-client] 音频自动播放未解锁(可点「播放话术」)", e);
|
||
}
|
||
},
|
||
{ once: true, capture: true, passive: true },
|
||
);
|
||
|
||
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 pending 的 prompt_audio_mp3_base64;优先用解锁后的单例 Audio;失败则回退 speechSynthesis */
|
||
async function playPromptAudioBase64(b64, textFallback) {
|
||
const t = (textFallback || "").trim();
|
||
const raw = typeof b64 === "string" ? b64.replace(/\s+/g, "") : "";
|
||
if (raw) {
|
||
try {
|
||
const bin = atob(raw);
|
||
const bytes = new Uint8Array(bin.length);
|
||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||
const blob = new Blob([bytes], { type: "audio/mpeg" });
|
||
const o = URL.createObjectURL(blob);
|
||
const a = getSharedPromptAudio();
|
||
if (lastPromptBlobUrl) {
|
||
URL.revokeObjectURL(lastPromptBlobUrl);
|
||
lastPromptBlobUrl = null;
|
||
}
|
||
lastPromptBlobUrl = o;
|
||
a.pause();
|
||
a.currentTime = 0;
|
||
a.src = o;
|
||
try {
|
||
await new Promise((resolve, reject) => {
|
||
const cleanupBlob = () => {
|
||
if (lastPromptBlobUrl === o) {
|
||
URL.revokeObjectURL(o);
|
||
lastPromptBlobUrl = null;
|
||
}
|
||
};
|
||
a.onended = () => {
|
||
cleanupBlob();
|
||
resolve();
|
||
};
|
||
a.onerror = () => {
|
||
cleanupBlob();
|
||
reject(new Error("Audio 元素解码/播放失败"));
|
||
};
|
||
const p = a.play();
|
||
if (p && typeof p.catch === "function") {
|
||
p.catch((err) => {
|
||
cleanupBlob();
|
||
reject(err);
|
||
});
|
||
}
|
||
});
|
||
return;
|
||
} catch (playErr) {
|
||
if (lastPromptBlobUrl === o) {
|
||
URL.revokeObjectURL(o);
|
||
lastPromptBlobUrl = null;
|
||
}
|
||
console.warn("[demo-client] MP3 play() 被拒或失败,尝试浏览器朗读", playErr);
|
||
}
|
||
} catch (e) {
|
||
console.warn("[demo-client] Base64 MP3 解码失败,尝试浏览器朗读", e);
|
||
}
|
||
}
|
||
if (t) await speakTextPromise(t);
|
||
}
|
||
|
||
if (window.speechSynthesis) {
|
||
window.speechSynthesis.addEventListener("voiceschanged", () => {});
|
||
}
|
||
|
||
$("surgery-id").addEventListener("input", () => {
|
||
lastSpokenConfirmationId = null;
|
||
lastPendingPayload = null;
|
||
_pendingSyncSeq++;
|
||
});
|
||
|
||
async function playLastPendingManually() {
|
||
const p = lastPendingPayload;
|
||
if (!p || !p.confirmation_id) return;
|
||
const pt = (p.prompt_text || "").trim();
|
||
const cid = p.confirmation_id;
|
||
try {
|
||
lastSpokenConfirmationId = cid;
|
||
await playPromptAudioBase64(p.prompt_audio_mp3_base64, pt);
|
||
} catch (e) {
|
||
console.warn("[demo-client] 手动播放失败", e);
|
||
if (lastSpokenConfirmationId === cid) lastSpokenConfirmationId = null;
|
||
}
|
||
}
|
||
|
||
async function runFetchPendingOnce() {
|
||
const sid = surgeryId();
|
||
if (!/^\d{6}$/.test(sid)) return;
|
||
const startSeq = _pendingSyncSeq;
|
||
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;
|
||
}
|
||
if (startSeq !== _pendingSyncSeq) {
|
||
fetchPendingOnce();
|
||
return;
|
||
}
|
||
const raw = await res.text();
|
||
let body;
|
||
try {
|
||
body = raw ? JSON.parse(raw) : null;
|
||
} catch {
|
||
body = raw;
|
||
}
|
||
if (startSeq !== _pendingSyncSeq) {
|
||
fetchPendingOnce();
|
||
return;
|
||
}
|
||
if (res.status === 404) {
|
||
// 无待确认为常态,不写入右侧「响应日志」,减少刷屏
|
||
} else {
|
||
addLog("GET", url, res.status, body);
|
||
}
|
||
const box = $("pending-render");
|
||
if (res.status === 200 && body && body.confirmation_id) {
|
||
const prevId = lastPendingPayload && lastPendingPayload.confirmation_id;
|
||
if (prevId && prevId !== body.confirmation_id) {
|
||
recordingWav = null;
|
||
$("btn-resolve").disabled = true;
|
||
$("audio-preview").hidden = true;
|
||
$("btn-download").style.display = "none";
|
||
$("rec-info").textContent = "新待确认已入队,请重新录音后上传";
|
||
$("rec-info").className = "warn small";
|
||
}
|
||
box.hidden = false;
|
||
lastPendingPayload = body;
|
||
$("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>
|
||
<div style="margin-top:10px">
|
||
<button type="button" class="secondary" id="btn-play-pending">▶ 播放话术(MP3 或浏览器朗读)</button>
|
||
<span class="small muted" style="margin-left:8px">首次在页面任意处点按可解锁自动播报;仍失败时点此处</span>
|
||
</div>`;
|
||
const btnPlay = $("btn-play-pending");
|
||
if (btnPlay) btnPlay.onclick = () => void playLastPendingManually();
|
||
const pt = (body.prompt_text || "").trim();
|
||
const ttsOn = $("tts-pending") && $("tts-pending").checked;
|
||
const cidForTts = body.confirmation_id;
|
||
if (ttsOn && pt && cidForTts !== lastSpokenConfirmationId) {
|
||
lastSpokenConfirmationId = cidForTts;
|
||
void (async () => {
|
||
try {
|
||
await playPromptAudioBase64(body.prompt_audio_mp3_base64, pt);
|
||
} catch (e) {
|
||
console.warn("[demo-client] 自动播报未完成(可点「播放话术」)", e);
|
||
if (lastSpokenConfirmationId === cidForTts) lastSpokenConfirmationId = null;
|
||
}
|
||
})();
|
||
}
|
||
} else if (res.status === 404) {
|
||
lastPendingPayload = null;
|
||
lastSpokenConfirmationId = null;
|
||
box.hidden = false;
|
||
box.innerHTML = '<span class="muted">暂无待确认项。请先 §4.4 拉取到待确认后再 §4.5 录音上传。</span>';
|
||
$("confirmation-id").value = "";
|
||
$("btn-resolve").disabled = true;
|
||
recordingWav = null;
|
||
$("audio-preview").hidden = true;
|
||
$("btn-download").style.display = "none";
|
||
$("rec-info").textContent = "无待确认:无需录音;有新任务时会自动填入 confirmation_id";
|
||
$("rec-info").className = "muted small";
|
||
} else {
|
||
box.hidden = false;
|
||
box.innerHTML = `<span class="err">HTTP ${res.status}</span>`;
|
||
}
|
||
}
|
||
|
||
/** 对外入口:重叠调用自动排队;返回的 Promise 在整段链完成后 settle(便于 §4.5 await) */
|
||
function fetchPendingOnce() {
|
||
const run = _pendingFetchChain.then(
|
||
() => runFetchPendingOnce(),
|
||
() => runFetchPendingOnce(),
|
||
);
|
||
_pendingFetchChain = run.catch((e) => {
|
||
console.warn("[demo-client] pending fetch", e);
|
||
});
|
||
return run;
|
||
}
|
||
|
||
$("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";
|
||
lastSpokenConfirmationId = null;
|
||
_pendingSyncSeq++;
|
||
$("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: 1–4 streams for one-click upload
|
||
// ============================================================
|
||
function getDebugStreamCount() {
|
||
const sel = $("debug-stream-count");
|
||
const v = sel ? parseInt(sel.value, 10) : 2;
|
||
if (v === 1 || v === 2 || v === 3 || v === 4) return v;
|
||
return 2;
|
||
}
|
||
|
||
function applyDebugStreamVisibility() {
|
||
const n = getDebugStreamCount();
|
||
for (let i = 1; i <= 4; i++) {
|
||
const el = $("debug-stream-" + i);
|
||
if (!el) continue;
|
||
el.style.display = i <= n ? "block" : "none";
|
||
}
|
||
}
|
||
|
||
if ($("debug-stream-count")) {
|
||
$("debug-stream-count").addEventListener("change", () => {
|
||
applyDebugStreamVisibility();
|
||
});
|
||
applyDebugStreamVisibility();
|
||
}
|
||
|
||
for (let i = 1; i <= 4; i++) {
|
||
const pick = $("btn-dbg-pick-" + i);
|
||
const vfile = $("debug-vfile-" + i);
|
||
const vpath = $("debug-vpath-" + i);
|
||
const hint = $("debug-hint-" + i);
|
||
if (pick && vfile) {
|
||
pick.onclick = () => vfile.click();
|
||
vfile.addEventListener("change", (e) => {
|
||
const f = e.target.files && e.target.files[0];
|
||
if (!f) return;
|
||
vpath.value = "./" + f.name;
|
||
hint.textContent = "已选: " + f.name;
|
||
});
|
||
}
|
||
}
|
||
|
||
$("btn-debug-apply-cams").onclick = () => {
|
||
const defCams = ["or-cam-01", "or-cam-02", "or-cam-03", "or-cam-04"];
|
||
const n = getDebugStreamCount();
|
||
const parts = [];
|
||
for (let i = 1; i <= n; i++) {
|
||
const a = ($("debug-cam-" + i).value || defCams[i - 1]).trim() || defCams[i - 1];
|
||
parts.push(a);
|
||
}
|
||
$("camera-ids").value = parts.join(",");
|
||
};
|
||
|
||
(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 + "(拖放)";
|
||
});
|
||
}
|
||
for (let i = 1; i <= 4; i++) {
|
||
bindStreamCard($("debug-stream-" + i), "debug-vpath-" + i, "debug-hint-" + i);
|
||
}
|
||
})();
|
||
|
||
// ============================================================
|
||
// Boot
|
||
// ============================================================
|
||
loadLabels();
|
||
$("base-url").addEventListener("change", () => { refreshOrchStatus(); });
|
||
refreshOrchStatus();
|
||
</script>
|
||
</body>
|
||
</html>
|