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.
735 lines
42 KiB
HTML
735 lines
42 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:.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 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=1787937410"></script>
|
||
</body>
|
||
</html>
|