Sanad Lite d9b2d5427f Voice fidelity, Live Gemini tab, and dashboard fixes
Replay now matches the robots and no longer cuts words:
- read the turn to turnComplete, not generationComplete, and drain the
  socket before each send; breaking early truncated every sentence and
  left frames that the next turn mis-read as its own reply
- accept a take only if the model's own transcript covers the text AND
  the audio is long enough to contain it (the transcript reports the
  full text even for a 0.8s clip)
- pitch gate: reject an off-tone take and re-ask, per voice, using a
  pure-Python F0 estimator (no numpy on the host)
- continuation: speak the words a voice skipped instead of retrying a
  line it stops on deterministically
- fresh Live session per replay; delivery drifts as turns accumulate

Live Gemini tab: browser talks to Gemini directly (the reverse proxy
cannot upgrade a WebSocket), with a persona library - named personas,
per-robot selection, built-ins that cannot be overwritten.

Dashboard: records search + voice filter, log panel falls back to
polling, sign-in history with CSV/JSON export, and JS errors now show
on the page instead of silently blanking a tab.
2026-09-02 22:56:18 +04:00

735 lines
42 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sanad Dashboard</title>
<style>
:root{--bg:#0a0f1a;--panel:#111827;--panel2:#1a2332;--accent:#0ea5e9;--accent2:#6366f1;--text:#e2e8f0;--muted:#64748b;--dim:#475569;--danger:#ef4444;--success:#22c55e;--warn:#f59e0b;--border:#1e293b;--glow:0 0 20px rgba(14,165,233,.08);--radius:12px}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Inter','Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
/* Header */
header{background:linear-gradient(135deg,#111827 0%,#1a2332 100%);padding:.7rem 1.5rem;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--border);position:sticky;top:0;z-index:100;backdrop-filter:blur(12px)}
header h1{font-size:1.3rem;font-weight:700;letter-spacing:-.02em} header h1 span{background:linear-gradient(135deg,var(--accent),var(--accent2));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.hdr-right{display:flex;align-items:center;gap:.8rem;font-size:.78rem}
.dot{width:8px;height:8px;border-radius:50%;display:inline-block}
.dot-ok{background:var(--success);box-shadow:0 0 6px var(--success)} .dot-err{background:var(--danger);box-shadow:0 0 6px var(--danger)} .dot-warn{background:var(--warn)}
.hdr-badge{padding:2px 7px;border-radius:4px;font-size:.68rem;font-weight:600;text-transform:uppercase;letter-spacing:.04em}
.hdr-badge-err{background:rgba(239,68,68,.15);color:var(--danger);border:1px solid rgba(239,68,68,.3)}
.hdr-badge-ok{background:rgba(34,197,94,.12);color:var(--success);border:1px solid rgba(34,197,94,.25)}
#estop{background:var(--danger);color:#fff;border:none;padding:.35rem .9rem;border-radius:6px;font-weight:700;font-size:.75rem;cursor:pointer;letter-spacing:.03em;box-shadow:0 0 12px rgba(239,68,68,.3);transition:all .15s}
#estop:hover{box-shadow:0 0 20px rgba(239,68,68,.5);transform:scale(1.04)}
/* Tabs */
.tabs{display:flex;gap:0;background:var(--panel);border-bottom:1px solid var(--border);padding:0 1.5rem;overflow-x:auto}
.tab{padding:.55rem 1.1rem;font-size:.78rem;color:var(--muted);cursor:pointer;border-bottom:2px solid transparent;transition:all .15s;white-space:nowrap;font-weight:500}
.tab:hover{color:var(--text)} .tab.active{color:var(--accent);border-bottom-color:var(--accent)}
.tab-content{display:none;padding:1rem 1.5rem} .tab-content.active{display:block}
/* Grid */
.grid{display:grid;grid-template-columns:1fr 1fr;gap:.8rem}
@media(max-width:900px){.grid{grid-template-columns:1fr}}
/* Cards */
.card{background:var(--panel);border-radius:var(--radius);padding:1rem 1.1rem;border:1px solid var(--border);box-shadow:var(--glow);transition:border-color .2s}
.card:hover{border-color:rgba(14,165,233,.2)}
.card h3{font-size:.82rem;color:var(--accent);margin-bottom:.6rem;display:flex;align-items:center;gap:.4rem;font-weight:600;text-transform:uppercase;letter-spacing:.04em}
.card h3 svg{width:14px;height:14px;opacity:.7}
.card-full{grid-column:1/-1}
/* Buttons */
.btn{display:inline-flex;align-items:center;gap:.3rem;padding:.4rem .75rem;border-radius:6px;font-size:.76rem;font-weight:500;cursor:pointer;border:1px solid transparent;transition:all .15s}
.btn:disabled{opacity:.4;cursor:not-allowed;pointer-events:none}
.btn-primary{background:var(--accent);color:#fff;border-color:var(--accent)} .btn-primary:hover{opacity:.85}
.btn-danger{background:rgba(239,68,68,.12);color:var(--danger);border-color:rgba(239,68,68,.3)} .btn-danger:hover{background:rgba(239,68,68,.2)}
.btn-ghost{background:transparent;color:var(--muted);border-color:var(--border)} .btn-ghost:hover{color:var(--text);border-color:var(--dim)}
.btn-success{background:rgba(34,197,94,.12);color:var(--success);border-color:rgba(34,197,94,.3)} .btn-success:hover{background:rgba(34,197,94,.2)}
.btn-sm{padding:.25rem .5rem;font-size:.7rem}
.btn.loading{pointer-events:none;opacity:.6} .btn.loading::after{content:'';width:12px;height:12px;border:2px solid currentColor;border-top-color:transparent;border-radius:50%;animation:spin .5s linear infinite;margin-left:.3rem}
@keyframes spin{to{transform:rotate(360deg)}}
/* Form */
input,textarea,select{background:var(--bg);color:var(--text);border:1px solid var(--border);padding:.4rem .6rem;border-radius:6px;width:100%;font-size:.8rem;font-family:inherit;transition:border-color .15s}
input:focus,textarea:focus,select:focus{outline:none;border-color:var(--accent)}
textarea{resize:vertical;min-height:50px}
label{font-size:.72rem;color:var(--muted);font-weight:500;text-transform:uppercase;letter-spacing:.04em}
.row{display:flex;gap:.4rem;align-items:center;margin-bottom:.4rem}
/* Badge */
.badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:10px;font-size:.68rem;font-weight:600}
.badge-ok{background:rgba(34,197,94,.12);color:var(--success)} .badge-err{background:rgba(239,68,68,.12);color:var(--danger)} .badge-warn{background:rgba(245,158,11,.12);color:var(--warn)} .badge-info{background:rgba(99,102,241,.12);color:var(--accent2)}
/* Table */
table{width:100%;border-collapse:collapse;font-size:.76rem}
th{color:var(--muted);font-weight:600;text-transform:uppercase;font-size:.68rem;letter-spacing:.04em;padding:6px 8px;border-bottom:1px solid var(--border)}
td{padding:5px 8px;border-bottom:1px solid rgba(30,41,55,.5)}
tr:hover td{background:rgba(14,165,233,.03)}
/* Action buttons grid */
.action-btn{background:var(--panel2);color:var(--text);border:1px solid var(--border);padding:.35rem .6rem;border-radius:6px;cursor:pointer;font-size:.72rem;transition:all .15s;display:inline-flex;align-items:center;gap:3px}
.action-btn:hover{background:var(--accent);border-color:var(--accent);color:#fff}
.action-btn.running{background:var(--accent);color:#fff;animation:pulse 1s infinite;pointer-events:none}
.action-btn:disabled{opacity:.3;cursor:not-allowed}
.type-dot{width:5px;height:5px;border-radius:50%;flex-shrink:0} .type-sdk{background:#a78bfa} .type-jsonl{background:var(--success)}
.action-list{max-height:260px;overflow-y:auto;border:1px solid var(--border);border-radius:6px;background:var(--panel2)}
.action-row{display:flex;align-items:center;gap:.5rem;padding:.4rem .6rem;border-bottom:1px solid var(--border);cursor:pointer;font-size:.78rem;user-select:none;transition:background .1s}
.action-row:last-child{border-bottom:none}
.action-row:hover{background:rgba(255,255,255,.04)}
.action-row.selected{background:var(--accent);color:#fff}
.action-row.running{background:var(--accent);color:#fff;animation:pulse 1s infinite}
.action-row .r-name{flex:1;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.action-row .r-meta{color:var(--dim);font-size:.7rem;font-variant-numeric:tabular-nums;white-space:nowrap}
.action-row.selected .r-meta{color:rgba(255,255,255,.85)}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
/* Mute */
.mute-btn{min-width:80px;text-align:center;padding:.35rem .6rem;border-radius:6px;font-size:.76rem;font-weight:500;cursor:pointer;border:1px solid var(--border);transition:all .15s}
.mute-btn.off{background:rgba(34,197,94,.1);color:var(--success);border-color:rgba(34,197,94,.25)}
.mute-btn.on{background:rgba(239,68,68,.12);color:var(--danger);border-color:rgba(239,68,68,.3)}
/* Gallery */
.gallery-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:.5rem;max-height:220px;overflow-y:auto;padding:.2rem}
.gallery-grid img{width:100%;height:85px;object-fit:cover;border-radius:8px;cursor:pointer;border:2px solid var(--border);transition:all .15s}
.gallery-grid img:hover{border-color:var(--accent);transform:scale(1.03)}
/* Log box */
.log-box{background:#000;color:#4ade80;font-family:'JetBrains Mono','Fira Code',monospace;font-size:.7rem;padding:.6rem;border-radius:8px;overflow-y:auto;white-space:pre-wrap;line-height:1.4;border:1px solid var(--border)}
/* Toast */
#toast-box{position:fixed;top:4rem;right:1rem;z-index:9999;display:flex;flex-direction:column;gap:.4rem}
.toast{padding:.55rem 1rem;border-radius:8px;font-size:.78rem;color:#fff;animation:slideIn .25s;max-width:360px;word-break:break-word;backdrop-filter:blur(8px);box-shadow:0 4px 12px rgba(0,0,0,.3)}
.toast-ok{background:rgba(22,101,52,.92)} .toast-err{background:rgba(153,27,27,.92)} .toast-info{background:rgba(30,64,175,.92)}
@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}
/* Empty state */
.empty{color:var(--dim);font-size:.78rem;text-align:center;padding:1.5rem;font-style:italic}
/* Toggle switch */
.switch{position:relative;width:36px;height:20px;display:inline-block}
.switch input{opacity:0;width:0;height:0}
.switch .slider{position:absolute;inset:0;background:var(--dim);border-radius:10px;cursor:pointer;transition:.2s}
.switch .slider::before{content:'';position:absolute;width:16px;height:16px;left:2px;bottom:2px;background:#fff;border-radius:50%;transition:.2s}
.switch input:checked+.slider{background:var(--accent)}
.switch input:checked+.slider::before{transform:translateX(16px)}
/* Scrollbar */
::-webkit-scrollbar{width:6px} ::-webkit-scrollbar-track{background:transparent} ::-webkit-scrollbar-thumb{background:var(--dim);border-radius:3px} ::-webkit-scrollbar-thumb:hover{background:var(--muted)}
</style>
</head>
<body>
<div id="js-error" style="display:none;position:sticky;top:0;z-index:9999;
background:#7f1d1d;color:#fff;padding:.5rem .75rem;font:12px/1.5 monospace;
white-space:pre-wrap"></div>
<script>
// Installed before every other script so load-time failures are visible.
// Without this a ReferenceError in live.js silently blanks the Live tab.
(function () {
function show(text) {
var el = document.getElementById('js-error');
if (!el) { return; }
el.style.display = 'block';
el.textContent = (el.textContent ? el.textContent + '\n' : '') + text;
}
window.addEventListener('error', function (e) {
show('JS error: ' + (e.message || e.type)
+ (e.filename ? ' [' + e.filename.split('/').pop() + ':' + e.lineno + ']' : ''));
});
window.addEventListener('unhandledrejection', function (e) {
var r = e.reason;
show('Unhandled promise: ' + ((r && (r.message || r)) || 'unknown'));
});
})();
</script>
<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 class="btn btn-ghost btn-sm" onclick="logout()">Logout</button>
</div>
</header>
<!-- Tabs -->
<div class="tabs">
<div class="tab active" onclick="switchTab('voice')">Voice & Audio</div>
<div class="tab" onclick="switchTab('live')">Live Gemini</div>
<div class="tab" onclick="switchTab('recordings')">Recordings</div>
<div class="tab" onclick="switchTab('settings')">Settings & Logs</div>
</div>
<!-- ==================== TAB: Voice & Audio ==================== -->
<div class="tab-content active" id="tab-voice">
<div class="grid">
<!-- Gemini API Key (read-only — key now lives in cPanel env var only) -->
<div class="card card-full">
<h3>Gemini API Key</h3>
<div style="font-size:.72rem;color:var(--muted);margin-bottom:.4rem">
Read-only. Source of truth: cPanel → <strong>Setup Python App</strong>
<strong>Environment variables</strong><code>api</code>. To rotate,
edit that field and restart uvicorn. 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 id="gm-key-msg" style="font-size:.7rem;margin-top:.3rem;color:var(--muted)"></div>
</div>
<!-- Robot Voice (Gemini prebuilt voice picker) -->
<div class="card card-full">
<h3>Robot Voice</h3>
<div style="font-size:.72rem;color:var(--muted);margin-bottom:.5rem">
Pick which robot's voice Gemini speaks with. Applies on the next
<strong>Generate &amp; Play</strong> and is saved for next time.
</div>
<div class="row" id="voice-options" style="gap:.4rem;flex-wrap:wrap">
<span style="color:var(--muted);font-size:.72rem">Loading…</span>
</div>
<div id="voice-current" style="font-size:.7rem;margin-top:.4rem;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-warning" style="display:none;margin:.4rem 0;padding:.45rem .6rem;border-radius:4px;background:rgba(224,160,48,.12);border:1px solid rgba(224,160,48,.55);color:#e0a030;font-size:.75rem;line-height:1.5"></div>
<div id="tr-session" style="font-size:.72rem;color:var(--muted);margin-top:.3rem;line-height:1.6"></div>
</div>
</div>
</div>
</div>
</div>
<!-- ==================== TAB: Live Gemini ==================== -->
<div class="tab-content" id="tab-live">
<div class="grid">
<div class="card card-full">
<h3>Live Conversation <span id="live-status" style="font-size:.7rem;font-weight:400;color:var(--dim)">idle</span></h3>
<p style="font-size:.72rem;color:var(--dim);margin:.2rem 0 .5rem">
Talk to the robot in real time. Audio goes straight from this browser to Gemini —
the server only issues a short-lived session token, so the API key is never exposed.
Needs microphone permission.
</p>
<label>Robot</label>
<div id="live-voices" class="row" style="gap:.3rem;flex-wrap:wrap;margin:.2rem 0 .6rem"></div>
<div class="row" style="gap:.4rem;align-items:center;flex-wrap:wrap;margin-bottom:.4rem">
<button class="btn btn-ghost btn-sm" onclick="liveRequestMic()">Allow microphone</button>
<button class="btn btn-ghost btn-sm" onclick="liveTestSpeaker()">Test speaker</button>
<span id="live-perm" style="font-size:.7rem;color:var(--dim)"></span>
</div>
<div class="row" style="gap:.4rem;align-items:center;margin-bottom:.5rem">
<button class="btn btn-primary" id="live-connect" onclick="liveToggle()">Connect</button>
<span style="font-size:.68rem;color:var(--dim)">model: <span id="live-model">--</span></span>
</div>
<div id="live-diag" style="font-size:.68rem;color:var(--dim);margin:.1rem 0 .5rem;font-family:monospace"></div>
<label>Conversation</label>
<div class="log-box" id="live-transcript" style="height:220px;color:#e5e7eb"></div>
</div>
<div class="card card-full">
<h3>Persona <span id="persona-for" style="font-size:.75rem;font-weight:400;color:#38bdf8"></span></h3>
<p style="font-size:.72rem;color:var(--dim);margin:.2rem 0 .5rem">
How the robot selected above behaves in conversation — its character, dialect and
answering style. Pick any persona from the library and press
<em>Use for this robot</em>; it applies on the next connection.
</p>
<div class="row" style="gap:.35rem;flex-wrap:wrap;align-items:center;margin-bottom:.4rem">
<select id="persona-picker" onchange="livePickPersona(this.value)"
style="flex:1 1 240px;max-width:420px;width:auto"></select>
<button class="btn btn-primary btn-sm" onclick="liveUsePersona()">Use for this robot</button>
<span id="persona-active" style="font-size:.72rem;color:#4ade80"></span>
</div>
<div id="persona-note" style="font-size:.72rem;color:#f87171;margin:.1rem 0"></div>
<input id="persona-name" placeholder="Persona name" style="width:100%;margin-bottom:.3rem">
<textarea id="live-persona" rows="10" spellcheck="false"
style="width:100%;font-size:.78rem;line-height:1.7"></textarea>
<div class="row" style="margin-top:.35rem;gap:.3rem;flex-wrap:wrap">
<button class="btn btn-success btn-sm" onclick="liveSavePersona()">Save</button>
<button class="btn btn-ghost btn-sm" onclick="liveSavePersonaAs()">Save as new</button>
<button class="btn btn-danger btn-sm" onclick="liveDeletePersona()" style="margin-left:auto">Delete</button>
</div>
</div>
</div>
</div>
<!-- ==================== TAB: Recordings ==================== -->
<div class="tab-content" id="tab-recordings">
<div class="grid">
<!-- Saved Records -->
<div class="card card-full">
<h3>Saved Records</h3>
<div class="row" style="gap:.35rem;margin-bottom:.45rem;flex-wrap:wrap;align-items:center">
<input id="records-search" type="search" oninput="renderRecords()"
placeholder="Search name or text… بحث بالاسم أو النص"
style="flex:1;min-width:190px">
<select id="records-voice" onchange="renderRecords()" style="min-width:140px">
<option value="">All voices</option>
</select>
<select id="records-sort" onchange="renderRecords()" style="min-width:150px">
<option value="new">Newest first</option>
<option value="old">Oldest first</option>
<option value="name">Name AZ</option>
<option value="long">Longest audio</option>
<option value="plays">Most replayed</option>
</select>
<button class="btn btn-ghost btn-sm" onclick="clearRecordSearch()">Clear</button>
</div>
<div id="records-list"><div class="empty">No records saved</div></div>
<div class="row" style="margin-top:.3rem;gap:.3rem">
<button class="btn btn-ghost btn-sm" onclick="refreshRecords()">Refresh</button>
<button class="btn btn-danger btn-sm" onclick="deleteAllRecords()" style="margin-left:auto"
title="Permanently delete every WAV in data/audio and clear the index">Delete All</button>
</div>
</div>
</div>
</div>
<!-- ==================== TAB: Settings & Logs ==================== -->
<div class="tab-content" id="tab-settings">
<div class="grid">
<!-- Voice Rule — edit the replay instruction that shapes how the robot speaks -->
<div class="card card-full">
<h3>Voice Rule</h3>
<div style="font-size:.72rem;color:var(--muted);margin-bottom:.4rem">
The instruction that shapes how the robot speaks your typed text (accent, verbatim, gender).
<strong>Save &amp; Apply</strong> takes effect on the next Generate &amp; Play.
</div>
<textarea id="rule-content" placeholder="Voice rule…" dir="auto" style="min-height:130px"></textarea>
<div class="row" style="margin-top:.3rem">
<button class="btn btn-primary btn-sm" onclick="saveRule(this)">Save &amp; Apply</button>
<button class="btn btn-ghost btn-sm" onclick="refreshRule()">Reload</button>
</div>
</div>
<!-- Logs -->
<div class="card card-full">
<h3>Sign-in History</h3>
<div id="login-list"><div class="empty">Loading…</div></div>
<div class="row" style="margin-top:.3rem">
<button class="btn btn-ghost btn-sm" onclick="refreshLogins()">Refresh</button>
<button class="btn btn-success btn-sm" onclick="exportLogins('csv')">Export CSV</button>
<button class="btn btn-ghost btn-sm" onclick="exportLogins('json')">JSON</button>
</div>
</div>
<div class="card card-full">
<h3>Live Logs <span id="log-mode" style="font-size:.68rem;font-weight:400;color:var(--dim)">connecting…</span></h3>
<div class="row" style="margin-bottom:.3rem;flex-wrap:wrap;gap:.3rem">
<button class="btn btn-ghost btn-sm" onclick="saveLogSnapshot()" title="Save a timestamped copy of all .log files under logs/">Save Snapshot</button>
<button class="btn btn-primary btn-sm" onclick="copyAllLogs(this)" title="Fetch system status + every log file and copy to clipboard">Copy All</button>
<button class="btn btn-ghost btn-sm" onclick="copyVisibleLogs(this)" title="Copy only what's currently in the log box below">Copy Visible</button>
<button class="btn btn-ghost btn-sm" onclick="downloadLogBundle()" title="Download the full bundle as a .txt file">Download</button>
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('log-box').textContent=''">Clear</button>
</div>
<div class="log-box" id="log-box" style="height:300px"></div>
</div>
</div>
</div>
<script>
const API='';
function toast(m,t='info'){const b=document.getElementById('toast-box'),e=document.createElement('div');e.className='toast toast-'+t;e.textContent=m;b.appendChild(e);setTimeout(()=>e.remove(),3500);}
function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');}
function btnLoad(b){if(b&&b.classList)b.classList.add('loading');}
function btnDone(b){if(b&&b.classList)b.classList.remove('loading');}
async function api(m,p,b){const o={method:m,headers:{'Content-Type':'application/json'},credentials:'same-origin'};if(b)o.body=JSON.stringify(b);const r=await fetch(API+p,o);if(r.status===401){location.href='/login?next='+encodeURIComponent(location.pathname);throw new Error('Not authenticated');}let j={};const txt=await r.text();if(txt){try{j=JSON.parse(txt);}catch(e){const msg=r.ok?'Unexpected non-JSON response from server':('Server error '+r.status+' (timeout or gateway)');toast(msg,'err');throw new Error(msg);}}if(!r.ok){toast(j.detail||j.error||('Error '+r.status),'err');throw new Error(j.detail||j.error||('Error '+r.status));}return j;}
// Tabs
function switchTab(name){document.querySelectorAll('.tab').forEach(t=>t.classList.toggle('active',t.textContent.toLowerCase().includes(name.slice(0,4))));document.querySelectorAll('.tab-content').forEach(c=>c.classList.toggle('active',c.id==='tab-'+name));}
// Logout — clears the session cookie, then bounces to /login
async function logout(){
try{await fetch('/api/auth/logout',{method:'POST',credentials:'same-origin'});}catch(e){}
location.href='/login';
}
// 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||'env';
src.className='badge badge-ok';
msg.textContent=`Length: ${r.length} chars · read-only (cPanel env)`;
}else{
inp.value='';
inp.placeholder='(no key configured)';
src.textContent='empty';
src.className='badge badge-err';
msg.textContent='No API key — set it in cPanel → Setup Python App → Environment variables → "api".';
}
}catch(e){}
if(b)btnDone(b);
}
// Audio — only updates the mic/spk badges and mute-shortcut buttons that
// still exist in the Voice & Audio cards. Heavier audio device picker
// + G1 volume slider were removed with the Operations tab.
async function refreshAudio(){
try{
const r=await api('GET','/api/audio/status');
document.getElementById('mic-badge').style.display=r.mic_muted?'inline-flex':'none';
document.getElementById('spk-badge').style.display=r.speaker_muted?'inline-flex':'none';
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=>{
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;
});
}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();}
// Voice Rule — edit the replay instruction (sanad_rule.txt [REPLAY_SYSTEM_PROMPT]).
async function refreshRule(){try{const r=await api('GET','/api/prompt/rule');document.getElementById('rule-content').value=r.content||'';}catch(e){}}
async function saveRule(b){btnLoad(b);try{const r=await api('POST','/api/prompt/rule',{content:document.getElementById('rule-content').value});toast(r.message||'Rule saved','ok');}catch(e){}btnDone(b);}
// Typed Replay — audio plays in YOUR browser (not the server's speaker).
let _trAudio=null,_trGenerating=false;
function _playTRInBrowser(){
try{if(_trAudio){_trAudio.pause();_trAudio.src='';}}catch(e){}
_trAudio=new Audio(API+'/api/typed-replay/audio/last?t='+Date.now());
return _trAudio.play();
}
async function trGenerate(b){
if(_trGenerating)return toast('Still generating — please wait…','info');
const t=document.getElementById('tr-text').value;
if(!t)return toast('Enter text','err');
_trGenerating=true;btnLoad(b);
try{
const res=await api('POST','/api/typed-replay/say',{
text:t,
record:document.getElementById('tr-capture').checked,
record_name:document.getElementById('tr-name').value,
});
// Surface an off-voice / partial take instead of leaving it in the log.
const warnEl=document.getElementById('tr-warning');
if(warnEl){
if(res&&res.voice_warning){warnEl.textContent='\u26a0 '+res.voice_warning;warnEl.style.display='';}
else{warnEl.style.display='none';warnEl.textContent='';}
}
await _playTRInBrowser();
const hz=(res&&res.pitch_hz)?`${Math.round(res.pitch_hz)} Hz`:'';
toast((res&&res.voice_warning)?('Generated, but check the warning'+hz):('Generated — playing in your browser'+hz),
(res&&res.voice_warning)?'err':'ok');
refreshTR();
}catch(e){toast('Play failed: '+(e&&e.message||e),'err');}
finally{_trGenerating=false;btnDone(b);}
}
async function trReplayLast(b){
btnLoad(b);
try{
await api('POST','/api/typed-replay/replay-last');
await _playTRInBrowser();
toast('Replayed','ok');
refreshTR();
}catch(e){toast('Replay failed: '+(e&&e.message||e),'err');}
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){}}
// Records
let _records=[];
// Fold Arabic so a search for "سلام" also matches "السلام"/"سَلام": strip
// diacritics and tatweel, then normalise the alef/ya/ta-marbuta variants.
function _norm(s){return (s==null?'':String(s)).toLowerCase()
.replace(/[\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06ED\u0640]/g,'')
.replace(/[\u0623\u0625\u0622\u0671]/g,'\u0627')
.replace(/\u0629/g,'\u0647').replace(/\u0649/g,'\u064A')
.replace(/\u0624/g,'\u0648').replace(/\u0626/g,'\u064A');}
function _dur(rec){const f=(rec.files||{});const a=f.speaker_recording||f.gemini_raw_output||{};return a.duration_seconds||0;}
function _saved(rec){return ((rec.timeline||{}).saved_at)||((rec.timeline||{}).audio_generated_at)||'';}
function clearRecordSearch(){const b=document.getElementById('records-search');if(b)b.value='';const v=document.getElementById('records-voice');if(v)v.value='';renderRecords();}
function renderRecords(){
const el=document.getElementById('records-list');if(!el)return;
if(!_records.length){el.innerHTML='<div class="empty">No records saved</div>';return;}
const q=_norm((document.getElementById('records-search')||{}).value||'').trim();
const terms=q?q.split(/\s+/):[];
// every term must appear somewhere in the name or the text
const voiceSel=document.getElementById('records-voice');
const want=(voiceSel||{}).value||'';
let list=_records.filter(r=>{
if(want && (r.voice||'')!==want) return false;
const hay=_norm(r.record_name)+' '+_norm(r.text)+' '+_norm(r.voice_label)+' '+_norm(r.voice);
return terms.every(t=>hay.indexOf(t)>=0);});
const sort=((document.getElementById('records-sort')||{}).value)||'new';
const cmp={new:(a,b)=>_saved(b).localeCompare(_saved(a)),
old:(a,b)=>_saved(a).localeCompare(_saved(b)),
name:(a,b)=>String(a.record_name).localeCompare(String(b.record_name)),
long:(a,b)=>_dur(b)-_dur(a),
plays:(a,b)=>(b.replay_count||0)-(a.replay_count||0)}[sort];
list=list.slice().sort(cmp);
const head=`<div style="font-size:.7rem;color:var(--dim);margin-bottom:.3rem">Showing ${list.length} of ${_records.length}${terms.length?' — filtered':''}</div>`;
if(!list.length){el.innerHTML=head+'<div class="empty">Nothing matches that search</div>';return;}
el.innerHTML=head+'<table><tr><th>Name</th><th>Voice</th><th>Text</th><th>Saved</th><th>Dur</th><th>Pitch</th><th>Plays</th><th></th></tr>'+
list.map(rec=>{const n=esc(rec.record_name);const d=_dur(rec);
const vl=rec.voice_label||rec.voice||'--';
return `<tr><td>${n}</td>`+
`<td style="white-space:nowrap;font-size:.72rem">${esc(vl)}</td>`+
`<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(rec.text||'')}">${esc(rec.text||'')}</td>`+
`<td style="white-space:nowrap;font-size:.7rem;color:var(--dim)">${esc((_saved(rec)||'--').slice(0,16))}</td>`+
`<td style="white-space:nowrap">${d?d.toFixed(1)+'s':'--'}</td>`+
`<td style="white-space:nowrap;font-size:.72rem${rec.voice_warning?';color:#e0a030':''}" title="${esc(rec.voice_warning||'')}">${rec.pitch_hz?Math.round(rec.pitch_hz)+' Hz':'--'}${rec.voice_warning?' \u26a0':''}</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-success btn-sm" onclick="downloadRecord('${n}','speaker')">Download</button> `+
`<button class="btn btn-danger btn-sm" onclick="deleteRecord('${n}')">Del</button></td></tr>`;}).join('')+'</table>';
}
function _syncVoiceFilter(){
const sel=document.getElementById('records-voice');if(!sel)return;
const seen=new Map();
_records.forEach(r=>{if(r.voice)seen.set(r.voice,r.voice_label||r.voice);});
const keep=sel.value;
sel.innerHTML='<option value="">All voices</option>'+
[...seen.entries()].map(([v,l])=>`<option value="${esc(v)}">${esc(l)}</option>`).join('');
if([...seen.keys()].includes(keep))sel.value=keep;
}
async function refreshRecords(){try{const r=await api('GET','/api/records/');_records=r.records||[];_syncVoiceFilter();renderRecords();}catch(e){}}
// Browser-side playback — streams the WAV from /api/records/audio/{name}
// and plays it through the user's speakers (not the robot's).
let _recordAudio=null;
function playRecord(name,kind){
try{if(_recordAudio){_recordAudio.pause();_recordAudio.src='';}}catch(e){}
const url=API+'/api/records/audio/'+encodeURIComponent(name)+'?kind='+encodeURIComponent(kind||'speaker');
_recordAudio=new Audio(url);
_recordAudio.play()
.then(()=>toast('Playing: '+name+' ('+(kind||'speaker')+')','ok'))
.catch(err=>toast('Play failed: '+(err&&err.message||err),'err'));
}
async function deleteRecord(name){if(confirm('Delete '+name+'?'))try{await api('POST','/api/records/delete',{record_name:name});toast('Deleted','ok');refreshRecords();}catch(e){}}
async function deleteAllRecords(){
if(!confirm('Delete ALL saved recordings? This wipes every WAV in data/audio and cannot be undone.'))return;
try{
const r=await api('POST','/api/records/delete-all');
toast(`Deleted ${r.deleted_count} file${r.deleted_count===1?'':'s'}`,'ok');
refreshRecords();
}catch(e){toast('Delete all failed: '+(e&&e.message||e),'err');}
}
function downloadRecord(name,kind){
const k=kind||'speaker';
const a=document.createElement('a');
a.href=API+'/api/records/audio/'+encodeURIComponent(name)+'?kind='+encodeURIComponent(k);
a.download=name+(k==='raw'?'_raw':'')+'.wav';
document.body.appendChild(a);
a.click();
setTimeout(()=>a.remove(),100);
toast('Downloading: '+name,'info');
}
// 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';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, _logPoll=null, _logCursor=-1, _logMode='';
function _logAppend(lines){
const box=document.getElementById('log-box');if(!box||!lines||!lines.length)return;
box.textContent+=lines.join('\n')+'\n';
const all=box.textContent.split('\n');
if(all.length>1000)box.textContent=all.slice(-500).join('\n');
box.scrollTop=box.scrollHeight;
}
function _logStatus(mode){
if(_logMode===mode)return; _logMode=mode;
const el=document.getElementById('log-mode');
if(el)el.textContent=mode==='ws'?'live (websocket)':(mode==='poll'?'live (polling)':'disconnected');
}
// Poll the ring buffer. Used whenever the WebSocket cannot be established —
// which is always on the public URL, because the reverse proxy in front of
// this app forwards the upgrade as a plain GET.
async function _pollLogs(){
try{
const r=await api('GET','/api/logs/live?cursor='+_logCursor+'&limit=300');
if(r&&r.lines){_logAppend(r.lines);_logCursor=r.cursor;_logStatus('poll');}
}catch(e){_logStatus('');}
}
function _startPolling(){
if(_logPoll)return;
_pollLogs();
_logPoll=setInterval(_pollLogs,2000);
}
function connectLogs(){
const p=location.protocol==='https:'?'wss':'ws';
let opened=false;
try{ logWs=new WebSocket(`${p}://${location.host}/ws/logs`); }
catch(e){ _startPolling(); return; }
logWs.onopen=()=>{opened=true;if(_logPoll){clearInterval(_logPoll);_logPoll=null;}_logStatus('ws');};
logWs.onmessage=e=>_logAppend([e.data]);
logWs.onerror=()=>{try{logWs.close();}catch(_){}};
logWs.onclose=()=>{
// No WebSocket here: fall back rather than retrying forever into a proxy
// that will never upgrade.
_startPolling();
if(opened)setTimeout(connectLogs,5000);
};
// Belt and braces: if it never opens, start polling anyway.
setTimeout(()=>{if(!opened)_startPolling();},3000);
}
// ── sign-in history ────────────────────────────────────────────────
// Exports the FULL history, not just the rows on screen. Navigating rather
// than fetching lets the browser save the file and send the session cookie.
function exportLogins(fmt){
window.location='/api/auth/logins/export?format='+encodeURIComponent(fmt||'csv');
}
async function refreshLogins(){
try{
const r=await api('GET','/api/auth/logins?limit=20');
const el=document.getElementById('login-list');if(!el)return;
const rows=r.logins||[];
if(!rows.length){el.innerHTML='<div class="empty">No sign-ins recorded yet</div>';return;}
el.innerHTML='<table><tr><th>When</th><th>Device</th><th>IP</th><th></th></tr>'+
rows.map(x=>`<tr>`+
`<td style="white-space:nowrap">${esc(x.at||'')}</td>`+
`<td title="${esc(x.user_agent||'')}">${esc(x.device||'')}</td>`+
`<td style="font-size:.72rem;color:var(--dim)">${esc(x.ip||'')}</td>`+
`<td>${x.ok?'<span style="color:#4ade80">ok</span>':'<span style="color:#f87171">failed</span>'}</td>`+
`</tr>`).join('')+'</table>';
}catch(e){}
}
// Init — every audio feature in lite plays client-side via <audio> tags.
// Robot Voice — map friendly robot labels to Gemini voices; switch + persist.
async function refreshVoices(){
try{
const r=await api('GET','/api/voice/voices');
const box=document.getElementById('voice-options');
if(box)box.innerHTML=(r.options||[]).map(o=>`<button class="btn ${o.active?'btn-primary':'btn-ghost'} btn-sm" data-voice="${esc(o.voice)}" onclick="setVoice(this.dataset.voice,this)">${esc(o.label)}</button>`).join('')||'<span style="color:var(--muted);font-size:.72rem">No voices configured</span>';
const cur=document.getElementById('voice-current');
if(cur)cur.innerHTML=`Current: <strong>${esc(r.current_label||r.current||'--')}</strong>`+(r.current?` <span style="color:var(--dim)">(${esc(r.current)})</span>`:'');
}catch(e){}
}
async function setVoice(voice,b){
btnLoad(b);
try{const r=await api('POST','/api/voice/voice',{voice});toast(r.message||('Voice set to '+(r.label||voice)),'ok');refreshVoices();}catch(e){}
btnDone(b);
}
refreshStatus();refreshAudio();refreshTR();refreshApiKey();refreshVoices();refreshRule();refreshRecords();connectLogs();refreshLogins();if(window.liveInit)liveInit();
setInterval(refreshStatus,5000);
</script>
<script src="/static/live.js?v=1787937410"></script>
</body>
</html>