robopark 3.3.7 → 3.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scheduler/static/voice-join.html +124 -10
- package/ui/standalone/public/voice-join.html +124 -10
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
<!doctype html>
|
|
2
2
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
3
|
-
<meta name="theme-color" content="#080704"><meta name="robopark-voice-join-build" content="production-motion-
|
|
3
|
+
<meta name="theme-color" content="#080704"><meta name="robopark-voice-join-build" content="production-motion-kiosk-20260729-1">
|
|
4
4
|
<title>RoboPark Character Conversation</title>
|
|
5
5
|
<style>
|
|
6
6
|
.deployment{display:grid;gap:8px;margin:14px 0}.deployment-row{display:grid;grid-template-columns:minmax(150px,1fr) 2fr auto;gap:9px;align-items:center;padding:.7rem;border:1px solid var(--line);border-radius:12px;background:rgba(255,255,255,.025)}.deployment-row b{font:.62rem var(--mono);color:var(--pale)}.deployment-row span{font:.54rem var(--mono);color:var(--muted)}.deployment-row .ready{color:var(--ok)}.deployment-actions{display:flex;flex-wrap:wrap;gap:5px}.deployment-actions button{border:1px solid var(--line);border-radius:8px;background:rgba(244,207,114,.09);color:var(--gold);padding:.38rem .5rem;font:.5rem var(--mono);cursor:pointer}
|
|
7
7
|
.audio-controls,.call-controls{position:relative;width:min(440px,94%);margin-top:12px;border:1px solid var(--line);border-radius:14px;background:rgba(0,0,0,.3);text-align:left}.audio-controls summary,.call-controls summary{padding:.65rem .8rem;cursor:pointer;color:var(--gold);font:600 .58rem var(--mono);letter-spacing:.07em;text-transform:uppercase}.audio-controls summary::marker,.call-controls summary::marker{color:var(--gold)}.audio-panel,.call-panel{display:grid;gap:.75rem;padding:0 .85rem .85rem}.audio-row{display:grid;grid-template-columns:105px 1fr 45px;gap:.55rem;align-items:center}.audio-row label,.audio-value,.call-row label{font:600 .54rem var(--mono);color:var(--muted)}.audio-value{text-align:right;color:var(--pale)}.audio-row input[type=range]{width:100%;accent-color:var(--gold)}.audio-note,.call-note{margin:0;color:#7e755f;font:.5rem/1.45 var(--mono)}.call-row{display:grid;gap:.3rem}.call-row select{width:100%;min-width:0;border:1px solid var(--line);border-radius:9px;background:#090805;color:var(--ink);padding:.52rem;font:600 .55rem var(--mono)}.call-actions{display:grid;grid-template-columns:1fr 1fr;gap:7px}.call-actions button{border:1px solid var(--line);border-radius:9px;background:rgba(244,207,114,.1);color:var(--gold);padding:.52rem;font:600 .52rem var(--mono);cursor:pointer}.call-actions button.primary{background:linear-gradient(135deg,#f5d77f,#b87b1d);color:#171005}.call-status.ok{color:var(--ok)}.call-status.bad{color:var(--bad)}.mode-badge{display:inline-flex;align-items:center;gap:.35rem;padding:.28rem .48rem;border:1px solid var(--line);border-radius:999px;color:var(--muted);font:600 .5rem var(--mono)}.mode-badge.production{color:var(--ok);border-color:rgba(100,223,160,.4)}
|
|
8
8
|
:root{--bg:#080704;--panel:rgba(20,17,11,.88);--line:rgba(247,211,116,.22);--gold:#f4cf72;--pale:#fff0bd;--ink:#f7f1df;--muted:#a99e84;--ok:#64dfa0;--warn:#f0bd55;--bad:#ff776c;--mono:"JetBrains Mono","Cascadia Code",monospace;--display:Georgia,"Times New Roman",serif}*{box-sizing:border-box}html,body{margin:0;min-height:100%;background:radial-gradient(circle at 50% 8%,#332510 0,#100d08 35%,#050403 78%);color:var(--ink);font-family:Verdana,Geneva,sans-serif}body:before{content:"";position:fixed;inset:0;pointer-events:none;background:linear-gradient(115deg,transparent 0 48%,rgba(244,207,114,.025) 49% 50%,transparent 51%),repeating-linear-gradient(0deg,transparent 0 34px,rgba(255,255,255,.012) 35px)}button,input{font:inherit}.shell{width:min(1120px,100%);min-height:100vh;margin:auto;padding:clamp(14px,3vw,34px);display:grid;grid-template-rows:auto 1fr auto;gap:18px}.top{display:flex;align-items:center;justify-content:space-between;gap:16px}.brand{font:700 .65rem var(--mono);letter-spacing:.18em;text-transform:uppercase;color:var(--gold)}.ops-toggle{border:1px solid var(--line);border-radius:999px;background:rgba(0,0,0,.35);color:var(--muted);padding:.55rem .8rem;cursor:pointer}.ops-toggle.on{color:var(--pale);border-color:var(--gold)}.stage{position:relative;min-height:620px;display:grid;grid-template-columns:minmax(0,1.3fr) minmax(280px,.7fr);overflow:hidden;border:1px solid var(--line);border-radius:28px;background:linear-gradient(145deg,rgba(34,27,14,.82),rgba(8,7,5,.92));box-shadow:0 28px 90px rgba(0,0,0,.45)}.hero{position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:34px;text-align:center;overflow:hidden}.hero:before{content:"";position:absolute;width:500px;height:500px;border:1px solid rgba(244,207,114,.1);border-radius:50%;box-shadow:0 0 0 55px rgba(244,207,114,.018),0 0 0 120px rgba(244,207,114,.012)}.avatar-wrap{position:relative;width:min(330px,72vw);aspect-ratio:1;display:grid;place-items:center;border:1px solid rgba(244,207,114,.3);border-radius:50%;background:radial-gradient(circle,rgba(244,207,114,.16),rgba(8,7,5,.62) 58%,#050403 74%);box-shadow:0 0 90px rgba(214,164,55,.16)}.avatar-wrap.live{animation:breathe 2.2s ease-in-out infinite}.avatar-wrap.speaking{box-shadow:0 0 110px rgba(244,207,114,.42);border-color:var(--pale)}@keyframes breathe{50%{transform:scale(1.018)}}.avatar{position:relative;z-index:2;width:78%;height:78%;object-fit:contain;filter:drop-shadow(0 20px 28px rgba(0,0,0,.58))}.fallback{position:absolute;font:700 4rem var(--display);color:var(--gold)}h1{position:relative;margin:22px 0 5px;font:700 clamp(2rem,5vw,3.5rem) var(--display);color:var(--pale)}.subtitle{position:relative;color:var(--muted);font-size:.8rem}.state{position:relative;margin-top:14px;font:600 .68rem var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--gold)}.join{position:relative;margin-top:22px;min-width:210px;border:1px solid var(--gold);border-radius:999px;padding:.9rem 1.25rem;background:linear-gradient(135deg,#f5d77f,#b87b1d);color:#171005;font-weight:800;cursor:pointer;box-shadow:0 12px 30px rgba(184,123,29,.24)}.join:disabled{opacity:.48;cursor:not-allowed}.end{position:relative;margin-top:10px;border:0;background:transparent;color:var(--muted);cursor:pointer}.side{position:relative;border-left:1px solid var(--line);padding:25px;display:flex;flex-direction:column;gap:14px;background:rgba(0,0,0,.2)}.vision{position:relative;aspect-ratio:16/10;border:1px solid var(--line);border-radius:17px;overflow:hidden;background:#050403}.vision img,.vision video{width:100%;height:100%;object-fit:contain;background:#050403}.vision .label{position:absolute;left:9px;top:9px;padding:.32rem .45rem;border-radius:7px;background:rgba(0,0,0,.7);font:600 .5rem var(--mono);color:var(--gold)}.transcript{flex:1;min-height:220px;max-height:360px;overflow:auto;padding:.65rem;border:1px solid var(--line);border-radius:15px;background:rgba(0,0,0,.22)}.turn{margin:.45rem 0;padding:.55rem .65rem;border-radius:11px;font-size:.75rem;line-height:1.45}.turn.user{margin-left:13%;background:rgba(244,207,114,.12);border:1px solid rgba(244,207,114,.18)}.turn.assistant{margin-right:13%;background:rgba(255,255,255,.045)}.compose{display:flex;gap:8px}.compose input{min-width:0;flex:1;border:1px solid var(--line);border-radius:11px;background:#090805;color:var(--ink);padding:.7rem}.compose button{border:1px solid var(--line);border-radius:11px;background:rgba(244,207,114,.12);color:var(--gold);padding:.7rem;cursor:pointer}.ops{display:none;position:absolute;inset:12px;z-index:8;padding:16px;border:1px solid rgba(244,207,114,.35);border-radius:18px;background:rgba(5,4,3,.94);backdrop-filter:blur(16px);overflow:auto}.ops.open{display:block}.ops-head{display:flex;align-items:center;justify-content:space-between}.ops h2{margin:0;font:700 1.2rem var(--display);color:var(--pale)}.close{border:1px solid var(--line);border-radius:9px;background:transparent;color:var(--muted);padding:.4rem .55rem;cursor:pointer}.facts{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px;margin:14px 0}.fact{padding:.7rem;border:1px solid var(--line);border-radius:12px;background:rgba(255,255,255,.025)}.fact span{display:block;color:var(--muted);font:500 .49rem var(--mono);text-transform:uppercase;letter-spacing:.11em}.fact b{display:block;margin-top:.3rem;color:var(--ink);font:600 .64rem var(--mono);word-break:break-all}.pipeline{display:grid;grid-template-columns:repeat(5,1fr);gap:7px;margin:15px 0}.step{padding:.65rem .3rem;border:1px solid var(--line);border-radius:11px;text-align:center;color:var(--muted);font:600 .52rem var(--mono);text-transform:uppercase}.step.done{color:var(--ok);border-color:rgba(100,223,160,.4)}.step.live{color:#171005;border-color:var(--gold);background:var(--gold);box-shadow:0 0 24px rgba(244,207,114,.25)}.step.bad{color:var(--bad);border-color:rgba(255,119,108,.5)}.events{display:grid;gap:7px}.event{display:grid;grid-template-columns:95px 70px 1fr;gap:8px;padding:.6rem;border-bottom:1px solid var(--line);font:500 .55rem var(--mono)}.event .ok{color:var(--ok)}.event .error,.event .failed{color:var(--bad)}.foot{text-align:center;color:#716956;font:500 .52rem var(--mono);letter-spacing:.08em}.error-box{display:none;margin-top:12px;color:var(--bad);font:.65rem var(--mono)}@media(max-width:780px){.shell{padding:10px}.stage{grid-template-columns:1fr;min-height:0}.hero{min-height:600px;padding:24px 15px}.side{border-left:0;border-top:1px solid var(--line)}.pipeline{grid-template-columns:1fr 1fr}.top{padding:4px}.event{grid-template-columns:75px 55px 1fr}}
|
|
9
|
-
</style></head><body><main class="shell"><header class="top"><div class="brand">RoboPark / managed character channel</div><div><button class="ops-toggle on" id="always-on" aria-pressed="true" hidden>Always on: ON</button> <button class="ops-toggle" id="ops-toggle">Operations</button></div></header><section class="stage"><div class="hero"><div class="avatar-wrap" id="avatar-wrap"><div class="fallback" id="fallback">RP</div><img class="avatar" id="avatar" alt=""></div><h1 id="character-name">Loading character...</h1><div class="subtitle" id="subtitle">Secure ElevenLabs conversation managed by RoboPark</div><div class="state" id="state">validating link</div><button class="join" id="join" disabled>Join conversation</button><button class="end" id="end" hidden>End conversation</button><div class="error-box" id="error"></div></div><aside class="side"><div class="vision" id="vision" hidden><img id="robot-video" alt="Live robot camera"><span class="label">LIVE ROBOT VISION</span></div><div class="transcript" id="transcript"><div class="turn assistant">The transcript will appear here when the conversation begins.</div></div><form class="compose" id="compose"><input id="message" placeholder="Type a message" disabled><button id="send" disabled>Send</button></form></aside><section class="ops" id="ops"><div class="ops-head"><h2>Live management overlay</h2><button class="close" id="ops-close">Close</button></div><div class="facts"><div class="fact"><span>Session</span><b id="f-session">not started</b></div><div class="fact"><span>Character</span><b id="f-character">--</b></div><div class="fact"><span>Agent</span><b id="f-agent">--</b></div><div class="fact"><span>Branch</span><b id="f-branch">default</b></div><div class="fact"><span>Robot</span><b id="f-robot">operator only</b></div><div class="fact"><span>Share link</span><b id="f-share">--</b></div></div><div class="pipeline" id="pipeline"><div class="step live" data-stage="configured">configured</div><div class="step" data-stage="permission">permission</div><div class="step" data-stage="signed_session">signed</div><div class="step" data-stage="connected">connected</div><div class="step" data-stage="conversation">conversation</div></div><div class="events" id="events"><div class="event"><span>waiting</span><span>info</span><span>Session telemetry will stream here.</span></div></div></section></section><footer class="foot">No API credentials are stored in this link. Each visit creates an isolated signed session.</footer></main><script src="/vendor/elevenlabs-client.iife.js"></script><script>
|
|
9
|
+
</style></head><body><main class="shell"><header class="top"><div class="brand">RoboPark / managed character channel</div><div><button class="ops-toggle on" id="always-on" aria-pressed="true" hidden>Always on: ON</button><button class="ops-toggle" id="kiosk" aria-pressed="false">Motion calls: OFF</button> <button class="ops-toggle" id="ops-toggle">Operations</button></div></header><section class="stage"><div class="hero"><div class="avatar-wrap" id="avatar-wrap"><div class="fallback" id="fallback">RP</div><img class="avatar" id="avatar" alt=""></div><h1 id="character-name">Loading character...</h1><div class="subtitle" id="subtitle">Secure ElevenLabs conversation managed by RoboPark</div><div class="state" id="state">validating link</div><button class="join" id="join" disabled>Join conversation</button><button class="end" id="end" hidden>End conversation</button><div class="error-box" id="error"></div></div><aside class="side"><div class="vision" id="vision" hidden><img id="robot-video" alt="Live robot camera"><span class="label">LIVE ROBOT VISION</span></div><div class="transcript" id="transcript"><div class="turn assistant">The transcript will appear here when the conversation begins.</div></div><form class="compose" id="compose"><input id="message" placeholder="Type a message" disabled><button id="send" disabled>Send</button></form></aside><section class="ops" id="ops"><div class="ops-head"><h2>Live management overlay</h2><button class="close" id="ops-close">Close</button></div><div class="facts"><div class="fact"><span>Session</span><b id="f-session">not started</b></div><div class="fact"><span>Character</span><b id="f-character">--</b></div><div class="fact"><span>Agent</span><b id="f-agent">--</b></div><div class="fact"><span>Branch</span><b id="f-branch">default</b></div><div class="fact"><span>Robot</span><b id="f-robot">operator only</b></div><div class="fact"><span>Share link</span><b id="f-share">--</b></div></div><div class="pipeline" id="pipeline"><div class="step live" data-stage="configured">configured</div><div class="step" data-stage="permission">permission</div><div class="step" data-stage="signed_session">signed</div><div class="step" data-stage="connected">connected</div><div class="step" data-stage="conversation">conversation</div></div><div class="events" id="events"><div class="event"><span>waiting</span><span>info</span><span>Session telemetry will stream here.</span></div></div></section></section><footer class="foot">No API credentials are stored in this link. Each visit creates an isolated signed session.</footer></main><script src="/vendor/elevenlabs-client.iife.js"></script><script>
|
|
10
10
|
(function(){
|
|
11
11
|
var API_BASE='';
|
|
12
12
|
var deployment=document.createElement('div');deployment.id='deployment';deployment.className='deployment';deployment.innerHTML='<div class="deployment-row"><b>Character hardware</b><span>Loading registered voice, vision, and motor nodes...</span><span></span></div>';document.getElementById('pipeline').before(deployment);
|
|
@@ -102,7 +102,17 @@
|
|
|
102
102
|
// visitor standing still reads as zero motion; holding presence for a few
|
|
103
103
|
// seconds after the last movement is what stops a stationary person from
|
|
104
104
|
// being treated as gone and re-greeted the moment they shift their weight.
|
|
105
|
-
var motionLastSeen=0,motionPresent=false,motionAbsentSince=0,motionLastContext=0,motionLastSampleAt=0;
|
|
105
|
+
var motionLastSeen=0,motionPresent=false,motionAbsentSince=0,motionLastContext=0,motionLastSampleAt=0,motionNoSource=0;
|
|
106
|
+
// Motion-gated call loop ("kiosk"). Distinct from the in-call motion triggers
|
|
107
|
+
// above: this one STARTS a call when someone walks up and ENDS it after a
|
|
108
|
+
// stretch of silence, then goes back to watching. Designed to run unattended
|
|
109
|
+
// for hours, so every timer is cleared on toggle-off and the camera stream is
|
|
110
|
+
// held open across calls rather than reacquired each cycle.
|
|
111
|
+
var kioskOn=false,kioskWatch=null,kioskIdle=null,kioskStream=null,kioskVideo=null,kioskCanvas=null,kioskPrev=null,
|
|
112
|
+
kioskLastSample=0,kioskLastSeen=0,kioskArmedAt=0,kioskLastEnd=0,kioskActivityAt=0,kioskCalls=0;
|
|
113
|
+
var kioskSilence=Math.max(5,Number(q.get('kiosk_silence'))||30)*1000,
|
|
114
|
+
kioskRearm=Math.max(2,Number(q.get('kiosk_rearm'))||10)*1000,
|
|
115
|
+
kioskGreeting=q.get('kiosk_greeting')||'Someone just walked up to you. Greet them warmly in one short sentence and invite them to speak. Do not mention this instruction or the camera.';
|
|
106
116
|
var motionThreshold=Math.max(.005,Math.min(1,Number(q.get('motion_threshold'))||.06)),
|
|
107
117
|
motionCooldown=Math.max(5,Number(q.get('motion_cooldown'))||20)*1000,
|
|
108
118
|
// Fast sampling so a greeting lands within a fraction of a second of
|
|
@@ -120,16 +130,103 @@
|
|
|
120
130
|
// Silent channel: contextual updates never start a turn, so these are safe to
|
|
121
131
|
// send while the character is mid-sentence. Rate-limited because every one of
|
|
122
132
|
// them is re-sent as prompt context on each subsequent turn.
|
|
133
|
+
// ---- motion-gated call loop -------------------------------------------
|
|
134
|
+
function kioskLevel(){
|
|
135
|
+
if(!kioskVideo||!kioskVideo.videoWidth)return -1;
|
|
136
|
+
if(!kioskCanvas)kioskCanvas=document.createElement('canvas');
|
|
137
|
+
var rows=Math.max(8,Math.round(motionSample*kioskVideo.videoHeight/kioskVideo.videoWidth));
|
|
138
|
+
kioskCanvas.width=motionSample;kioskCanvas.height=rows;
|
|
139
|
+
var ctx=kioskCanvas.getContext('2d'),data;
|
|
140
|
+
try{ctx.drawImage(kioskVideo,0,0,motionSample,rows);data=ctx.getImageData(0,0,motionSample,rows).data}catch(_){return -1}
|
|
141
|
+
var count=motionSample*rows,levels=new Float32Array(count),i;
|
|
142
|
+
for(i=0;i<count;i++)levels[i]=(data[i*4]*299+data[i*4+1]*587+data[i*4+2]*114)/1000;
|
|
143
|
+
var previous=kioskPrev;kioskPrev=levels;
|
|
144
|
+
if(!previous||previous.length!==count)return -1;
|
|
145
|
+
var total=0;for(i=0;i<count;i++)total+=Math.abs(levels[i]-previous[i]);
|
|
146
|
+
return total/count/255;
|
|
147
|
+
}
|
|
148
|
+
function kioskWatchTick(){
|
|
149
|
+
// Only watches between calls. While one is live the in-call motion triggers
|
|
150
|
+
// and the silence watchdog are in charge.
|
|
151
|
+
if(!kioskOn||desired||conversation||connecting||ending)return;
|
|
152
|
+
var now=Date.now();
|
|
153
|
+
if(now<kioskArmedAt)return;
|
|
154
|
+
var level=kioskLevel();
|
|
155
|
+
if(level<0)return;
|
|
156
|
+
var elapsed=kioskLastSample?Math.max(1,now-kioskLastSample):motionInterval;
|
|
157
|
+
kioskLastSample=now;
|
|
158
|
+
if(level*1000/elapsed<motionThreshold)return;
|
|
159
|
+
// Require the frame to have been quiet before triggering, so the visitor who
|
|
160
|
+
// just hung up standing in front of the lens does not immediately redial.
|
|
161
|
+
if(kioskLastSeen&&now-kioskLastSeen<kioskRearm){kioskLastSeen=now;return}
|
|
162
|
+
kioskLastSeen=now;
|
|
163
|
+
kioskCalls++;
|
|
164
|
+
event('kiosk_call','ok','Motion started a call',{call_number:kioskCalls});
|
|
165
|
+
text('state','someone approached - connecting');
|
|
166
|
+
start();
|
|
167
|
+
}
|
|
168
|
+
function kioskIdleTick(){
|
|
169
|
+
if(!kioskOn||!conversation||ending)return;
|
|
170
|
+
if(!kioskActivityAt)return;
|
|
171
|
+
var quiet=Date.now()-kioskActivityAt;
|
|
172
|
+
if(quiet<kioskSilence)return;
|
|
173
|
+
event('kiosk_call','ok','Ending call after silence',{silent_ms:Math.round(quiet)});
|
|
174
|
+
// Rearm only after the visitor has actually left the frame; kioskArmedAt
|
|
175
|
+
// gates the watcher and kioskLastSeen forces a fresh quiet period.
|
|
176
|
+
kioskArmedAt=Date.now()+kioskRearm;
|
|
177
|
+
end('kiosk_idle');
|
|
178
|
+
}
|
|
179
|
+
function kioskNote(){kioskActivityAt=Date.now()}
|
|
180
|
+
function kioskStart(){
|
|
181
|
+
if(kioskWatch)return Promise.resolve();
|
|
182
|
+
return navigator.mediaDevices.getUserMedia({video:cameraDeviceId?{deviceId:{exact:cameraDeviceId}}:true})
|
|
183
|
+
.then(function(stream){
|
|
184
|
+
kioskStream=stream;
|
|
185
|
+
if(!kioskVideo){kioskVideo=document.createElement('video');kioskVideo.autoplay=true;kioskVideo.muted=true;kioskVideo.playsInline=true;kioskVideo.style.display='none';document.body.appendChild(kioskVideo)}
|
|
186
|
+
kioskVideo.srcObject=stream;
|
|
187
|
+
var play=kioskVideo.play();if(play&&play.catch)play.catch(function(){});
|
|
188
|
+
kioskPrev=null;kioskLastSample=0;kioskLastSeen=0;kioskArmedAt=Date.now()+3000;
|
|
189
|
+
kioskWatch=setInterval(kioskWatchTick,motionInterval);
|
|
190
|
+
kioskIdle=setInterval(kioskIdleTick,1000);
|
|
191
|
+
event('kiosk_armed','ok','Motion-gated calling armed',{silence_ms:kioskSilence,rearm_ms:kioskRearm,threshold:motionThreshold});
|
|
192
|
+
text('state','watching for visitors');
|
|
193
|
+
})
|
|
194
|
+
.catch(function(error){
|
|
195
|
+
kioskOn=false;syncKioskButton();
|
|
196
|
+
event('kiosk_armed','failed',error&&error.message||String(error),{});
|
|
197
|
+
text('state','camera unavailable for motion calling');
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
function kioskStop(){
|
|
201
|
+
if(kioskWatch){clearInterval(kioskWatch);kioskWatch=null}
|
|
202
|
+
if(kioskIdle){clearInterval(kioskIdle);kioskIdle=null}
|
|
203
|
+
if(kioskStream){kioskStream.getTracks().forEach(function(track){track.stop()});kioskStream=null}
|
|
204
|
+
if(kioskVideo)kioskVideo.srcObject=null;
|
|
205
|
+
kioskPrev=null;
|
|
206
|
+
}
|
|
207
|
+
function syncKioskButton(){
|
|
208
|
+
var button=el('kiosk');if(!button)return;
|
|
209
|
+
button.classList.toggle('on',kioskOn);
|
|
210
|
+
button.setAttribute('aria-pressed',String(kioskOn));
|
|
211
|
+
button.textContent='Motion calls: '+(kioskOn?'ON':'OFF');
|
|
212
|
+
}
|
|
123
213
|
function motionContext(now,text){
|
|
124
214
|
if(now-motionLastContext<motionContextGap)return;
|
|
125
215
|
motionLastContext=now;
|
|
126
216
|
try{conversation.sendContextualUpdate(text)}catch(_){}
|
|
127
217
|
}
|
|
128
218
|
function motionTick(myGeneration){
|
|
129
|
-
|
|
130
|
-
if(ending||!conversation||myGeneration!==generation||!alwaysOn)return;
|
|
219
|
+
if(ending||!conversation||myGeneration!==generation)return;
|
|
131
220
|
var level=motionLevel(),now=Date.now();
|
|
132
|
-
if(
|
|
221
|
+
if(now<motionArmedAt)return;
|
|
222
|
+
if(level<0){
|
|
223
|
+
// No usable frame: the camera element is hidden, has no dimensions yet,
|
|
224
|
+
// or the canvas is tainted. Silence here is indistinguishable from "no
|
|
225
|
+
// one is there", so say it once instead of never triggering in secret.
|
|
226
|
+
if(!motionNoSource){motionNoSource=now;event('motion_source','warn','No readable camera frame; motion triggers are inactive',{})}
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if(motionNoSource){event('motion_source','ok','Camera frame available; motion triggers active',{blind_ms:now-motionNoSource});motionNoSource=0}
|
|
133
230
|
// Normalise to change-per-second. The raw difference between two frames
|
|
134
231
|
// scales with the gap between them, so without this a faster sample rate
|
|
135
232
|
// would silently make the configured threshold four times harder to hit.
|
|
@@ -165,7 +262,17 @@
|
|
|
165
262
|
try{conversation.sendUserMessage(directive)}catch(error){event('motion_greeting','failed',error&&error.message||String(error),{});return}
|
|
166
263
|
event('motion_greeting','ok','Motion greeting triggered',{absence_ms:absence===Infinity?null:Math.round(absence),threshold:motionThreshold})
|
|
167
264
|
}
|
|
168
|
-
function startMotion(myGeneration){
|
|
265
|
+
function startMotion(myGeneration){
|
|
266
|
+
stopMotion();motionPrev=null;motionLastFire=0;motionSent={};motionLastSeen=0;motionPresent=false;motionAbsentSince=0;motionLastContext=0;motionLastSampleAt=0;motionNoSource=0;
|
|
267
|
+
motionDirective=q.get('motion_prompt')||'';
|
|
268
|
+
if(q.get('motion')==='off'){event('motion_armed','skipped','Motion triggers disabled by ?motion=off',{});return}
|
|
269
|
+
motionArmedAt=Date.now()+4000;
|
|
270
|
+
motionTimer=setInterval(function(){motionTick(myGeneration)},motionInterval);
|
|
271
|
+
// Announce the configuration once. Motion used to be gated on both
|
|
272
|
+
// production mode and the always-on toggle, and failing either produced
|
|
273
|
+
// total silence with nothing to indicate why.
|
|
274
|
+
event('motion_armed','ok','Motion presence tracking armed',{interval_ms:motionInterval,threshold:motionThreshold,hold_ms:motionHold,absence_ms:motionAbsence,cooldown_ms:motionCooldown})
|
|
275
|
+
}
|
|
169
276
|
function stopMotion(){if(motionTimer){clearInterval(motionTimer);motionTimer=null}motionPrev=null}
|
|
170
277
|
function showRobotVideo(){return startLocalVideo().catch(function(){})}
|
|
171
278
|
function applyCallMode(){mode=el('call-mode').value==='production'?'production':'test';el('robot-row').style.display=mode==='production'?'grid':'none';el('mode-badge').textContent=mode==='production'?'PRODUCTION ? ROBOT VIDEO + TOOLS':'TEST ? BROWSER ONLY';el('mode-badge').className='mode-badge '+(mode==='production'?'production':'');if(mode==='test')robot='';else robot=el('call-robot').value==='none'?'':el('call-robot').value;text('f-mode',mode);text('f-robot',mode==='production'?(robot||'missing robot'):'not requested');showRobotVideo()}
|
|
@@ -200,7 +307,9 @@
|
|
|
200
307
|
function stopTimers(){if(retryTimer){clearTimeout(retryTimer);retryTimer=null}if(keepalive){clearInterval(keepalive);keepalive=null}if(poller){clearInterval(poller);poller=null}stopVision();stopMotion()}
|
|
201
308
|
function scheduleReconnect(){if(!desired||!alwaysOn||ending)return;connecting=false;conversation=null;conversationId=null;var attempt=++retryAttempt,delay=Math.min(30000,Math.round(750*Math.pow(2,Math.min(attempt-1,6))+Math.random()*500));text('state','reconnecting in '+Math.ceil(delay/1000)+'s');stage('connected');el('avatar-wrap').classList.remove('speaking');if(retryTimer)clearTimeout(retryTimer);retryTimer=setTimeout(function(){retryTimer=null;connectAttempt('reconnect')},delay)}
|
|
202
309
|
function recover(myGeneration,details){if(myGeneration!==generation||ending||!desired)return;var captured=plainDetails(details);captured.last_provider_error=lastProviderError;event('provider_error','warning','ElevenLabs disconnected; automatic recovery is running',captured);closeManaged('provider_disconnected',captured);stopTimers();session=null;eventToken=null;conversation=null;conversationId=null;connecting=false;el('avatar-wrap').classList.remove('live','speaking');el('join').hidden=true;el('end').hidden=false;el('message').disabled=true;el('send').disabled=true;scheduleReconnect()}
|
|
203
|
-
function end(reason){if(ending)return;ending=true;desired=false;generation++;stopTimers();var active=conversation,details={manual:true,reason:reason||'visitor'};if(active)try{active.endSession()}catch(_){};if(cameraRoom){cameraRoom.disconnect();cameraRoom=null}if(localVideoStream){localVideoStream.getTracks().forEach(function(track){track.stop()});localVideoStream=null}var robotStream=el('robot-stream');if(robotStream){robotStream.removeAttribute('src');robotStream.hidden=true}el('robot-video').hidden=false;closeManaged(reason||'visitor',details);session=null;eventToken=null;conversation=null;conversationId=null;connecting=false;retryAttempt=0;stage('conversation','done');text('state','manually disconnected - supervisor stopped');el('avatar-wrap').classList.remove('live','speaking');el('join').hidden=false;el('join').disabled=false;el('join').textContent='Start conversation';el('end').hidden=true;el('message').disabled=true;el('send').disabled=true;ending=false
|
|
310
|
+
function end(reason){if(ending)return;ending=true;desired=false;generation++;stopTimers();var active=conversation,details={manual:true,reason:reason||'visitor'};if(active)try{active.endSession()}catch(_){};if(cameraRoom){cameraRoom.disconnect();cameraRoom=null}if(localVideoStream){localVideoStream.getTracks().forEach(function(track){track.stop()});localVideoStream=null}var robotStream=el('robot-stream');if(robotStream){robotStream.removeAttribute('src');robotStream.hidden=true}el('robot-video').hidden=false;closeManaged(reason||'visitor',details);session=null;eventToken=null;conversation=null;conversationId=null;connecting=false;retryAttempt=0;stage('conversation','done');text('state','manually disconnected - supervisor stopped');el('avatar-wrap').classList.remove('live','speaking');el('join').hidden=false;el('join').disabled=false;el('join').textContent='Start conversation';el('end').hidden=true;el('message').disabled=true;el('send').disabled=true;ending=false;kioskActivityAt=0;// Back to watching. kioskArmedAt already holds off the re-trigger so a
|
|
311
|
+
// visitor still standing there cannot immediately redial.
|
|
312
|
+
if(kioskOn&&!kioskArmedAt)kioskArmedAt=Date.now()+kioskRearm}
|
|
204
313
|
// This call lives on an ElevenLabs WebSocket and never joins a LiveKit room,
|
|
205
314
|
// so the control center cannot observe it. Check in on a timer, and close the
|
|
206
315
|
// session on unload, or the dashboard cannot tell a live call from a closed
|
|
@@ -211,7 +320,7 @@
|
|
|
211
320
|
try{navigator.sendBeacon(API_BASE+'/api/sessions/'+encodeURIComponent(session)+'/end?reason=page_closed',new Blob([JSON.stringify({details:{page_closed:true},source:'public_join'})],{type:'application/json'}))}catch(_){}
|
|
212
321
|
});
|
|
213
322
|
function callPayload(){return {character_preset_id:character,voice_stack_id:stack||null,client_name:'Public character link',engine:'elevenlabs',agent_id:agent,branch_id:branch||null,robot_id:robot||null,join_link_id:share||null}}
|
|
214
|
-
function connectAttempt(kind){if(!desired||ending||connecting||conversation)return;connecting=true;var myGeneration=++generation;text('state',kind==='reconnect'?'obtaining fresh voice session':'connecting');stage('signed_session');api('/api/sessions/voice-call',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(callPayload())}).then(function(call){if(myGeneration!==generation||!desired)throw new Error('connection superseded');session=call.session_id;eventToken=call.event_token;text('f-session',session);text('f-agent',call.agent_id);text('f-branch',call.branch_id||'default');text('f-robot',call.robot_id||'operator only');if(call.robot_video_ref&&robot){el('vision').hidden=false;el('robot-stream').src=API_BASE+'/api/robots/'+encodeURIComponent(call.robot_video_ref)+'/video_feed';el('robot-stream').hidden=false;el('robot-video').hidden=true}stage('connected');event('signed_session','ok','Fresh signed ElevenLabs session created',{join_link_id:share,robot_id:robot,reconnect_attempt:retryAttempt});return ensureElevenLabsClient().then(function(SDK){return SDK.Conversation.startSession({signedUrl:call.signed_url,connectionType:'websocket',useWakeLock:true,onConnect:function(info){if(myGeneration!==generation)return;conversationId=info.conversationId;lastProviderError=null;retryAttempt=0;text('state','listening');el('error').style.display='none';el('avatar-wrap').classList.add('live');stage('conversation');management('/joined',{conversation_id:conversationId,connection_type:'websocket',engine:'elevenlabs',robot_id:robot,join_link_id:share,source:'public_join'});event('provider_connected','ok','ElevenLabs conversation connected',{conversation_id:conversationId,supervised:true});startVision(myGeneration);motionLastTurn=Date.now();if(
|
|
323
|
+
function connectAttempt(kind){if(!desired||ending||connecting||conversation)return;connecting=true;var myGeneration=++generation;text('state',kind==='reconnect'?'obtaining fresh voice session':'connecting');stage('signed_session');api('/api/sessions/voice-call',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(callPayload())}).then(function(call){if(myGeneration!==generation||!desired)throw new Error('connection superseded');session=call.session_id;eventToken=call.event_token;text('f-session',session);text('f-agent',call.agent_id);text('f-branch',call.branch_id||'default');text('f-robot',call.robot_id||'operator only');if(call.robot_video_ref&&robot){el('vision').hidden=false;el('robot-stream').src=API_BASE+'/api/robots/'+encodeURIComponent(call.robot_video_ref)+'/video_feed';el('robot-stream').hidden=false;el('robot-video').hidden=true}stage('connected');event('signed_session','ok','Fresh signed ElevenLabs session created',{join_link_id:share,robot_id:robot,reconnect_attempt:retryAttempt});return ensureElevenLabsClient().then(function(SDK){return SDK.Conversation.startSession({signedUrl:call.signed_url,connectionType:'websocket',useWakeLock:true,onConnect:function(info){if(myGeneration!==generation)return;conversationId=info.conversationId;lastProviderError=null;retryAttempt=0;text('state','listening');el('error').style.display='none';el('avatar-wrap').classList.add('live');stage('conversation');management('/joined',{conversation_id:conversationId,connection_type:'websocket',engine:'elevenlabs',robot_id:robot,join_link_id:share,source:'public_join'});event('provider_connected','ok','ElevenLabs conversation connected',{conversation_id:conversationId,supervised:true});startVision(myGeneration);motionLastTurn=Date.now();startMotion(myGeneration);kioskNote();if(kioskOn){try{motionSent[kioskGreeting]=1;conversation.sendUserMessage(kioskGreeting)}catch(_){}}},onDisconnect:function(info){recover(myGeneration,info||{reason:'provider_disconnected'})},onError:function(message,context){if(myGeneration!==generation)return;lastProviderError={message:String(message||'Provider error'),context:plainDetails(context)};event('provider_error','failed',lastProviderError.message,lastProviderError);text('state','provider error')},onStatusChange:function(info){if(myGeneration===generation)event('provider_status',info&&info.status==='disconnected'?'warning':'ok',info&&info.status||'unknown',{})},onMessage:function(info){if(myGeneration!==generation)return;var message=String(info&&info.message||'').trim(),role=info&&info.role==='user'?'user':'assistant';if(role==='user'&&motionSent[message])return;motionLastTurn=Date.now();kioskNote();append(role,message);if(message){history.push({role:role,text:message});history=history.slice(-20)}saveTurn(role,message)},onModeChange:function(info){if(myGeneration!==generation)return;var speaking=info.mode==='speaking';motionMode=speaking?'speaking':'listening';if(speaking)kioskNote();el('avatar-wrap').classList.toggle('speaking',speaking);text('state',speaking?'speaking':'listening');event('mode','ok',info.mode,{})},onInterruption:function(info){event('interruption','ok','Agent interrupted',info||{})},onMCPToolCall:function(info){event('mcp_tool','running','Tool call',info||{})}})})}).then(function(active){connecting=false;if(myGeneration!==generation||!desired){try{active.endSession()}catch(_){}return}conversation=active;startKeepalive();el('join').hidden=true;el('end').hidden=false;el('message').disabled=false;el('send').disabled=false;pollDetail()}).catch(function(error){connecting=false;if(myGeneration!==generation||!desired)return;var details={message:error&&error.message||String(error),last_provider_error:lastProviderError};event('provider_error','failed','Voice connection attempt failed',details);closeManaged('connection_failed',details);session=null;eventToken=null;conversation=null;conversationId=null;
|
|
215
324
|
// A 4xx means the link itself is wrong and retrying cannot fix it. Anything
|
|
216
325
|
// else (5xx, signing timeout, network drop) is transient, and dead-ending
|
|
217
326
|
// here is what left a failed call with no reconnect until a manual re-join.
|
|
@@ -223,13 +332,18 @@
|
|
|
223
332
|
if(!permanent&&alwaysOn&&desired&&!ending){el('join').hidden=true;el('end').hidden=false;scheduleReconnect();return}
|
|
224
333
|
fail(details.message)})}
|
|
225
334
|
function start(){if(desired||conversation||connecting)return;if(mode==='production'&&!robot){fail('Select a registered production robot first.');return}alwaysOn=true;el('always-on').classList.add('on');el('always-on').setAttribute('aria-pressed','true');el('always-on').textContent='Always on: ON';if(mode==='test')ensureMicAudioContext();desired=true;retryAttempt=0;el('join').disabled=true;text('state','requesting microphone permission');stage('permission');var audio=inputDeviceId?{deviceId:{exact:inputDeviceId}}:true;navigator.mediaDevices.getUserMedia({audio:audio}).then(function(stream){stream.getTracks().forEach(function(track){track.stop()});return startLocalVideo()}).then(function(){connectAttempt('initial')}).catch(function(error){if(desired){text('state','microphone unavailable; retrying');scheduleReconnect()}else{fail(error&&error.message||String(error))}})}
|
|
335
|
+
el('kiosk').onclick=function(){
|
|
336
|
+
kioskOn=!kioskOn;syncKioskButton();
|
|
337
|
+
if(kioskOn){kioskStart()}
|
|
338
|
+
else{kioskStop();text('state',conversation?'connected':'motion calling off')}
|
|
339
|
+
};
|
|
226
340
|
el('always-on').onclick=function(){alwaysOn=!alwaysOn;this.classList.toggle('on',alwaysOn);this.setAttribute('aria-pressed',String(alwaysOn));this.textContent='Always on: '+(alwaysOn?'ON':'OFF');if(!alwaysOn&&retryTimer){clearTimeout(retryTimer);retryTimer=null;text('state','disconnected - automatic recovery paused')}else if(alwaysOn&&desired&&!conversation&&!connecting){scheduleReconnect()}};
|
|
227
341
|
function pollDetail(){applySpeakerVolume()}
|
|
228
342
|
function deploymentRequest(path,options){var token=q.get('token')||'',suffix=token?(path.indexOf('?')>=0?'&':'?')+'token='+encodeURIComponent(token):'';return fetch(API_BASE+path+suffix,Object.assign({cache:'no-store'},options||{})).then(function(response){if(!response.ok)throw new Error('request '+response.status);return response.json()})}
|
|
229
343
|
function renderDeployment(data){var token=q.get('token')||'',rows=[];[['voice_vision_boxes','Windows voice + vision',['vision','conversation']],['motor_nodes','Pi motor server',['motor']]].forEach(function(group){var devices=data[group[0]]||[];if(!devices.length){rows.push('<div class="deployment-row"><b>'+group[1]+'</b><span>not registered</span><span></span></div>');return}devices.forEach(function(device){var seen=Date.parse(device.last_heartbeat||''),live=isFinite(seen)&&Date.now()-seen<90000,buttons=token?group[2].map(function(service){return '<button data-device="'+device.id+'" data-service="'+service+'" data-action="start">Start '+service+'</button><button data-device="'+device.id+'" data-service="'+service+'" data-action="restart">Restart</button><button data-device="'+device.id+'" data-service="'+service+'" data-action="stop">Stop</button>'}).join(''):'management token required for controls';rows.push('<div class="deployment-row"><b>'+group[1]+'</b><span class="'+(live?'ready':'')+'">'+String(device.name||device.id)+' / '+(live?'online':'stale')+'</span><div class="deployment-actions">'+buttons+'</div></div>')})});deployment.innerHTML=rows.join('');deployment.querySelectorAll('button[data-action]').forEach(function(button){button.onclick=function(){var action=button.getAttribute('data-action'),service=button.getAttribute('data-service'),device=button.getAttribute('data-device');if((action==='stop'||action==='restart')&&!confirm(action+' '+service+'?'))return;button.disabled=true;deploymentRequest('/api/devices/'+encodeURIComponent(device)+'/services/'+encodeURIComponent(service)+'/'+action,{method:'POST',headers:{'content-type':'application/json'}}).then(function(){button.textContent='queued'}).catch(function(error){button.textContent='failed';fail(error.message)}).then(function(){setTimeout(function(){button.disabled=false},1800)})}})}
|
|
230
344
|
function loadDeployment(){deploymentRequest('/api/characters/'+encodeURIComponent(character)+'/deployment').then(renderDeployment).catch(function(){deployment.innerHTML='<div class="deployment-row"><b>Character hardware</b><span>deployment status unavailable</span><span></span></div>'})}
|
|
231
345
|
el('speaker-volume').value=String(speakerVolume);text('speaker-value',speakerVolume+'%');el('mic-sensitivity').value=String(micSensitivity);text('mic-value',micSensitivity+'%');el('speaker-volume').oninput=function(){speakerVolume=Math.max(0,Math.min(100,Number(this.value)||0));text('speaker-value',speakerVolume+'%');persistNumber('robopark.voice.speaker',speakerVolume);applySpeakerVolume()};el('mic-sensitivity').oninput=function(){setMicSensitivity(this.value);text('mic-value',micSensitivity+'%');persistNumber('robopark.voice.micSensitivity',micSensitivity)};
|
|
232
|
-
el('ops-toggle').onclick=function(){el('ops').classList.toggle('open');this.classList.toggle('on',el('ops').classList.contains('open'))};el('ops-close').onclick=function(){el('ops').classList.remove('open');el('ops-toggle').classList.remove('on')};el('join').onclick=start;el('end').onclick=function(){end('visitor')};el('compose').onsubmit=function(e){e.preventDefault();var message=el('message').value.trim();if(message&&conversation){conversation.sendUserMessage(message);el('message').value=''}};
|
|
346
|
+
el('ops-toggle').onclick=function(){el('ops').classList.toggle('open');this.classList.toggle('on',el('ops').classList.contains('open'))};el('ops-close').onclick=function(){el('ops').classList.remove('open');el('ops-toggle').classList.remove('on')};el('join').onclick=start;if(q.get('kiosk')==='1'||q.get('kiosk')==='on'){kioskOn=true;syncKioskButton();kioskStart()}el('end').onclick=function(){end('visitor')};el('compose').onsubmit=function(e){e.preventDefault();var message=el('message').value.trim();if(message&&conversation){conversation.sendUserMessage(message);el('message').value=''}};
|
|
233
347
|
text('f-mode',mode);text('f-character',character);text('f-agent',agent);text('f-branch',branch||'default');text('f-robot',mode==='production'?(robot||'missing robot'):'not requested');text('f-share',share||'untracked');
|
|
234
348
|
if(character)loadDeployment();
|
|
235
349
|
if(!character||!agent){fail('This join link is incomplete. Ask the operator for a new character link.');return}if(mode==='production'&&!robot){fail('This production link has no registered robot. Ask the operator for a corrected link.');return}api('/api/character-presets/'+encodeURIComponent(character)).then(function(preset){text('character-name',preset.name||preset.id);text('subtitle',(mode==='test'?'Browser-only test call · ':'Production on-site call · ')+(preset.description||'Secure ElevenLabs conversation managed by RoboPark'));text('f-character',preset.name||preset.id);text('fallback',String(preset.name||preset.id).slice(0,2).toUpperCase());if(preset.img){el('avatar').src=preset.img;el('avatar').onload=function(){el('fallback').style.display='none'}};text('state','ready to connect');el('join').disabled=false}).catch(function(){fail('Character configuration is unavailable.')});
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
<!doctype html>
|
|
2
2
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
3
|
-
<meta name="theme-color" content="#080704"><meta name="robopark-voice-join-build" content="production-motion-
|
|
3
|
+
<meta name="theme-color" content="#080704"><meta name="robopark-voice-join-build" content="production-motion-kiosk-20260729-1">
|
|
4
4
|
<title>RoboPark Character Conversation</title>
|
|
5
5
|
<style>
|
|
6
6
|
.deployment{display:grid;gap:8px;margin:14px 0}.deployment-row{display:grid;grid-template-columns:minmax(150px,1fr) 2fr auto;gap:9px;align-items:center;padding:.7rem;border:1px solid var(--line);border-radius:12px;background:rgba(255,255,255,.025)}.deployment-row b{font:.62rem var(--mono);color:var(--pale)}.deployment-row span{font:.54rem var(--mono);color:var(--muted)}.deployment-row .ready{color:var(--ok)}.deployment-actions{display:flex;flex-wrap:wrap;gap:5px}.deployment-actions button{border:1px solid var(--line);border-radius:8px;background:rgba(244,207,114,.09);color:var(--gold);padding:.38rem .5rem;font:.5rem var(--mono);cursor:pointer}
|
|
7
7
|
.audio-controls,.call-controls{position:relative;width:min(440px,94%);margin-top:12px;border:1px solid var(--line);border-radius:14px;background:rgba(0,0,0,.3);text-align:left}.audio-controls summary,.call-controls summary{padding:.65rem .8rem;cursor:pointer;color:var(--gold);font:600 .58rem var(--mono);letter-spacing:.07em;text-transform:uppercase}.audio-controls summary::marker,.call-controls summary::marker{color:var(--gold)}.audio-panel,.call-panel{display:grid;gap:.75rem;padding:0 .85rem .85rem}.audio-row{display:grid;grid-template-columns:105px 1fr 45px;gap:.55rem;align-items:center}.audio-row label,.audio-value,.call-row label{font:600 .54rem var(--mono);color:var(--muted)}.audio-value{text-align:right;color:var(--pale)}.audio-row input[type=range]{width:100%;accent-color:var(--gold)}.audio-note,.call-note{margin:0;color:#7e755f;font:.5rem/1.45 var(--mono)}.call-row{display:grid;gap:.3rem}.call-row select{width:100%;min-width:0;border:1px solid var(--line);border-radius:9px;background:#090805;color:var(--ink);padding:.52rem;font:600 .55rem var(--mono)}.call-actions{display:grid;grid-template-columns:1fr 1fr;gap:7px}.call-actions button{border:1px solid var(--line);border-radius:9px;background:rgba(244,207,114,.1);color:var(--gold);padding:.52rem;font:600 .52rem var(--mono);cursor:pointer}.call-actions button.primary{background:linear-gradient(135deg,#f5d77f,#b87b1d);color:#171005}.call-status.ok{color:var(--ok)}.call-status.bad{color:var(--bad)}.mode-badge{display:inline-flex;align-items:center;gap:.35rem;padding:.28rem .48rem;border:1px solid var(--line);border-radius:999px;color:var(--muted);font:600 .5rem var(--mono)}.mode-badge.production{color:var(--ok);border-color:rgba(100,223,160,.4)}
|
|
8
8
|
:root{--bg:#080704;--panel:rgba(20,17,11,.88);--line:rgba(247,211,116,.22);--gold:#f4cf72;--pale:#fff0bd;--ink:#f7f1df;--muted:#a99e84;--ok:#64dfa0;--warn:#f0bd55;--bad:#ff776c;--mono:"JetBrains Mono","Cascadia Code",monospace;--display:Georgia,"Times New Roman",serif}*{box-sizing:border-box}html,body{margin:0;min-height:100%;background:radial-gradient(circle at 50% 8%,#332510 0,#100d08 35%,#050403 78%);color:var(--ink);font-family:Verdana,Geneva,sans-serif}body:before{content:"";position:fixed;inset:0;pointer-events:none;background:linear-gradient(115deg,transparent 0 48%,rgba(244,207,114,.025) 49% 50%,transparent 51%),repeating-linear-gradient(0deg,transparent 0 34px,rgba(255,255,255,.012) 35px)}button,input{font:inherit}.shell{width:min(1120px,100%);min-height:100vh;margin:auto;padding:clamp(14px,3vw,34px);display:grid;grid-template-rows:auto 1fr auto;gap:18px}.top{display:flex;align-items:center;justify-content:space-between;gap:16px}.brand{font:700 .65rem var(--mono);letter-spacing:.18em;text-transform:uppercase;color:var(--gold)}.ops-toggle{border:1px solid var(--line);border-radius:999px;background:rgba(0,0,0,.35);color:var(--muted);padding:.55rem .8rem;cursor:pointer}.ops-toggle.on{color:var(--pale);border-color:var(--gold)}.stage{position:relative;min-height:620px;display:grid;grid-template-columns:minmax(0,1.3fr) minmax(280px,.7fr);overflow:hidden;border:1px solid var(--line);border-radius:28px;background:linear-gradient(145deg,rgba(34,27,14,.82),rgba(8,7,5,.92));box-shadow:0 28px 90px rgba(0,0,0,.45)}.hero{position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:34px;text-align:center;overflow:hidden}.hero:before{content:"";position:absolute;width:500px;height:500px;border:1px solid rgba(244,207,114,.1);border-radius:50%;box-shadow:0 0 0 55px rgba(244,207,114,.018),0 0 0 120px rgba(244,207,114,.012)}.avatar-wrap{position:relative;width:min(330px,72vw);aspect-ratio:1;display:grid;place-items:center;border:1px solid rgba(244,207,114,.3);border-radius:50%;background:radial-gradient(circle,rgba(244,207,114,.16),rgba(8,7,5,.62) 58%,#050403 74%);box-shadow:0 0 90px rgba(214,164,55,.16)}.avatar-wrap.live{animation:breathe 2.2s ease-in-out infinite}.avatar-wrap.speaking{box-shadow:0 0 110px rgba(244,207,114,.42);border-color:var(--pale)}@keyframes breathe{50%{transform:scale(1.018)}}.avatar{position:relative;z-index:2;width:78%;height:78%;object-fit:contain;filter:drop-shadow(0 20px 28px rgba(0,0,0,.58))}.fallback{position:absolute;font:700 4rem var(--display);color:var(--gold)}h1{position:relative;margin:22px 0 5px;font:700 clamp(2rem,5vw,3.5rem) var(--display);color:var(--pale)}.subtitle{position:relative;color:var(--muted);font-size:.8rem}.state{position:relative;margin-top:14px;font:600 .68rem var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--gold)}.join{position:relative;margin-top:22px;min-width:210px;border:1px solid var(--gold);border-radius:999px;padding:.9rem 1.25rem;background:linear-gradient(135deg,#f5d77f,#b87b1d);color:#171005;font-weight:800;cursor:pointer;box-shadow:0 12px 30px rgba(184,123,29,.24)}.join:disabled{opacity:.48;cursor:not-allowed}.end{position:relative;margin-top:10px;border:0;background:transparent;color:var(--muted);cursor:pointer}.side{position:relative;border-left:1px solid var(--line);padding:25px;display:flex;flex-direction:column;gap:14px;background:rgba(0,0,0,.2)}.vision{position:relative;aspect-ratio:16/10;border:1px solid var(--line);border-radius:17px;overflow:hidden;background:#050403}.vision img,.vision video{width:100%;height:100%;object-fit:contain;background:#050403}.vision .label{position:absolute;left:9px;top:9px;padding:.32rem .45rem;border-radius:7px;background:rgba(0,0,0,.7);font:600 .5rem var(--mono);color:var(--gold)}.transcript{flex:1;min-height:220px;max-height:360px;overflow:auto;padding:.65rem;border:1px solid var(--line);border-radius:15px;background:rgba(0,0,0,.22)}.turn{margin:.45rem 0;padding:.55rem .65rem;border-radius:11px;font-size:.75rem;line-height:1.45}.turn.user{margin-left:13%;background:rgba(244,207,114,.12);border:1px solid rgba(244,207,114,.18)}.turn.assistant{margin-right:13%;background:rgba(255,255,255,.045)}.compose{display:flex;gap:8px}.compose input{min-width:0;flex:1;border:1px solid var(--line);border-radius:11px;background:#090805;color:var(--ink);padding:.7rem}.compose button{border:1px solid var(--line);border-radius:11px;background:rgba(244,207,114,.12);color:var(--gold);padding:.7rem;cursor:pointer}.ops{display:none;position:absolute;inset:12px;z-index:8;padding:16px;border:1px solid rgba(244,207,114,.35);border-radius:18px;background:rgba(5,4,3,.94);backdrop-filter:blur(16px);overflow:auto}.ops.open{display:block}.ops-head{display:flex;align-items:center;justify-content:space-between}.ops h2{margin:0;font:700 1.2rem var(--display);color:var(--pale)}.close{border:1px solid var(--line);border-radius:9px;background:transparent;color:var(--muted);padding:.4rem .55rem;cursor:pointer}.facts{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px;margin:14px 0}.fact{padding:.7rem;border:1px solid var(--line);border-radius:12px;background:rgba(255,255,255,.025)}.fact span{display:block;color:var(--muted);font:500 .49rem var(--mono);text-transform:uppercase;letter-spacing:.11em}.fact b{display:block;margin-top:.3rem;color:var(--ink);font:600 .64rem var(--mono);word-break:break-all}.pipeline{display:grid;grid-template-columns:repeat(5,1fr);gap:7px;margin:15px 0}.step{padding:.65rem .3rem;border:1px solid var(--line);border-radius:11px;text-align:center;color:var(--muted);font:600 .52rem var(--mono);text-transform:uppercase}.step.done{color:var(--ok);border-color:rgba(100,223,160,.4)}.step.live{color:#171005;border-color:var(--gold);background:var(--gold);box-shadow:0 0 24px rgba(244,207,114,.25)}.step.bad{color:var(--bad);border-color:rgba(255,119,108,.5)}.events{display:grid;gap:7px}.event{display:grid;grid-template-columns:95px 70px 1fr;gap:8px;padding:.6rem;border-bottom:1px solid var(--line);font:500 .55rem var(--mono)}.event .ok{color:var(--ok)}.event .error,.event .failed{color:var(--bad)}.foot{text-align:center;color:#716956;font:500 .52rem var(--mono);letter-spacing:.08em}.error-box{display:none;margin-top:12px;color:var(--bad);font:.65rem var(--mono)}@media(max-width:780px){.shell{padding:10px}.stage{grid-template-columns:1fr;min-height:0}.hero{min-height:600px;padding:24px 15px}.side{border-left:0;border-top:1px solid var(--line)}.pipeline{grid-template-columns:1fr 1fr}.top{padding:4px}.event{grid-template-columns:75px 55px 1fr}}
|
|
9
|
-
</style></head><body><main class="shell"><header class="top"><div class="brand">RoboPark / managed character channel</div><div><button class="ops-toggle on" id="always-on" aria-pressed="true" hidden>Always on: ON</button> <button class="ops-toggle" id="ops-toggle">Operations</button></div></header><section class="stage"><div class="hero"><div class="avatar-wrap" id="avatar-wrap"><div class="fallback" id="fallback">RP</div><img class="avatar" id="avatar" alt=""></div><h1 id="character-name">Loading character...</h1><div class="subtitle" id="subtitle">Secure ElevenLabs conversation managed by RoboPark</div><div class="state" id="state">validating link</div><button class="join" id="join" disabled>Join conversation</button><button class="end" id="end" hidden>End conversation</button><div class="error-box" id="error"></div></div><aside class="side"><div class="vision" id="vision" hidden><img id="robot-video" alt="Live robot camera"><span class="label">LIVE ROBOT VISION</span></div><div class="transcript" id="transcript"><div class="turn assistant">The transcript will appear here when the conversation begins.</div></div><form class="compose" id="compose"><input id="message" placeholder="Type a message" disabled><button id="send" disabled>Send</button></form></aside><section class="ops" id="ops"><div class="ops-head"><h2>Live management overlay</h2><button class="close" id="ops-close">Close</button></div><div class="facts"><div class="fact"><span>Session</span><b id="f-session">not started</b></div><div class="fact"><span>Character</span><b id="f-character">--</b></div><div class="fact"><span>Agent</span><b id="f-agent">--</b></div><div class="fact"><span>Branch</span><b id="f-branch">default</b></div><div class="fact"><span>Robot</span><b id="f-robot">operator only</b></div><div class="fact"><span>Share link</span><b id="f-share">--</b></div></div><div class="pipeline" id="pipeline"><div class="step live" data-stage="configured">configured</div><div class="step" data-stage="permission">permission</div><div class="step" data-stage="signed_session">signed</div><div class="step" data-stage="connected">connected</div><div class="step" data-stage="conversation">conversation</div></div><div class="events" id="events"><div class="event"><span>waiting</span><span>info</span><span>Session telemetry will stream here.</span></div></div></section></section><footer class="foot">No API credentials are stored in this link. Each visit creates an isolated signed session.</footer></main><script src="/vendor/elevenlabs-client.iife.js"></script><script>
|
|
9
|
+
</style></head><body><main class="shell"><header class="top"><div class="brand">RoboPark / managed character channel</div><div><button class="ops-toggle on" id="always-on" aria-pressed="true" hidden>Always on: ON</button><button class="ops-toggle" id="kiosk" aria-pressed="false">Motion calls: OFF</button> <button class="ops-toggle" id="ops-toggle">Operations</button></div></header><section class="stage"><div class="hero"><div class="avatar-wrap" id="avatar-wrap"><div class="fallback" id="fallback">RP</div><img class="avatar" id="avatar" alt=""></div><h1 id="character-name">Loading character...</h1><div class="subtitle" id="subtitle">Secure ElevenLabs conversation managed by RoboPark</div><div class="state" id="state">validating link</div><button class="join" id="join" disabled>Join conversation</button><button class="end" id="end" hidden>End conversation</button><div class="error-box" id="error"></div></div><aside class="side"><div class="vision" id="vision" hidden><img id="robot-video" alt="Live robot camera"><span class="label">LIVE ROBOT VISION</span></div><div class="transcript" id="transcript"><div class="turn assistant">The transcript will appear here when the conversation begins.</div></div><form class="compose" id="compose"><input id="message" placeholder="Type a message" disabled><button id="send" disabled>Send</button></form></aside><section class="ops" id="ops"><div class="ops-head"><h2>Live management overlay</h2><button class="close" id="ops-close">Close</button></div><div class="facts"><div class="fact"><span>Session</span><b id="f-session">not started</b></div><div class="fact"><span>Character</span><b id="f-character">--</b></div><div class="fact"><span>Agent</span><b id="f-agent">--</b></div><div class="fact"><span>Branch</span><b id="f-branch">default</b></div><div class="fact"><span>Robot</span><b id="f-robot">operator only</b></div><div class="fact"><span>Share link</span><b id="f-share">--</b></div></div><div class="pipeline" id="pipeline"><div class="step live" data-stage="configured">configured</div><div class="step" data-stage="permission">permission</div><div class="step" data-stage="signed_session">signed</div><div class="step" data-stage="connected">connected</div><div class="step" data-stage="conversation">conversation</div></div><div class="events" id="events"><div class="event"><span>waiting</span><span>info</span><span>Session telemetry will stream here.</span></div></div></section></section><footer class="foot">No API credentials are stored in this link. Each visit creates an isolated signed session.</footer></main><script src="/vendor/elevenlabs-client.iife.js"></script><script>
|
|
10
10
|
(function(){
|
|
11
11
|
var deployment=document.createElement('div');deployment.id='deployment';deployment.className='deployment';deployment.innerHTML='<div class="deployment-row"><b>Character hardware</b><span>Loading registered voice, vision, and motor nodes...</span><span></span></div>';document.getElementById('pipeline').before(deployment);
|
|
12
12
|
var audioControls=document.createElement('details');audioControls.id='audio-controls';audioControls.className='audio-controls';audioControls.innerHTML='<summary>Audio levels</summary><div class="audio-panel"><div class="audio-row"><label for="speaker-volume">Speaker</label><input id="speaker-volume" type="range" min="0" max="100" step="1"><output class="audio-value" id="speaker-value"></output></div><div class="audio-row"><label for="mic-sensitivity">Microphone</label><input id="mic-sensitivity" type="range" min="0" max="100" step="1"><output class="audio-value" id="mic-value"></output></div><p class="audio-note">Changes apply immediately and stay on this browser.</p></div>';document.getElementById('error').after(audioControls);
|
|
@@ -101,7 +101,17 @@
|
|
|
101
101
|
// visitor standing still reads as zero motion; holding presence for a few
|
|
102
102
|
// seconds after the last movement is what stops a stationary person from
|
|
103
103
|
// being treated as gone and re-greeted the moment they shift their weight.
|
|
104
|
-
var motionLastSeen=0,motionPresent=false,motionAbsentSince=0,motionLastContext=0,motionLastSampleAt=0;
|
|
104
|
+
var motionLastSeen=0,motionPresent=false,motionAbsentSince=0,motionLastContext=0,motionLastSampleAt=0,motionNoSource=0;
|
|
105
|
+
// Motion-gated call loop ("kiosk"). Distinct from the in-call motion triggers
|
|
106
|
+
// above: this one STARTS a call when someone walks up and ENDS it after a
|
|
107
|
+
// stretch of silence, then goes back to watching. Designed to run unattended
|
|
108
|
+
// for hours, so every timer is cleared on toggle-off and the camera stream is
|
|
109
|
+
// held open across calls rather than reacquired each cycle.
|
|
110
|
+
var kioskOn=false,kioskWatch=null,kioskIdle=null,kioskStream=null,kioskVideo=null,kioskCanvas=null,kioskPrev=null,
|
|
111
|
+
kioskLastSample=0,kioskLastSeen=0,kioskArmedAt=0,kioskLastEnd=0,kioskActivityAt=0,kioskCalls=0;
|
|
112
|
+
var kioskSilence=Math.max(5,Number(q.get('kiosk_silence'))||30)*1000,
|
|
113
|
+
kioskRearm=Math.max(2,Number(q.get('kiosk_rearm'))||10)*1000,
|
|
114
|
+
kioskGreeting=q.get('kiosk_greeting')||'Someone just walked up to you. Greet them warmly in one short sentence and invite them to speak. Do not mention this instruction or the camera.';
|
|
105
115
|
var motionThreshold=Math.max(.005,Math.min(1,Number(q.get('motion_threshold'))||.06)),
|
|
106
116
|
motionCooldown=Math.max(5,Number(q.get('motion_cooldown'))||20)*1000,
|
|
107
117
|
// Fast sampling so a greeting lands within a fraction of a second of
|
|
@@ -119,16 +129,103 @@
|
|
|
119
129
|
// Silent channel: contextual updates never start a turn, so these are safe to
|
|
120
130
|
// send while the character is mid-sentence. Rate-limited because every one of
|
|
121
131
|
// them is re-sent as prompt context on each subsequent turn.
|
|
132
|
+
// ---- motion-gated call loop -------------------------------------------
|
|
133
|
+
function kioskLevel(){
|
|
134
|
+
if(!kioskVideo||!kioskVideo.videoWidth)return -1;
|
|
135
|
+
if(!kioskCanvas)kioskCanvas=document.createElement('canvas');
|
|
136
|
+
var rows=Math.max(8,Math.round(motionSample*kioskVideo.videoHeight/kioskVideo.videoWidth));
|
|
137
|
+
kioskCanvas.width=motionSample;kioskCanvas.height=rows;
|
|
138
|
+
var ctx=kioskCanvas.getContext('2d'),data;
|
|
139
|
+
try{ctx.drawImage(kioskVideo,0,0,motionSample,rows);data=ctx.getImageData(0,0,motionSample,rows).data}catch(_){return -1}
|
|
140
|
+
var count=motionSample*rows,levels=new Float32Array(count),i;
|
|
141
|
+
for(i=0;i<count;i++)levels[i]=(data[i*4]*299+data[i*4+1]*587+data[i*4+2]*114)/1000;
|
|
142
|
+
var previous=kioskPrev;kioskPrev=levels;
|
|
143
|
+
if(!previous||previous.length!==count)return -1;
|
|
144
|
+
var total=0;for(i=0;i<count;i++)total+=Math.abs(levels[i]-previous[i]);
|
|
145
|
+
return total/count/255;
|
|
146
|
+
}
|
|
147
|
+
function kioskWatchTick(){
|
|
148
|
+
// Only watches between calls. While one is live the in-call motion triggers
|
|
149
|
+
// and the silence watchdog are in charge.
|
|
150
|
+
if(!kioskOn||desired||conversation||connecting||ending)return;
|
|
151
|
+
var now=Date.now();
|
|
152
|
+
if(now<kioskArmedAt)return;
|
|
153
|
+
var level=kioskLevel();
|
|
154
|
+
if(level<0)return;
|
|
155
|
+
var elapsed=kioskLastSample?Math.max(1,now-kioskLastSample):motionInterval;
|
|
156
|
+
kioskLastSample=now;
|
|
157
|
+
if(level*1000/elapsed<motionThreshold)return;
|
|
158
|
+
// Require the frame to have been quiet before triggering, so the visitor who
|
|
159
|
+
// just hung up standing in front of the lens does not immediately redial.
|
|
160
|
+
if(kioskLastSeen&&now-kioskLastSeen<kioskRearm){kioskLastSeen=now;return}
|
|
161
|
+
kioskLastSeen=now;
|
|
162
|
+
kioskCalls++;
|
|
163
|
+
event('kiosk_call','ok','Motion started a call',{call_number:kioskCalls});
|
|
164
|
+
text('state','someone approached - connecting');
|
|
165
|
+
start();
|
|
166
|
+
}
|
|
167
|
+
function kioskIdleTick(){
|
|
168
|
+
if(!kioskOn||!conversation||ending)return;
|
|
169
|
+
if(!kioskActivityAt)return;
|
|
170
|
+
var quiet=Date.now()-kioskActivityAt;
|
|
171
|
+
if(quiet<kioskSilence)return;
|
|
172
|
+
event('kiosk_call','ok','Ending call after silence',{silent_ms:Math.round(quiet)});
|
|
173
|
+
// Rearm only after the visitor has actually left the frame; kioskArmedAt
|
|
174
|
+
// gates the watcher and kioskLastSeen forces a fresh quiet period.
|
|
175
|
+
kioskArmedAt=Date.now()+kioskRearm;
|
|
176
|
+
end('kiosk_idle');
|
|
177
|
+
}
|
|
178
|
+
function kioskNote(){kioskActivityAt=Date.now()}
|
|
179
|
+
function kioskStart(){
|
|
180
|
+
if(kioskWatch)return Promise.resolve();
|
|
181
|
+
return navigator.mediaDevices.getUserMedia({video:cameraDeviceId?{deviceId:{exact:cameraDeviceId}}:true})
|
|
182
|
+
.then(function(stream){
|
|
183
|
+
kioskStream=stream;
|
|
184
|
+
if(!kioskVideo){kioskVideo=document.createElement('video');kioskVideo.autoplay=true;kioskVideo.muted=true;kioskVideo.playsInline=true;kioskVideo.style.display='none';document.body.appendChild(kioskVideo)}
|
|
185
|
+
kioskVideo.srcObject=stream;
|
|
186
|
+
var play=kioskVideo.play();if(play&&play.catch)play.catch(function(){});
|
|
187
|
+
kioskPrev=null;kioskLastSample=0;kioskLastSeen=0;kioskArmedAt=Date.now()+3000;
|
|
188
|
+
kioskWatch=setInterval(kioskWatchTick,motionInterval);
|
|
189
|
+
kioskIdle=setInterval(kioskIdleTick,1000);
|
|
190
|
+
event('kiosk_armed','ok','Motion-gated calling armed',{silence_ms:kioskSilence,rearm_ms:kioskRearm,threshold:motionThreshold});
|
|
191
|
+
text('state','watching for visitors');
|
|
192
|
+
})
|
|
193
|
+
.catch(function(error){
|
|
194
|
+
kioskOn=false;syncKioskButton();
|
|
195
|
+
event('kiosk_armed','failed',error&&error.message||String(error),{});
|
|
196
|
+
text('state','camera unavailable for motion calling');
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
function kioskStop(){
|
|
200
|
+
if(kioskWatch){clearInterval(kioskWatch);kioskWatch=null}
|
|
201
|
+
if(kioskIdle){clearInterval(kioskIdle);kioskIdle=null}
|
|
202
|
+
if(kioskStream){kioskStream.getTracks().forEach(function(track){track.stop()});kioskStream=null}
|
|
203
|
+
if(kioskVideo)kioskVideo.srcObject=null;
|
|
204
|
+
kioskPrev=null;
|
|
205
|
+
}
|
|
206
|
+
function syncKioskButton(){
|
|
207
|
+
var button=el('kiosk');if(!button)return;
|
|
208
|
+
button.classList.toggle('on',kioskOn);
|
|
209
|
+
button.setAttribute('aria-pressed',String(kioskOn));
|
|
210
|
+
button.textContent='Motion calls: '+(kioskOn?'ON':'OFF');
|
|
211
|
+
}
|
|
122
212
|
function motionContext(now,text){
|
|
123
213
|
if(now-motionLastContext<motionContextGap)return;
|
|
124
214
|
motionLastContext=now;
|
|
125
215
|
try{conversation.sendContextualUpdate(text)}catch(_){}
|
|
126
216
|
}
|
|
127
217
|
function motionTick(myGeneration){
|
|
128
|
-
|
|
129
|
-
if(ending||!conversation||myGeneration!==generation||!alwaysOn)return;
|
|
218
|
+
if(ending||!conversation||myGeneration!==generation)return;
|
|
130
219
|
var level=motionLevel(),now=Date.now();
|
|
131
|
-
if(
|
|
220
|
+
if(now<motionArmedAt)return;
|
|
221
|
+
if(level<0){
|
|
222
|
+
// No usable frame: the camera element is hidden, has no dimensions yet,
|
|
223
|
+
// or the canvas is tainted. Silence here is indistinguishable from "no
|
|
224
|
+
// one is there", so say it once instead of never triggering in secret.
|
|
225
|
+
if(!motionNoSource){motionNoSource=now;event('motion_source','warn','No readable camera frame; motion triggers are inactive',{})}
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if(motionNoSource){event('motion_source','ok','Camera frame available; motion triggers active',{blind_ms:now-motionNoSource});motionNoSource=0}
|
|
132
229
|
// Normalise to change-per-second. The raw difference between two frames
|
|
133
230
|
// scales with the gap between them, so without this a faster sample rate
|
|
134
231
|
// would silently make the configured threshold four times harder to hit.
|
|
@@ -164,7 +261,17 @@
|
|
|
164
261
|
try{conversation.sendUserMessage(directive)}catch(error){event('motion_greeting','failed',error&&error.message||String(error),{});return}
|
|
165
262
|
event('motion_greeting','ok','Motion greeting triggered',{absence_ms:absence===Infinity?null:Math.round(absence),threshold:motionThreshold})
|
|
166
263
|
}
|
|
167
|
-
function startMotion(myGeneration){
|
|
264
|
+
function startMotion(myGeneration){
|
|
265
|
+
stopMotion();motionPrev=null;motionLastFire=0;motionSent={};motionLastSeen=0;motionPresent=false;motionAbsentSince=0;motionLastContext=0;motionLastSampleAt=0;motionNoSource=0;
|
|
266
|
+
motionDirective=q.get('motion_prompt')||'';
|
|
267
|
+
if(q.get('motion')==='off'){event('motion_armed','skipped','Motion triggers disabled by ?motion=off',{});return}
|
|
268
|
+
motionArmedAt=Date.now()+4000;
|
|
269
|
+
motionTimer=setInterval(function(){motionTick(myGeneration)},motionInterval);
|
|
270
|
+
// Announce the configuration once. Motion used to be gated on both
|
|
271
|
+
// production mode and the always-on toggle, and failing either produced
|
|
272
|
+
// total silence with nothing to indicate why.
|
|
273
|
+
event('motion_armed','ok','Motion presence tracking armed',{interval_ms:motionInterval,threshold:motionThreshold,hold_ms:motionHold,absence_ms:motionAbsence,cooldown_ms:motionCooldown})
|
|
274
|
+
}
|
|
168
275
|
function stopMotion(){if(motionTimer){clearInterval(motionTimer);motionTimer=null}motionPrev=null}
|
|
169
276
|
function showRobotVideo(){return startLocalVideo().catch(function(){})}
|
|
170
277
|
function applyCallMode(){mode=el('call-mode').value==='production'?'production':'test';el('robot-row').style.display=mode==='production'?'grid':'none';el('mode-badge').textContent=mode==='production'?'PRODUCTION ? ROBOT VIDEO + TOOLS':'TEST ? BROWSER ONLY';el('mode-badge').className='mode-badge '+(mode==='production'?'production':'');if(mode==='test')robot='';else robot=el('call-robot').value==='none'?'':el('call-robot').value;text('f-mode',mode);text('f-robot',mode==='production'?(robot||'missing robot'):'not requested');showRobotVideo()}
|
|
@@ -199,7 +306,9 @@
|
|
|
199
306
|
function stopTimers(){if(retryTimer){clearTimeout(retryTimer);retryTimer=null}if(keepalive){clearInterval(keepalive);keepalive=null}if(poller){clearInterval(poller);poller=null}stopVision();stopMotion()}
|
|
200
307
|
function scheduleReconnect(){if(!desired||!alwaysOn||ending)return;connecting=false;conversation=null;conversationId=null;var attempt=++retryAttempt,delay=Math.min(30000,Math.round(750*Math.pow(2,Math.min(attempt-1,6))+Math.random()*500));text('state','reconnecting in '+Math.ceil(delay/1000)+'s');stage('connected');el('avatar-wrap').classList.remove('speaking');if(retryTimer)clearTimeout(retryTimer);retryTimer=setTimeout(function(){retryTimer=null;connectAttempt('reconnect')},delay)}
|
|
201
308
|
function recover(myGeneration,details){if(myGeneration!==generation||ending||!desired)return;var captured=plainDetails(details);captured.last_provider_error=lastProviderError;event('provider_error','warning','ElevenLabs disconnected; automatic recovery is running',captured);closeManaged('provider_disconnected',captured);stopTimers();session=null;eventToken=null;conversation=null;conversationId=null;connecting=false;el('avatar-wrap').classList.remove('live','speaking');el('join').hidden=true;el('end').hidden=false;el('message').disabled=true;el('send').disabled=true;scheduleReconnect()}
|
|
202
|
-
function end(reason){if(ending)return;ending=true;desired=false;generation++;stopTimers();var active=conversation,details={manual:true,reason:reason||'visitor'};if(active)try{active.endSession()}catch(_){};if(cameraRoom){cameraRoom.disconnect();cameraRoom=null}if(localVideoStream){localVideoStream.getTracks().forEach(function(track){track.stop()});localVideoStream=null}var robotStream=el('robot-stream');if(robotStream){robotStream.removeAttribute('src');robotStream.hidden=true}el('robot-video').hidden=false;closeManaged(reason||'visitor',details);session=null;eventToken=null;conversation=null;conversationId=null;connecting=false;retryAttempt=0;stage('conversation','done');text('state','manually disconnected - supervisor stopped');el('avatar-wrap').classList.remove('live','speaking');el('join').hidden=false;el('join').disabled=false;el('join').textContent='Start conversation';el('end').hidden=true;el('message').disabled=true;el('send').disabled=true;ending=false
|
|
309
|
+
function end(reason){if(ending)return;ending=true;desired=false;generation++;stopTimers();var active=conversation,details={manual:true,reason:reason||'visitor'};if(active)try{active.endSession()}catch(_){};if(cameraRoom){cameraRoom.disconnect();cameraRoom=null}if(localVideoStream){localVideoStream.getTracks().forEach(function(track){track.stop()});localVideoStream=null}var robotStream=el('robot-stream');if(robotStream){robotStream.removeAttribute('src');robotStream.hidden=true}el('robot-video').hidden=false;closeManaged(reason||'visitor',details);session=null;eventToken=null;conversation=null;conversationId=null;connecting=false;retryAttempt=0;stage('conversation','done');text('state','manually disconnected - supervisor stopped');el('avatar-wrap').classList.remove('live','speaking');el('join').hidden=false;el('join').disabled=false;el('join').textContent='Start conversation';el('end').hidden=true;el('message').disabled=true;el('send').disabled=true;ending=false;kioskActivityAt=0;// Back to watching. kioskArmedAt already holds off the re-trigger so a
|
|
310
|
+
// visitor still standing there cannot immediately redial.
|
|
311
|
+
if(kioskOn&&!kioskArmedAt)kioskArmedAt=Date.now()+kioskRearm}
|
|
203
312
|
// This call lives on an ElevenLabs WebSocket and never joins a LiveKit room,
|
|
204
313
|
// so the control center cannot observe it. Check in on a timer, and close the
|
|
205
314
|
// session on unload, or the dashboard cannot tell a live call from a closed
|
|
@@ -210,7 +319,7 @@
|
|
|
210
319
|
try{navigator.sendBeacon('/robopark/api/sessions/'+encodeURIComponent(session)+'/end?reason=page_closed',new Blob([JSON.stringify({details:{page_closed:true},source:'public_join'})],{type:'application/json'}))}catch(_){}
|
|
211
320
|
});
|
|
212
321
|
function callPayload(){return {character_preset_id:character,voice_stack_id:stack||null,client_name:'Public character link',engine:'elevenlabs',agent_id:agent,branch_id:branch||null,robot_id:robot||null,join_link_id:share||null}}
|
|
213
|
-
function connectAttempt(kind){if(!desired||ending||connecting||conversation)return;connecting=true;var myGeneration=++generation;text('state',kind==='reconnect'?'obtaining fresh voice session':'connecting');stage('signed_session');api('/api/sessions/voice-call',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(callPayload())}).then(function(call){if(myGeneration!==generation||!desired)throw new Error('connection superseded');session=call.session_id;eventToken=call.event_token;text('f-session',session);text('f-agent',call.agent_id);text('f-branch',call.branch_id||'default');text('f-robot',call.robot_id||'operator only');if(call.robot_video_ref&&robot){el('vision').hidden=false;el('robot-stream').src='/fed/media/robots/'+encodeURIComponent(call.robot_video_ref)+'/video_feed';el('robot-stream').hidden=false;el('robot-video').hidden=true}stage('connected');event('signed_session','ok','Fresh signed ElevenLabs session created',{join_link_id:share,robot_id:robot,reconnect_attempt:retryAttempt});return ensureElevenLabsClient().then(function(SDK){return SDK.Conversation.startSession({signedUrl:call.signed_url,connectionType:'websocket',useWakeLock:true,onConnect:function(info){if(myGeneration!==generation)return;conversationId=info.conversationId;lastProviderError=null;retryAttempt=0;text('state','listening');el('error').style.display='none';el('avatar-wrap').classList.add('live');stage('conversation');management('/joined',{conversation_id:conversationId,connection_type:'websocket',engine:'elevenlabs',robot_id:robot,join_link_id:share,source:'public_join'});event('provider_connected','ok','ElevenLabs conversation connected',{conversation_id:conversationId,supervised:true});startVision(myGeneration);motionLastTurn=Date.now();if(
|
|
322
|
+
function connectAttempt(kind){if(!desired||ending||connecting||conversation)return;connecting=true;var myGeneration=++generation;text('state',kind==='reconnect'?'obtaining fresh voice session':'connecting');stage('signed_session');api('/api/sessions/voice-call',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(callPayload())}).then(function(call){if(myGeneration!==generation||!desired)throw new Error('connection superseded');session=call.session_id;eventToken=call.event_token;text('f-session',session);text('f-agent',call.agent_id);text('f-branch',call.branch_id||'default');text('f-robot',call.robot_id||'operator only');if(call.robot_video_ref&&robot){el('vision').hidden=false;el('robot-stream').src='/fed/media/robots/'+encodeURIComponent(call.robot_video_ref)+'/video_feed';el('robot-stream').hidden=false;el('robot-video').hidden=true}stage('connected');event('signed_session','ok','Fresh signed ElevenLabs session created',{join_link_id:share,robot_id:robot,reconnect_attempt:retryAttempt});return ensureElevenLabsClient().then(function(SDK){return SDK.Conversation.startSession({signedUrl:call.signed_url,connectionType:'websocket',useWakeLock:true,onConnect:function(info){if(myGeneration!==generation)return;conversationId=info.conversationId;lastProviderError=null;retryAttempt=0;text('state','listening');el('error').style.display='none';el('avatar-wrap').classList.add('live');stage('conversation');management('/joined',{conversation_id:conversationId,connection_type:'websocket',engine:'elevenlabs',robot_id:robot,join_link_id:share,source:'public_join'});event('provider_connected','ok','ElevenLabs conversation connected',{conversation_id:conversationId,supervised:true});startVision(myGeneration);motionLastTurn=Date.now();startMotion(myGeneration);kioskNote();if(kioskOn){try{motionSent[kioskGreeting]=1;conversation.sendUserMessage(kioskGreeting)}catch(_){}}},onDisconnect:function(info){recover(myGeneration,info||{reason:'provider_disconnected'})},onError:function(message,context){if(myGeneration!==generation)return;lastProviderError={message:String(message||'Provider error'),context:plainDetails(context)};event('provider_error','failed',lastProviderError.message,lastProviderError);text('state','provider error')},onStatusChange:function(info){if(myGeneration===generation)event('provider_status',info&&info.status==='disconnected'?'warning':'ok',info&&info.status||'unknown',{})},onMessage:function(info){if(myGeneration!==generation)return;var message=String(info&&info.message||'').trim(),role=info&&info.role==='user'?'user':'assistant';if(role==='user'&&motionSent[message])return;motionLastTurn=Date.now();kioskNote();append(role,message);if(message){history.push({role:role,text:message});history=history.slice(-20)}saveTurn(role,message)},onModeChange:function(info){if(myGeneration!==generation)return;var speaking=info.mode==='speaking';motionMode=speaking?'speaking':'listening';if(speaking)kioskNote();el('avatar-wrap').classList.toggle('speaking',speaking);text('state',speaking?'speaking':'listening');event('mode','ok',info.mode,{})},onInterruption:function(info){event('interruption','ok','Agent interrupted',info||{})},onMCPToolCall:function(info){event('mcp_tool','running','Tool call',info||{})}})})}).then(function(active){connecting=false;if(myGeneration!==generation||!desired){try{active.endSession()}catch(_){}return}conversation=active;startKeepalive();el('join').hidden=true;el('end').hidden=false;el('message').disabled=false;el('send').disabled=false;pollDetail()}).catch(function(error){connecting=false;if(myGeneration!==generation||!desired)return;var details={message:error&&error.message||String(error),last_provider_error:lastProviderError};event('provider_error','failed','Voice connection attempt failed',details);closeManaged('connection_failed',details);session=null;eventToken=null;conversation=null;conversationId=null;
|
|
214
323
|
// A 4xx means the link itself is wrong and retrying cannot fix it. Anything
|
|
215
324
|
// else (5xx, signing timeout, network drop) is transient, and dead-ending
|
|
216
325
|
// here is what left a failed call with no reconnect until a manual re-join.
|
|
@@ -222,13 +331,18 @@
|
|
|
222
331
|
if(!permanent&&alwaysOn&&desired&&!ending){el('join').hidden=true;el('end').hidden=false;scheduleReconnect();return}
|
|
223
332
|
fail(details.message)})}
|
|
224
333
|
function start(){if(desired||conversation||connecting)return;if(mode==='production'&&!robot){fail('Select a registered production robot first.');return}alwaysOn=true;el('always-on').classList.add('on');el('always-on').setAttribute('aria-pressed','true');el('always-on').textContent='Always on: ON';if(mode==='test')ensureMicAudioContext();desired=true;retryAttempt=0;el('join').disabled=true;text('state','requesting microphone permission');stage('permission');var audio=inputDeviceId?{deviceId:{exact:inputDeviceId}}:true;navigator.mediaDevices.getUserMedia({audio:audio}).then(function(stream){stream.getTracks().forEach(function(track){track.stop()});return startLocalVideo()}).then(function(){connectAttempt('initial')}).catch(function(error){if(desired){text('state','microphone unavailable; retrying');scheduleReconnect()}else{fail(error&&error.message||String(error))}})}
|
|
334
|
+
el('kiosk').onclick=function(){
|
|
335
|
+
kioskOn=!kioskOn;syncKioskButton();
|
|
336
|
+
if(kioskOn){kioskStart()}
|
|
337
|
+
else{kioskStop();text('state',conversation?'connected':'motion calling off')}
|
|
338
|
+
};
|
|
225
339
|
el('always-on').onclick=function(){alwaysOn=!alwaysOn;this.classList.toggle('on',alwaysOn);this.setAttribute('aria-pressed',String(alwaysOn));this.textContent='Always on: '+(alwaysOn?'ON':'OFF');if(!alwaysOn&&retryTimer){clearTimeout(retryTimer);retryTimer=null;text('state','disconnected - automatic recovery paused')}else if(alwaysOn&&desired&&!conversation&&!connecting){scheduleReconnect()}};
|
|
226
340
|
function pollDetail(){applySpeakerVolume()}
|
|
227
341
|
function deploymentRequest(path,options){var token=q.get('token')||'',suffix=token?(path.indexOf('?')>=0?'&':'?')+'token='+encodeURIComponent(token):'';return fetch('/robopark'+path+suffix,Object.assign({cache:'no-store'},options||{})).then(function(response){if(!response.ok)throw new Error('request '+response.status);return response.json()})}
|
|
228
342
|
function renderDeployment(data){var token=q.get('token')||'',rows=[];[['voice_vision_boxes','Windows voice + vision',['vision','conversation']],['motor_nodes','Pi motor server',['motor']]].forEach(function(group){var devices=data[group[0]]||[];if(!devices.length){rows.push('<div class="deployment-row"><b>'+group[1]+'</b><span>not registered</span><span></span></div>');return}devices.forEach(function(device){var seen=Date.parse(device.last_heartbeat||''),live=isFinite(seen)&&Date.now()-seen<90000,buttons=token?group[2].map(function(service){return '<button data-device="'+device.id+'" data-service="'+service+'" data-action="start">Start '+service+'</button><button data-device="'+device.id+'" data-service="'+service+'" data-action="restart">Restart</button><button data-device="'+device.id+'" data-service="'+service+'" data-action="stop">Stop</button>'}).join(''):'management token required for controls';rows.push('<div class="deployment-row"><b>'+group[1]+'</b><span class="'+(live?'ready':'')+'">'+String(device.name||device.id)+' / '+(live?'online':'stale')+'</span><div class="deployment-actions">'+buttons+'</div></div>')})});deployment.innerHTML=rows.join('');deployment.querySelectorAll('button[data-action]').forEach(function(button){button.onclick=function(){var action=button.getAttribute('data-action'),service=button.getAttribute('data-service'),device=button.getAttribute('data-device');if((action==='stop'||action==='restart')&&!confirm(action+' '+service+'?'))return;button.disabled=true;deploymentRequest('/api/devices/'+encodeURIComponent(device)+'/services/'+encodeURIComponent(service)+'/'+action,{method:'POST',headers:{'content-type':'application/json'}}).then(function(){button.textContent='queued'}).catch(function(error){button.textContent='failed';fail(error.message)}).then(function(){setTimeout(function(){button.disabled=false},1800)})}})}
|
|
229
343
|
function loadDeployment(){deploymentRequest('/api/characters/'+encodeURIComponent(character)+'/deployment').then(renderDeployment).catch(function(){deployment.innerHTML='<div class="deployment-row"><b>Character hardware</b><span>deployment status unavailable</span><span></span></div>'})}
|
|
230
344
|
el('speaker-volume').value=String(speakerVolume);text('speaker-value',speakerVolume+'%');el('mic-sensitivity').value=String(micSensitivity);text('mic-value',micSensitivity+'%');el('speaker-volume').oninput=function(){speakerVolume=Math.max(0,Math.min(100,Number(this.value)||0));text('speaker-value',speakerVolume+'%');persistNumber('robopark.voice.speaker',speakerVolume);applySpeakerVolume()};el('mic-sensitivity').oninput=function(){setMicSensitivity(this.value);text('mic-value',micSensitivity+'%');persistNumber('robopark.voice.micSensitivity',micSensitivity)};
|
|
231
|
-
el('ops-toggle').onclick=function(){el('ops').classList.toggle('open');this.classList.toggle('on',el('ops').classList.contains('open'))};el('ops-close').onclick=function(){el('ops').classList.remove('open');el('ops-toggle').classList.remove('on')};el('join').onclick=start;el('end').onclick=function(){end('visitor')};el('compose').onsubmit=function(e){e.preventDefault();var message=el('message').value.trim();if(message&&conversation){conversation.sendUserMessage(message);el('message').value=''}};
|
|
345
|
+
el('ops-toggle').onclick=function(){el('ops').classList.toggle('open');this.classList.toggle('on',el('ops').classList.contains('open'))};el('ops-close').onclick=function(){el('ops').classList.remove('open');el('ops-toggle').classList.remove('on')};el('join').onclick=start;if(q.get('kiosk')==='1'||q.get('kiosk')==='on'){kioskOn=true;syncKioskButton();kioskStart()}el('end').onclick=function(){end('visitor')};el('compose').onsubmit=function(e){e.preventDefault();var message=el('message').value.trim();if(message&&conversation){conversation.sendUserMessage(message);el('message').value=''}};
|
|
232
346
|
text('f-mode',mode);text('f-character',character);text('f-agent',agent);text('f-branch',branch||'default');text('f-robot',mode==='production'?(robot||'missing robot'):'not requested');text('f-share',share||'untracked');
|
|
233
347
|
if(character)loadDeployment();
|
|
234
348
|
if(!character||!agent){fail('This join link is incomplete. Ask the operator for a new character link.');return}if(mode==='production'&&!robot){fail('This production link has no registered robot. Ask the operator for a corrected link.');return}api('/api/character-presets/'+encodeURIComponent(character)).then(function(preset){text('character-name',preset.name||preset.id);text('subtitle',(mode==='test'?'Browser-only test call · ':'Production on-site call · ')+(preset.description||'Secure ElevenLabs conversation managed by RoboPark'));text('f-character',preset.name||preset.id);text('fallback',String(preset.name||preset.id).slice(0,2).toUpperCase());if(preset.img){el('avatar').src=preset.img;el('avatar').onload=function(){el('fallback').style.display='none'}};text('state','ready to connect');el('join').disabled=false}).catch(function(){fail('Character configuration is unavailable.')});
|