Sanad/dashboard/static/index.html

1324 lines
81 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}
*{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)}
#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>
<!-- 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('vision')">Camera & Vision</div>
<div class="tab" onclick="switchTab('recordings')">Recordings</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>
</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>
<!-- Live Camera Feed -->
<div class="card">
<h3><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg>Live Camera</h3>
<canvas id="camera-feed" width="640" height="480" style="width:100%;max-height:260px;border-radius:8px;background:#000"></canvas>
<div class="row" style="margin-top:.4rem">
<button class="btn btn-success btn-sm" onclick="startLocalCam(this)">Start</button>
<button class="btn btn-danger btn-sm" onclick="stopLocalCam(this)">Stop</button>
<button class="btn btn-ghost btn-sm" onclick="reconnectCamera()">Reconnect</button>
<span id="ops-cam-state" class="badge" style="margin-left:.3rem"></span>
</div>
<div class="row" style="margin-top:.3rem">
<button class="btn btn-primary" onclick="capturePhoto(this)">Capture</button>
<select id="capture-gesture" style="width:130px"><option value="">No gesture</option></select>
<button class="btn btn-success" onclick="captureWithMotion(this)">Capture + Gesture</button>
</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>
<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 class="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>
<div style="display:flex;gap:1rem;flex-wrap:wrap">
<div style="flex:1">
<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>
</div>
<div style="flex:1">
<label>Playback</label>
<div class="row" style="margin-top:.3rem"><input id="play-macro-name" placeholder="Macro to play" style="flex:1"><button class="btn btn-success btn-sm" onclick="playMacro(this)">Play</button></div>
</div>
</div>
<div id="macro-status" style="font-size:.72rem;color:var(--muted);margin-top:.3rem"></div>
</div>
</div>
</div>
<!-- ==================== TAB: Camera & Vision ==================== -->
<div class="tab-content" id="tab-vision">
<div class="grid">
<!-- Camera Devices -->
<div class="card">
<h3>Camera Device</h3>
<div class="row">
<label>Profile</label>
<select id="camdev-profile" style="flex:1" onchange="selectCamProfile(this.value)">
<option value="">Loading...</option>
</select>
<button class="btn btn-ghost btn-sm" onclick="scanCameras(this)" title="Re-scan plugged cameras">Scan</button>
</div>
<div id="camdev-detected" style="margin-top:.3rem;font-size:.65rem;color:var(--dim)"></div>
<details style="margin-top:.5rem">
<summary style="cursor:pointer;font-size:.72rem;color:var(--dim)">All plugged cameras</summary>
<div id="camdev-list" style="margin-top:.3rem;font-size:.7rem"></div>
</details>
<details style="margin-top:.4rem">
<summary style="cursor:pointer;font-size:.72rem;color:var(--dim)">Pin RealSense serial to slot</summary>
<div class="row" style="margin-top:.3rem">
<select id="camdev-pin-profile" style="flex:1"></select>
<input id="camdev-pin-serial" placeholder="Serial..." style="flex:1">
<button class="btn btn-primary btn-sm" onclick="pinCamSerial(this)">Pin</button>
</div>
<div style="font-size:.65rem;color:var(--dim);margin-top:.2rem">Use this when you have two RealSense units and want to lock which one is "primary".</div>
</details>
<div class="row" style="margin-top:.5rem">
<button class="btn btn-success btn-sm" onclick="startLocalCam(this)">Start Capture</button>
<button class="btn btn-danger btn-sm" onclick="stopLocalCam(this)">Stop Capture</button>
<span id="local-cam-state" class="badge"></span>
</div>
<div style="margin-top:.5rem;font-size:.72rem;color:var(--dim)" id="camdev-status"></div>
</div>
<!-- Camera Config (legacy) -->
<div class="card">
<h3>Camera Configuration</h3>
<div class="row"><label>Source</label><select id="cam-source" style="flex:1" onchange="setCamSource(this.value)"></select></div>
<div class="row">
<label>Resolution</label>
<input id="cam-w" type="number" value="640" style="width:60px"> <span style="color:var(--dim)">x</span>
<input id="cam-h" type="number" value="480" style="width:60px"> <span style="color:var(--dim)">@</span>
<input id="cam-fps" type="number" value="30" style="width:45px"> <span style="color:var(--dim)">fps</span>
<button class="btn btn-primary btn-sm" onclick="setCamRes()">Set</button>
</div>
<div class="row"><label>RealSense</label><input id="cam-serial" placeholder="Serial number" style="flex:1"><button class="btn btn-ghost btn-sm" onclick="setPreferredCam()">Save</button></div>
<div style="margin-top:.4rem"><a href="/api/vision/preview.mjpg" target="_blank" style="font-size:.72rem;color:var(--accent);text-decoration:none">Open MJPEG Stream in new tab</a></div>
</div>
<!-- YOLO Detector -->
<div class="card">
<h3>YOLO Vision Detector</h3>
<div class="row">
<button class="btn btn-primary" onclick="loadDetector(this)">Load Model</button>
<button class="btn btn-success" onclick="runDetection(this)">Detect Now</button>
<span id="yolo-status" class="badge"></span>
</div>
<div id="yolo-result" style="margin-top:.4rem;font-size:.72rem;color:var(--muted)"></div>
</div>
<!-- Photo Gallery -->
<div class="card card-full">
<h3>Photo Gallery</h3>
<div class="row">
<button class="btn btn-ghost btn-sm" onclick="refreshPhotos()">Refresh</button>
<button class="btn btn-primary btn-sm" onclick="downloadAllPhotos()">Download ZIP</button>
<button class="btn btn-danger btn-sm" onclick="clearPhotos()">Clear All</button>
<span id="photo-count" style="margin-left:auto;font-size:.72rem;color:var(--muted)"></span>
</div>
<div class="gallery-grid" id="photo-gallery"><div class="empty">No photos yet</div></div>
</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>
<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: 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){document.querySelectorAll('.tab').forEach(t=>t.classList.toggle('active',t.textContent.toLowerCase().includes(name.slice(0,4))));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();
}
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();
}
// Camera
async function capturePhoto(b){btnLoad(b);try{await api('POST','/api/vision/capture');toast('Photo captured','ok');refreshPhotos();}catch(e){}btnDone(b);}
async function captureWithMotion(b){btnLoad(b);const g=document.getElementById('capture-gesture').value;const url=g?'/api/vision/capture?motion_file='+encodeURIComponent(g):'/api/vision/capture';try{await api('POST',url);toast('Captured'+(g?' + gesture':''),'ok');refreshPhotos();}catch(e){}btnDone(b);}
async function refreshCamSources(){try{const r=await api('GET','/api/vision/cameras');const sel=document.getElementById('cam-source');sel.innerHTML='<option value="">Auto</option>'+(r||[]).map(c=>`<option value="${esc(c.source||c.serial||c.name)}">${esc(c.name||c.source||c.serial)}</option>`).join('');}catch(e){}}
async function populateGestureSelect(){try{const r=await api('GET','/api/replay/files');const sel=document.getElementById('capture-gesture');sel.innerHTML='<option value="">No gesture</option>'+(r.files||[]).map(f=>`<option value="${esc(f.name)}">${esc(f.name)}</option>`).join('');}catch(e){}}
async function setCamSource(v){if(v)try{await api('POST','/api/vision/set-source',{source:v});toast('Camera source set','ok');}catch(e){}}
async function setCamRes(){const w=+document.getElementById('cam-w').value,h=+document.getElementById('cam-h').value,f=+document.getElementById('cam-fps').value;try{await api('POST','/api/vision/set-resolution',{width:w,height:h,fps:f});toast(`${w}x${h}@${f}fps`,'ok');}catch(e){}}
async function setPreferredCam(){const s=document.getElementById('cam-serial').value;if(s)try{await api('POST','/api/vision/set-preferred-camera',{serial:s});toast('Saved','ok');}catch(e){}}
// Photos
async function refreshPhotos(){try{const r=await api('GET','/api/vision/photos');const el=document.getElementById('photo-gallery');document.getElementById('photo-count').textContent=`${r.total} photos`;if(!r.photos?.length){el.innerHTML='<div class="empty">No photos yet</div>';return;}el.innerHTML=r.photos.map(p=>{const n=esc(p.name);return`<img src="/api/vision/photos/${encodeURIComponent(p.name)}" title="${n}\n${p.size_kb}KB\n${p.created_at}" onclick="if(confirm('Delete ${n}?'))deletePhoto('${n}')">`;}).join('');}catch(e){}}
async function deletePhoto(n){try{await api('DELETE','/api/vision/photos/'+encodeURIComponent(n));toast('Deleted','ok');refreshPhotos();}catch(e){}}
function downloadAllPhotos(){window.open('/api/vision/photos/download-zip');}
async function clearPhotos(){if(confirm('Delete ALL photos?'))try{const r=await api('POST','/api/vision/photos/clear');toast(`Cleared ${r.deleted_count}`,'ok');refreshPhotos();}catch(e){}}
// Camera devices
async function refreshCamDevices(){
try{
const r=await api('GET','/api/vision/devices');
const cur=r.current||{};
const curDev=cur.device||{};
const curId=cur.profile?cur.profile.id:'';
const detIds=r.detected_ids||[];
// Profile dropdown
const profSel=document.getElementById('camdev-profile');
profSel.innerHTML=(r.profiles||[]).map(p=>{
const avail=detIds.indexOf(p.id)>=0;
const sel=p.id===curId?' selected':'';
const tag=avail?'':' (no device)';
return `<option value="${esc(p.id)}"${sel}${avail?'':' disabled'}>${esc(p.label)}${tag}</option>`;
}).join('');
// Pin profile dropdown
const pinSel=document.getElementById('camdev-pin-profile');
pinSel.innerHTML=(r.profiles||[]).filter(p=>p.backend==='realsense').map(p=>
`<option value="${esc(p.id)}">${esc(p.label)}</option>`).join('');
// Detected summary
const det=document.getElementById('camdev-detected');
const counts=r.counts||{};
det.innerHTML=`<strong>Detected:</strong> ${counts.realsense||0} RealSense, ${counts.v4l2||0} V4L2 (total ${counts.total||0})`;
// All devices list
const list=document.getElementById('camdev-list');
if(!(r.all_devices||[]).length){
list.innerHTML='<div class="empty">No cameras plugged</div>';
}else{
list.innerHTML='<table><tr><th>Backend</th><th>Name</th><th>Serial / Path</th><th></th></tr>'+
r.all_devices.map(d=>{
const idVal=d.serial||d.device_path;
const action=d.serial
?`<button class="btn btn-primary btn-sm" onclick="selectCamSerial('${esc(d.serial)}')">Use</button>`
:`<button class="btn btn-primary btn-sm" onclick="selectCamPath('${esc(d.device_path)}')">Use</button>`;
return `<tr><td>${esc(d.backend)}</td><td>${esc(d.name||'-')}</td><td><code style="font-size:.65rem">${esc(idVal||'-')}</code></td><td>${action}</td></tr>`;
}).join('')+'</table>';
}
// Status text
const st=document.getElementById('camdev-status');
if(curDev.name){
st.innerHTML=`<strong>Active:</strong> ${esc(curDev.name)}<br>`+
(curDev.serial?`Serial: <code>${esc(curDev.serial)}</code><br>`:'')+
(curDev.device_path?`Path: <code>${esc(curDev.device_path)}</code><br>`:'')+
`<span style="color:var(--muted)">via ${esc(cur.source_kind||'?')}</span>`;
}else{
st.innerHTML='<span style="color:#f55">No camera selected</span>';
}
}catch(e){}
}
async function scanCameras(b){
if(b)btnLoad(b);
try{await api('POST','/api/vision/devices/scan');toast('Re-scanned cameras','ok');}catch(e){}
if(b)btnDone(b);
refreshCamDevices();
}
async function selectCamProfile(profileId){
if(!profileId)return;
try{
await api('POST','/api/vision/devices/select-profile',{profile_id:profileId});
toast('Camera profile switched','ok');
}catch(e){}
refreshCamDevices();
}
async function selectCamSerial(serial){
if(!serial)return;
try{
await api('POST','/api/vision/devices/select-serial',{serial});
toast('Camera selected by serial','ok');
}catch(e){}
refreshCamDevices();
}
async function selectCamPath(path){
if(!path)return;
try{
await api('POST','/api/vision/devices/select-path',{device_path:path});
toast('Camera selected by path','ok');
}catch(e){}
refreshCamDevices();
}
async function startLocalCam(b){
btnLoad(b);
try{
const r=await api('POST','/api/vision/local/start',{});
if(r.ok){
toast('Camera started: '+(r.backend||'?'),'ok');
}else{
toast('Camera start failed: '+(r.error||'unknown'),'err');
}
}catch(e){}
btnDone(b);
setTimeout(refreshLocalCam, 500);
}
async function stopLocalCam(b){
btnLoad(b);
try{await api('POST','/api/vision/local/stop');toast('Camera stopped','info');}catch(e){}
btnDone(b);
refreshLocalCam();
}
async function refreshLocalCam(){
try{
const r=await api('GET','/api/vision/local/status');
const els=[document.getElementById('local-cam-state'),document.getElementById('ops-cam-state')];
els.forEach(el=>{
if(!el)return;
if(r.running){
el.textContent=(r.backend||'on')+(r.serial?(' '+r.serial.slice(-6)):'')+' '+r.width+'x'+r.height+'@'+r.fps;
el.className='badge badge-ok';
}else if(r.last_error){
el.textContent='error';
el.className='badge badge-err';
el.title=r.last_error;
}else{
el.textContent='stopped';
el.className='badge badge-warn';
}
});
}catch(e){}
}
async function pinCamSerial(b){
const pid=document.getElementById('camdev-pin-profile').value;
const serial=document.getElementById('camdev-pin-serial').value.trim();
if(!pid||!serial){toast('Pick a profile and enter a serial','err');return;}
btnLoad(b);
try{
await api('POST','/api/vision/devices/assign-serial',{profile_id:pid,serial:serial});
toast(`Pinned ${serial}${pid}`,'ok');
document.getElementById('camdev-pin-serial').value='';
}catch(e){}
btnDone(b);
refreshCamDevices();
}
// 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);}
// 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();populateGestureSelect();}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();populateGestureSelect();}}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();populateGestureSelect();}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');}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){}}
// 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){}}
// YOLO
async function loadDetector(b){btnLoad(b);try{const r=await api('POST','/api/detector/load');const el=document.getElementById('yolo-status');el.textContent=r.ok?'Loaded':'Failed';el.className='badge '+(r.ok?'badge-ok':'badge-err');toast(r.ok?'Model loaded':'Failed',r.ok?'ok':'err');}catch(e){}btnDone(b);}
async function runDetection(b){btnLoad(b);try{const r=await api('POST','/api/detector/detect');document.getElementById('yolo-result').innerHTML=`<strong>Persons:</strong> ${r.person_count} | <strong>Faces:</strong> ${r.face_count} | <strong>Group:</strong> ${r.group_detected?'Yes ('+r.group_size+')':'No'} | <strong>Intent:</strong> ${r.intent_detected?'Yes':'No'} | ${r.detection_ms}ms`;}catch(e){document.getElementById('yolo-result').textContent='Detection failed';}btnDone(b);}
async function refreshDetector(){try{const r=await api('GET','/api/detector/status');const el=document.getElementById('yolo-status');el.textContent=r.loaded?'Loaded':'Not loaded';el.className='badge '+(r.loaded?'badge-ok':'badge-warn');}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);}
// WebSocket camera
let camWs;function connectCamera(){if(camWs&&camWs.readyState<=1)try{camWs.close();}catch(e){}const p=location.protocol==='https:'?'wss':'ws';camWs=new WebSocket(`${p}://${location.host}/ws/camera`);camWs.binaryType='arraybuffer';const canvas=document.getElementById('camera-feed'),ctx=canvas.getContext('2d');camWs.onmessage=e=>{const url=URL.createObjectURL(new Blob([e.data],{type:'image/jpeg'})),img=new Image();img.onload=()=>{ctx.drawImage(img,0,0,canvas.width,canvas.height);URL.revokeObjectURL(url);};img.onerror=()=>URL.revokeObjectURL(url);img.src=url;};camWs.onclose=()=>setTimeout(connectCamera,3000);}
function reconnectCamera(){if(camWs)try{camWs.close();}catch(e){}setTimeout(connectCamera,300);toast('Camera reconnecting...','info');}
// 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){}
}
// Init — vision/camera/detector fetches removed; those endpoints were deleted.
refreshStatus();refreshSystem();refreshAudio();refreshAudioDevices();refreshSkills();refreshReplayFiles();refreshScripts();refreshPrompt();refreshRecords();populateGestureSelect();refreshLiveVoice();refreshLiveSub();refreshTR();refreshWakeActions();refreshApiKey();connectLogs();
setTimeout(autoConnectGemini,2000);setTimeout(autoStartLiveSub,3000);
setInterval(refreshStatus,5000);setInterval(refreshSystem,30000);setInterval(refreshLiveVoice,5000);setInterval(refreshLiveSub,5000);
</script>
</body>
</html>