1601 lines
91 KiB
HTML
1601 lines
91 KiB
HTML
<!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 & 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 2–3 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');}
|
||
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>
|