Live Gemini could not use an external speaker or headset. An AudioContext
is bound to whichever output was default when it was created, and
getUserMedia({audio:true}) takes the system default input, so plugging a
device in afterwards left audio going to the old one — silently, with no
error to explain it.
* Explicit Mic and Speaker pickers. Capture opens the chosen deviceId
exactly and can be switched mid-session; playback is routed through a
MediaStreamAudioDestinationNode into a hidden <audio> element so
setSinkId() can move it to the chosen sink. Both lists refresh on
devicechange and are remembered in localStorage.
* Windows reports each device three times (default, communications, and
the real one), so an Anker would have appeared three times with no way
to tell them apart. The pseudo-devices are now collapsed.
* Capture moved to an AudioWorklet (Blob-built, no extra file served)
with the ScriptProcessor kept as a fallback.
* A compatibility line reports what the browser actually supports, and
the diagnostics line now shows the output sink and capture kind — the
difference between "not listening" and "not speaking" without a
debugger.
Verified in both engines with Playwright (chromium PASS, firefox PASS: no
page errors, personas and device lists populated, test tone plays) and
against real hardware, where setSinkId matched the selection and a named
microphone opened by deviceId. Two engine-specific bugs fell out of that
run and are fixed here: reading AudioContext.prototype.audioWorklet invokes
the getter and throws in both browsers, which aborted init and left the tab
empty, and the init steps are now isolated so one failure cannot take the
rest down.
Also in this commit:
* README rewritten against what the code does today — the voice-fidelity
rationale and its three gates, why words used to cut off, the Live tab
and why the browser talks to Google directly, personas, device
selection, recordings search/filter, sign-in history, a current API
list, the cPanel production setup, and the known limits.
* shell_scripts/start_cpanel.sh — the launcher that actually keeps the
site up (HTTP health check, not a TCP probe) was only on the host.
* data/live_personas.json — the persona library (G1, R1, Agibot, T800)
existed only on the server; it is user-written content worth keeping.
* .gitignore covers runtime state (logins, generated WAVs, .env, backups).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
746 lines
43 KiB
HTML
746 lines
43 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="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 & 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:.35rem;align-items:center;flex-wrap:wrap;margin-bottom:.35rem">
|
||
<label style="font-size:.7rem;color:var(--dim);min-width:52px">Mic</label>
|
||
<select id="live-mic" onchange="liveSetMic(this.value)" style="flex:1 1 220px;max-width:340px"></select>
|
||
<label style="font-size:.7rem;color:var(--dim);min-width:52px">Speaker</label>
|
||
<select id="live-out" onchange="liveSetOutput(this.value)" style="flex:1 1 220px;max-width:340px"></select>
|
||
<button class="btn btn-ghost btn-sm" onclick="liveListDevices()">Refresh devices</button>
|
||
</div>
|
||
<!-- Playback is routed through this element so setSinkId() can send it to
|
||
the chosen speaker; an AudioContext alone always uses the default. -->
|
||
<audio id="live-audio-out" autoplay style="display:none"></audio>
|
||
<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-compat" style="font-size:.66rem;color:var(--dim);margin:.1rem 0"></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 A–Z</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 & Apply</strong> takes effect on the next Generate & 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 & 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,'&').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'},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=1788376613"></script>
|
||
</body>
|
||
</html>
|