303 lines
17 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
<title>Sanad — Basic Communication · YS Lootah Tech</title>
<style>
:root{--bg:#0e1726;--card:#16223a;--line:#27365a;--ink:#e9f0fb;--mut:#90a2c4;
--accent:#2f7ad6;--ok:#1f9d57;--warn:#e0a800;--bad:#d6455d;--radius:12px}
*{box-sizing:border-box}
html,body{margin:0}
body{background:var(--bg);color:var(--ink);
font-family:-apple-system,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;
font-size:14px;line-height:1.5;-webkit-text-size-adjust:100%}
header{position:sticky;top:0;background:#0b1320;border-bottom:1px solid var(--line);
padding:10px 18px;display:flex;align-items:center;gap:10px 13px;flex-wrap:wrap;z-index:5}
.brand{display:flex;align-items:center;gap:10px;min-width:0}
.brand .t{display:flex;flex-direction:column;line-height:1.15;min-width:0}
.brand h1{font-size:15.5px;margin:0;color:#cfe0ff;white-space:nowrap}
.brand .by{font-size:10.5px;color:var(--mut)}
.pills{display:flex;gap:8px;flex-wrap:wrap}
.pill{font-size:11px;padding:3px 9px;border-radius:20px;border:1px solid var(--line);color:var(--mut);white-space:nowrap}
.pill.on{background:rgba(31,157,87,.15);color:#7fe0a6;border-color:#1f9d57}
.pill.off{background:rgba(214,69,93,.12);color:#f2a3ae;border-color:#d6455d}
.pill.warn{background:rgba(224,168,0,.12);color:#ffd766;border-color:#e0a800}
.spacer{flex:1}
main{max-width:1100px;margin:0 auto;padding:16px 18px 8px;display:flex;flex-direction:column;gap:6px}
.section{font-size:11px;text-transform:uppercase;letter-spacing:1.4px;color:var(--mut);
font-weight:700;margin:14px 4px 4px;border-bottom:1px solid var(--line);padding-bottom:5px}
.section:first-of-type{margin-top:2px}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:14px;align-items:start}
.card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);padding:16px;min-width:0}
.card h2{margin:0 0 10px;font-size:14px;color:#bcd2ff;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.card h2 .sub{font-size:11px;color:var(--mut);font-weight:400}
label{display:block;font-size:12px;color:var(--mut);margin:10px 0 4px}
textarea,select,input[type=text],input[type=password]{
width:100%;background:#0e1830;border:1px solid var(--line);color:var(--ink);
border-radius:8px;padding:10px;font:inherit}
textarea{min-height:118px;resize:vertical;font-family:'SFMono-Regular',Consolas,monospace;font-size:12.5px}
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
button{background:var(--accent);color:#fff;border:0;border-radius:8px;padding:10px 14px;
font:inherit;cursor:pointer;min-height:40px}
button.sec{background:#26395f}button.ok{background:var(--ok)}button.bad{background:var(--bad)}
button.sm{min-height:30px;padding:6px 12px}
input[type=range]{flex:1;min-width:120px}
.msg{font-size:12px;margin-top:8px;min-height:16px;word-break:break-word}
.msg.ok{color:#7fe0a6}.msg.err{color:#f2a3ae}.msg.info{color:var(--mut)}
.vol{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.vol b{width:42px;text-align:right}
#logbox{background:#0a1020;border:1px solid var(--line);border-radius:8px;height:200px;overflow:auto;
padding:8px;font-family:monospace;font-size:11.5px;color:#bcd;word-break:break-all}
.hint{font-size:11px;color:var(--mut);margin-top:6px}
.wide{grid-column:1/-1}
footer{max-width:1100px;margin:14px auto 30px;padding:0 18px;color:var(--mut);font-size:11.5px;
display:flex;align-items:center;gap:9px;flex-wrap:wrap}
@media (max-width:720px){ .grid{grid-template-columns:1fr} .wide{grid-column:auto}
.brand h1{font-size:14px;white-space:normal} header{padding:9px 13px} main{padding:13px} }
@media (max-width:430px){ main{padding:11px} .card{padding:13px}
.row button{flex:1 1 auto} button{padding:12px 14px;min-height:44px} textarea{min-height:104px} }
</style>
</head>
<body>
<header>
<div class="brand">
<svg viewBox="0 0 120 120" width="32" height="32" aria-label="YS Lootah Tech" role="img" style="flex:none">
<rect x="3" y="3" width="114" height="114" rx="24" fill="#0a1a3f" stroke="#3a5da8" stroke-width="2.5"/>
<text x="60" y="76" font-family="Arial,Helvetica,sans-serif" font-size="52" font-weight="800"
fill="#d7e6ff" text-anchor="middle" letter-spacing="-3">YS</text>
</svg>
<div class="t">
<h1>Sanad — Basic Communication</h1>
<span class="by">powered by YS Lootah Tech</span>
</div>
</div>
<span class="spacer"></span>
<div class="pills">
<span class="pill" id="pill-license">license …</span>
<span class="pill" id="pill-live">session …</span>
<span class="pill" id="pill-lang">lang …</span>
</div>
</header>
<main>
<!-- ───────── TALK ───────── -->
<div class="section">Talk</div>
<div class="grid">
<section class="card">
<h2>🎙️ Conversation <span class="sub">live voice</span></h2>
<div class="row">
<button id="btn-start" class="ok">Start talking</button>
<button id="btn-stop" class="bad sec">Stop</button>
<button id="btn-refresh-live" class="sec">Refresh</button>
</div>
<div class="msg info" id="msg-live">Start the live session, then just speak to the robot.</div>
</section>
<section class="card">
<h2>💬 Say a line <span class="sub">type → robot speaks</span></h2>
<textarea id="say-text" placeholder="اكتب جملة ليقولها سند… / Type a sentence for Sanad to say"></textarea>
<div class="row" style="margin-top:8px"><button id="btn-say">Speak it</button></div>
<div class="msg info" id="msg-say"></div>
</section>
<section class="card wide">
<h2>🪪 Robot persona <span class="sub">who Sanad is, tone & language/dialect — applied live</span></h2>
<textarea id="persona-text" placeholder="Loading persona…"></textarea>
<div class="row" style="margin-top:8px">
<button id="btn-persona-save" class="ok">Save &amp; Apply</button>
<button id="btn-persona-reload" class="sec">Reload</button>
<span class="hint">Put the language directive here (e.g. “Speak Khaleeji Arabic”). Saving restarts the live session so it applies immediately.</span>
</div>
<div class="msg info" id="msg-persona"></div>
</section>
</div>
<!-- ───────── SETTINGS ───────── -->
<div class="section">Settings</div>
<div class="grid">
<section class="card">
<h2>🔑 Gemini API key <span class="sub">add / delete</span></h2>
<div class="msg info" id="key-status"></div>
<label for="key-input">Add / update key</label>
<input type="password" id="key-input" placeholder="paste your Gemini key…" autocomplete="off"/>
<div class="row" style="margin-top:8px">
<button id="btn-key-save" class="ok">Add key</button>
<button id="btn-key-show" class="sec" type="button">Show</button>
<button id="btn-key-delete" class="bad">Delete key</button>
</div>
<div class="msg info" id="msg-key"></div>
<div class="hint">Accepts any Gemini key (AIza… or AQ.… tokens). Stored masked; deleting stops conversation until a new key is added.</div>
</section>
<section class="card">
<h2>🔊 Audio <span class="sub">speaker / mic + volume</span></h2>
<label for="profile-sel">Speaker &amp; mic (profile)</label>
<select id="profile-sel"><option>loading…</option></select>
<div class="row" style="margin-top:8px">
<button id="btn-profile-apply">Use this</button>
<button id="btn-audio-refresh" class="sec">Rescan devices</button>
</div>
<label style="margin-top:12px">Robot (chest) volume</label>
<div class="vol">
<input type="range" id="vol" min="0" max="100" value="80"/>
<b id="vol-val">80</b>
<button id="btn-mute" class="sec" title="Mute / unmute chest speaker">Mute</button>
</div>
<div class="msg info" id="msg-audio"></div>
<div class="hint">Plug an Anker/USB device → Rescan → pick it in the profile list. Chest volume needs the builtin (DDS) profile.</div>
</section>
</div>
<!-- ───────── DIAGNOSTICS ───────── -->
<div class="section">Diagnostics</div>
<div class="grid">
<section class="card wide">
<h2>📜 Logs <span class="sub">live</span>
<span style="flex:1"></span>
<button id="btn-logs-dl" class="sec sm">⬇ Download</button>
<button id="btn-logs-del" class="bad sm">Delete logs</button>
</h2>
<div id="logbox">connecting…</div>
<div class="msg info" id="msg-logs"></div>
</section>
</div>
</main>
<footer>
<svg viewBox="0 0 120 120" width="18" height="18" role="img" aria-label="YS" style="flex:none">
<rect x="3" y="3" width="114" height="114" rx="24" fill="#0a1a3f" stroke="#3a5da8" stroke-width="3"/>
<text x="60" y="78" font-family="Arial" font-size="54" font-weight="800" fill="#d7e6ff" text-anchor="middle" letter-spacing="-3">YS</text>
</svg>
<span>Sanad — powered by <strong>YS Lootah Tech</strong></span>
</footer>
<script>
const $ = (id) => document.getElementById(id);
async function api(method, path, body){
const opt = {method, headers:{}};
if(body!==undefined){opt.headers["Content-Type"]="application/json";opt.body=JSON.stringify(body);}
const r = await fetch(path, opt);
let data=null; try{data=await r.json();}catch(e){}
if(!r.ok){ const m=(data&&(data.detail||data.error||data.message))||(r.status+" "+r.statusText);
throw new Error(typeof m==="string"?m:JSON.stringify(m)); }
return data||{};
}
function setMsg(id,text,kind){const el=$(id);el.textContent=text||"";el.className="msg "+(kind||"info");}
function pill(id,text,cls){const el=$(id);el.textContent=text;el.className="pill "+(cls||"");}
async function loadStatus(){
try{ const p = await api("GET","/api/package");
pill("pill-license", p.license&&p.license.valid?"licensed":"unlicensed", p.license&&p.license.valid?"on":"off");
pill("pill-lang", "lang: "+(p.language||"?"));
}catch(e){ pill("pill-license","status error","off"); }
refreshLive();
}
async function refreshLive(){
try{ const s = await api("GET","/api/live-subprocess/status");
const running = s.state==="running"||s.running===true;
pill("pill-live", running?"session: live":"session: stopped", running?"on":"off");
}catch(e){ pill("pill-live","session: n/a","warn"); }
}
// conversation
$("btn-start").onclick = async()=>{ setMsg("msg-live","starting…");
try{ await api("POST","/api/live-subprocess/start"); setMsg("msg-live","Live session started — speak now.","ok"); }
catch(e){ setMsg("msg-live","Start failed: "+e.message,"err"); } refreshLive(); };
$("btn-stop").onclick = async()=>{ setMsg("msg-live","stopping…");
try{ await api("POST","/api/live-subprocess/stop"); setMsg("msg-live","Stopped.","ok"); }
catch(e){ setMsg("msg-live","Stop failed: "+e.message,"err"); } refreshLive(); };
$("btn-refresh-live").onclick = refreshLive;
// say
$("btn-say").onclick = async()=>{ const t=$("say-text").value.trim();
if(!t){setMsg("msg-say","Type something first.","err");return;}
setMsg("msg-say","speaking…");
try{ await api("POST","/api/p1/say",{text:t}); setMsg("msg-say","Done.","ok"); }
catch(e){ setMsg("msg-say","Failed: "+e.message,"err"); } };
// persona
async function loadPersona(){ try{ const p=await api("GET","/api/p1/persona");
$("persona-text").value = p.system_prompt||""; setMsg("msg-persona","Loaded.","info"); }
catch(e){ setMsg("msg-persona","Load failed: "+e.message,"err"); } }
$("btn-persona-save").onclick = async()=>{ setMsg("msg-persona","saving…");
try{ const r=await api("POST","/api/p1/persona",{content:$("persona-text").value});
setMsg("msg-persona",(r.message||"Saved."),"ok"); refreshLive(); }
catch(e){ setMsg("msg-persona","Save failed: "+e.message,"err"); } };
$("btn-persona-reload").onclick = loadPersona;
// api key
async function loadKey(){ try{ const k=await api("GET","/api/p1/api-key");
$("key-status").textContent = k.has_key
? ("Current: "+(k.masked||"set")+" (source: "+(k.source||"?")+")") : "No key set."; }
catch(e){ $("key-status").textContent="status error"; } }
$("btn-key-show").onclick = ()=>{ const i=$("key-input"); i.type=i.type==="password"?"text":"password"; };
$("btn-key-save").onclick = async()=>{ const k=$("key-input").value.trim();
if(!k){ setMsg("msg-key","Paste a key first.","err"); return; }
setMsg("msg-key","saving…");
try{ const r=await api("POST","/api/p1/api-key",{api_key:k});
setMsg("msg-key",(r.message||"Added."),"ok"); $("key-input").value=""; loadKey(); refreshLive(); }
catch(e){ setMsg("msg-key","Failed: "+e.message,"err"); } };
$("btn-key-delete").onclick = async()=>{
if(!confirm("Delete the Gemini API key? Conversation will stop until you add a new one.")) return;
setMsg("msg-key","deleting…");
try{ const r=await api("POST","/api/p1/api-key/delete");
setMsg("msg-key",(r.message||"Deleted."),"ok"); loadKey(); refreshLive(); }
catch(e){ setMsg("msg-key","Failed: "+e.message,"err"); } };
// audio
async function loadProfiles(){ try{ const d=await api("GET","/api/audio/profiles");
const sel=$("profile-sel"); sel.innerHTML="";
(d.profiles||[]).forEach(p=>{ const o=document.createElement("option");
o.value=p.id; o.textContent=(p.label||p.name||p.id)+(p.available?" ✓ plugged":""); sel.appendChild(o); });
if(!sel.options.length){ sel.innerHTML="<option>no profiles</option>"; }
}catch(e){ setMsg("msg-audio","Profiles: "+e.message,"err"); } }
$("btn-profile-apply").onclick = async()=>{ const id=$("profile-sel").value; setMsg("msg-audio","switching…");
try{ await api("POST","/api/audio/select-profile",{profile_id:id}); setMsg("msg-audio","Switched to "+id+".","ok"); loadVol(); }
catch(e){ setMsg("msg-audio","Switch failed: "+e.message,"err"); } };
$("btn-audio-refresh").onclick = async()=>{ setMsg("msg-audio","rescanning…");
try{ await api("POST","/api/audio/refresh"); await loadProfiles(); setMsg("msg-audio","Devices rescanned.","ok"); }
catch(e){ setMsg("msg-audio","Rescan failed: "+e.message,"err"); } };
async function loadVol(){ try{ const v=await api("GET","/api/audio/g1-speaker/volume");
if(typeof v.current_volume==="number"){ $("vol").value=v.current_volume; $("vol-val").textContent=v.current_volume; }
if(v.available===false){ setMsg("msg-audio","Chest volume unavailable (plugged profile / no SDK) — use OS volume for USB.","info"); }
}catch(e){} }
let volTimer=null;
$("vol").oninput = ()=>{ $("vol-val").textContent=$("vol").value;
clearTimeout(volTimer); volTimer=setTimeout(async()=>{
try{ await api("POST","/api/audio/g1-speaker/volume",{level:parseInt($("vol").value,10)}); }
catch(e){ setMsg("msg-audio","Volume: "+e.message,"err"); } },300); };
$("btn-mute").onclick = async()=>{ try{ await api("POST","/api/audio/g1-speaker/mute"); loadVol(); setMsg("msg-audio","Toggled mute.","ok"); }
catch(e){ setMsg("msg-audio","Mute: "+e.message,"err"); } };
// logs
function connectLogs(){ try{
const proto = location.protocol==="https:"?"wss:":"ws:";
const ws = new WebSocket(proto+"//"+location.host+"/ws/logs");
const box=$("logbox"); let first=true;
ws.onopen=()=>{ box.textContent=""; };
ws.onmessage=(ev)=>{ if(first){box.textContent="";first=false;}
let line=ev.data; try{const j=JSON.parse(ev.data); line=j.line||j.message||ev.data;}catch(e){}
box.textContent += line+"\n"; box.scrollTop=box.scrollHeight; };
ws.onerror=()=>{ box.textContent="(logs stream unavailable)"; };
ws.onclose=()=>{ setTimeout(connectLogs, 4000); };
}catch(e){ $("logbox").textContent="(logs unavailable)"; } }
$("btn-logs-dl").onclick = async()=>{ setMsg("msg-logs","preparing download…");
try{ const r = await fetch("/api/logs/bundle?lines=2000"); if(!r.ok) throw new Error(r.status);
const blob = await r.blob(); const ts = new Date().toISOString().replace(/[:.]/g,"-").slice(0,19);
const a=document.createElement("a"); a.href=URL.createObjectURL(blob); a.download="sanad_p1_logs_"+ts+".txt";
document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(a.href),5000);
setMsg("msg-logs","Downloaded sanad_p1_logs_"+ts+".txt","ok");
}catch(e){ setMsg("msg-logs","Download failed: "+e.message,"err"); } };
$("btn-logs-del").onclick = async()=>{
if(!confirm("Delete ALL log files on the robot?")) return;
setMsg("msg-logs","deleting…");
try{ const r=await api("POST","/api/p1/logs/delete"); $("logbox").textContent="";
setMsg("msg-logs","Deleted "+(r.count||0)+" log file(s).","ok"); }
catch(e){ setMsg("msg-logs","Delete failed: "+e.message,"err"); } };
loadStatus(); loadPersona(); loadKey(); loadProfiles(); loadVol(); connectLogs();
setInterval(refreshLive, 6000);
</script>
</body>
</html>