2348 lines
135 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="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sanad Dashboard</title>
<style>
:root{--bg:#0a0f1a;--panel:#111827;--panel2:#1a2332;--accent:#0ea5e9;--accent2:#6366f1;--text:#e2e8f0;--muted:#64748b;--dim:#475569;--danger:#ef4444;--success:#22c55e;--warn:#f59e0b;--border:#1e293b;--glow:0 0 20px rgba(14,165,233,.08);--radius:12px;--err:#ef4444}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Inter','Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
/* Header */
header{background:linear-gradient(135deg,#111827 0%,#1a2332 100%);padding:.7rem 1.5rem;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--border);position:sticky;top:0;z-index:100;backdrop-filter:blur(12px)}
header h1{font-size:1.3rem;font-weight:700;letter-spacing:-.02em} header h1 span{background:linear-gradient(135deg,var(--accent),var(--accent2));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.hdr-right{display:flex;align-items:center;gap:.8rem;font-size:.78rem}
.dot{width:8px;height:8px;border-radius:50%;display:inline-block}
.dot-ok{background:var(--success);box-shadow:0 0 6px var(--success)} .dot-err{background:var(--danger);box-shadow:0 0 6px var(--danger)} .dot-warn{background:var(--warn)}
.hdr-badge{padding:2px 7px;border-radius:4px;font-size:.68rem;font-weight:600;text-transform:uppercase;letter-spacing:.04em}
.hdr-badge-err{background:rgba(239,68,68,.15);color:var(--danger);border:1px solid rgba(239,68,68,.3)}
.hdr-badge-ok{background:rgba(34,197,94,.12);color:var(--success);border:1px solid rgba(34,197,94,.25)}
/* N2 — global subsystem status strip + Controller tab */
.status-pills{display:flex;gap:.45rem;align-items:center;padding:.3rem 1.5rem;background:var(--panel);border-bottom:1px solid var(--border);flex-wrap:wrap}
.pill-off{background:rgba(100,116,139,.12);color:var(--muted);border:1px solid var(--border)}
.pill-on{background:rgba(34,197,94,.14);color:var(--success);border:1px solid rgba(34,197,94,.3)}
.pill-soon{opacity:.45;cursor:not-allowed;background:rgba(100,116,139,.08);color:var(--muted);border:1px dashed var(--border)}
.steppad{display:grid;grid-template-columns:repeat(3,1fr);gap:.3rem;max-width:230px}
.steppad button{font-size:1rem;padding:.5rem 0}
.ctrl-strip{padding-left:0;border:none;background:transparent}
.motion-locked{opacity:.5;pointer-events:none;filter:grayscale(.4)}
#motion-lock-banner{display:none;align-items:center;gap:.5rem;margin-bottom:.6rem;padding:.5rem .7rem;background:rgba(245,158,11,.12);border:1px solid rgba(245,158,11,.35);border-radius:8px;color:var(--warn);font-size:.74rem}
#estop{background:var(--danger);color:#fff;border:none;padding:.35rem .9rem;border-radius:6px;font-weight:700;font-size:.75rem;cursor:pointer;letter-spacing:.03em;box-shadow:0 0 12px rgba(239,68,68,.3);transition:all .15s}
#estop:hover{box-shadow:0 0 20px rgba(239,68,68,.5);transform:scale(1.04)}
/* Tabs */
.tabs{display:flex;gap:0;background:var(--panel);border-bottom:1px solid var(--border);padding:0 1.5rem;overflow-x:auto}
.tab{padding:.55rem 1.1rem;font-size:.78rem;color:var(--muted);cursor:pointer;border-bottom:2px solid transparent;transition:all .15s;white-space:nowrap;font-weight:500}
.tab:hover{color:var(--text)} .tab.active{color:var(--accent);border-bottom-color:var(--accent)}
.tab-content{display:none;padding:1rem 1.5rem} .tab-content.active{display:block}
/* Grid */
.grid{display:grid;grid-template-columns:1fr 1fr;gap:.8rem}
@media(max-width:900px){.grid{grid-template-columns:1fr}}
/* Cards */
.card{background:var(--panel);border-radius:var(--radius);padding:1rem 1.1rem;border:1px solid var(--border);box-shadow:var(--glow);transition:border-color .2s}
.card:hover{border-color:rgba(14,165,233,.2)}
.card h3{font-size:.82rem;color:var(--accent);margin-bottom:.6rem;display:flex;align-items:center;gap:.4rem;font-weight:600;text-transform:uppercase;letter-spacing:.04em}
.card h3 svg{width:14px;height:14px;opacity:.7}
.card-full{grid-column:1/-1}
/* Buttons */
.btn{display:inline-flex;align-items:center;gap:.3rem;padding:.4rem .75rem;border-radius:6px;font-size:.76rem;font-weight:500;cursor:pointer;border:1px solid transparent;transition:all .15s}
.btn:disabled{opacity:.4;cursor:not-allowed;pointer-events:none}
.btn-primary{background:var(--accent);color:#fff;border-color:var(--accent)} .btn-primary:hover{opacity:.85}
.btn-danger{background:rgba(239,68,68,.12);color:var(--danger);border-color:rgba(239,68,68,.3)} .btn-danger:hover{background:rgba(239,68,68,.2)}
.btn-ghost{background:transparent;color:var(--muted);border-color:var(--border)} .btn-ghost:hover{color:var(--text);border-color:var(--dim)}
.btn-success{background:rgba(34,197,94,.12);color:var(--success);border-color:rgba(34,197,94,.3)} .btn-success:hover{background:rgba(34,197,94,.2)}
.btn-sm{padding:.25rem .5rem;font-size:.7rem}
.btn.loading{pointer-events:none;opacity:.6} .btn.loading::after{content:'';width:12px;height:12px;border:2px solid currentColor;border-top-color:transparent;border-radius:50%;animation:spin .5s linear infinite;margin-left:.3rem}
@keyframes spin{to{transform:rotate(360deg)}}
/* Form */
input,textarea,select{background:var(--bg);color:var(--text);border:1px solid var(--border);padding:.4rem .6rem;border-radius:6px;width:100%;font-size:.8rem;font-family:inherit;transition:border-color .15s}
input:focus,textarea:focus,select:focus{outline:none;border-color:var(--accent)}
textarea{resize:vertical;min-height:50px}
label{font-size:.72rem;color:var(--muted);font-weight:500;text-transform:uppercase;letter-spacing:.04em}
.row{display:flex;gap:.4rem;align-items:center;margin-bottom:.4rem}
/* Badge */
.badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:10px;font-size:.68rem;font-weight:600}
.badge-ok{background:rgba(34,197,94,.12);color:var(--success)} .badge-err{background:rgba(239,68,68,.12);color:var(--danger)} .badge-warn{background:rgba(245,158,11,.12);color:var(--warn)} .badge-info{background:rgba(99,102,241,.12);color:var(--accent2)}
/* Table */
table{width:100%;border-collapse:collapse;font-size:.76rem}
th{color:var(--muted);font-weight:600;text-transform:uppercase;font-size:.68rem;letter-spacing:.04em;padding:6px 8px;border-bottom:1px solid var(--border)}
td{padding:5px 8px;border-bottom:1px solid rgba(30,41,55,.5)}
tr:hover td{background:rgba(14,165,233,.03)}
/* Action buttons grid */
.action-btn{background:var(--panel2);color:var(--text);border:1px solid var(--border);padding:.35rem .6rem;border-radius:6px;cursor:pointer;font-size:.72rem;transition:all .15s;display:inline-flex;align-items:center;gap:3px}
.action-btn:hover{background:var(--accent);border-color:var(--accent);color:#fff}
.action-btn.running{background:var(--accent);color:#fff;animation:pulse 1s infinite;pointer-events:none}
.action-btn:disabled{opacity:.3;cursor:not-allowed}
.type-dot{width:5px;height:5px;border-radius:50%;flex-shrink:0} .type-sdk{background:#a78bfa} .type-jsonl{background:var(--success)}
.action-list{max-height:260px;overflow-y:auto;border:1px solid var(--border);border-radius:6px;background:var(--panel2)}
.action-row{display:flex;align-items:center;gap:.5rem;padding:.4rem .6rem;border-bottom:1px solid var(--border);cursor:pointer;font-size:.78rem;user-select:none;transition:background .1s}
.action-row:last-child{border-bottom:none}
.action-row:hover{background:rgba(255,255,255,.04)}
.action-row.selected{background:var(--accent);color:#fff}
.action-row.running{background:var(--accent);color:#fff;animation:pulse 1s infinite}
.action-row .r-name{flex:1;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.action-row .r-meta{color:var(--dim);font-size:.7rem;font-variant-numeric:tabular-nums;white-space:nowrap}
.action-row.selected .r-meta{color:rgba(255,255,255,.85)}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
/* Mute */
.mute-btn{min-width:80px;text-align:center;padding:.35rem .6rem;border-radius:6px;font-size:.76rem;font-weight:500;cursor:pointer;border:1px solid var(--border);transition:all .15s}
.mute-btn.off{background:rgba(34,197,94,.1);color:var(--success);border-color:rgba(34,197,94,.25)}
.mute-btn.on{background:rgba(239,68,68,.12);color:var(--danger);border-color:rgba(239,68,68,.3)}
/* Gallery */
.gallery-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:.5rem;max-height:220px;overflow-y:auto;padding:.2rem}
.gallery-grid img{width:100%;height:85px;object-fit:cover;border-radius:8px;cursor:pointer;border:2px solid var(--border);transition:all .15s}
.gallery-grid img:hover{border-color:var(--accent);transform:scale(1.03)}
/* Log box */
.log-box{background:#000;color:#4ade80;font-family:'JetBrains Mono','Fira Code',monospace;font-size:.7rem;padding:.6rem;border-radius:8px;overflow-y:auto;white-space:pre-wrap;line-height:1.4;border:1px solid var(--border)}
/* Toast */
#toast-box{position:fixed;top:4rem;right:1rem;z-index:9999;display:flex;flex-direction:column;gap:.4rem}
.toast{padding:.55rem 1rem;border-radius:8px;font-size:.78rem;color:#fff;animation:slideIn .25s;max-width:360px;word-break:break-word;backdrop-filter:blur(8px);box-shadow:0 4px 12px rgba(0,0,0,.3)}
.toast-ok{background:rgba(22,101,52,.92)} .toast-err{background:rgba(153,27,27,.92)} .toast-info{background:rgba(30,64,175,.92)}
@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}
/* Empty state */
.empty{color:var(--dim);font-size:.78rem;text-align:center;padding:1.5rem;font-style:italic}
/* Toggle switch */
.switch{position:relative;width:36px;height:20px;display:inline-block}
.switch input{opacity:0;width:0;height:0}
.switch .slider{position:absolute;inset:0;background:var(--dim);border-radius:10px;cursor:pointer;transition:.2s}
.switch .slider::before{content:'';position:absolute;width:16px;height:16px;left:2px;bottom:2px;background:#fff;border-radius:50%;transition:.2s}
.switch input:checked+.slider{background:var(--accent)}
.switch input:checked+.slider::before{transform:translateX(16px)}
/* Scrollbar */
::-webkit-scrollbar{width:6px} ::-webkit-scrollbar-track{background:transparent} ::-webkit-scrollbar-thumb{background:var(--dim);border-radius:3px} ::-webkit-scrollbar-thumb:hover{background:var(--muted)}
</style>
</head>
<body>
<div id="toast-box"></div>
<!-- Header -->
<header>
<h1><span>Sanad</span> Dashboard</h1>
<div class="hdr-right">
<span id="mic-badge" class="hdr-badge hdr-badge-err" style="display:none">MIC OFF</span>
<span id="spk-badge" class="hdr-badge hdr-badge-err" style="display:none">SPK OFF</span>
<span id="gemini-badge" class="hdr-badge" style="display:none"></span>
<span id="arm-hdr-badge" class="hdr-badge" style="display:none"></span>
<span class="dot" id="status-dot"></span>
<span id="status-text" style="font-size:.78rem">Connecting...</span>
<button id="estop" onclick="emergencyStop()">E-STOP</button>
</div>
</header>
<!-- N2 — global subsystem status strip (visible on every tab) -->
<div id="status-pills" class="status-pills">
<span class="hdr-badge pill-off" id="pill-camera" title="Camera / vision">CAM</span>
<span class="hdr-badge pill-off" id="pill-face" title="Face recognition">FACE</span>
<span class="hdr-badge pill-off" id="pill-place" title="Place / zone recognition">PLACE</span>
<span class="hdr-badge pill-off" id="pill-movement" title="Movement (manual locomotion armed)">MOVE</span>
</div>
<!-- Tabs -->
<div class="tabs">
<div class="tab active" onclick="switchTab('operations')">Operations</div>
<div class="tab" onclick="switchTab('voice')">Voice & Audio</div>
<div class="tab" onclick="switchTab('motion')">Motion & Replay</div>
<div class="tab" onclick="switchTab('controller')">Controller</div>
<div class="tab" onclick="switchTab('recognition')">Recognition</div>
<div class="tab" onclick="switchTab('recordings')">Recordings</div>
<div class="tab" onclick="switchTab('temp')">Temperature</div>
<div class="tab" onclick="switchTab('terminal')">Terminal</div>
<div class="tab" onclick="switchTab('settings')">Settings & Logs</div>
</div>
<!-- ==================== TAB: Operations ==================== -->
<div class="tab-content active" id="tab-operations">
<div class="grid">
<!-- Quick Voice -->
<div class="card">
<h3><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/></svg>Quick Voice</h3>
<div class="row"><select id="engine" style="width:110px"><option value="gemini">Gemini</option><option value="local">Local TTS</option></select></div>
<textarea id="voice-text" placeholder="Type text to speak..." rows="2"></textarea>
<div class="row" style="margin-top:.4rem">
<button class="btn btn-primary" onclick="generate(this)">Generate & Play</button>
<button class="btn btn-ghost" onclick="connectGemini(this)">Connect</button>
<button class="btn btn-danger" onclick="disconnectGemini(this)">Disconnect</button>
</div>
<div id="voice-result" style="margin-top:.3rem;font-size:.72rem;color:var(--muted)"></div>
</div>
<!-- System Info -->
<div class="card" id="system-info-card">
<h3><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>System Info</h3>
<div id="sys-summary" style="font-size:.78rem;line-height:1.5">
<div class="empty">Loading...</div>
</div>
<details style="margin-top:.4rem">
<summary style="cursor:pointer;font-size:.72rem;color:var(--dim)">Network interfaces</summary>
<div id="sys-network" style="font-size:.7rem;margin-top:.3rem"></div>
</details>
<details style="margin-top:.4rem">
<summary style="cursor:pointer;font-size:.72rem;color:var(--dim)">Subsystems (connected / disconnected)</summary>
<div id="sys-subsystems" style="font-size:.7rem;margin-top:.3rem"></div>
</details>
</div>
<!-- Audio Control -->
<div class="card">
<h3><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/></svg>Audio Control</h3>
<div class="row" style="justify-content:space-between">
<div><label>Microphone</label><br><button id="mic-btn" class="mute-btn off" onclick="toggleMic()">Unmuted</button></div>
<div><label>Speaker</label><br><button id="spk-btn" class="mute-btn off" onclick="toggleSpeaker()">Unmuted</button></div>
</div>
<div style="margin-top:.7rem">
<div style="display:flex;justify-content:space-between;align-items:center">
<label>G1 Built-in Speaker Volume</label>
<span id="g1-vol-label" style="font-size:.72rem;color:var(--dim)"></span>
</div>
<div class="row" style="margin-top:.25rem;gap:.3rem;align-items:center">
<button class="btn btn-ghost btn-sm" onclick="setG1Vol(0,this)" title="Mute">0</button>
<input id="g1-vol-slider" type="range" min="0" max="100" step="5" value="100"
oninput="document.getElementById('g1-vol-label').textContent=this.value+'%'"
onchange="setG1Vol(parseInt(this.value),this)"
style="flex:1">
<button class="btn btn-ghost btn-sm" onclick="setG1Vol(100,this)" title="Full">100</button>
</div>
<div id="g1-vol-status" style="font-size:.65rem;color:var(--dim);margin-top:.2rem">
Persisted in data/motions/config.json · applies live via DDS
</div>
</div>
<div style="margin-top:.6rem">
<label>Audio device profile</label>
<div class="row" style="margin-top:.3rem">
<select id="audio-profile" style="flex:1" onchange="selectAudioProfile(this.value)">
<option value="">Loading profiles...</option>
</select>
<button class="btn btn-primary btn-sm" onclick="applyAudioProfile(this)" title="Apply selected profile to PulseAudio">Apply</button>
<button class="btn btn-ghost btn-sm" onclick="scanAudioDevices(this)" title="Scan all USB ports for audio devices">Scan</button>
<button class="btn btn-ghost btn-sm" onclick="resetAudioSubsystem(this)" title="SOFT reset: restart PulseAudio/pipewire-pulse. Use when devices look stuck. Does NOT recover a missing USB mic — for that use USB Reset.">Reset PA</button>
<button class="btn btn-ghost btn-sm" onclick="usbResetAnker(this)" title="HARD reset: unbind+rebind snd-usb-audio for the Anker (VID:PID 291a:3301). Use when Anker is plugged but the mic profile is missing from pactl. Needs a one-time sudoers setup — see hint in the error toast if it fails." style="color:var(--warn,#f5a623)">USB Reset</button>
</div>
<div id="audio-detected" style="margin-top:.3rem;font-size:.65rem;color:var(--dim)"></div>
</div>
<details style="margin-top:.5rem">
<summary style="cursor:pointer;font-size:.72rem;color:var(--dim)">Manual sink / source override</summary>
<div class="row" style="margin-top:.3rem">
<select id="audio-sink" style="flex:1"></select>
</div>
<div class="row" style="margin-top:.3rem">
<select id="audio-source" style="flex:1"></select>
</div>
<div class="row" style="margin-top:.3rem">
<button class="btn btn-primary btn-sm" onclick="applyManualAudio(this)">Apply</button>
</div>
</details>
<div style="margin-top:.5rem;font-size:.72rem;color:var(--dim)" id="audio-status-text"></div>
</div>
<!-- Motion Quick Panel -->
<div class="card">
<h3><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>Quick Actions</h3>
<div class="row">
<label>Speed</label>
<select id="action-speed" style="width:65px"><option value="0.5">0.5x</option><option value="1.0" selected>1.0x</option><option value="1.5">1.5x</option><option value="2.0">2.0x</option></select>
<span id="arm-busy-badge" class="badge badge-err" style="display:none">BUSY</span>
<button class="btn btn-danger btn-sm" onclick="cancelAction()" style="margin-left:auto">Cancel</button>
</div>
<div id="running-action" style="font-size:.75rem;color:var(--accent);margin-bottom:.3rem;display:none"></div>
<div id="sdk-actions" style="display:flex;flex-wrap:wrap;gap:3px;margin-top:.2rem"></div>
<div id="jsonl-actions" style="display:flex;flex-wrap:wrap;gap:3px;margin-top:.4rem"></div>
</div>
</div>
</div>
<!-- ==================== TAB: Voice & Audio ==================== -->
<div class="tab-content" id="tab-voice">
<div class="grid">
<!-- Live Voice Commands -->
<div class="card">
<h3>Live Voice Commands</h3>
<div class="row">
<button class="btn btn-success" onclick="startLiveVoice(this)">Start</button>
<button class="btn btn-danger" onclick="stopLiveVoice(this)">Stop</button>
<span id="lv-state" class="badge"></span>
<button class="btn btn-sm mic-mute-shortcut btn-success" onclick="toggleMic()" style="margin-left:auto">Mic: LIVE</button>
<button class="btn btn-sm spk-mute-shortcut btn-success" onclick="toggleSpeaker()">Speaker: LIVE</button>
</div>
<div class="row" style="margin-top:.4rem;gap:1.2rem">
<div class="row" style="gap:.4rem">
<label>Arm Trigger</label>
<label class="switch" title="Master gate — when OFF, voice never moves the arm"><input type="checkbox" id="lv-trigger-enabled" onchange="setTriggerEnabled(this.checked)"><span class="slider"></span></label>
</div>
<div class="row" style="gap:.4rem">
<label>Deferred Trigger</label>
<label class="switch" title="When ON, arm fires ~0.6s after you stop talking"><input type="checkbox" id="lv-deferred" onchange="setDeferredMode(this.checked)"><span class="slider"></span></label>
</div>
</div>
<div style="margin-top:.4rem;font-size:.72rem;color:var(--muted)">
<div>Last heard: <strong id="lv-last-text">--</strong></div>
<div>Pending action: <strong id="lv-pending">--</strong></div>
<div>Audio attached: <strong id="lv-audio">--</strong> | Arm attached: <strong id="lv-arm">--</strong> | Gemini: <strong id="lv-gem">--</strong></div>
<div id="lv-error" style="color:#f55;margin-top:.2rem"></div>
</div>
<div class="row" style="justify-content:flex-end;margin-top:.3rem;gap:.3rem">
<button class="btn btn-ghost btn-sm" onclick="copyLogBox('lv-transcript',this)" title="Copy transcript to clipboard">Copy</button>
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('lv-transcript').textContent=''" title="Clear transcript">Clear</button>
</div>
<div id="lv-transcript" class="log-box" style="height:90px;margin-top:.3rem"></div>
</div>
<!-- Live Gemini Subprocess -->
<div class="card">
<h3>Live Gemini Process</h3>
<div class="row">
<button class="btn btn-success" onclick="startLiveSub(this)">Start</button>
<button class="btn btn-danger" onclick="stopLiveSub(this)">Stop</button>
<button id="ls-cam-btn" class="btn btn-sm btn-ghost" onclick="toggleGeminiCamera(this)" title="Stream camera frames to Gemini Live — same toggle as the Recognition tab">Camera: --</button>
<span id="ls-state" class="badge"></span>
<button class="btn btn-sm mic-mute-shortcut btn-success" onclick="toggleMic()" style="margin-left:auto">Mic: LIVE</button>
<button class="btn btn-sm spk-mute-shortcut btn-success" onclick="toggleSpeaker()">Speaker: LIVE</button>
</div>
<div style="margin-top:.4rem;font-size:.72rem;color:var(--muted)">
<div>State: <strong id="ls-msg">--</strong></div>
<div>User: <strong id="ls-user">--</strong></div>
</div>
<div class="row" style="justify-content:flex-end;margin-top:.3rem;gap:.3rem">
<button class="btn btn-ghost btn-sm" onclick="copyLogBox('ls-log',this)" title="Copy the Live Gemini subprocess log tail">Copy</button>
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('ls-log').textContent=''" title="Clear the log display (server-side buffer stays)">Clear</button>
</div>
<div id="ls-log" class="log-box" style="height:110px;margin-top:.3rem"></div>
</div>
<!-- Gemini API Key -->
<div class="card card-full">
<h3>Gemini API Key</h3>
<div style="font-size:.72rem;color:var(--muted);margin-bottom:.4rem">
The key used by <strong>GeminiVoiceClient</strong> and the <strong>Live Gemini subprocess</strong>.
Saved to <code>data/motions/config.json</code>. Get a free key at
<a href="https://aistudio.google.com/app/apikey" target="_blank" style="color:var(--accent)">aistudio.google.com/app/apikey</a>.
</div>
<div class="row" style="align-items:center;gap:.4rem">
<label style="min-width:70px">Current</label>
<input id="gm-key-current" readonly style="flex:1;font-family:monospace;letter-spacing:1px" placeholder="(not loaded)">
<span id="gm-key-source" class="badge" style="font-size:.65rem"></span>
<button class="btn btn-ghost btn-sm" onclick="refreshApiKey(this)" title="Reload masked key from server">Refresh</button>
</div>
<div class="row" style="align-items:center;gap:.4rem;margin-top:.4rem">
<label style="min-width:70px">New key</label>
<input id="gm-key-new" type="password" placeholder="Paste new AIza... key here" style="flex:1;font-family:monospace" autocomplete="off">
<button class="btn btn-ghost btn-sm" onclick="toggleApiKeyVisibility()" title="Show/hide while typing">👁</button>
<button class="btn btn-primary btn-sm" onclick="saveApiKey(this)" title="Validate, save to config.json, hot-swap in memory">Save</button>
</div>
<div id="gm-key-msg" style="font-size:.7rem;margin-top:.3rem;color:var(--muted)"></div>
</div>
<!-- Typed Replay -->
<div class="card card-full">
<h3>Typed Replay Engine</h3>
<div style="display:flex;gap:1rem;flex-wrap:wrap">
<div style="flex:2;min-width:280px">
<textarea id="tr-text" placeholder="Type the sentence Gemini should speak exactly..." rows="2"></textarea>
<div class="row" style="margin-top:.3rem">
<label class="switch"><input type="checkbox" id="tr-capture" checked><span class="slider"></span></label>
<label style="margin-right:.5rem">Record speaker</label>
<input id="tr-name" placeholder="Record name (optional)" style="flex:1">
</div>
<div class="row" style="margin-top:.3rem">
<button class="btn btn-primary" onclick="trGenerate(this)">Generate & Play</button>
<button class="btn btn-ghost" onclick="trReplayLast(this)">Replay Last</button>
<button class="btn btn-success" onclick="trSaveLast(this)">Save Last</button>
</div>
</div>
<div style="flex:1;min-width:200px">
<label>Session</label>
<div id="tr-session" style="font-size:.72rem;color:var(--muted);margin-top:.3rem;line-height:1.6"></div>
</div>
</div>
</div>
<!-- Wake Phrases -->
<div class="card card-full">
<h3>Wake Phrase Manager</h3>
<div class="row"><select id="wp-action" style="flex:1" onchange="loadWakePhrases(this.value)"><option value="">-- select action --</option></select><button class="btn btn-ghost btn-sm" onclick="refreshWakeActions()">Refresh</button></div>
<div id="wp-phrases" style="max-height:140px;overflow-y:auto;font-size:.75rem;margin-top:.3rem"></div>
<div class="row" style="margin-top:.3rem">
<input id="wp-new" placeholder="New phrase (Arabic or English)" style="flex:1">
<button class="btn btn-primary btn-sm" onclick="addWakePhrase()">Add</button>
</div>
</div>
</div>
</div>
<!-- ==================== TAB: Motion & Replay ==================== -->
<div class="tab-content" id="tab-motion">
<div id="motion-lock-banner">🔒 Arm actions are disabled while <b>movement</b> is enabled (Controller tab). Disable movement to replay / trigger / teach.</div>
<div class="grid" id="motion-grid">
<!-- Full Motion Control -->
<div class="card card-full">
<h3>Motion Control</h3>
<div class="row">
<label>Gestural Speaking</label>
<label class="switch"><input type="checkbox" id="gestural" onchange="toggleGestural(this.checked)"><span class="slider"></span></label>
<span style="margin-left:1rem"><label>Speed</label></span>
<select id="action-speed-2" style="width:65px"><option value="0.5">0.5x</option><option value="1.0" selected>1.0x</option><option value="1.5">1.5x</option><option value="2.0">2.0x</option></select>
<span id="arm-busy-badge-2" class="badge badge-err" style="display:none">BUSY</span>
<button class="btn btn-danger btn-sm" onclick="cancelAction()" style="margin-left:auto">Cancel</button>
</div>
<div style="display:flex;gap:1rem;flex-wrap:wrap;margin-top:.4rem">
<div style="flex:1;min-width:260px">
<div class="row" style="justify-content:space-between;align-items:center;margin:0 0 .3rem 0">
<label style="margin:0">SDK Actions (built-in)</label>
<button id="play-sdk-btn" class="btn btn-primary btn-sm" onclick="playSelectedAction('sdk')" disabled>Play</button>
</div>
<div id="sdk-actions-2" class="action-list"></div>
</div>
<div style="flex:1;min-width:260px">
<div class="row" style="justify-content:space-between;align-items:center;margin:0 0 .3rem 0">
<label style="margin:0">JSONL Replays (recorded)</label>
<button id="play-jsonl-btn" class="btn btn-primary btn-sm" onclick="playSelectedAction('jsonl')" disabled>Play</button>
</div>
<div id="jsonl-actions-2" class="action-list"></div>
</div>
</div>
</div>
<!-- Replay Manager -->
<div class="card card-full">
<h3>Replay Manager</h3>
<div style="display:flex;gap:1rem;flex-wrap:wrap">
<div style="flex:1;min-width:260px">
<label>Motion Files</label>
<div id="replay-files" style="max-height:200px;overflow-y:auto;margin-top:.3rem"></div>
<div class="row" style="margin-top:.4rem">
<button class="btn btn-ghost btn-sm" onclick="refreshReplayFiles()">Refresh</button>
<label style="cursor:pointer"><input type="file" accept=".jsonl" id="upload-jsonl" style="display:none" onchange="uploadMotionFile(this)"><span class="btn btn-success btn-sm" style="cursor:pointer">Upload .jsonl</span></label>
</div>
</div>
<div style="flex:1;min-width:260px">
<label>Test Replay</label>
<div class="row" style="margin-top:.3rem">
<input id="replay-name" placeholder="e.g. laugh.jsonl" style="flex:1">
<select id="replay-speed" style="width:65px"><option value="0.5">0.5x</option><option value="1.0" selected>1.0x</option><option value="1.5">1.5x</option><option value="2.0">2.0x</option></select>
<button class="btn btn-primary btn-sm" onclick="testReplay(this)">Play</button>
<button class="btn btn-danger btn-sm" onclick="cancelReplay()">Cancel</button>
</div>
<label style="margin-top:.7rem;display:block">Teaching Mode</label>
<div class="row" style="margin-top:.3rem">
<input id="teach-name" placeholder="New motion name" style="flex:1">
<input id="teach-duration" type="number" value="15" min="3" max="120" style="width:55px" title="Duration in seconds">
<button class="btn btn-primary btn-sm" onclick="startTeaching(this)">Teach</button>
<button class="btn btn-danger btn-sm" onclick="stopTeaching(this)">Stop</button>
</div>
<div id="teach-status" style="font-size:.72rem;color:var(--muted);margin-top:.3rem"></div>
</div>
</div>
</div>
<!-- Macro Recorder -->
<div class="card card-full">
<h3>Macro Recorder (Audio + Motion)</h3>
<!-- Record -->
<label>Record</label>
<div class="row" style="margin-top:.3rem">
<input id="macro-name" placeholder="Macro name" style="flex:1">
<button class="btn btn-primary btn-sm" onclick="startMacro(this)">Record</button>
<button class="btn btn-danger btn-sm" onclick="stopMacro(this)">Stop</button>
</div>
<!-- Play: pick a voice + motion (either optional), play in parallel -->
<label style="margin-top:.6rem;display:block">Play</label>
<div class="row" style="margin-top:.3rem;gap:.4rem;flex-wrap:wrap">
<div style="flex:1;min-width:200px">
<div style="font-size:.68rem;color:var(--muted);margin-bottom:.15rem">Voice (WAV)</div>
<select id="combo-voice" style="width:100%"><option value="">— none —</option></select>
</div>
<div style="flex:1;min-width:200px">
<div style="font-size:.68rem;color:var(--muted);margin-bottom:.15rem">Motion (JSONL)</div>
<select id="combo-motion" style="width:100%"><option value="">— none —</option></select>
</div>
<div style="align-self:flex-end">
<div style="font-size:.68rem;color:var(--muted);margin-bottom:.15rem">Speed</div>
<select id="combo-speed" style="width:75px"><option value="0.5">0.5x</option><option value="1.0" selected>1.0x</option><option value="1.5">1.5x</option><option value="2.0">2.0x</option></select>
</div>
<div style="align-self:flex-end;display:flex;gap:.3rem">
<button class="btn btn-ghost btn-sm" onclick="refreshCombo()" title="Reload file lists"></button>
<button class="btn btn-success btn-sm" onclick="playCombo(this)">Play</button>
<button class="btn btn-danger btn-sm" onclick="stopCombo(this)" title="Stop audio + return arm to home">Stop</button>
</div>
</div>
<div id="combo-status" style="font-size:.7rem;color:var(--muted);margin-top:.3rem"></div>
<div id="macro-status" style="font-size:.72rem;color:var(--muted);margin-top:.3rem"></div>
</div>
</div>
</div>
<!-- ==================== TAB: Recognition ==================== -->
<div class="tab-content" id="tab-recognition">
<div class="grid">
<!-- Status & Toggles -->
<div class="card card-full">
<h3>Camera Vision &amp; Face Recognition</h3>
<div class="row" style="gap:1rem;flex-wrap:wrap">
<div class="row" style="gap:.4rem">
<label style="min-width:7rem">Camera Vision</label>
<label class="switch">
<input type="checkbox" id="rec-vision-toggle" onchange="setVisionEnabled(this.checked)">
<span class="slider"></span>
</label>
<span id="rec-camera-status" class="badge" style="margin-left:.5rem">--</span>
</div>
<div class="row" style="gap:.4rem">
<label style="min-width:7rem">Face Recognition</label>
<label class="switch">
<input type="checkbox" id="rec-facerec-toggle" onchange="setFaceRecEnabled(this.checked)">
<span class="slider"></span>
</label>
<span id="rec-facerec-status" class="badge" style="margin-left:.5rem">--</span>
</div>
<button class="btn btn-ghost btn-sm" onclick="syncGallery(this)" title="Re-send gallery to live Gemini session">↻ Sync Gallery</button>
</div>
<div style="margin-top:.4rem;font-size:.7rem;color:var(--dim)" id="rec-status-line">
Toggles take effect within ~1 second on the running Gemini session — no restart required.
</div>
</div>
<!-- Live Preview -->
<div class="card">
<h3>Live Preview</h3>
<div id="rec-preview-wrap" style="background:#000;border-radius:.4rem;overflow:hidden;text-align:center;min-height:180px;display:flex;align-items:center;justify-content:center">
<img id="rec-preview-img" src="" alt="" style="max-width:100%;display:none">
<div id="rec-preview-empty" style="color:var(--dim);font-size:.75rem;padding:1rem">Camera off — toggle Vision ON to see the live feed.</div>
</div>
<div style="margin-top:.3rem;font-size:.65rem;color:var(--dim)" id="rec-preview-meta">--</div>
<div style="margin-top:.45rem">
<div style="font-size:.7rem;color:var(--dim);margin-bottom:.2rem">Resolution / FPS</div>
<div class="row" style="gap:.25rem;flex-wrap:wrap" id="rec-res-buttons">
<button class="btn btn-ghost btn-sm" data-w="424" data-h="240" data-fps="15" onclick="setCameraMode(this)">424×240 · 15</button>
<button class="btn btn-ghost btn-sm" data-w="424" data-h="240" data-fps="30" onclick="setCameraMode(this)">424×240 · 30</button>
<button class="btn btn-ghost btn-sm" data-w="640" data-h="480" data-fps="15" onclick="setCameraMode(this)">640×480 · 15</button>
<button class="btn btn-ghost btn-sm" data-w="640" data-h="480" data-fps="30" onclick="setCameraMode(this)">640×480 · 30</button>
<button class="btn btn-ghost btn-sm" data-w="1280" data-h="720" data-fps="15" onclick="setCameraMode(this)">1280×720 · 15</button>
<button class="btn btn-ghost btn-sm" data-w="1920" data-h="1080" data-fps="8" onclick="setCameraMode(this)">1920×1080 · 8</button>
</div>
<div style="font-size:.7rem;color:var(--dim);margin:.35rem 0 .2rem">JPEG Quality</div>
<div class="row" style="gap:.25rem" id="rec-quality-buttons">
<button class="btn btn-ghost btn-sm" data-q="50" onclick="setCameraQuality(this)">Low</button>
<button class="btn btn-ghost btn-sm" data-q="70" onclick="setCameraQuality(this)">Med</button>
<button class="btn btn-ghost btn-sm" data-q="85" onclick="setCameraQuality(this)">High</button>
</div>
</div>
<div style="margin-top:.3rem;font-size:.6rem;color:var(--dim)">
Each button rebuilds the capture pipeline (~0.5 s). Modes match the
RealSense D435I colour sensor — on USB 2.x, stick to 424×240 or 640×480.
If the feed is grayscale/IR, pin the colour node with <code>SANAD_CAMERA_USB_INDEX</code>.
</div>
</div>
<!-- Add New Face -->
<div class="card">
<h3>Add New Face</h3>
<div class="row">
<label>Name</label>
<input id="rec-newface-name" placeholder="(optional)" style="flex:1">
</div>
<div style="margin-top:.4rem">
<label style="font-size:.72rem;color:var(--dim)">Description — who is this person? (Gemini reads it)</label>
<textarea id="rec-newface-desc" rows="2" placeholder="e.g. Qassam, lead engineer on the robotics team — likes coffee" style="width:100%;margin-top:.2rem;font-size:.78rem;resize:vertical"></textarea>
</div>
<div class="row" style="margin-top:.4rem">
<button class="btn btn-success btn-sm" onclick="enrollFromCamera(this)" title="Snap current frame">📷 Capture</button>
<label class="btn btn-primary btn-sm" style="cursor:pointer;margin:0">
📁 Upload images
<input type="file" id="rec-upload-input" multiple accept="image/jpeg,image/png" style="display:none" onchange="enrollFromUpload(this)">
</label>
</div>
<div style="margin-top:.4rem;font-size:.65rem;color:var(--dim)">
Tip: add 23 photos / different angles per person for best recognition.
The description is sent to Gemini with the photos — it can then greet
and talk about the person using what you wrote.
</div>
</div>
<!-- Enrolled Faces -->
<div class="card card-full">
<h3>Enrolled Faces <span id="rec-faces-count" style="font-weight:normal;color:var(--dim);font-size:.75rem"></span></h3>
<div class="row">
<button class="btn btn-ghost btn-sm" onclick="refreshFaces()">↻ Refresh</button>
<span style="margin-left:auto;font-size:.65rem;color:var(--dim)" id="rec-gallery-version"></span>
</div>
<div id="rec-faces-list" style="margin-top:.6rem"><div class="empty">Loading…</div></div>
</div>
<!-- Zone Recognition toggle + active destination -->
<div class="card card-full">
<h3>Zones &amp; Places</h3>
<div class="row" style="gap:1rem;flex-wrap:wrap;align-items:center">
<div class="row" style="gap:.4rem">
<label style="min-width:7rem">Zone Recognition</label>
<label class="switch">
<input type="checkbox" id="rec-zonerec-toggle" onchange="setZoneRecEnabled(this.checked)">
<span class="slider"></span>
</label>
<span id="rec-zonerec-status" class="badge" style="margin-left:.5rem">--</span>
</div>
<button class="btn btn-ghost btn-sm" onclick="syncZones(this)" title="Re-send zones/places to live Gemini session">↻ Sync</button>
<span style="margin-left:auto;font-size:.65rem;color:var(--dim)" id="rec-zones-version"></span>
</div>
<div class="row" style="margin-top:.5rem;gap:.4rem;align-items:center">
<span style="font-size:.72rem;color:var(--dim)">Destination:</span>
<span id="rec-nav-target" style="font-size:.78rem">none</span>
<button class="btn btn-ghost btn-sm" id="rec-nav-clear" onclick="clearNavTarget(this)" style="display:none">Clear destination</button>
</div>
<div style="margin-top:.4rem;font-size:.7rem;color:var(--dim)">
Group locations into <b>zones</b>, add <b>places</b> inside each (name + description +
optional reference photos), and link saved <b>faces</b> to a place. “Go here” sets a
destination and shows Gemini the place — the robot drives there once movement
(locomotion) is enabled.
</div>
</div>
<!-- Add New Zone -->
<div class="card card-full">
<h3>Add New Zone</h3>
<div class="row" style="flex-wrap:wrap;gap:.4rem">
<input id="rec-newzone-name" placeholder="Zone name (e.g. Ground Floor)" style="flex:1;min-width:12rem">
<input id="rec-newzone-desc" placeholder="Description (optional)" style="flex:2;min-width:12rem">
<button class="btn btn-success btn-sm" onclick="createZone(this)"> Add zone</button>
</div>
</div>
<!-- Zones list -->
<div class="card card-full">
<h3>Zones <span id="rec-zones-count" style="font-weight:normal;color:var(--dim);font-size:.75rem"></span></h3>
<div class="row">
<button class="btn btn-ghost btn-sm" onclick="refreshZones()">↻ Refresh</button>
</div>
<div id="rec-zones-list" style="margin-top:.6rem"><div class="empty">Loading…</div></div>
</div>
</div>
</div>
<!-- ==================== TAB: Temperature ==================== -->
<div class="tab-content" id="tab-temp">
<div class="card card-full" style="padding:0;overflow:hidden">
<iframe id="temp3d-frame" title="G1 Motor Temperature (3D)"
style="width:100%;height:80vh;border:0;display:block;background:#0b0d12"
src="about:blank"></iframe>
</div>
<div style="margin-top:.4rem;font-size:.65rem;color:var(--dim)">
Live motor surface/winding temperatures from <code>rt/lowstate</code> on the full
G1 (29 DOF). Blue ≈ 30&deg;C → red ≈ 120&deg;C. Drag to orbit, scroll to zoom.
Streamed over <code>/ws/motor-temps</code> — no second DDS subscriber.
</div>
</div>
<!-- ==================== TAB: Controller (N2) ==================== -->
<div class="tab-content" id="tab-controller">
<!-- Sticky status bar -->
<div class="card card-full" id="ctrl-statusbar" style="position:sticky;top:0;z-index:50">
<div class="row" style="justify-content:space-between;flex-wrap:wrap;gap:.5rem">
<div class="row" style="gap:.6rem;align-items:center">
<span class="badge" id="ctrl-fsm-badge">FSM —</span>
<span class="dot" id="ctrl-ready-dot"></span><span id="ctrl-ready-text" style="font-size:.72rem">unknown</span>
<span class="badge badge-info" id="ctrl-msc-badge">MSC —</span>
<span class="badge" id="ctrl-sdk-badge">SDK —</span>
</div>
<div class="row" style="gap:.8rem;align-items:center">
<label class="row" style="margin:0;gap:.4rem;align-items:center;text-transform:none">
<input type="checkbox" id="ctrl-arm-toggle" style="width:auto" onchange="ctrlSetArmed(this.checked)"> Enable movement
</label>
<label class="row" style="margin:0;gap:.4rem;align-items:center;text-transform:none" title="Voice-driven locomotion — Gemini moves the robot when asked (EN/AR)">
<input type="checkbox" id="ctrl-gmove-toggle" style="width:auto" onchange="ctrlSetGeminiMove(this.checked)"> Enable Gemini movement
</label>
<button class="btn btn-danger" onclick="ctrlEstop(this)">E-STOP</button>
</div>
</div>
<!-- mirrored subsystem strip + coming-soon gates -->
<div class="status-pills ctrl-strip" style="margin-top:.5rem">
<span class="hdr-badge pill-off" id="ctrl-pill-camera">CAM</span>
<span class="hdr-badge pill-off" id="ctrl-pill-face">FACE</span>
<span class="hdr-badge pill-off" id="ctrl-pill-place">PLACE</span>
<span class="hdr-badge pill-off" id="ctrl-pill-movement">MOVE</span>
<span class="hdr-badge pill-off" id="ctrl-pill-gmove" title="Gemini voice-driven locomotion">GEMINI-MOVE</span>
<span class="hdr-badge pill-soon" title="Phase 4 — autonomous navigation">EXPLORE · soon</span>
</div>
<div style="font-size:.66rem;color:var(--dim);margin-top:.45rem">
Manual operator control. Robot is assumed standing in walking mode — use <b>Ready/Start</b> only if needed.
All controls below are locked until <b>Enable movement</b> is on; <b>E-STOP</b> always works.
While movement is on, arm replays/actions are disabled (and vice-versa).
</div>
</div>
<div class="grid">
<!-- Locomotion / Teleop -->
<div class="card">
<h3>Locomotion / Teleop</h3>
<div style="font-size:.68rem;color:var(--muted);margin-bottom:.4rem">Discrete step pad</div>
<div id="ctrl-steppad" class="steppad">
<button class="btn btn-ghost" onclick="ctrlStep('rotate_left',this)" title="Rotate left"></button>
<button class="btn btn-ghost" onclick="ctrlStep('forward',this)" title="Forward"></button>
<button class="btn btn-ghost" onclick="ctrlStep('rotate_right',this)" title="Rotate right"></button>
<button class="btn btn-ghost" onclick="ctrlStep('slide_left',this)" title="Slide left"></button>
<button class="btn btn-danger" onclick="ctrlStop(this)" title="Stop"></button>
<button class="btn btn-ghost" onclick="ctrlStep('slide_right',this)" title="Slide right"></button>
<button class="btn btn-ghost" style="visibility:hidden"></button>
<button class="btn btn-ghost" onclick="ctrlStep('backward',this)" title="Backward"></button>
<button class="btn btn-ghost" style="visibility:hidden"></button>
</div>
<div class="row" style="margin-top:.7rem;flex-wrap:wrap;gap:.5rem;align-items:center">
<button class="btn btn-primary" id="ctrl-teleop-btn" onclick="ctrlToggleTeleop()">Start teleop (WASD / Q-E)</button>
<label class="row" style="margin:0;gap:.35rem;align-items:center;text-transform:none"><input type="checkbox" id="ctrl-run-toggle" style="width:auto"> Run (1.2)</label>
</div>
<div id="ctrl-vel-readout" style="font-size:.7rem;color:var(--muted);margin-top:.4rem">vx 0.00 · vy 0.00 · ω 0.00</div>
<div style="font-size:.64rem;color:var(--dim);margin-top:.25rem">W/S forward·back · Q/E strafe · A/D rotate · Space halt</div>
</div>
<!-- Postures & Modes -->
<div class="card">
<h3>Postures &amp; Modes</h3>
<div class="row" style="flex-wrap:wrap;gap:.3rem">
<button class="btn btn-ghost" onclick="ctrlMode('prep',this)" title="StopMove→Damp→StandUp→height ramp">PREP</button>
<button class="btn btn-primary" onclick="ctrlMode('ready',this)" title="PREP + Start (FSM 200)">READY / START</button>
<button class="btn btn-ghost" onclick="ctrlPosture('stand_up',this)">StandUp</button>
<button class="btn btn-ghost" onclick="ctrlPosture('squat',this)">Squat</button>
<button class="btn btn-ghost" onclick="ctrlPosture('sit',this)">Sit</button>
<button class="btn btn-ghost" onclick="ctrlPosture('low_stand',this)">LowStand</button>
<button class="btn btn-ghost" onclick="ctrlPosture('high_stand',this)">HighStand</button>
<button class="btn btn-ghost" onclick="ctrlPosture('lie_to_stand',this)">Lie→Stand</button>
<button class="btn btn-danger" onclick="ctrlPosture('damp',this)">Damp</button>
<button class="btn btn-danger" onclick="ctrlPosture('zero_torque',this)">ZeroTorque</button>
</div>
</div>
<!-- MotionSwitcher / Low-Level -->
<div class="card">
<h3>MotionSwitcher / Low-Level</h3>
<div class="row" style="flex-wrap:wrap;gap:.3rem">
<button class="btn btn-ghost" onclick="ctrlMscSelectAi(this)">Select AI</button>
<button class="btn btn-ghost" onclick="ctrlMscRelease(this)">Release</button>
<button class="btn btn-ghost" onclick="ctrlMscShow(this)">Show mode</button>
<button class="btn btn-ghost" onclick="ctrlBalance(0,this)">Balance: static</button>
<button class="btn btn-ghost" onclick="ctrlBalance(1,this)">Balance: gait</button>
<button class="btn btn-ghost" onclick="ctrlReconnect(this)">Reconnect</button>
</div>
</div>
<!-- Diagnostics -->
<div class="card">
<h3>Diagnostics — joints 1228</h3>
<pre id="ctrl-joints" style="font-size:.66rem;max-height:240px;overflow:auto;background:var(--panel2);border-radius:6px;padding:.5rem;margin:0"></pre>
</div>
</div>
</div>
<!-- ==================== TAB: Recordings ==================== -->
<div class="tab-content" id="tab-recordings">
<div class="grid">
<!-- Skill Registry -->
<div class="card card-full">
<h3>Skill Registry</h3>
<div id="skills-list"><div class="empty">No skills configured</div></div>
<button class="btn btn-ghost btn-sm" onclick="refreshSkills()" style="margin-top:.4rem">Refresh</button>
</div>
<!-- Saved Records -->
<div class="card card-full">
<h3>Saved Records</h3>
<!-- Now-Playing control bar — auto-shown when audio_mgr has an active
G1 playback, hidden when idle. Pause/Resume/Stop act on whatever
is currently playing (one playback at a time). -->
<div id="rec-playback-bar" class="row" style="display:none;gap:.4rem;align-items:center;margin-bottom:.5rem;padding:.45rem .6rem;background:rgba(80,180,255,.08);border-radius:.4rem">
<span style="font-size:.78rem"><strong id="rec-playback-name">--</strong></span>
<span id="rec-playback-time" style="font-size:.7rem;color:var(--dim)">0.0 / 0.0 s</span>
<button class="btn btn-ghost btn-sm" id="rec-pause-btn" onclick="pauseRecord(this)" style="margin-left:auto">⏸ Pause</button>
<button class="btn btn-success btn-sm" id="rec-resume-btn" onclick="resumeRecord(this)" style="display:none">▶ Resume</button>
<button class="btn btn-danger btn-sm" onclick="stopRecord(this)">⏹ Stop</button>
</div>
<div id="records-list"><div class="empty">No records saved</div></div>
<button class="btn btn-ghost btn-sm" onclick="refreshRecords()" style="margin-top:.3rem">Refresh</button>
</div>
</div>
</div>
<!-- ==================== TAB: Terminal ==================== -->
<!-- In-browser shell on the robot. WebSocket → PTY bridge in
dashboard/websockets/terminal.py. Click "SSH" to spawn a shell;
click again or close the tab to terminate.
xterm.js + xterm-addon-fit loaded from jsdelivr (no bundler needed). -->
<div class="tab-content" id="tab-terminal">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.min.css">
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
<div class="card" style="display:flex;flex-direction:column;height:calc(100vh - 220px);min-height:480px">
<div class="row" style="align-items:center;gap:.4rem">
<h3 style="margin:0;flex:1">Terminal — unitree@robot</h3>
<span id="term-status" style="font-size:.7rem;color:var(--dim)">disconnected</span>
<button id="term-ssh-btn" class="btn btn-primary btn-sm" onclick="termConnect(this)">SSH</button>
<button id="term-stop-btn" class="btn btn-danger btn-sm" onclick="termDisconnect(this)" disabled>Disconnect</button>
<button class="btn btn-ghost btn-sm" onclick="termClear()" title="Clear screen (Ctrl+L also works)">Clear</button>
</div>
<div style="font-size:.65rem;color:var(--dim);margin-top:.2rem">
Runs as the dashboard's user on the robot (typically <code>unitree</code>). No SSH handshake — the dashboard is already on the robot. Works on whichever Wi-Fi the robot is connected to.
</div>
<div id="term-host" style="flex:1;margin-top:.5rem;background:#000;border-radius:6px;padding:.3rem;overflow:hidden"></div>
</div>
</div>
<!-- ==================== TAB: Settings & Logs ==================== -->
<div class="tab-content" id="tab-settings">
<div class="grid">
<!-- Scripts -->
<div class="card">
<h3>Scripts Manager</h3>
<div class="row"><select id="script-select" style="flex:1" onchange="loadScript(this.value)"><option value="">-- select --</option></select><button class="btn btn-ghost btn-sm" onclick="refreshScripts()">Refresh</button></div>
<textarea id="script-content" placeholder="Script content..." style="min-height:100px"></textarea>
<div class="row" style="margin-top:.3rem">
<button class="btn btn-primary btn-sm" onclick="saveScript()">Save</button>
<input id="script-new-name" placeholder="new_file.txt" style="flex:1">
<button class="btn btn-success btn-sm" onclick="createScript()">Create</button>
<button class="btn btn-danger btn-sm" onclick="deleteScript()">Delete</button>
</div>
</div>
<!-- Prompt -->
<div class="card">
<h3>Prompt Management</h3>
<div id="prompt-info" style="font-size:.7rem;color:var(--dim);margin-bottom:.3rem"></div>
<textarea id="prompt-content" placeholder="System prompt..." style="min-height:100px"></textarea>
<div class="row" style="margin-top:.3rem">
<button class="btn btn-primary btn-sm" onclick="updatePrompt()">Save</button>
<button class="btn btn-ghost btn-sm" onclick="reloadPrompt()">Reload from Disk</button>
</div>
</div>
<!-- Logs -->
<div class="card card-full">
<h3>Live Logs</h3>
<div class="row" style="margin-bottom:.3rem;flex-wrap:wrap;gap:.3rem">
<button class="btn btn-ghost btn-sm" onclick="saveLogSnapshot()" title="Save a timestamped copy of all .log files under logs/">Save Snapshot</button>
<button class="btn btn-primary btn-sm" onclick="copyAllLogs(this)" title="Fetch system status + every log file and copy to clipboard">Copy All</button>
<button class="btn btn-ghost btn-sm" onclick="copyVisibleLogs(this)" title="Copy only what's currently in the log box below">Copy Visible</button>
<button class="btn btn-ghost btn-sm" onclick="downloadLogBundle()" title="Download the full bundle as a .txt file">Download</button>
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('log-box').textContent=''">Clear</button>
</div>
<div class="log-box" id="log-box" style="height:300px"></div>
</div>
</div>
</div>
<script>
const API='';
function toast(m,t='info'){const b=document.getElementById('toast-box'),e=document.createElement('div');e.className='toast toast-'+t;e.textContent=m;b.appendChild(e);setTimeout(()=>e.remove(),3500);}
function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');}
function btnLoad(b){if(b&&b.classList)b.classList.add('loading');}
function btnDone(b){if(b&&b.classList)b.classList.remove('loading');}
async function api(m,p,b){const o={method:m,headers:{'Content-Type':'application/json'}};if(b)o.body=JSON.stringify(b);const r=await fetch(API+p,o);const j=await r.json();if(!r.ok){toast(j.detail||j.error||'Error '+r.status,'err');throw new Error(j.detail||j.error);}return j;}
// Tabs
function switchTab(name){
// Match the nav tab by its exact onclick target — NOT a substring of the
// label. "recognition" and "recordings" both start with "reco", so the old
// textContent.includes(name.slice(0,4)) lit up both tabs at once.
const want="switchTab('"+name+"')";
document.querySelectorAll('.tab').forEach(t=>t.classList.toggle('active',(t.getAttribute('onclick')||'').includes(want)));
document.querySelectorAll('.tab-content').forEach(c=>c.classList.toggle('active',c.id==='tab-'+name));
}
// Emergency Stop
async function emergencyStop(){try{await api('POST','/api/replay/cancel');await api('POST','/api/live-voice/stop');toast('EMERGENCY STOP sent','err');}catch(e){}}
// Voice
async function generate(b){btnLoad(b);try{const t=document.getElementById('voice-text').value,e=document.getElementById('engine').value;const r=await api('POST','/api/voice/generate',{text:t,engine:e});document.getElementById('voice-result').textContent=r.ok?'Done':'Failed';toast('Speech generated','ok');}catch(e){}btnDone(b);}
async function connectGemini(b){btnLoad(b);try{await api('POST','/api/voice/connect');toast('Gemini connected','ok');}catch(e){}btnDone(b);refreshStatus();}
async function disconnectGemini(b){btnLoad(b);try{await api('POST','/api/voice/disconnect');toast('Disconnected','info');}catch(e){}btnDone(b);refreshStatus();}
// Gemini API key — show masked current value + save a new one
async function refreshApiKey(b){
if(b)btnLoad(b);
try{
const r=await api('GET','/api/voice/api-key');
const inp=document.getElementById('gm-key-current');
const src=document.getElementById('gm-key-source');
const msg=document.getElementById('gm-key-msg');
if(r.has_key){
inp.value=r.masked||'';
inp.placeholder='';
src.textContent=r.source==='config_file'?'saved':'default';
src.className='badge '+(r.source==='config_file'?'badge-ok':'badge-warn');
msg.textContent=`Length: ${r.length} chars`;
}else{
inp.value='';
inp.placeholder='(no key configured)';
src.textContent='empty';
src.className='badge badge-err';
msg.textContent='No API key — paste one below to enable Gemini.';
}
}catch(e){}
if(b)btnDone(b);
}
function toggleApiKeyVisibility(){
const inp=document.getElementById('gm-key-new');
inp.type=inp.type==='password'?'text':'password';
}
async function saveApiKey(b){
const inp=document.getElementById('gm-key-new');
const key=(inp.value||'').trim();
const msg=document.getElementById('gm-key-msg');
if(!key){toast('Paste a key first','err');return;}
if(!key.startsWith('AIza')){
if(!confirm("Key doesn't start with 'AIza'. Gemini keys normally do. Save anyway?"))return;
}
btnLoad(b);
try{
const r=await api('POST','/api/voice/api-key',{api_key:key});
toast(`API key saved (${r.length} chars)`,'ok');
inp.value='';
inp.type='password';
msg.textContent=r.message||'Saved. Click Connect to apply.';
msg.style.color='var(--accent)';
setTimeout(()=>{msg.style.color='var(--muted)';},4000);
await refreshApiKey();
refreshStatus();
}catch(e){
msg.textContent='Save failed: '+(e.message||'unknown');
msg.style.color='#f55';
}
btnDone(b);
}
// System info
async function refreshSystem(){
try{
const r=await api('GET','/api/system/info');
const d=r.dashboard||{};
const subs=r.subsystems||{};
const audio=r.audio||{};
const cam=r.camera||{};
const host=r.host||{};
const dds=r.dds||{};
const url=d.url||('http://'+(d.display_host||'?')+':'+(d.port||'?'));
const audioCur=(audio.current||{});
const audioProf=audioCur.profile?audioCur.profile.label:'(none)';
const camCur=(cam.current||{});
const camDev=camCur.device||{};
const camName=camDev.name||'(no camera)';
document.getElementById('sys-summary').innerHTML=
`<div><strong>URL:</strong> <a href="${esc(url)}" target="_blank" style="color:var(--accent)">${esc(url)}</a></div>`+
`<div><strong>Bound:</strong> ${esc(d.bound_host||'?')}:${d.port||'?'} (iface: <code>${esc(d.interface||'?')}</code>)</div>`+
`<div><strong>Host:</strong> ${esc(host.hostname||'?')} | Python ${esc(host.python||'?')}</div>`+
`<div><strong>Subsystems:</strong> <span style="color:#5fdc8a">${subs.connected||0} connected</span> / <span style="color:#f55">${subs.disconnected||0} not connected</span> (${subs.total||0} total)</div>`+
`<div><strong>DDS interface:</strong> <code>${esc(dds.interface||'?')}</code></div>`+
`<div><strong>Audio profile:</strong> ${esc(audioProf)}</div>`+
`<div style="font-size:.65rem;color:var(--dim);padding-left:.7rem">sink: ${esc(audioCur.sink||'?')}<br>source: ${esc(audioCur.source||'?')}</div>`+
`<div><strong>Camera:</strong> ${esc(camName)}${camDev.serial?' <code style="font-size:.6rem">'+esc(camDev.serial)+'</code>':''}</div>`;
// Network interfaces
const ifaces=(r.network||{}).interfaces||[];
document.getElementById('sys-network').innerHTML=ifaces.length
? '<table>'+ifaces.map(i=>{
const up=i.is_up?'<span style="color:#5fdc8a">●</span>':'<span style="color:#f55">○</span>';
return `<tr><td>${up}</td><td><code>${esc(i.name)}</code></td><td>${esc(i.ip||'-')}</td></tr>`;
}).join('')+'</table>'
: '<div class="empty">No interfaces</div>';
// Subsystem grid
const list=subs.list||[];
document.getElementById('sys-subsystems').innerHTML=list.length
? '<table>'+list.map(s=>{
const dot=s.connected?'<span style="color:#5fdc8a">✓</span>':'<span style="color:#f55">✗</span>';
return `<tr><td>${dot}</td><td>${esc(s.name)}</td></tr>`;
}).join('')+'</table>'
: '<div class="empty">No subsystems reported</div>';
}catch(e){}
}
// Audio
async function refreshAudio(){
try{
const r=await api('GET','/api/audio/status');
const mb=document.getElementById('mic-btn'),sb=document.getElementById('spk-btn');
mb.textContent=r.mic_muted?'Muted':'Unmuted';
mb.className='mute-btn '+(r.mic_muted?'on':'off');
sb.textContent=r.speaker_muted?'Muted':'Unmuted';
sb.className='mute-btn '+(r.speaker_muted?'on':'off');
document.getElementById('mic-badge').style.display=r.mic_muted?'inline-flex':'none';
document.getElementById('spk-badge').style.display=r.speaker_muted?'inline-flex':'none';
// Sync any mute-shortcut buttons spread across other cards.
// Plain-text labels (no emoji) so they render on every browser.
// Effective state: speaker is muted if EITHER pactl sink or G1 is muted.
document.querySelectorAll('.mic-mute-shortcut').forEach(btn=>{
btn.textContent=r.mic_muted?'Mic: MUTED':'Mic: LIVE';
btn.className='btn btn-sm mic-mute-shortcut '+(r.mic_muted?'btn-danger':'btn-success');
btn.title=r.mic_muted?'Microphone is MUTED — click to unmute':'Microphone is LIVE — click to mute';
});
document.querySelectorAll('.spk-mute-shortcut').forEach(btn=>{
// Build a tooltip that shows both paths (pactl + G1) so it's clear
// why the button is red.
const parts=[];
if(r.pulse_sink_muted)parts.push('PulseAudio sink muted');
if(r.g1_speaker_muted)parts.push('G1 speaker volume 0');
const tip=r.speaker_muted
? ('Speaker MUTED ('+(parts.join(', ')||'unknown')+') — click to unmute')
: 'Speaker LIVE — click to mute (hits both PulseAudio and G1 DDS)';
btn.textContent=r.speaker_muted?'Speaker: MUTED':'Speaker: LIVE';
btn.className='btn btn-sm spk-mute-shortcut '+(r.speaker_muted?'btn-danger':'btn-success');
btn.title=tip;
});
// G1 speaker volume slider sync (only if user isn't currently dragging
// it — don't clobber mid-drag input from the user)
const vslider=document.getElementById('g1-vol-slider');
const vlabel=document.getElementById('g1-vol-label');
if(vslider && document.activeElement!==vslider){
const cv=(typeof r.g1_current_volume==='number')?r.g1_current_volume:(typeof r.g1_user_volume==='number'?r.g1_user_volume:100);
vslider.value=cv;
vlabel.textContent=cv+'%';
}
const cur=r.current||{};
const profLabel=cur.profile?cur.profile.label:('('+(cur.source_kind||'manual')+')');
document.getElementById('audio-status-text').innerHTML=
`<strong>${esc(profLabel)}</strong><br>`+
`Sink: ${esc(r.sink||'-')}<br>`+
`Source: ${esc(r.source||'-')}`+
(r.pactl_available?'':'<br><span style="color:#f55">pactl not available</span>')+
(typeof r.g1_current_volume==='number'?`<br>G1 speaker: <strong>${r.g1_current_volume}%</strong> (user pref: ${r.g1_user_volume}%)`:'');
}catch(e){}
}
async function toggleMic(){try{await api('POST','/api/audio/mic/mute');}catch(e){}refreshAudio();}
async function toggleSpeaker(){try{await api('POST','/api/audio/speaker/mute');}catch(e){}refreshAudio();}
// G1 built-in speaker volume (DDS SetVolume). Applies immediately to
// live playback, persists to data/motions/config.json for next restart.
async function setG1Vol(level,b){
if(b)btnLoad(b);
try{
const r=await api('POST','/api/audio/g1-speaker/volume',{level:level});
document.getElementById('g1-vol-slider').value=r.current_volume;
document.getElementById('g1-vol-label').textContent=r.current_volume+'%';
const st=document.getElementById('g1-vol-status');
if(r.muted){
st.textContent='G1 speaker MUTED — will restore to '+r.user_volume+'% on unmute';
st.style.color='#f55';
}else{
st.textContent='G1 volume: '+r.current_volume+'% — saved to config.json';
st.style.color='var(--success)';
}
setTimeout(()=>{st.style.color='var(--dim)';},2500);
toast('G1 volume → '+r.current_volume+'%','ok');
}catch(e){
document.getElementById('g1-vol-status').textContent='Failed: '+(e.message||'unknown');
document.getElementById('g1-vol-status').style.color='#f55';
}
if(b)btnDone(b);
refreshAudio();
}
// Audio device picker
async function scanAudioDevices(b){
btnLoad(b);
try{
await api('POST','/api/audio/apply');
toast('Audio devices scanned','ok');
}catch(e){}
btnDone(b);
refreshAudioDevices();
refreshAudio();
}
// SOFT reset — restart pulseaudio/pipewire-pulse. Fixes Pulse-side state.
// Does NOT recover a kernel-side missing USB mic descriptor — for that
// use usbResetAnker.
async function resetAudioSubsystem(b){
if(!confirm('Reset PulseAudio?\n\nThis restarts the audio daemon on the robot.\n\nRequirements:\n - Live Gemini must be stopped\n - No record can be playing\n\nThis fixes stuck PulseAudio state. It does NOT recover a missing\nUSB mic profile — if the Anker mic still does not appear afterwards,\nuse the USB Reset button instead.'))return;
btnLoad(b);
try{
const r=await api('POST','/api/audio/reset');
if(r&&r.ok){
const inOk=r.input_recovered, outOk=r.output_recovered;
if(inOk&&outOk){
toast('Audio subsystem reset · '+(r.flavour||'pulse')+' OK','ok');
}else if(outOk){
toast('Reset done but mic still missing — try USB Reset','err');
}else{
toast('Reset done but no devices detected — check USB','err');
}
}else{
toast('Reset returned no result','err');
}
}catch(e){
toast('Reset failed: '+((e&&e.message)||'unknown'),'err');
}
btnDone(b);
refreshAudioDevices();
refreshAudio();
}
// HARD reset — snd-usb-audio unbind+rebind scoped to Anker VID:PID.
// Forces the kernel to re-parse UAC1 descriptors. Needs sudoers entry
// installed once via:
// sudo bash shell_scripts/reset_anker_usb.sh --setup-sudoers
async function usbResetAnker(b){
if(!confirm('USB Reset Anker?\n\nThis unbinds and re-binds the snd-usb-audio driver\nfor the Anker dongle, forcing the kernel to re-parse\nthe USB Audio Class descriptors.\n\nUse this when the Anker is plugged but the mic profile\nis missing from the dashboard (PulseAudio shows the sink\nbut no source).\n\nRequirements:\n - Live Gemini must be stopped\n - No record can be playing\n\nIf this fails with "permission denied", run on the robot ONCE:\n sudo bash shell_scripts/reset_anker_usb.sh --setup-sudoers'))return;
btnLoad(b);
try{
const r=await api('POST','/api/audio/usb-reset');
if(r&&r.ok){
if(r.input_recovered){
toast('USB reset OK · Anker mic recovered','ok');
}else{
toast('USB reset done but mic not in pactl yet — give it 2s and click Scan','err');
}
}else{
const hint=(r&&r.hint)?(' · '+r.hint):'';
toast('USB reset failed'+hint,'err');
}
}catch(e){
toast('USB reset failed: '+((e&&e.message)||'unknown'),'err');
}
btnDone(b);
refreshAudioDevices();
refreshAudio();
}
async function refreshAudioDevices(b){
if(b)btnLoad(b);
try{
const r=await api('GET','/api/audio/devices');
// Profile dropdown
const cur=r.current||{};
const curId=cur.profile?cur.profile.id:'';
const profSel=document.getElementById('audio-profile');
const detectedIds=r.detected_ids||[];
profSel.innerHTML=(r.profiles||[]).map(p=>{
const avail=detectedIds.indexOf(p.id)>=0;
const sel=p.id===curId?' selected':'';
const tag=avail?'':' (not plugged)';
return `<option value="${esc(p.id)}"${sel}${avail?'':' disabled'}>${esc(p.label)}${tag}</option>`;
}).join('');
// Manual sink/source dropdowns
const sinkSel=document.getElementById('audio-sink');
const srcSel=document.getElementById('audio-source');
sinkSel.innerHTML=(r.all_sinks||[]).map(s=>{
const sel=s.name===cur.sink?' selected':'';
return `<option value="${esc(s.name)}"${sel}>${esc(s.description||s.name)}</option>`;
}).join('');
srcSel.innerHTML=(r.all_sources||[]).map(s=>{
const sel=s.name===cur.source?' selected':'';
return `<option value="${esc(s.name)}"${sel}>${esc(s.description||s.name)}</option>`;
}).join('');
// Detected summary
const det=document.getElementById('audio-detected');
if((r.detected||[]).length){
det.innerHTML='Plugged: '+r.detected.map(d=>esc(d.profile.label)).join(', ');
}else{
det.innerHTML=r.pactl_available?'No known profiles plugged':'pactl unavailable';
}
}catch(e){}
if(b)btnDone(b);
refreshAudio();
}
async function selectAudioProfile(profileId){
if(!profileId)return;
try{
await api('POST','/api/audio/select-profile',{profile_id:profileId});
// Auto-apply PulseAudio defaults after switching
await api('POST','/api/audio/apply');
toast('Audio profile switched & applied','ok');
}catch(e){}
refreshAudio();
refreshAudioDevices();
}
async function applyAudioProfile(b){
btnLoad(b);
try{
await api('POST','/api/audio/apply');
toast('Audio applied to PulseAudio','ok');
}catch(e){}
btnDone(b);
refreshAudio();
refreshAudioDevices();
}
async function applyManualAudio(b){
btnLoad(b);
const sink=document.getElementById('audio-sink').value;
const src=document.getElementById('audio-source').value;
try{
await api('POST','/api/audio/select-manual',{sink:sink,source:src});
toast('Manual audio applied','ok');
}catch(e){}
btnDone(b);
refreshAudio();
refreshAudioDevices();
}
// Motion
async function toggleGestural(v){try{await api('POST','/api/motion/gestural-speaking?enabled='+v);}catch(e){}}
let _armBusy=false,_runId=null;
let _selectedAction={sdk:null,jsonl:null};
function selectActionRow(id,kind,name){
_selectedAction[kind]=id;
const listId=kind==='sdk'?'sdk-actions-2':'jsonl-actions-2';
document.querySelectorAll('#'+listId+' .action-row').forEach(el=>{
el.classList.toggle('selected', el.dataset.id===String(id));
});
const btn=document.getElementById('play-'+kind+'-btn');
btn.disabled=_armBusy;
btn.textContent='Play '+name.replace(/_/g,' ');
}
function playSelectedAction(kind){
const id=_selectedAction[kind];
if(id==null||_armBusy)return;
const listId=kind==='sdk'?'sdk-actions-2':'jsonl-actions-2';
const el=document.querySelector('#'+listId+' .action-row.selected');
const name=el?el.querySelector('.r-name').textContent.trim().replace(/ /g,'_'):'';
triggerAction(id,name);
}
function _renderChips(acts){
const sdkEl=document.getElementById('sdk-actions'),jsonlEl=document.getElementById('jsonl-actions');
if(!sdkEl)return;
let sh='',jh='';
for(const a of acts){
const isR=_runId===a.id,cls='action-btn'+(isR?' running':'')+(_armBusy&&!isR?' disabled':''),dis=_armBusy&&!isR?'disabled':'';
const dot=a.file?'<span class="type-dot type-jsonl"></span>':'<span class="type-dot type-sdk"></span>';
const btn=`<button class="${cls}" ${dis} onclick="triggerAction(${a.id},'${esc(a.name)}')" title="${esc(a.file||'SDK')}">${dot}${esc(a.name).replace(/_/g,' ')}</button>`;
if(a.file)jh+=btn;else sh+=btn;
}
sdkEl.innerHTML=sh||'<span class="empty" style="padding:.3rem">No SDK actions</span>';
jsonlEl.innerHTML=jh||'<span class="empty" style="padding:.3rem">No JSONL files</span>';
}
function _renderList(acts){
const sdkEl=document.getElementById('sdk-actions-2'),jsonlEl=document.getElementById('jsonl-actions-2');
if(!sdkEl)return;
const rowFor=(a,kind)=>{
const isR=_runId===a.id,isSel=_selectedAction[kind]===a.id;
const cls='action-row'+(isR?' running':'')+(isSel?' selected':'');
const meta=a.file?esc(a.file):(a.category?esc(a.category):'SDK');
return `<div class="${cls}" data-id="${a.id}" onclick="selectActionRow(${a.id},'${kind}','${esc(a.name)}')" ondblclick="triggerAction(${a.id},'${esc(a.name)}')" title="id=${a.id}">`
+`<span class="type-dot ${a.file?'type-jsonl':'type-sdk'}"></span>`
+`<span class="r-name">${esc(a.name).replace(/_/g,' ')}</span>`
+`<span class="r-meta">${meta} · #${a.id}</span>`
+`</div>`;
};
let sh='',jh='';
for(const a of acts){ if(a.file)jh+=rowFor(a,'jsonl'); else sh+=rowFor(a,'sdk'); }
sdkEl.innerHTML=sh||'<div class="empty">No SDK actions</div>';
jsonlEl.innerHTML=jh||'<div class="empty">No JSONL files</div>';
// keep play-buttons in sync with busy state + whether selection still exists
for(const kind of ['sdk','jsonl']){
const btn=document.getElementById('play-'+kind+'-btn');
if(!btn)continue;
const stillExists=acts.some(a=>a.id===_selectedAction[kind] && (kind==='jsonl'?!!a.file:!a.file));
if(!stillExists){_selectedAction[kind]=null;btn.disabled=true;btn.textContent='Play';}
else{btn.disabled=_armBusy;}
}
}
async function renderActions(arm){
if(!arm)return;
_armBusy=arm.busy||false;
try{
const r=await api('GET','/api/motion/actions');
const acts=r.actions||[];
_renderChips(acts);
_renderList(acts);
['','2'].forEach(sfx=>{const bb=document.getElementById('arm-busy-badge'+sfx);if(bb)bb.style.display=_armBusy?'inline-flex':'none';});
const hdr=document.getElementById('arm-hdr-badge');
if(_armBusy){hdr.style.display='inline-flex';hdr.className='hdr-badge hdr-badge-err';hdr.textContent='ARM BUSY';}
else{hdr.style.display='none';}
}catch(e){}
}
async function triggerAction(id,name){if(_armBusy)return;_runId=id;_armBusy=true;document.getElementById('running-action').textContent='Running: '+name.replace(/_/g,' ')+'...';document.getElementById('running-action').style.display='block';renderActions({busy:true});const speed=parseFloat(document.getElementById('action-speed').value||document.getElementById('action-speed-2').value);try{await api('POST','/api/motion/trigger',{action_id:id,speed});}catch(e){}pollArmBusy();}
async function cancelAction(){try{const r=await api('POST','/api/replay/cancel');toast(r&&r.message?r.message:'Cancelled','info');}catch(e){}_armBusy=false;_runId=null;document.getElementById('running-action').style.display='none';refreshStatus();}
let _armPoll;function pollArmBusy(){clearInterval(_armPoll);_armPoll=setInterval(async()=>{try{const s=await api('GET','/api/replay/status');if(!s.arm?.busy){clearInterval(_armPoll);_armBusy=false;_runId=null;document.getElementById('running-action').style.display='none';refreshStatus();}}catch(e){clearInterval(_armPoll);}},500);}
// Skills
async function refreshSkills(){try{const r=await api('GET','/api/skills/');const el=document.getElementById('skills-list');if(!(r.skills||[]).length){el.innerHTML='<div class="empty">No skills configured</div>';return;}el.innerHTML='<table><tr><th>ID</th><th>Audio</th><th>Motion</th><th>Mode</th><th></th></tr>'+(r.skills||[]).map(s=>`<tr><td>${esc(s.id)}</td><td>${esc(s.audio_file||'--')}</td><td>${esc(s.motion_file||'--')}</td><td>${s.sync_mode}</td><td><button class="btn btn-primary btn-sm" onclick="execSkill('${esc(s.id)}',this)">Run</button></td></tr>`).join('')+'</table>';}catch(e){}}
async function execSkill(id,b){btnLoad(b);try{const r=await api('POST',`/api/skills/${id}/execute`);toast(r.ok?`${id} done (${r.elapsed_sec}s)`:`Failed: ${r.error}`,r.ok?'ok':'err');}catch(e){}btnDone(b);}
// Macros
async function startMacro(b){const n=document.getElementById('macro-name').value;if(!n)return toast('Enter name','err');btnLoad(b);try{await api('POST','/api/macros/record/start',{name:n});toast('Recording...','ok');document.getElementById('macro-status').textContent='Recording: '+n+'...';}catch(e){}btnDone(b);}
async function stopMacro(b){btnLoad(b);try{const r=await api('POST','/api/macros/record/stop');toast('Saved','ok');document.getElementById('macro-status').textContent=`Saved: ${r.name} (${r.duration_sec}s)`;}catch(e){}btnDone(b);}
async function playMacro(b){const n=document.getElementById('play-macro-name').value;if(!n)return toast('Enter name','err');btnLoad(b);try{await api('POST','/api/macros/play',{name:n});toast('Played: '+n,'ok');}catch(e){}btnDone(b);}
// Ad-hoc combined playback — select voice + motion, play in parallel.
// Motion list = SDK built-ins + JSONL replays (via /api/motion/actions),
// so the dropdown offers every arm action — not just recorded files.
async function refreshCombo(){
try{
const [av,am]=await Promise.all([
api('GET','/api/macros/audio-files'),
api('GET','/api/motion/actions'),
]);
const voiceSel=document.getElementById('combo-voice');
const motionSel=document.getElementById('combo-motion');
const prevV=voiceSel.value, prevM=motionSel.value;
voiceSel.innerHTML='<option value="">— none —</option>'
+(av.files||[]).map(f=>`<option value="${esc(f.name)}">${esc(f.name)} (${f.size_kb}KB)</option>`).join('');
// Motion: group by category, SDK first then JSONL
const acts=am.actions||[];
const sdk=acts.filter(a=>!a.file);
const jsl=acts.filter(a=>!!a.file);
let html='<option value="">— none —</option>';
if(sdk.length){
html+='<optgroup label="SDK built-ins">';
for(const a of sdk){html+=`<option value="${a.id}">${esc(a.name).replace(/_/g,' ')} (#${a.id})</option>`;}
html+='</optgroup>';
}
if(jsl.length){
html+='<optgroup label="JSONL replays">';
for(const a of jsl){html+=`<option value="${a.id}">${esc(a.file)} (#${a.id})</option>`;}
html+='</optgroup>';
}
motionSel.innerHTML=html;
if(prevV)voiceSel.value=prevV;
if(prevM)motionSel.value=prevM;
}catch(e){toast('Could not load combined lists','err');}
}
async function playCombo(b){
const v=document.getElementById('combo-voice').value;
const mRaw=document.getElementById('combo-motion').value;
const actionId=mRaw?parseInt(mRaw,10):null;
if(!v&&actionId==null)return toast('Pick a voice or motion (or both)','err');
const speed=parseFloat(document.getElementById('combo-speed').value||'1.0');
const st=document.getElementById('combo-status');
const mLabel=mRaw?document.getElementById('combo-motion').selectedOptions[0].textContent:'(no motion)';
st.textContent='Playing: '+[v||'(no voice)',mLabel].join(' + ')+'...';
btnLoad(b);
try{
const r=await api('POST','/api/macros/play-combined',{
audio_file:v,
action_id:actionId,
speed,
});
const parts=[];
if(r.audio_played)parts.push('audio='+r.audio_played);
if(r.motion_played)parts.push('motion='+r.motion_played);
if(r.audio_error)parts.push('audio_err='+r.audio_error);
if(r.motion_error)parts.push('motion_err='+r.motion_error);
st.textContent='Done: '+parts.join(', ');
toast('Combined play done','ok');
}catch(e){st.textContent='Failed';}
btnDone(b);
}
async function stopCombo(b){
const st=document.getElementById('combo-status');
btnLoad(b);
try{
const r=await api('POST','/api/macros/stop-combined');
const parts=[];
if(r.motion_stopped)parts.push('motion stopped');
if(r.audio_stopped)parts.push('audio stopped');
st.textContent='Stopped: '+(parts.join(', ')||'nothing was playing');
toast('Stopped','info');
}catch(e){st.textContent='Stop failed';}
btnDone(b);
}
// Replay
async function refreshReplayFiles(){try{const r=await api('GET','/api/replay/files');const el=document.getElementById('replay-files');if(!(r.files||[]).length){el.innerHTML='<div class="empty">No motion files</div>';return;}el.innerHTML='<table><tr><th>File</th><th>Frames</th><th>Duration</th><th>Size</th><th></th></tr>'+(r.files||[]).map(f=>`<tr><td>${esc(f.name)}</td><td>${f.frames}</td><td>${f.duration_sec}s</td><td>${f.size_kb}KB</td><td><button class="btn btn-primary btn-sm" onclick="document.getElementById('replay-name').value='${esc(f.name)}';testReplay()">Play</button> <button class="btn btn-danger btn-sm" onclick="deleteMotionFile('${esc(f.name)}')">Del</button></td></tr>`).join('')+'</table>';}catch(e){}}
async function testReplay(b){const n=document.getElementById('replay-name').value,s=parseFloat(document.getElementById('replay-speed').value);if(!n)return;btnLoad(b);try{await api('POST','/api/replay/test',{name:n,speed:s});toast('Replay: '+n,'ok');pollArmBusy();}catch(e){}btnDone(b);}
async function cancelReplay(){try{const r=await api('POST','/api/replay/cancel');toast(r&&r.message?r.message:'Cancelled','info');}catch(e){}}
async function deleteMotionFile(n){if(confirm('Delete '+n+'?'))try{await api('DELETE','/api/replay/files/'+encodeURIComponent(n));toast('Deleted','ok');refreshReplayFiles();}catch(e){}}
async function uploadMotionFile(input){if(!input.files[0])return;const fd=new FormData();fd.append('file',input.files[0]);try{const r=await fetch('/api/replay/files/upload',{method:'POST',body:fd});if(!r.ok){const j=await r.json();toast(j.detail||'Upload failed','err');}else{toast('Uploaded','ok');refreshReplayFiles();}}catch(e){toast('Upload error','err');}input.value='';}
async function startTeaching(b){const n=document.getElementById('teach-name').value,d=parseFloat(document.getElementById('teach-duration').value);if(!n)return toast('Enter name','err');btnLoad(b);try{await api('POST','/api/replay/teach/start',{name:n,duration_sec:d});toast('Teaching: '+n,'ok');pollTeachStatus();}catch(e){}btnDone(b);}
async function stopTeaching(b){btnLoad(b);try{const r=await api('POST','/api/replay/teach/stop');toast(`Saved: ${r.name} (${r.frames} frames)`,'ok');document.getElementById('teach-status').textContent=`Done: ${r.frames} frames`;refreshReplayFiles();}catch(e){}btnDone(b);}
let _teachPoll;function pollTeachStatus(){clearInterval(_teachPoll);_teachPoll=setInterval(async()=>{try{const r=await api('GET','/api/replay/teach/status');document.getElementById('teach-status').textContent=`${r.phase} | ${r.elapsed_sec}s | ${r.frames_recorded} frames`;if(!r.recording){clearInterval(_teachPoll);refreshReplayFiles();}}catch(e){clearInterval(_teachPoll);}},500);}
// Scripts
async function refreshScripts(){try{const r=await api('GET','/api/scripts/');const sel=document.getElementById('script-select');sel.innerHTML='<option value="">-- select --</option>'+(r.files||[]).map(f=>`<option value="${esc(f.name)}">${esc(f.name)} (${f.size_bytes}B)</option>`).join('');}catch(e){}}
async function loadScript(name){if(!name)return;try{const r=await api('POST','/api/scripts/load',{name});document.getElementById('script-content').value=r.content||'';}catch(e){}}
async function saveScript(){const name=document.getElementById('script-select').value,content=document.getElementById('script-content').value;if(!name)return toast('Select file','err');try{await api('POST','/api/scripts/save',{name,content});toast('Saved','ok');refreshScripts();}catch(e){}}
async function createScript(){const name=document.getElementById('script-new-name').value,content=document.getElementById('script-content').value;if(!name)return toast('Enter filename','err');try{await api('POST','/api/scripts/create',{name,content});toast('Created: '+name,'ok');refreshScripts();}catch(e){}}
async function deleteScript(){const name=document.getElementById('script-select').value;if(!name)return;if(confirm('Delete '+name+'?'))try{await api('POST','/api/scripts/delete',{name});toast('Deleted','ok');document.getElementById('script-content').value='';refreshScripts();}catch(e){}}
// Prompt
async function refreshPrompt(){try{const r=await api('GET','/api/prompt/');document.getElementById('prompt-content').value=r.system_prompt||'';document.getElementById('prompt-info').textContent=`Script: ${r.script_path} | Rule: ${r.rule_path}`;}catch(e){}}
async function updatePrompt(){try{await api('POST','/api/prompt/update',{content:document.getElementById('prompt-content').value});toast('Saved','ok');}catch(e){}}
async function reloadPrompt(){try{const r=await api('POST','/api/prompt/reload');document.getElementById('prompt-content').value=r.system_prompt||'';toast('Reloaded','ok');}catch(e){}}
// Records
async function refreshRecords(){try{const r=await api('GET','/api/records/');const el=document.getElementById('records-list');if(!(r.records||[]).length){el.innerHTML='<div class="empty">No records saved</div>';return;}el.innerHTML=`<div style="font-size:.7rem;color:var(--dim);margin-bottom:.3rem">Total: ${r.total_records} | Updated: ${r.last_updated||'--'}</div><table><tr><th>Name</th><th>Text</th><th>Replays</th><th></th></tr>`+(r.records||[]).map(rec=>{const n=esc(rec.record_name);return`<tr><td>${n}</td><td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(rec.text||'')}</td><td>${rec.replay_count||0}</td><td><button class="btn btn-primary btn-sm" onclick="playRecord('${n}','speaker')">Play</button> <button class="btn btn-ghost btn-sm" onclick="playRecord('${n}','raw')">Raw</button> <button class="btn btn-danger btn-sm" onclick="deleteRecord('${n}')">Del</button></td></tr>`;}).join('')+'</table>';}catch(e){}}
async function playRecord(name,kind){try{await api('POST','/api/records/play',{record_name:name,file_kind:kind});toast('Playing: '+name,'ok');refreshPlaybackStatus();}catch(e){}}
async function deleteRecord(name){if(confirm('Delete '+name+'?'))try{await api('POST','/api/records/delete',{record_name:name});toast('Deleted','ok');refreshRecords();}catch(e){}}
// Saved-record playback controls — operate on the active G1 playback
// (one at a time). The Pause/Resume buttons swap in refreshPlaybackStatus
// based on what audio_mgr reports; the bar hides itself when nothing plays.
async function pauseRecord(b){
if(b) btnLoad(b);
try{await api('POST','/api/records/pause');refreshPlaybackStatus();}
catch(e){toast('Pause failed: '+(e.message||e),'err');}
if(b) btnDone(b);
}
async function resumeRecord(b){
if(b) btnLoad(b);
try{await api('POST','/api/records/resume');refreshPlaybackStatus();}
catch(e){toast('Resume failed: '+(e.message||e),'err');}
if(b) btnDone(b);
}
async function stopRecord(b){
if(b) btnLoad(b);
try{await api('POST','/api/records/stop');toast('Stopped','info');refreshPlaybackStatus();}
catch(e){toast('Stop failed: '+(e.message||e),'err');}
if(b) btnDone(b);
}
async function refreshPlaybackStatus(){
try{
const s=await api('GET','/api/records/playback-status');
const bar=document.getElementById('rec-playback-bar');
if(!bar) return;
if(!s.playing){
bar.style.display='none';
return;
}
bar.style.display='flex';
document.getElementById('rec-playback-name').textContent =
s.record_name || '(unnamed)';
document.getElementById('rec-playback-time').textContent =
(s.position_sec||0).toFixed(1)+' / '+(s.duration_sec||0).toFixed(1)+' s';
const pauseBtn=document.getElementById('rec-pause-btn');
const resumeBtn=document.getElementById('rec-resume-btn');
if(s.paused){
pauseBtn.style.display='none';
resumeBtn.style.display='inline-block';
}else{
pauseBtn.style.display='inline-block';
resumeBtn.style.display='none';
}
}catch(e){}
}
// Live Voice
async function startLiveVoice(b){
btnLoad(b);
try{
const r=await api('POST','/api/live-voice/start');
if(r.started){
toast('Live voice started','ok');
}else{
toast('Live voice failed: '+(r.error||r.message||'unknown'),'err');
}
}catch(e){}
btnDone(b);
refreshLiveVoice();
}
async function stopLiveVoice(b){btnLoad(b);try{await api('POST','/api/live-voice/stop');toast('Stopped','info');}catch(e){}btnDone(b);refreshLiveVoice();}
async function setDeferredMode(v){try{await api('POST','/api/live-voice/deferred-mode?enabled='+v);}catch(e){}}
async function setTriggerEnabled(v){try{await api('POST','/api/live-voice/trigger-enabled?enabled='+v);}catch(e){}}
async function refreshLiveVoice(){
try{
const r=await api('GET','/api/live-voice/status');
const st=document.getElementById('lv-state');
const running = r.running===true;
st.textContent = running ? ('active ('+(r.dispatch_actions||0)+' actions)') : 'idle';
st.className='badge '+(running?'badge-ok':'badge-warn');
document.getElementById('lv-last-text').textContent=r.last_heard||'--';
document.getElementById('lv-pending').textContent=r.pending_action||r.last_action||'--';
document.getElementById('lv-deferred').checked=r.deferred_mode===true;
document.getElementById('lv-trigger-enabled').checked=r.trigger_enabled===true;
document.getElementById('lv-audio').textContent=r.audio_attached?'yes':'no';
document.getElementById('lv-arm').textContent=r.arm_attached?'yes':'no';
document.getElementById('lv-gem').textContent=r.gemini_connected?'connected':'disconnected';
// Render arm trigger log — one line per fire
const triggers=r.triggers||[];
const lines=triggers.map(t=>{
const mode=t.mode==='deferred'?'[defer]':'[now]';
return `[${t.time}] ${mode} ${t.action_name} (id=${t.action_id}) ← "${t.user_text}"`;
});
document.getElementById('lv-transcript').textContent=lines.join('\n')||'(waiting for voice triggers...)';
document.getElementById('lv-error').textContent='';
}catch(e){}
}
// Live Subprocess
async function startLiveSub(b){btnLoad(b);try{await api('POST','/api/live-subprocess/start');toast('Started','ok');}catch(e){}btnDone(b);refreshLiveSub();}
async function stopLiveSub(b){btnLoad(b);try{await api('POST','/api/live-subprocess/stop');toast('Stopped','info');}catch(e){}btnDone(b);refreshLiveSub();}
async function refreshLiveSub(){try{const r=await api('GET','/api/live-subprocess/status');const st=document.getElementById('ls-state');st.textContent=r.state||'stopped';st.className='badge '+(r.running?'badge-ok':'badge-warn');document.getElementById('ls-msg').textContent=r.state_message||'--';document.getElementById('ls-user').textContent=r.last_user_text||'--';document.getElementById('ls-log').textContent=(r.log_tail||[]).slice(-25).join('\n');}catch(e){}}
// Typed Replay
async function trGenerate(b){const t=document.getElementById('tr-text').value;if(!t)return toast('Enter text','err');btnLoad(b);try{await api('POST','/api/typed-replay/say',{text:t,record:document.getElementById('tr-capture').checked,record_name:document.getElementById('tr-name').value});toast('Generated & played','ok');refreshTR();}catch(e){}btnDone(b);}
async function trReplayLast(b){btnLoad(b);try{await api('POST','/api/typed-replay/replay-last');toast('Replayed','ok');refreshTR();}catch(e){}btnDone(b);}
async function trSaveLast(b){btnLoad(b);try{await api('POST','/api/typed-replay/save-last',{record_name:document.getElementById('tr-name').value});toast('Saved','ok');refreshTR();refreshRecords();}catch(e){}btnDone(b);}
async function refreshTR(){try{const r=await api('GET','/api/typed-replay/status');const s=r.session||{};document.getElementById('tr-session').innerHTML=`<strong>Text:</strong> ${esc(s.text||'--')}<br><strong>Audio:</strong> ${s.has_audio?'Yes':'No'} | <strong>Capture:</strong> ${s.has_capture?'Yes':'No'}<br><strong>Replays:</strong> ${s.replay_count||0}<br><strong>Generated:</strong> ${s.generated_at||'--'}<br><strong>Saved:</strong> ${esc(s.saved_as||'--')}`;}catch(e){}}
// Wake Phrases
async function refreshWakeActions(){try{const r=await api('GET','/api/wake-phrases/');const sel=document.getElementById('wp-action');sel.innerHTML='<option value="">-- select action --</option>'+(r.actions||[]).map(a=>`<option value="${esc(a.action)}">${esc(a.action)} (${a.phrase_count})</option>`).join('');}catch(e){}}
async function loadWakePhrases(action){if(!action)return;try{const r=await api('GET',`/api/wake-phrases/${encodeURIComponent(action)}`);const el=document.getElementById('wp-phrases');if(!(r.phrases||[]).length){el.innerHTML='<div class="empty">No phrases</div>';return;}el.innerHTML=(r.phrases||[]).map(p=>`<div class="row"><span style="flex:1;font-size:.78rem">${esc(p)}</span><button class="btn btn-danger btn-sm" onclick="removeWakePhrase(document.getElementById('wp-action').value,this.dataset.p)" data-p="${esc(p)}">X</button></div>`).join('');}catch(e){}}
async function addWakePhrase(){const action=document.getElementById('wp-action').value,phrase=document.getElementById('wp-new').value;if(!action||!phrase)return toast('Select action & enter phrase','err');try{await api('POST','/api/wake-phrases/add',{action,phrase});toast('Added','ok');document.getElementById('wp-new').value='';loadWakePhrases(action);}catch(e){}}
async function removeWakePhrase(action,phrase){try{await api('POST','/api/wake-phrases/remove',{action,phrase});toast('Removed','ok');loadWakePhrases(action);}catch(e){}}
// Log Snapshot
async function saveLogSnapshot(){try{const r=await api('POST','/api/logs/snapshot');toast(`Snapshot saved (${r.snapshots?.length||0} files)`,'ok');}catch(e){}}
// Logs — copy + download
async function _fetchLogBundle(lines){
const url='/api/logs/bundle'+(lines?('?lines='+lines):'');
const r=await fetch(url);
if(!r.ok){toast('Bundle fetch failed: HTTP '+r.status,'err');throw new Error('bundle fetch failed');}
return await r.text();
}
async function _copyToClipboard(text){
// Prefer the modern Clipboard API (https or localhost)
if(navigator.clipboard&&window.isSecureContext){
try{await navigator.clipboard.writeText(text);return true;}catch(e){/* fall through */}
}
// Fallback: hidden textarea + execCommand (works on http://wlan-ip:8000)
try{
const ta=document.createElement('textarea');
ta.value=text;
ta.setAttribute('readonly','');
ta.style.position='fixed';ta.style.left='-9999px';ta.style.top='0';
document.body.appendChild(ta);
ta.focus();ta.select();
const ok=document.execCommand('copy');
document.body.removeChild(ta);
return ok;
}catch(e){return false;}
}
async function copyAllLogs(b){
if(b)btnLoad(b);
try{
toast('Fetching log bundle...','info');
const text=await _fetchLogBundle(1000);
const ok=await _copyToClipboard(text);
const kb=(text.length/1024).toFixed(1);
if(ok){
toast(`Copied ${kb} KB to clipboard`,'ok');
}else{
toast('Clipboard unavailable — use Download instead','err');
}
}catch(e){}
if(b)btnDone(b);
}
async function copyVisibleLogs(b){
if(b)btnLoad(b);
const text=document.getElementById('log-box').textContent||'';
if(!text.trim()){toast('Log box is empty','info');if(b)btnDone(b);return;}
const ok=await _copyToClipboard(text);
const kb=(text.length/1024).toFixed(1);
toast(ok?`Copied ${kb} KB to clipboard`:'Clipboard unavailable','ok');
if(b)btnDone(b);
}
// Generic — copy whatever is inside the element with the given id.
// Used by the small "Copy" button on every log-box card in the dashboard.
async function copyLogBox(elId, b){
if(b)btnLoad(b);
const el=document.getElementById(elId);
const text=(el?.textContent||'').trim();
if(!text){toast('Nothing to copy','info');if(b)btnDone(b);return;}
const ok=await _copyToClipboard(text);
const kb=(text.length/1024).toFixed(1);
toast(ok?`Copied ${kb} KB`:'Clipboard unavailable',ok?'ok':'err');
if(b)btnDone(b);
}
function downloadLogBundle(){
// Browser directly downloads the /bundle endpoint — no intermediate JS
const ts=new Date().toISOString().replace(/[:.]/g,'-').slice(0,19);
const a=document.createElement('a');
a.href='/api/logs/bundle?lines=5000';
a.download=`sanad_bundle_${ts}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
toast('Downloading bundle...','info');
}
// Status
async function refreshStatus(){try{const s=await api('GET','/api/status');document.getElementById('status-dot').className='dot dot-ok';document.getElementById('status-text').textContent='Online';document.getElementById('gestural').checked=s.brain?.gestural_speaking||false;renderActions(s.arm);const gb=document.getElementById('gemini-badge');if(s.voice?.connected){gb.style.display='inline-flex';gb.className='hdr-badge hdr-badge-ok';gb.textContent='GEMINI';}else{gb.style.display='inline-flex';gb.className='hdr-badge hdr-badge-err';gb.textContent='GEMINI OFF';}}catch(e){document.getElementById('status-dot').className='dot dot-err';document.getElementById('status-text').textContent='Offline';}}
// WebSocket logs
let logWs;function connectLogs(){const p=location.protocol==='https:'?'wss':'ws';logWs=new WebSocket(`${p}://${location.host}/ws/logs`);const box=document.getElementById('log-box');logWs.onmessage=e=>{box.textContent+=e.data+'\n';if(box.childNodes.length>1000)box.textContent=box.textContent.split('\n').slice(-500).join('\n');box.scrollTop=box.scrollHeight;};logWs.onclose=()=>setTimeout(connectLogs,3000);}
// Auto-connect Gemini and auto-start Live Subprocess on page load
async function autoConnectGemini(){
try{
const r=await api('GET','/api/voice/status');
if(r.gemini&&!r.gemini.connected){
// Fire and forget — don't block page load (Gemini connect can take 10-30s)
fetch(API+'/api/voice/connect',{method:'POST',headers:{'Content-Type':'application/json'}})
.then(r=>r.json()).then(r=>{if(r.connected){toast('Gemini connected','ok');refreshStatus();}})
.catch(()=>{});
}
}catch(e){}
}
async function autoStartLiveSub(){
try{
const r=await api('GET','/api/live-subprocess/status');
if(!r.running&&r.available!==false){
await api('POST','/api/live-subprocess/start');
toast('Live Gemini process auto-started','ok');
refreshLiveSub();
}
}catch(e){}
}
// ── Recognition tab (camera vision + face recognition) ──
// Mirror of /api/recognition/state.vision_enabled — kept fresh by
// refreshRecognition() so the Live-Gemini-panel Camera button can flip
// it without a round-trip GET.
let _recVisionEnabled=false;
async function refreshRecognition(){
try{
const r=await api('GET','/api/recognition/state');
_recVisionEnabled=!!r.vision_enabled;
const v=document.getElementById('rec-vision-toggle');
const f=document.getElementById('rec-facerec-toggle');
if(v) v.checked=!!r.vision_enabled;
if(f) f.checked=!!r.face_rec_enabled;
const cs=document.getElementById('rec-camera-status');
if(cs){
const c=r.camera||{};
cs.title=c.error||'';
if(c.running&&c.backend){
cs.textContent=c.backend+' '+(c.width||'')+'x'+(c.height||'')
+(c.reconnect_count?(' ↻'+c.reconnect_count):'');
cs.className='badge badge-ok';
}else if(c.running&&!c.backend){
// thread alive but between reconnect attempts (camera unplugged)
cs.textContent='reconnecting…';cs.className='badge badge-warn';
}else if(c.error){
cs.textContent='error';cs.className='badge badge-warn';
}else{
cs.textContent='off';cs.className='badge';
}
}
// Camera button in the Live Gemini Process panel (Voice & Audio tab) —
// same toggle as the Recognition tab, surfaced where it's handy.
const cb=document.getElementById('ls-cam-btn');
if(cb){
const c=r.camera||{};
if(c.running&&c.backend){
cb.textContent='Camera: ON';
cb.className='btn btn-sm btn-success';
cb.title='Streaming '+(c.backend||'')+' '+(c.width||'')+'x'+(c.height||'')+' to Gemini — click to turn off';
}else if(c.running&&!c.backend){
cb.textContent='Camera: …';
cb.className='btn btn-sm btn-ghost';
cb.title='Camera reconnecting…';
}else if(r.vision_enabled&&c.error){
cb.textContent='Camera: N/A';
cb.className='btn btn-sm btn-danger';
cb.title='Vision on but no camera backend: '+(c.error||'');
}else{
cb.textContent='Camera: OFF';
cb.className='btn btn-sm btn-ghost';
cb.title='Click to stream camera frames to Gemini Live';
}
}
const fs=document.getElementById('rec-facerec-status');
if(fs){fs.textContent=r.face_rec_enabled?'on':'off';fs.className='badge '+(r.face_rec_enabled?'badge-ok':'');}
const fc=document.getElementById('rec-faces-count');
if(fc) fc.textContent=`(${r.faces_count} faces, ${r.photos_count} photos)`;
const gv=document.getElementById('rec-gallery-version');
if(gv) gv.textContent='v.'+r.gallery_version;
// toggle preview visibility — only when actively capturing (has a backend)
const img=document.getElementById('rec-preview-img');
const empty=document.getElementById('rec-preview-empty');
const meta=document.getElementById('rec-preview-meta');
const c2=r.camera||{};
if(c2.running&&c2.backend){
img.style.display='inline-block';empty.style.display='none';
if(meta) meta.textContent=`${c2.width}x${c2.height} @ ${c2.fps}fps · seq=${c2.frame_seq}`;
}else{
img.style.display='none';empty.style.display='block';
if(empty) empty.textContent=(c2.running&&!c2.backend)
? 'Camera reconnecting…'
: 'Camera off — toggle Vision ON to see the live feed.';
if(meta) meta.textContent='--';
}
// Highlight the active resolution / quality buttons to match the live
// capture profile (works whether the camera is running or idle).
document.querySelectorAll('#rec-res-buttons button').forEach(btn=>{
const on = parseInt(btn.dataset.w)===c2.width
&& parseInt(btn.dataset.h)===c2.height
&& parseInt(btn.dataset.fps)===c2.fps;
btn.className='btn btn-sm '+(on?'btn-primary':'btn-ghost');
});
document.querySelectorAll('#rec-quality-buttons button').forEach(btn=>{
const on = parseInt(btn.dataset.q)===c2.jpeg_quality;
btn.className='btn btn-sm '+(on?'btn-primary':'btn-ghost');
});
}catch(e){}
}
// Resolution / FPS button menu — each click POSTs one mode and the
// CameraDaemon rebuilds the pipeline at it. refreshRecognition() then
// highlights whichever button matches the live profile.
async function setCameraMode(btn){
btnLoad(btn);
try{
const body={
width: parseInt(btn.dataset.w),
height: parseInt(btn.dataset.h),
fps: parseInt(btn.dataset.fps),
};
const r=await api('POST','/api/recognition/camera-config',body);
const p=r.profile||body;
toast(`Camera → ${p.width}×${p.height} @ ${p.fps}fps`,'ok');
refreshRecognition();
}catch(e){toast('Resolution change failed: '+(e.message||e),'err');}
btnDone(btn);
}
async function setCameraQuality(btn){
btnLoad(btn);
try{
const q=parseInt(btn.dataset.q);
await api('POST','/api/recognition/camera-config',{jpeg_quality:q});
toast('JPEG quality → '+q,'ok');
refreshRecognition();
}catch(e){toast('Quality change failed: '+(e.message||e),'err');}
btnDone(btn);
}
// Camera button in the Live Gemini Process panel — flips the same
// vision toggle the Recognition tab owns. _recVisionEnabled is the
// last-known state (refreshed every 5 s by refreshRecognition).
async function toggleGeminiCamera(b){
if(b) btnLoad(b);
const next=!_recVisionEnabled;
try{
const r=await api('POST','/api/recognition/vision?on='+(next?'1':'0'));
_recVisionEnabled=!!(r&&r.vision_enabled);
toast(next?'Camera ON for Gemini':'Camera OFF for Gemini','ok');
}catch(e){
toast('Camera toggle failed: '+(e.message||e),'err');
}
if(b) btnDone(b);
refreshRecognition(); // refresh both the panel button + the Recognition tab
}
async function setVisionEnabled(on){
try{
const r=await api('POST','/api/recognition/vision?on='+(on?'1':'0'));
toast(on?'Vision ON':'Vision OFF','ok');
refreshRecognition();
}catch(e){
toast('Vision toggle failed: '+(e.message||e),'err');
refreshRecognition();
}
}
async function setFaceRecEnabled(on){
try{
const r=await api('POST','/api/recognition/face-rec?on='+(on?'1':'0'));
toast(on?'Face Recognition ON':'Face Recognition OFF','ok');
if(r&&r.warning) toast(r.warning,'info');
refreshRecognition();
}catch(e){
toast('Face Rec toggle failed: '+(e.message||e),'err');
refreshRecognition();
}
}
async function syncGallery(b){
if(b) btnLoad(b);
try{await api('POST','/api/recognition/sync');toast('Gallery sync requested','ok');refreshRecognition();}
catch(e){toast('Sync failed','err');}
if(b) btnDone(b);
}
// Preview poller — bumps the img src each tick to defeat caching.
let _recPreviewTimer=null;
function startRecPreview(){
if(_recPreviewTimer) return;
const tick=()=>{
const img=document.getElementById('rec-preview-img');
if(img && img.style.display!=='none'){
img.src='/api/recognition/frame.jpg?t='+Date.now();
}
};
tick();
_recPreviewTimer=setInterval(tick,500);
}
function stopRecPreview(){if(_recPreviewTimer){clearInterval(_recPreviewTimer);_recPreviewTimer=null;}}
// Hook into tab switch — start/stop preview when recognition tab is active.
(function(){
const origSwitchTab=window.switchTab;
window.switchTab=function(name){
origSwitchTab(name);
if(name==='recognition'){refreshRecognition();refreshFaces();refreshZones();startRecPreview();}
else{stopRecPreview();}
};
})();
// Face CRUD stubs — filled in milestone 5
async function refreshFaces(){
const el=document.getElementById('rec-faces-list');
if(!el) return;
try{
const r=await api('GET','/api/recognition/faces');
if(!r.faces||!r.faces.length){el.innerHTML='<div class="empty">No faces enrolled yet</div>';return;}
el.innerHTML=r.faces.map(f=>renderFaceCard(f)).join('');
}catch(e){
el.innerHTML='<div class="empty">(face gallery not yet wired)</div>';
}
}
function renderFaceCard(f){
const name=f.name||`(face_${f.id})`;
const photos=(f.photos||[]).map(p=>{
const url=`/api/recognition/faces/${f.id}/photo/${encodeURIComponent(p.name)}`;
return `<div style="display:inline-block;margin:.2rem;text-align:center">
<img src="${url}?t=${Date.now()}" alt="${esc(p.name)}" style="width:72px;height:72px;object-fit:cover;border-radius:.3rem;background:#222"/>
<div style="font-size:.6rem;color:var(--dim);margin-top:.1rem">
<a href="${url}?download=1" download style="color:var(--accent);text-decoration:none">⬇</a>
<a href="#" onclick="deletePhoto(${f.id},'${esc(p.name)}');return false" style="color:var(--err);text-decoration:none;margin-left:.3rem">🗑</a>
</div>
</div>`;
}).join('');
return `<div class="card" style="margin-top:.5rem">
<div class="row" style="align-items:center">
<strong>face_${f.id}</strong>
<span style="color:var(--dim)">—</span>
<span id="rec-name-${f.id}" style="flex:1">${esc(name)}</span>
<button class="btn btn-ghost btn-sm" onclick="renameFace(${f.id})" title="Rename">✏</button>
<span style="color:var(--dim);font-size:.7rem">${(f.photos||[]).length} photo(s)</span>
</div>
<div style="margin-top:.25rem;font-size:.72rem">
<span style="color:var(--dim)">Description:</span>
<span id="rec-desc-${f.id}" style="color:var(--muted)">${f.description?esc(f.description):''}</span>${f.description?'':'<span style="color:var(--dim)">(none — no extra context for Gemini)</span>'}
<button class="btn btn-ghost btn-sm" onclick="describeFace(${f.id})" title="Edit description Gemini sees">✏</button>
</div>
<div style="margin-top:.3rem">${photos}</div>
<div class="row" style="margin-top:.4rem">
<button class="btn btn-success btn-sm" onclick="captureToFace(${f.id},this)">📷 Capture</button>
<label class="btn btn-primary btn-sm" style="cursor:pointer;margin:0">
📁 Upload
<input type="file" multiple accept="image/jpeg,image/png" style="display:none" onchange="uploadToFace(${f.id},this)">
</label>
<a class="btn btn-ghost btn-sm" href="/api/recognition/faces/${f.id}/download.zip" download>⬇ ZIP</a>
<button class="btn btn-danger btn-sm" style="margin-left:auto" onclick="deleteFace(${f.id})">🗑 Delete face</button>
</div>
</div>`;
}
// Build the ?name=&description= query string from the Add-New-Face inputs.
function _newFaceQuery(){
const name=document.getElementById('rec-newface-name').value.trim();
const desc=document.getElementById('rec-newface-desc').value.trim();
const qs=[];
if(name) qs.push('name='+encodeURIComponent(name));
if(desc) qs.push('description='+encodeURIComponent(desc));
return qs.length?('?'+qs.join('&')):'';
}
function _clearNewFaceInputs(){
document.getElementById('rec-newface-name').value='';
document.getElementById('rec-newface-desc').value='';
}
async function enrollFromCamera(b){
btnLoad(b);
try{
const r=await api('POST','/api/recognition/faces/enroll'+_newFaceQuery());
toast('Enrolled face_'+r.face.id+(r.face.description?' (with description)':''),'ok');
_clearNewFaceInputs();
refreshFaces();refreshRecognition();
}catch(e){toast('Enroll failed: '+(e.message||e),'err');}
btnDone(b);
}
async function enrollFromUpload(input){
const files=input.files;if(!files||!files.length)return;
const fd=new FormData();for(const f of files) fd.append('files',f);
try{
const resp=await fetch('/api/recognition/faces/upload'+_newFaceQuery(),{method:'POST',body:fd});
if(!resp.ok)throw new Error(await resp.text());
const r=await resp.json();
toast('Uploaded face_'+r.face.id+' ('+files.length+' photos'+(r.face.description?', with description':'')+')','ok');
_clearNewFaceInputs();
input.value='';
refreshFaces();refreshRecognition();
}catch(e){toast('Upload failed: '+(e.message||e),'err');}
}
async function captureToFace(id,b){
btnLoad(b);
try{await api('POST','/api/recognition/faces/'+id+'/capture');toast('Added photo','ok');refreshFaces();}
catch(e){toast('Capture failed','err');}
btnDone(b);
}
async function uploadToFace(id,input){
const files=input.files;if(!files||!files.length)return;
const fd=new FormData();for(const f of files) fd.append('files',f);
try{
const resp=await fetch('/api/recognition/faces/'+id+'/upload',{method:'POST',body:fd});
if(!resp.ok)throw new Error(await resp.text());
toast('Uploaded '+files.length+' photo(s)','ok');
input.value='';
refreshFaces();
}catch(e){toast('Upload failed: '+(e.message||e),'err');}
}
async function renameFace(id){
const el=document.getElementById('rec-name-'+id);if(!el)return;
const cur=el.textContent.replace(/^\((.*)\)$/,'$1');
const next=prompt('New name (blank to clear):',cur==='face_'+id?'':cur);
if(next===null) return;
try{
await api('POST','/api/recognition/faces/'+id+'/rename',{name:next});
toast('Renamed','ok');refreshFaces();
}catch(e){toast('Rename failed','err');}
}
async function describeFace(id){
const el=document.getElementById('rec-desc-'+id);
const cur=el?el.textContent.trim():'';
const next=prompt('Description for Gemini — who is this person? '+
'(blank to clear)',cur);
if(next===null) return;
try{
await api('POST','/api/recognition/faces/'+id+'/describe',{description:next});
toast(next.trim()?'Description saved':'Description cleared','ok');
refreshFaces();
}catch(e){toast('Save failed: '+(e.message||e),'err');}
}
async function deletePhoto(id,name){
if(!confirm('Delete photo '+name+'?'))return;
try{
await api('DELETE','/api/recognition/faces/'+id+'/photo/'+encodeURIComponent(name));
toast('Photo deleted','ok');refreshFaces();
}catch(e){toast('Delete failed: '+(e.message||e),'err');}
}
async function deleteFace(id){
if(!confirm('Delete face_'+id+' and all photos?'))return;
try{
await api('DELETE','/api/recognition/faces/'+id);
toast('Face deleted','ok');refreshFaces();refreshRecognition();
}catch(e){toast('Delete failed','err');}
}
// ── Zones → Places → linked Faces (+ "go here" destination) ──
let _facesCache=[]; // [{id,name}] from the face gallery, for the link picker
let _navTarget=null; // active destination {zone_id,place_id,zone_name,place_name}
async function setZoneRecEnabled(on){
try{await api('POST','/api/zones/zone-rec?on='+(on?'1':'0'));toast(on?'Zone Recognition ON':'Zone Recognition OFF','ok');refreshZones();}
catch(e){toast('Zone Rec toggle failed: '+(e.message||e),'err');refreshZones();}
}
async function syncZones(b){
if(b) btnLoad(b);
try{await api('POST','/api/zones/sync');toast('Zones sync requested','ok');refreshZones();}
catch(e){toast('Sync failed','err');}
if(b) btnDone(b);
}
async function clearNavTarget(b){
if(b) btnLoad(b);
try{await api('POST','/api/zones/nav/clear');toast('Destination cleared','ok');refreshZones();}
catch(e){toast('Clear failed','err');}
if(b) btnDone(b);
}
async function refreshZones(){
try{const fr=await api('GET','/api/recognition/faces');_facesCache=(fr.faces||[]).map(f=>({id:f.id,name:f.name||('face_'+f.id)}));}catch(e){_facesCache=[];}
try{
const r=await api('GET','/api/zones/state');
const t=document.getElementById('rec-zonerec-toggle'); if(t) t.checked=!!r.zone_rec_enabled;
const zs=document.getElementById('rec-zonerec-status'); if(zs){zs.textContent=r.zone_rec_enabled?'on':'off';zs.className='badge '+(r.zone_rec_enabled?'badge-ok':'');}
const zc=document.getElementById('rec-zones-count'); if(zc) zc.textContent=`(${r.zones_count} zones, ${r.places_count} places)`;
const zv=document.getElementById('rec-zones-version'); if(zv) zv.textContent='v.'+r.zones_version;
_navTarget=r.nav_target||null;
const nt=document.getElementById('rec-nav-target'), nc=document.getElementById('rec-nav-clear');
if(nt){
if(_navTarget){nt.textContent=(_navTarget.place_name||('place_'+_navTarget.place_id))+' · '+(_navTarget.zone_name||('zone_'+_navTarget.zone_id));nt.style.color='var(--accent)';}
else{nt.textContent='none';nt.style.color='var(--dim)';}
}
if(nc) nc.style.display=_navTarget?'':'none';
}catch(e){}
const el=document.getElementById('rec-zones-list'); if(!el) return;
try{
const r=await api('GET','/api/zones');
if(!r.zones||!r.zones.length){el.innerHTML='<div class="empty">No zones yet — add one above</div>';return;}
el.innerHTML=r.zones.map(z=>renderZoneCard(z)).join('');
}catch(e){el.innerHTML='<div class="empty">(zone gallery not available)</div>';}
}
function _faceOptions(selectedIds){
const sel=new Set((selectedIds||[]).map(Number));
if(!_facesCache.length) return '<option disabled>(no saved faces)</option>';
return _facesCache.map(f=>`<option value="${f.id}" ${sel.has(f.id)?'selected':''}>${esc(f.name)}</option>`).join('');
}
function renderZoneCard(z){
const zname=z.name||`(zone_${z.id})`;
const places=(z.places||[]).map(p=>renderPlaceCard(z.id,p)).join('') || '<div class="empty" style="margin:.3rem 0">No places in this zone yet</div>';
return `<div class="card" style="margin-top:.6rem;border-left:3px solid var(--accent2)">
<div class="row" style="align-items:center;gap:.3rem">
<strong>📍 ${esc(zname)}</strong>
<button class="btn btn-ghost btn-sm" onclick="renameZone(${z.id})" title="Rename zone">✏</button>
<span style="flex:1;color:var(--muted);font-size:.72rem">${z.description?esc(z.description):'<span style=color:var(--dim)>(no description)</span>'}</span>
<button class="btn btn-ghost btn-sm" onclick="describeZone(${z.id})" title="Edit zone description">📝</button>
<span style="color:var(--dim);font-size:.7rem">${(z.places||[]).length} place(s)</span>
<button class="btn btn-danger btn-sm" onclick="deleteZone(${z.id})" title="Delete zone + its places">🗑</button>
</div>
<div style="margin-top:.4rem;padding-left:.6rem">${places}</div>
<div class="row" style="margin-top:.4rem;padding-left:.6rem;gap:.3rem;flex-wrap:wrap">
<input id="z${z.id}-np-name" placeholder="New place name" style="flex:1;min-width:8rem;font-size:.78rem">
<input id="z${z.id}-np-desc" placeholder="Description (optional)" style="flex:2;min-width:8rem;font-size:.78rem">
<button class="btn btn-success btn-sm" onclick="createPlaceInZone(${z.id},this)"> place</button>
</div>
</div>`;
}
function renderPlaceCard(zid,p){
const pname=p.name||`(place_${p.id})`;
const photos=(p.photos||[]).map(ph=>{
const url=`/api/zones/${zid}/places/${p.id}/photo/${encodeURIComponent(ph.name)}`;
return `<div style="display:inline-block;margin:.15rem;text-align:center">
<img src="${url}?t=${Date.now()}" alt="${esc(ph.name)}" style="width:64px;height:64px;object-fit:cover;border-radius:.3rem;background:#222"/>
<div style="font-size:.55rem"><a href="#" onclick="deletePlacePhoto(${zid},${p.id},'${esc(ph.name)}');return false" style="color:var(--err);text-decoration:none">🗑</a></div>
</div>`;
}).join('');
const chips=(p.faces||[]).map(f=>`<span class="badge" style="margin-right:.2rem">${esc(f.name||('face_'+f.id))}</span>`).join('') || '<span style="color:var(--dim);font-size:.7rem">none</span>';
const isDest=_navTarget&&_navTarget.zone_id===zid&&_navTarget.place_id===p.id;
return `<div class="card" style="margin-top:.35rem;background:var(--panel2)">
<div class="row" style="align-items:center;gap:.3rem">
<span>🏷</span><span id="rec-pname-${zid}-${p.id}" style="flex:1">${esc(pname)}</span>
<button class="btn btn-ghost btn-sm" onclick="renamePlace(${zid},${p.id})" title="Rename">✏</button>
${isDest?'<span class="badge badge-ok">destination</span>':`<button class="btn btn-primary btn-sm" onclick="goToPlace(${zid},${p.id},this)" title="Set as destination">▶ Go here</button>`}
<button class="btn btn-danger btn-sm" onclick="deletePlace(${zid},${p.id})">🗑</button>
</div>
<div style="margin-top:.2rem;font-size:.72rem"><span style="color:var(--dim)">Description:</span>
<span id="rec-pdesc-${zid}-${p.id}" style="color:var(--muted)">${p.description?esc(p.description):'<span style=color:var(--dim)>(none)</span>'}</span>
<button class="btn btn-ghost btn-sm" onclick="describePlace(${zid},${p.id})" title="Edit description">✏</button>
</div>
<div style="margin-top:.25rem;font-size:.72rem"><span style="color:var(--dim)">People here:</span> ${chips}</div>
<div class="row" style="margin-top:.2rem;gap:.3rem;align-items:center">
<select id="pf-${zid}-${p.id}" multiple size="3" style="font-size:.72rem;min-width:9rem">${_faceOptions(p.face_ids)}</select>
<button class="btn btn-ghost btn-sm" onclick="savePlaceFaces(${zid},${p.id})" title="Link selected saved faces to this place">Save people</button>
</div>
<div style="margin-top:.25rem">${photos}</div>
<div class="row" style="margin-top:.3rem;gap:.3rem">
<button class="btn btn-success btn-sm" onclick="captureToPlace(${zid},${p.id},this)">📷 Capture</button>
<label class="btn btn-primary btn-sm" style="cursor:pointer;margin:0">📁 Upload<input type="file" multiple accept="image/jpeg,image/png" style="display:none" onchange="uploadToPlace(${zid},${p.id},this)"></label>
<a class="btn btn-ghost btn-sm" href="/api/zones/${zid}/places/${p.id}/download.zip" download>⬇ ZIP</a>
</div>
</div>`;
}
async function createZone(b){
const name=document.getElementById('rec-newzone-name').value.trim();
const desc=document.getElementById('rec-newzone-desc').value.trim();
if(!name&&!desc){toast('Enter a zone name or description','err');return;}
const qs=[]; if(name)qs.push('name='+encodeURIComponent(name)); if(desc)qs.push('description='+encodeURIComponent(desc));
btnLoad(b);
try{await api('POST','/api/zones/create?'+qs.join('&'));toast('Zone added','ok');
document.getElementById('rec-newzone-name').value='';document.getElementById('rec-newzone-desc').value='';refreshZones();}
catch(e){toast('Add zone failed: '+(e.message||e),'err');}
btnDone(b);
}
async function renameZone(zid){
const next=prompt('New zone name (blank to clear):'); if(next===null)return;
try{await api('POST','/api/zones/'+zid+'/rename',{name:next});toast('Renamed','ok');refreshZones();}catch(e){toast('Rename failed','err');}
}
async function describeZone(zid){
const next=prompt('Zone description (blank to clear):'); if(next===null)return;
try{await api('POST','/api/zones/'+zid+'/describe',{description:next});toast('Saved','ok');refreshZones();}catch(e){toast('Save failed','err');}
}
async function deleteZone(zid){
if(!confirm('Delete this zone AND all its places?'))return;
try{await api('DELETE','/api/zones/'+zid);toast('Zone deleted','ok');refreshZones();}catch(e){toast('Delete failed','err');}
}
async function createPlaceInZone(zid,b){
const nameEl=document.getElementById('z'+zid+'-np-name'), descEl=document.getElementById('z'+zid+'-np-desc');
const name=(nameEl?nameEl.value:'').trim(), desc=(descEl?descEl.value:'').trim();
if(!name&&!desc){toast('Enter a place name or description','err');return;}
const qs=[]; if(name)qs.push('name='+encodeURIComponent(name)); if(desc)qs.push('description='+encodeURIComponent(desc));
btnLoad(b);
try{await api('POST','/api/zones/'+zid+'/places/create?'+qs.join('&'));toast('Place added','ok');refreshZones();}
catch(e){toast('Add place failed: '+(e.message||e),'err');}
btnDone(b);
}
async function renamePlace(zid,pid){
const next=prompt('New place name (blank to clear):'); if(next===null)return;
try{await api('POST','/api/zones/'+zid+'/places/'+pid+'/rename',{name:next});toast('Renamed','ok');refreshZones();}catch(e){toast('Rename failed','err');}
}
async function describePlace(zid,pid){
const next=prompt('Place description for Gemini (blank to clear):'); if(next===null)return;
try{await api('POST','/api/zones/'+zid+'/places/'+pid+'/describe',{description:next});toast('Saved','ok');refreshZones();}catch(e){toast('Save failed','err');}
}
async function deletePlace(zid,pid){
if(!confirm('Delete this place and its photos?'))return;
try{await api('DELETE','/api/zones/'+zid+'/places/'+pid);toast('Place deleted','ok');refreshZones();}catch(e){toast('Delete failed','err');}
}
async function savePlaceFaces(zid,pid){
const sel=document.getElementById('pf-'+zid+'-'+pid); if(!sel)return;
const ids=Array.from(sel.selectedOptions).map(o=>parseInt(o.value)).filter(n=>!isNaN(n));
try{await api('POST','/api/zones/'+zid+'/places/'+pid+'/faces',{face_ids:ids});toast('People linked: '+ids.length,'ok');refreshZones();}
catch(e){toast('Save people failed: '+(e.message||e),'err');}
}
async function captureToPlace(zid,pid,b){
btnLoad(b);
try{await api('POST','/api/zones/'+zid+'/places/'+pid+'/capture');toast('Added photo','ok');refreshZones();}
catch(e){toast('Capture failed: '+(e.message||e),'err');}
btnDone(b);
}
async function uploadToPlace(zid,pid,input){
const files=input.files;if(!files||!files.length)return;
const fd=new FormData();for(const f of files) fd.append('files',f);
try{const resp=await fetch('/api/zones/'+zid+'/places/'+pid+'/upload',{method:'POST',body:fd});
if(!resp.ok)throw new Error(await resp.text());toast('Uploaded '+files.length+' photo(s)','ok');input.value='';refreshZones();}
catch(e){toast('Upload failed: '+(e.message||e),'err');}
}
async function deletePlacePhoto(zid,pid,name){
if(!confirm('Delete photo '+name+'?'))return;
try{await api('DELETE','/api/zones/'+zid+'/places/'+pid+'/photo/'+encodeURIComponent(name));toast('Photo deleted','ok');refreshZones();}
catch(e){toast('Delete failed: '+(e.message||e),'err');}
}
async function goToPlace(zid,pid,b){
if(b) btnLoad(b);
try{const r=await api('POST','/api/zones/'+zid+'/places/'+pid+'/go');toast('Destination set: '+((r.nav_target&&r.nav_target.place_name)||('place_'+pid)),'ok');refreshZones();}
catch(e){toast('Set destination failed: '+(e.message||e),'err');}
if(b) btnDone(b);
}
// ==================== Controller tab (N2) ====================
let ctrlArmed=false, ctrlTeleop=false, ctrlVel={vx:0,vy:0,vyaw:0}, ctrlKeys=new Set(), ctrlTimer=null;
const CTRL_LIN=0.05, CTRL_ANG=0.2;
// silent POST (no toast) for high-frequency teleop / stop
function ctrlPost(path,body){return fetch(API+path,{method:'POST',headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined}).then(r=>r.json().catch(()=>({}))).catch(()=>({}));}
function setPill(id,on){const el=document.getElementById(id);if(!el)return;el.classList.toggle('pill-on',!!on);el.classList.toggle('pill-off',!on);}
function ctrlSetArmed(on){
ctrlPost('/api/controller/arm?on='+(on?'1':'0')).then(r=>{
ctrlArmed=!!r.armed; if(!ctrlArmed) ctrlStopTeleop();
ctrlRenderArmed(); refreshStatusStrip();
toast('Movement '+(ctrlArmed?'ENABLED':'disabled'), ctrlArmed?'ok':'info');
});
}
function ctrlRenderArmed(){
const t=document.getElementById('ctrl-arm-toggle'); if(t) t.checked=ctrlArmed;
document.querySelectorAll('#tab-controller .btn').forEach(b=>{
const oc=b.getAttribute('onclick')||'';
if(/ctrlEstop|ctrlStop\b/.test(oc)) return; // E-STOP + Stop always live
b.disabled=!ctrlArmed;
});
setPill('ctrl-pill-movement',ctrlArmed);
}
async function ctrlEstop(b){btnLoad(b);try{await ctrlPost('/api/controller/estop');ctrlStopTeleop();toast('E-STOP sent','err');}catch(e){}btnDone(b);refreshController();}
function ctrlStop(b){btnLoad(b);ctrlPost('/api/controller/stop').then(()=>btnDone(b));}
async function ctrlStep(dir,b){btnLoad(b);try{const r=await api('POST','/api/controller/step?dir='+dir);if(r&&r.warning)toast(r.warning,'warn');}catch(e){}btnDone(b);}
async function ctrlMode(m,b){btnLoad(b);try{await api('POST','/api/controller/mode/'+m);toast(m.toUpperCase()+' done','ok');}catch(e){}btnDone(b);refreshController();}
async function ctrlPosture(p,b){btnLoad(b);try{const r=await api('POST','/api/controller/posture/'+p);if(r&&r.warning)toast(r.warning,'warn');else toast(p+' sent','ok');}catch(e){}btnDone(b);refreshController();}
async function ctrlBalance(m,b){btnLoad(b);try{await api('POST','/api/controller/balance?mode='+m);toast('balance '+(m?'gait':'static'),'info');}catch(e){}btnDone(b);}
async function ctrlMscSelectAi(b){btnLoad(b);try{await api('POST','/api/controller/msc/select-ai');toast('MSC → ai','ok');}catch(e){}btnDone(b);refreshController();}
async function ctrlMscRelease(b){btnLoad(b);try{await api('POST','/api/controller/msc/release');toast('MSC released','ok');}catch(e){}btnDone(b);refreshController();}
async function ctrlMscShow(b){btnLoad(b);try{const r=await api('GET','/api/controller/msc');toast('MSC mode: '+(r.mode_name||'?'),'info');}catch(e){}btnDone(b);}
async function ctrlReconnect(b){btnLoad(b);try{await api('POST','/api/controller/reconnect');toast('reconnected','ok');}catch(e){}btnDone(b);refreshController();}
function ctrlSetGeminiMove(on){
ctrlPost('/api/controller/gemini-movement?on='+(on?'1':'0')).then(r=>{
toast('Gemini movement '+(r.movement_enabled?'ENABLED':'disabled'), r.movement_enabled?'ok':'info');
refreshStatusStrip();
});
}
// continuous teleop @10 Hz — held keys ramp velocity; cap enforced server-side
function ctrlToggleTeleop(){ctrlTeleop?ctrlStopTeleop():ctrlStartTeleop();}
function ctrlStartTeleop(){
if(!ctrlArmed){toast('Enable movement first','warn');return;}
ctrlTeleop=true; const btn=document.getElementById('ctrl-teleop-btn'); if(btn)btn.textContent='Stop teleop';
window.addEventListener('keydown',ctrlKeyDown); window.addEventListener('keyup',ctrlKeyUp);
ctrlTimer=setInterval(ctrlTick,100);
}
function ctrlStopTeleop(){
if(!ctrlTeleop && !ctrlTimer) return;
ctrlTeleop=false; ctrlKeys.clear(); ctrlVel={vx:0,vy:0,vyaw:0};
if(ctrlTimer){clearInterval(ctrlTimer);ctrlTimer=null;}
window.removeEventListener('keydown',ctrlKeyDown); window.removeEventListener('keyup',ctrlKeyUp);
const btn=document.getElementById('ctrl-teleop-btn'); if(btn)btn.textContent='Start teleop (WASD / Q-E)';
const r=document.getElementById('ctrl-vel-readout'); if(r)r.textContent='vx 0.00 · vy 0.00 · ω 0.00';
ctrlPost('/api/controller/stop');
}
function ctrlKeyDown(e){const k=(e.key||'').toLowerCase(); if(['w','a','s','d','q','e',' '].includes(k)){ctrlKeys.add(k);e.preventDefault();}}
function ctrlKeyUp(e){ctrlKeys.delete((e.key||'').toLowerCase());}
function ctrlTick(){
if(ctrlKeys.has(' ')){ctrlVel={vx:0,vy:0,vyaw:0};}
else{
ctrlVel.vx = ctrlKeys.has('w')? Math.min(2,ctrlVel.vx+CTRL_LIN) : ctrlKeys.has('s')? Math.max(-2,ctrlVel.vx-CTRL_LIN) : 0;
ctrlVel.vy = ctrlKeys.has('q')? Math.min(2,ctrlVel.vy+CTRL_LIN) : ctrlKeys.has('e')? Math.max(-2,ctrlVel.vy-CTRL_LIN) : 0;
ctrlVel.vyaw = ctrlKeys.has('a')? Math.min(3,ctrlVel.vyaw+CTRL_ANG) : ctrlKeys.has('d')? Math.max(-3,ctrlVel.vyaw-CTRL_ANG) : 0;
}
const run=(document.getElementById('ctrl-run-toggle')||{}).checked||false;
const r=document.getElementById('ctrl-vel-readout'); if(r)r.textContent=`vx ${ctrlVel.vx.toFixed(2)} · vy ${ctrlVel.vy.toFixed(2)} · ω ${ctrlVel.vyaw.toFixed(2)}`;
ctrlPost('/api/controller/move',{vx:ctrlVel.vx,vy:ctrlVel.vy,vyaw:ctrlVel.vyaw,run});
}
async function refreshController(){
try{
const s=await api('GET','/api/controller/status');
ctrlArmed=!!s.armed; ctrlRenderArmed();
const fb=document.getElementById('ctrl-fsm-badge'); if(fb)fb.textContent='FSM '+(s.fsm_id??'—');
const dot=document.getElementById('ctrl-ready-dot'), rt=document.getElementById('ctrl-ready-text');
if(dot)dot.className='dot '+(s.walk_ready?'dot-ok':'dot-warn');
if(rt)rt.textContent=s.walk_ready?'walk-ready':('mode '+(s.fsm_mode??'?'));
const mb=document.getElementById('ctrl-msc-badge'); if(mb)mb.textContent='MSC '+(s.msc_mode||'—');
const sb=document.getElementById('ctrl-sdk-badge'); if(sb)sb.textContent=s.sdk_available?(s.lc_ready?'SDK live':'SDK init…'):'SIM';
}catch(e){}
try{const j=await api('GET','/api/controller/joints');
const el=document.getElementById('ctrl-joints');
if(el)el.textContent=(j.joints||[]).map(x=>`${String(x.idx).padStart(2)} ${String(x.name).padEnd(16)} ${Number(x.q).toFixed(3)}`).join('\n');
}catch(e){}
}
// subsystem pills (global + controller mirror) + Motion-tab lockout — polled ~2.5s
async function refreshStatusStrip(){
try{
const s=await api('GET','/api/controller/status/summary');
const cam=!!(s.vision_enabled&&s.camera_running);
setPill('pill-camera',cam); setPill('pill-face',s.face_rec_enabled); setPill('pill-place',s.zone_rec_enabled); setPill('pill-movement',s.movement_armed);
setPill('ctrl-pill-camera',cam); setPill('ctrl-pill-face',s.face_rec_enabled); setPill('ctrl-pill-place',s.zone_rec_enabled); setPill('ctrl-pill-movement',s.movement_armed);
setPill('ctrl-pill-gmove', s.gemini_movement_enabled);
const gt=document.getElementById('ctrl-gmove-toggle'); if(gt && document.activeElement!==gt) gt.checked=!!s.gemini_movement_enabled;
// keep the manual arm checkbox + button-enable state in sync even if the
// robot was disarmed elsewhere (e.g. E-STOP) and the Controller tab is open.
const at=document.getElementById('ctrl-arm-toggle');
if(at && document.activeElement!==at && (!!s.movement_armed)!==ctrlArmed){ ctrlArmed=!!s.movement_armed; ctrlRenderArmed(); }
applyMovementLock(!!s.movement_armed);
}catch(e){}
}
function applyMovementLock(armed){
const banner=document.getElementById('motion-lock-banner'); if(banner)banner.style.display=armed?'flex':'none';
const grid=document.getElementById('motion-grid'); if(grid)grid.classList.toggle('motion-locked',armed);
}
// Terminal tab — xterm.js attached to a WebSocket → PTY bridge on the robot.
// Backend: dashboard/websockets/terminal.py.
// Connection model: "SSH" button opens the socket + spawns the shell; the
// Terminal tab itself doesn't auto-connect so leaving it open in the
// background doesn't keep a bash running unnecessarily.
let termInstance=null, termFit=null, termWS=null, termAutoSizeBound=false;
function termLog(line){
if(termInstance) termInstance.write('\r\n\x1b[2m[term] '+line+'\x1b[0m\r\n');
}
// Control messages MUST be prefixed with \x1f (Unit Separator). The
// backend uses the prefix to distinguish a control frame from raw
// keystrokes — without it, a user who pastes `{"type":"resize",...}`
// into the shell would silently resize the PTY instead of pasting.
const TERM_CTRL_PREFIX='\x1f';
function termFitSafe(){
if(!termFit||!termInstance) return;
try{ termFit.fit(); }catch(e){ return; }
if(termWS && termWS.readyState===1){
try{
termWS.send(TERM_CTRL_PREFIX+JSON.stringify({type:'resize', cols:termInstance.cols, rows:termInstance.rows}));
}catch(e){}
}
}
function termSetStatus(text,color){
const el=document.getElementById('term-status');
if(el){ el.textContent=text; el.style.color=color||'var(--dim)'; }
}
function termInit(){
if(termInstance) return;
if(typeof Terminal==='undefined'){ termSetStatus('xterm.js failed to load (check network/CDN)','var(--danger,#e57373)'); return; }
termInstance=new Terminal({
cursorBlink:true,
fontFamily:'ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace',
fontSize:13,
theme:{ background:'#000000', foreground:'#e0e0e0', cursor:'#00d4ff' },
scrollback:5000,
convertEol:true,
});
if(typeof FitAddon!=='undefined' && FitAddon.FitAddon){
termFit=new FitAddon.FitAddon();
termInstance.loadAddon(termFit);
}
termInstance.open(document.getElementById('term-host'));
termFitSafe();
// Send keystrokes upstream to the PTY.
termInstance.onData(function(d){
if(termWS && termWS.readyState===1){
try{ termWS.send(d); }catch(e){}
}
});
// Re-fit on window resize once xterm is attached.
if(!termAutoSizeBound){
window.addEventListener('resize', termFitSafe);
termAutoSizeBound=true;
}
}
async function termConnect(b){
termInit();
if(termWS && (termWS.readyState===0 || termWS.readyState===1)){
toast('Terminal already connected','info'); return;
}
btnLoad(b);
const scheme=(location.protocol==='https:'?'wss:':'ws:');
const url=scheme+'//'+location.host+'/ws/terminal';
termSetStatus('connecting…','var(--warn,#f5a623)');
try{
termWS=new WebSocket(url);
}catch(e){
termSetStatus('ws construct failed','var(--danger,#e57373)');
btnDone(b); return;
}
termWS.onopen=function(){
termSetStatus('connected','var(--success,#4caf50)');
document.getElementById('term-stop-btn').disabled=false;
document.getElementById('term-ssh-btn').disabled=true;
btnDone(b);
// Send initial sizing so the PTY knows the right window.
try{
if(termInstance) termWS.send(TERM_CTRL_PREFIX+JSON.stringify({type:'init', cols:termInstance.cols, rows:termInstance.rows}));
}catch(e){}
termFitSafe();
if(termInstance) termInstance.focus();
};
termWS.onmessage=function(ev){
if(termInstance) termInstance.write(typeof ev.data==='string'?ev.data:'');
};
termWS.onerror=function(){
termSetStatus('ws error','var(--danger,#e57373)');
};
termWS.onclose=function(ev){
termSetStatus('disconnected (code '+ev.code+')','var(--dim)');
document.getElementById('term-stop-btn').disabled=true;
document.getElementById('term-ssh-btn').disabled=false;
btnDone(b);
if(termInstance) termLog('session closed');
};
}
function termDisconnect(b){
btnLoad(b);
try{ if(termWS) termWS.close(1000,'user disconnect'); }catch(e){}
termWS=null;
btnDone(b);
}
function termClear(){
if(termInstance){ termInstance.clear(); termInstance.focus(); }
}
// Temperature tab — lazy-load the 3D iframe on first open so its WebSocket
// only connects when the user actually views it. Also wires the Controller tab:
// refresh on enter, and stop teleop (release the window key listeners) on leave.
// Terminal tab: lazy-init xterm on first open and re-fit on every entry so
// the shell lays out correctly after a tab switch.
(function(){
const origSwitchTab=window.switchTab;
window.switchTab=function(name){
origSwitchTab(name);
if(name!=='controller') ctrlStopTeleop(); // don't leave WASD bound to other tabs
if(name==='controller') refreshController();
if(name==='temp'){
const f=document.getElementById('temp3d-frame');
if(f && (!f.src || /about:blank$/.test(f.src))){
f.src='/static/temp3d/index.html';
}
}
if(name==='terminal'){
// Defer to next frame so the panel's display:flex has applied —
// FitAddon measures the host div and needs non-zero dimensions.
requestAnimationFrame(function(){ termInit(); termFitSafe(); if(termInstance) termInstance.focus(); });
}
};
})();
// Init — vision/camera/detector fetches removed; those endpoints were deleted.
refreshStatus();refreshSystem();refreshAudio();refreshAudioDevices();refreshSkills();refreshReplayFiles();refreshScripts();refreshPrompt();refreshRecords();refreshLiveVoice();refreshLiveSub();refreshTR();refreshWakeActions();refreshApiKey();refreshCombo();refreshRecognition();refreshZones();refreshPlaybackStatus();refreshStatusStrip();connectLogs();
setTimeout(autoConnectGemini,2000);setTimeout(autoStartLiveSub,3000);
setInterval(refreshStatus,5000);setInterval(refreshSystem,30000);setInterval(refreshLiveVoice,5000);setInterval(refreshLiveSub,5000);setInterval(refreshRecognition,5000);setInterval(refreshPlaybackStatus,1000);
setInterval(refreshStatusStrip,2500);
setInterval(function(){const t=document.getElementById('tab-controller');if(t&&t.classList.contains('active'))refreshController();},2000);
// Safety: if the tab loses focus / is hidden while teleoping, a keyup can be
// missed and a key would "stick" (robot keeps moving). Stop teleop on blur/hide.
window.addEventListener('blur',function(){ ctrlStopTeleop(); });
document.addEventListener('visibilitychange',function(){ if(document.hidden) ctrlStopTeleop(); });
</script>
</body>
</html>