Sanadv3/dashboard/static/index.html

1601 lines
91 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('recognition')">Recognition</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>
<!-- 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 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>
<!-- 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>
</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){
// 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();
}
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');}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){}}
// 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();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');}
}
// 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();connectLogs();
setTimeout(autoConnectGemini,2000);setTimeout(autoStartLiveSub,3000);
setInterval(refreshStatus,5000);setInterval(refreshSystem,30000);setInterval(refreshLiveVoice,5000);setInterval(refreshLiveSub,5000);setInterval(refreshRecognition,5000);
</script>
</body>
</html>