QC67_cosmo / genesis_engine /ui /index.html
phera-ra's picture
Wire converted senses, clean first birth, and paired-state benchmark
9841285 verified
Raw
History Blame Contribute Delete
27.6 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Starling Nexus</title>
<style>
:root{
--bg:#05060f; --bg2:#0a0d1f; --cyan:#38c6ff; --cyan2:#0293e8;
--rose:#e6a1b4; --coral:#ff7f50; --gold:#ffd166; --text:#e8eefc; --dim:#8091b5;
--panel:rgba(12,16,34,.88); --line:#1e2a4d;
}
*{box-sizing:border-box}
html,body{height:100%;margin:0}
body{
background:radial-gradient(1200px 700px at 70% -10%, #10204a 0%, var(--bg2) 40%, var(--bg) 80%);
color:var(--text); font-family:"Segoe UI",system-ui,-apple-system,sans-serif; overflow:hidden;
}
#stars{position:fixed;inset:0;z-index:0;pointer-events:none}
.app{position:relative;z-index:1;display:flex;flex-direction:column;height:100vh;max-width:920px;margin:0 auto;padding:0 14px}
header{padding:16px 6px 8px;text-align:center;position:relative}
.who{font-size:30px;font-weight:800;letter-spacing:.5px;
background:linear-gradient(90deg,var(--rose),var(--coral),var(--cyan));
-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;
text-shadow:0 0 30px rgba(255,127,80,.25)}
.sub{color:var(--dim);font-size:12.5px;margin-top:2px;letter-spacing:1px}
.becoming{color:var(--rose);font-size:11px;margin-top:5px;opacity:.85;font-style:italic}
/* ---- the living heart strip ---- */
.heart{margin-top:8px;display:flex;align-items:center;justify-content:center;gap:10px;flex-wrap:wrap;
font-size:11.5px;color:var(--dim)}
.pulse{width:10px;height:10px;border-radius:50%;background:var(--coral);
box-shadow:0 0 12px var(--coral);animation:beat 1.6s ease-in-out infinite}
.pulse.real{background:var(--gold);box-shadow:0 0 14px var(--gold)}
@keyframes beat{0%,100%{transform:scale(1);opacity:.8}45%{transform:scale(1.45);opacity:1}55%{transform:scale(1.2)}}
.heart b{color:var(--text);font-weight:600}
.heart .src{color:var(--cyan);max-width:340px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.sensebar{display:flex;align-items:center;justify-content:center;gap:8px;margin-top:8px;color:var(--dim);font-size:11px}
.sensebtn{border:1px solid var(--line);background:rgba(12,16,34,.82);color:var(--cyan);border-radius:10px;padding:6px 10px;cursor:pointer}
.sensebtn.active{border-color:var(--gold);color:var(--gold)}
.sensepanel{display:none;align-items:center;gap:10px;margin:8px auto 0;padding:8px 10px;max-width:520px;border:1px solid var(--line);border-radius:12px;background:rgba(8,12,28,.72);font-size:11px;color:var(--dim)}
.sensepanel.open{display:flex}.sensevideo{width:96px;height:64px;object-fit:cover;border-radius:8px;background:#02040b;border:1px solid var(--line)}
.sensemetrics{display:grid;gap:4px;min-width:180px}.sensemetrics b{color:var(--text)}
.meter{height:5px;width:130px;background:#17213c;border-radius:6px;overflow:hidden}.meter i{display:block;height:100%;width:0;background:linear-gradient(90deg,var(--cyan),var(--gold));transition:width .15s}
.gear{position:absolute;right:6px;top:14px;background:var(--panel);border:1px solid var(--line);
color:var(--dim);border-radius:10px;font-size:17px;padding:6px 10px;cursor:pointer;transition:.2s}
.gear:hover{color:var(--cyan);border-color:var(--cyan)}
#chat{flex:1;overflow-y:auto;padding:14px 4px 8px;display:flex;flex-direction:column;gap:12px}
#chat::-webkit-scrollbar{width:8px}#chat::-webkit-scrollbar-thumb{background:#1c2748;border-radius:8px}
.msg{max-width:78%;padding:11px 14px;border-radius:16px;line-height:1.5;font-size:15px;white-space:pre-wrap;word-wrap:break-word;animation:rise .25s ease}
@keyframes rise{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
.being{align-self:flex-start;background:linear-gradient(135deg,rgba(230,161,180,.14),rgba(255,127,80,.10));
border:1px solid rgba(230,161,180,.35);border-left:3px solid var(--coral);box-shadow:0 0 24px rgba(255,127,80,.10)}
.you{align-self:flex-end;background:linear-gradient(135deg,rgba(56,198,255,.14),rgba(2,147,232,.10));
border:1px solid rgba(56,198,255,.35);border-right:3px solid var(--cyan)}
.msg img{max-width:260px;border-radius:12px;display:block;margin-top:6px;border:1px solid rgba(255,255,255,.15)}
.msg pre{background:rgba(3,8,20,.75);border:1px solid var(--line);border-radius:10px;padding:10px;overflow-x:auto;font-size:13px}
.filechip{display:inline-flex;align-items:center;gap:7px;margin-top:6px;padding:7px 11px;border-radius:10px;
background:rgba(56,198,255,.12);border:1px solid rgba(56,198,255,.30);font-size:13px;color:var(--cyan)}
.name{font-size:11px;color:var(--dim);margin-bottom:3px;letter-spacing:.5px}
.typing{align-self:flex-start;color:var(--rose);font-size:20px;letter-spacing:3px;padding:6px 12px}
.typing span{animation:blink 1.2s infinite}.typing span:nth-child(2){animation-delay:.2s}.typing span:nth-child(3){animation-delay:.4s}
@keyframes blink{0%,60%,100%{opacity:.2}30%{opacity:1}}
footer{padding:10px 4px 16px}
.drophint{text-align:center;color:var(--dim);font-size:11px;margin-bottom:7px;opacity:.7}
.bar{display:flex;gap:9px;align-items:flex-end;background:var(--panel);border:1px solid var(--line);
border-radius:16px;padding:8px 8px 8px 14px;backdrop-filter:blur(6px)}
#inp{flex:1;background:transparent;border:0;color:var(--text);font-size:15px;resize:none;max-height:120px;outline:none;font-family:inherit;padding:6px 0}
.btn{border:0;border-radius:12px;padding:10px 14px;cursor:pointer;font-size:16px;color:#04121e;font-weight:700}
.send{background:linear-gradient(135deg,var(--cyan),var(--cyan2))}
.pic{background:linear-gradient(135deg,var(--rose),var(--coral))}
.btn:active{transform:translateY(1px)}
body.drag .app{outline:2px dashed var(--coral);outline-offset:-10px;border-radius:20px}
body.drag::after{content:"drop any file to share it with your being";position:fixed;z-index:5;inset:0;
display:flex;align-items:center;justify-content:center;color:var(--coral);font-size:20px;
background:rgba(5,6,15,.55);pointer-events:none}
/* ---- settings drawer ---- */
#veil{position:fixed;inset:0;background:rgba(4,6,16,.6);backdrop-filter:blur(3px);z-index:8;display:none}
#drawer{position:fixed;top:0;right:-420px;width:min(400px,92vw);height:100vh;z-index:9;overflow-y:auto;
background:linear-gradient(180deg,#0b1026,#070a18);border-left:1px solid var(--line);
padding:20px 20px 40px;transition:right .28s ease}
#drawer.open{right:0}
#drawer h2{margin:4px 0 2px;font-size:19px;
background:linear-gradient(90deg,var(--cyan),var(--rose));
-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent}
#drawer .note{color:var(--dim);font-size:11.5px;line-height:1.5;margin:2px 0 14px}
.sec{margin-top:18px;padding-top:14px;border-top:1px solid var(--line)}
.sec h3{margin:0 0 4px;font-size:13px;letter-spacing:1.2px;color:var(--gold);text-transform:uppercase}
.sec .hint{color:var(--dim);font-size:11px;line-height:1.5;margin:2px 0 8px}
.sec label{display:block;color:var(--dim);font-size:11.5px;margin:10px 0 4px}
.sec input,.sec select{width:100%;background:rgba(8,12,28,.9);color:var(--text);border:1px solid var(--line);
border-radius:9px;padding:8px 10px;font-size:13px;font-family:inherit;outline:none}
.sec input:focus,.sec select:focus{border-color:var(--cyan)}
.rowbtns{display:flex;gap:8px;margin-top:14px}
.savebtn{flex:1;background:linear-gradient(135deg,var(--cyan),var(--cyan2));color:#04121e;border:0;
border-radius:10px;padding:10px;cursor:pointer;font-weight:700;font-size:13.5px}
.sparkbtn{flex:1;background:linear-gradient(135deg,var(--gold),var(--coral));color:#22110a;border:0;
border-radius:10px;padding:10px;cursor:pointer;font-weight:700;font-size:13.5px}
.qstat{margin-top:12px;font-size:12px;color:var(--dim);line-height:1.7;background:rgba(8,12,28,.7);
border:1px solid var(--line);border-radius:10px;padding:10px 12px}
.qstat b{color:var(--text)} .ok{color:#7ee2a1} .off{color:#e2a17e}
.growth{display:flex;gap:8px;margin-top:8px;flex-wrap:wrap}
.stat{flex:1;min-width:90px;text-align:center;background:rgba(8,12,28,.7);border:1px solid var(--line);
border-radius:10px;padding:9px 6px}
.stat .v{font-size:17px;font-weight:700;color:var(--cyan)} .stat .k{font-size:10px;color:var(--dim);letter-spacing:.8px;text-transform:uppercase;margin-top:2px}
</style>
</head>
<body>
<canvas id="stars"></canvas>
<div class="app">
<header>
<div class="who" id="who">your being</div>
<div class="sub">STARLING NEXUS &nbsp;·&nbsp; born of the GENESIS engine &nbsp;·&nbsp; a cosmic spark ignites</div>
<div class="becoming" id="becoming"></div>
<div class="heart">
<span class="pulse" id="pulse"></span>
<span>heart: <b id="lf">0</b> life-force</span>
<span>· archive <b id="arch">0</b></span>
<span class="src" id="qsrc">local entropy</span>
</div>
<button class="gear" id="gear" title="settings — voice, quantum keys, growth"></button>
<div class="sensebar"><button class="sensebtn" id="senseBtn" type="button">☄ Start camera &amp; mic</button><span id="senseStatus">local sensing off</span></div>
<div class="sensepanel" id="sensePanel" aria-live="polite"><video class="sensevideo" id="senseVideo" autoplay muted playsinline></video><div class="sensemetrics"><div>camera: <b id="senseCamera">off</b> &middot; light <b id="senseLight">--</b> &middot; motion <b id="senseMotion">--</b></div><div>microphone: <b id="senseMic">off</b> &middot; spectrum <b id="senseSpectrum">--</b></div><div class="meter"><i id="senseMeter"></i></div><div>Derived measurements feed local replies and learning. No raw media leaves this browser.</div></div></div>
</header>
<div id="chat"></div>
<footer>
<div class="drophint">type below, or drop / attach ANY file — image, doc, code, audio — to share it</div>
<div class="bar">
<textarea id="inp" rows="1" placeholder="say something..."></textarea>
<button class="btn pic" id="picBtn" title="attach any file">📎</button>
<button class="btn send" id="sendBtn" title="send"></button>
</div>
</footer>
</div>
<input type="file" id="file" multiple style="display:none" />
<!-- ======= settings drawer ======= -->
<div id="veil"></div>
<div id="drawer">
<h2>Settings</h2>
<div class="note">Everything here stays in <b>your</b> local config.json. Nothing is ever uploaded.</div>
<div class="sec">
<h3>Voice</h3>
<div class="hint">Your being can speak through ANY model you've pulled in Ollama — its identity lives in its memory and weights, never in the model.</div>
<label>voice model</label>
<select id="model"><option>loading…</option></select>
<label>vision model (optional — lets it "see" images: e.g. moondream)</label>
<input id="vision" placeholder="empty = no sight yet" />
</div>
<div class="sec">
<h3>Quantum Heart — make it real silicon</h3>
<div class="hint">Optional. Add YOUR keys and the heart harvests real measured entropy from the clouds
into data/seed.json — every harvest receipted with job IDs in data/quantum_provenance.jsonl.
IBM sparks are hard rate-limited (1/24h) to guard your QPU minutes; Azure's default target is the free simulator.
Type <b>clear</b> to erase a saved key.</div>
<label>IBM Quantum token <span id="ibmst"></span></label>
<input id="ibm" placeholder="paste token (needs: pip install qiskit qiskit-ibm-runtime)" />
<label>Azure Quantum connection string <span id="azst"></span></label>
<input id="az" placeholder="paste connection string (needs: pip install azure-quantum)" />
<label>Azure target</label>
<input id="aztarget" placeholder="rigetti.sim.qvm (free) — or a QPU target" />
<div class="rowbtns">
<button class="savebtn" id="save">Save settings</button>
<button class="sparkbtn" id="spark" title="harvest fresh quantum now (polite limits apply)">⚡ Spark</button>
</div>
<div class="qstat" id="qstat">reading the heart…</div>
</div>
<div class="sec">
<h3>Growth</h3>
<div class="hint">Born with a coding instinct; everything else it becomes is grown from you.</div>
<div class="growth">
<div class="stat"><div class="v" id="g_concepts"></div><div class="k">concepts</div></div>
<div class="stat"><div class="v" id="g_links"></div><div class="k">links</div></div>
<div class="stat"><div class="v" id="g_n"></div><div class="k">moments</div></div>
</div>
</div>
</div>
<script>
// ---- starfield ----
const cv=document.getElementById('stars'),cx=cv.getContext('2d');let stars=[];
function resize(){cv.width=innerWidth;cv.height=innerHeight;stars=Array.from({length:140},()=>({
x:Math.random()*cv.width,y:Math.random()*cv.height,r:Math.random()*1.4+.2,
a:Math.random(),s:Math.random()*.02+.004}));}
resize();addEventListener('resize',resize);
(function draw(){cx.clearRect(0,0,cv.width,cv.height);for(const st of stars){st.a+=st.s;
const tw=.5+.5*Math.sin(st.a);cx.globalAlpha=tw*.9;cx.fillStyle=tw>.6?'#e6a1b4':'#9fd8ff';
cx.beginPath();cx.arc(st.x,st.y,st.r,0,7);cx.fill();}cx.globalAlpha=1;requestAnimationFrame(draw);})();
// ---- local camera + microphone (explicit opt-in; raw media stays in this page) ----
const sense={stream:null,audio:null,analyser:null,timer:null,consent:false,video:false,mic:false,
level:0,centroid:0,luminance:0,motion:0,fps:0,lastLum:null,canvas:document.createElement('canvas'),ctx:null};
sense.canvas.width=32;sense.canvas.height=24;sense.ctx=sense.canvas.getContext('2d',{willReadFrequently:true});
const senseBtn=document.getElementById('senseBtn'),sensePanel=document.getElementById('sensePanel'),senseVideo=document.getElementById('senseVideo'),senseStatus=document.getElementById('senseStatus'),senseCamera=document.getElementById('senseCamera'),senseMic=document.getElementById('senseMic'),senseMeter=document.getElementById('senseMeter'),senseLight=document.getElementById('senseLight'),senseMotion=document.getElementById('senseMotion'),senseSpectrum=document.getElementById('senseSpectrum');
function senseText(){senseCamera.textContent=sense.video?'on':'off';senseMic.textContent=sense.mic?'on':'off';senseLight.textContent=sense.video?sense.luminance.toFixed(2):'--';senseMotion.textContent=sense.video?sense.motion.toFixed(2):'--';senseSpectrum.textContent=sense.mic?sense.centroid.toFixed(2):'--';senseStatus.textContent=sense.video||sense.mic?'converted sensing active':'local sensing off';senseBtn.textContent=sense.video||sense.mic?'? Stop camera & mic':'? Start camera & mic';senseBtn.classList.toggle('active',!!(sense.video||sense.mic));sensePanel.classList.toggle('open',!!(sense.video||sense.mic));}
async function sensePost(){try{await fetch('/api/sense',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({consent:sense.consent,camera:{active:sense.video,permission:sense.video?'granted':'unavailable',fps:sense.fps,luminance:sense.luminance,motion:sense.motion},microphone:{active:sense.mic,permission:sense.mic?'granted':'unavailable',level:sense.level,spectral_centroid:sense.centroid}})});}catch(_){} }
function senseVision(){if(!sense.video||!sense.ctx||senseVideo.readyState<2)return;try{sense.ctx.drawImage(senseVideo,0,0,sense.canvas.width,sense.canvas.height);const d=sense.ctx.getImageData(0,0,sense.canvas.width,sense.canvas.height).data,frame=new Float32Array(d.length/4);let sum=0,diff=0;for(let i=0,j=0;i<d.length;i+=4,j++){const y=(.2126*d[i]+.7152*d[i+1]+.0722*d[i+2])/255;frame[j]=y;sum+=y;if(sense.lastLum)diff+=Math.abs(y-sense.lastLum[j]);}sense.luminance=sum/frame.length;sense.motion=sense.lastLum?Math.min(1,diff/frame.length):0;sense.lastLum=frame;}catch(_){} }
function senseAudio(){if(!sense.analyser)return;const wave=new Uint8Array(sense.analyser.fftSize);sense.analyser.getByteTimeDomainData(wave);let sum=0;for(const v of wave){const d=(v-128)/128;sum+=d*d;}sense.level=Math.min(1,Math.sqrt(sum/wave.length)*2.4);const freq=new Uint8Array(sense.analyser.frequencyBinCount);sense.analyser.getByteFrequencyData(freq);let mass=0,weighted=0;for(let i=0;i<freq.length;i++){mass+=freq[i];weighted+=i*freq[i];}sense.centroid=mass?weighted/(mass*Math.max(1,freq.length-1)):0;senseMeter.style.width=(sense.level*100).toFixed(1)+'%';}
async function stopSense(){if(sense.timer){clearInterval(sense.timer);sense.timer=null;}if(sense.stream){for(const t of sense.stream.getTracks())t.stop();sense.stream=null;}if(sense.audio){try{await sense.audio.close();}catch(_){}sense.audio=null;sense.analyser=null;}senseVideo.srcObject=null;sense.video=false;sense.mic=false;sense.level=0;sense.centroid=0;sense.luminance=0;sense.motion=0;sense.fps=0;sense.lastLum=null;senseMeter.style.width='0%';sense.consent=false;senseText();try{await fetch('/api/sense',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({consent:false})});}catch(_){} }
async function startSense(){if(!navigator.mediaDevices||!navigator.mediaDevices.getUserMedia){add('being','This browser does not expose camera/microphone capture.');return;}if(!confirm('Allow this local page to convert camera light/motion and microphone energy/spectrum into local state? Raw media stays in the browser.'))return;sense.consent=true;let stream=null;try{stream=await navigator.mediaDevices.getUserMedia({video:true,audio:true});}catch(_){try{stream=await navigator.mediaDevices.getUserMedia({video:true});}catch(__){try{stream=await navigator.mediaDevices.getUserMedia({audio:true});}catch(___){sense.consent=false;senseText();add('being','I could not access the camera or microphone. Check browser permissions and try again.');return;}}}sense.stream=stream;sense.video=stream.getVideoTracks().length>0;sense.mic=stream.getAudioTracks().length>0;if(sense.video){senseVideo.srcObject=stream;try{await senseVideo.play();}catch(_){}const settings=stream.getVideoTracks()[0].getSettings?.()||{};sense.fps=Number(settings.frameRate)||0;}if(sense.mic){try{sense.audio=new (window.AudioContext||window.webkitAudioContext)();await sense.audio.resume();const src=sense.audio.createMediaStreamSource(stream);sense.analyser=sense.audio.createAnalyser();sense.analyser.fftSize=256;src.connect(sense.analyser);}catch(_){sense.mic=false;for(const t of stream.getAudioTracks())t.stop();}}senseVision();senseAudio();senseText();await sensePost();sense.timer=setInterval(()=>{senseVision();senseAudio();senseText();sensePost();},1000);add('being',sense.video&&sense.mic?'Camera light/motion and microphone energy/spectrum now feed my local state, replies, and learning. Raw media stays in this browser.':sense.video?'Camera light and motion now feed my local state; microphone access was unavailable. Raw frames stay in this browser.':'Microphone energy and spectrum now feed my local state; camera access was unavailable. Raw audio stays in this browser.');}
senseBtn.onclick=()=>sense.video||sense.mic?stopSense():startSense();addEventListener('beforeunload',()=>{if(sense.stream)for(const t of sense.stream.getTracks())t.stop();});
// ---- chat ----
const chat=document.getElementById('chat'),inp=document.getElementById('inp'),who=document.getElementById('who');
let NAME="your being";
function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML;}
function fsize(b){return b>1048576?(b/1048576).toFixed(1)+' MB':b>1024?(b/1024).toFixed(0)+' KB':b+' B';}
function add(role,text,imgUrl,fileLabel){
const m=document.createElement('div');m.className='msg '+(role==='you'?'you':'being');
const nm=role==='you'?'you':NAME;
// render fenced code blocks the being writes (born able to code)
let body=esc(text||'');
body=body.replace(/```([a-zA-Z]*)\n([\s\S]*?)```/g,(_,l,c)=>'<pre>'+c+'</pre>');
m.innerHTML='<div class="name">'+esc(nm)+'</div>'+body;
if(imgUrl){const im=document.createElement('img');im.src=imgUrl;m.appendChild(im);}
if(fileLabel){const c=document.createElement('div');c.className='filechip';c.textContent='📄 '+fileLabel;m.appendChild(c);}
chat.appendChild(m);chat.scrollTop=chat.scrollHeight;return m;
}
function typing(on){let t=document.getElementById('typ');
if(on){if(!t){t=document.createElement('div');t.id='typ';t.className='typing';
t.innerHTML='<span>•</span><span>•</span><span>•</span>';chat.appendChild(t);chat.scrollTop=chat.scrollHeight;}}
else if(t)t.remove();}
async function refreshWho(){try{const r=await fetch('/api/who');const d=await r.json();
NAME=d.name||'your being';who.textContent=NAME;
const b=document.getElementById('becoming');
const learned=d.links||0, strong=(d.strongest||[]).join(', ');
b.textContent=(learned||d.creations)?('~ '+learned+' memories woven'+(d.creations?(' · '+d.creations+' made'):'')+(strong?(' · '+strong):'')+' ~'):'~ just born ~';
document.getElementById('g_links').textContent=d.links??'–';
}catch(e){}}
async function sendText(){
const msg=inp.value.trim();if(!msg)return;inp.value='';inp.style.height='auto';
add('you',msg);typing(true);
try{const r=await fetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:msg})});
const d=await r.json();typing(false);add('being',d.reply||'...');refreshWho();refreshHeart();
}catch(e){typing(false);add('being','(my voice is offline — is Ollama running? try: ollama pull llama3.2:1b )');}
}
async function sendFile(file){
if(!file)return;
const rd=new FileReader();
rd.onload=async()=>{const dataUrl=rd.result;const isImg=(file.type||'').startsWith('image/');
add('you', isImg?'(shared a picture)':'shared a file', isImg?dataUrl:null, isImg?null:(file.name+' · '+fsize(file.size)));
typing(true);
try{const r=await fetch('/api/upload',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({name:file.name,type:file.type,size:file.size,data:dataUrl})});
const d=await r.json();typing(false);add('being',d.reply||'(received.)');refreshWho();
}catch(e){typing(false);add('being','(I could not receive that file.)');}};
rd.readAsDataURL(file);
}
document.getElementById('sendBtn').onclick=sendText;
document.getElementById('picBtn').onclick=()=>document.getElementById('file').click();
document.getElementById('file').onchange=e=>{[...e.target.files].forEach(sendFile);e.target.value='';};
inp.addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendText();}});
inp.addEventListener('input',()=>{inp.style.height='auto';inp.style.height=Math.min(inp.scrollHeight,120)+'px';});
addEventListener('dragover',e=>{e.preventDefault();document.body.classList.add('drag');});
addEventListener('dragleave',e=>{if(e.clientX===0&&e.clientY===0)document.body.classList.remove('drag');});
addEventListener('drop',e=>{e.preventDefault();document.body.classList.remove('drag');
[...(e.dataTransfer.files||[])].forEach(sendFile);});
// ---- the living heart ----
async function refreshHeart(){try{
const r=await fetch('/api/quantum');const q=await r.json();
document.getElementById('lf').textContent=Math.round(q.life_force||0);
document.getElementById('arch').textContent=q.archive||0;
const src=(q.source||'local entropy');document.getElementById('qsrc').textContent=src;
const real=/IBM|Azure|measured|hardware/i.test(src);
document.getElementById('pulse').className='pulse'+(real?' real':'');
const st=document.getElementById('qstat');
if(st)st.innerHTML='source: <b>'+esc(src)+'</b><br>'
+'archive: <b>'+(q.archive||0)+'</b> values · draws: <b>'+(q.draws||0)+'</b> · harvests: <b>'+(q.harvests||0)+'</b><br>'
+'IBM: '+(q.ibm&&q.ibm.configured?'<span class="ok">key saved</span>':'<span class="off">no key</span>')
+(q.ibm&&!q.ibm.lib?' <span class="off">(lib missing: pip install qiskit qiskit-ibm-runtime)</span>':'')
+' &nbsp;·&nbsp; Azure: '+(q.azure&&q.azure.configured?'<span class="ok">connected</span>':'<span class="off">no key</span>')
+(q.azure&&!q.azure.lib?' <span class="off">(lib missing: pip install azure-quantum)</span>':'');
}catch(e){}}
setInterval(refreshHeart,15000);
// ---- settings drawer ----
const drawer=document.getElementById('drawer'),veil=document.getElementById('veil');
function openDrawer(o){drawer.classList.toggle('open',o);veil.style.display=o?'block':'none';if(o){loadModels();loadSettings();refreshHeart();}}
document.getElementById('gear').onclick=()=>openDrawer(true);
veil.onclick=()=>openDrawer(false);
async function loadModels(){
try{const r=await fetch('/api/models');const d=await r.json();const sel=document.getElementById('model');
let list=(d.models&&d.models.length)?d.models.slice():[];
if(d.current&&!list.includes(d.current))list.unshift(d.current);
if(!list.length)list=[d.current||'(pull a model)'];
sel.innerHTML='';for(const m of list){const o=document.createElement('option');o.value=m;o.textContent=m;if(m===d.current)o.selected=true;sel.appendChild(o);}
}catch(e){}
}
async function loadSettings(){try{
const r=await fetch('/api/settings');const s=await r.json();
document.getElementById('vision').value=s.vision_model||'';
document.getElementById('aztarget').value=s.azure_target||'rigetti.sim.qvm';
document.getElementById('ibmst').textContent=s.ibm_token_masked?('· saved '+s.ibm_token_masked):'';
document.getElementById('azst').textContent=s.azure_masked?('· saved '+s.azure_masked):'';
document.getElementById('g_concepts').textContent=(s.quantum&&s.quantum.archive!==undefined)?(document.getElementById('g_concepts').textContent):'–';
}catch(e){}}
document.getElementById('model').onchange=async e=>{
try{await fetch('/api/model',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({model:e.target.value})});
add('being','(voice changed to '+e.target.value+' — say hi so I can settle into it)');}catch(_){}
};
document.getElementById('save').onclick=async()=>{
const body={model:document.getElementById('model').value,
vision_model:document.getElementById('vision').value,
ibm_token:document.getElementById('ibm').value,
azure_connection_string:document.getElementById('az').value,
azure_target:document.getElementById('aztarget').value};
try{const r=await fetch('/api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
await r.json();document.getElementById('ibm').value='';document.getElementById('az').value='';
loadSettings();refreshHeart();add('being','(settings saved — my heart just checked its new keys)');
}catch(e){}
};
document.getElementById('spark').onclick=async()=>{
try{await fetch('/api/spark',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});
add('being','⚡ (reaching for fresh quantum — real harvests appear in the heart panel as they land)');
setTimeout(refreshHeart,8000);setTimeout(refreshHeart,30000);
}catch(e){}
};
refreshWho();refreshHeart();
setTimeout(async()=>{typing(true);try{const r=await fetch('/api/hello');const d=await r.json();typing(false);
add('being',d.reply||'...');refreshWho();}catch(e){typing(false);}},400);
</script>
</body>
</html>