robopark 3.1.2 → 3.3.0

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.
Files changed (101) hide show
  1. package/README.md +66 -95
  2. package/bin/robopark.js +9 -2
  3. package/conversation/elevenlabs_agent.py +1617 -1617
  4. package/conversation/supervisor_store.py +189 -189
  5. package/dist/{kernel/config-schema.js → config-schema.js} +1 -0
  6. package/dist/config-schema.js.map +1 -0
  7. package/dist/robopark/add-robot.js +1 -0
  8. package/dist/robopark/add-robot.js.map +1 -0
  9. package/dist/robopark/agent-ctl.js +1 -0
  10. package/dist/robopark/agent-ctl.js.map +1 -0
  11. package/dist/robopark/auto-start.js +51 -73
  12. package/dist/robopark/auto-start.js.map +1 -0
  13. package/dist/robopark/conversation.js +1 -0
  14. package/dist/robopark/conversation.js.map +1 -0
  15. package/dist/robopark/discovery.js +1 -0
  16. package/dist/robopark/discovery.js.map +1 -0
  17. package/dist/robopark/doctor.js +1 -0
  18. package/dist/robopark/doctor.js.map +1 -0
  19. package/dist/robopark/enroll.js +1 -0
  20. package/dist/robopark/enroll.js.map +1 -0
  21. package/dist/robopark/llm-set.js +1 -0
  22. package/dist/robopark/llm-set.js.map +1 -0
  23. package/dist/robopark/motor-control.js +1 -0
  24. package/dist/robopark/motor-control.js.map +1 -0
  25. package/dist/robopark/preview-agent-launcher.js +1 -0
  26. package/dist/robopark/preview-agent-launcher.js.map +1 -0
  27. package/dist/robopark/probe.js +16 -15
  28. package/dist/robopark/probe.js.map +1 -0
  29. package/dist/robopark/profile.js +1 -0
  30. package/dist/robopark/profile.js.map +1 -0
  31. package/dist/robopark/python-env.js +1 -7
  32. package/dist/robopark/python-env.js.map +1 -0
  33. package/dist/robopark/robot-runtime.js +1 -0
  34. package/dist/robopark/robot-runtime.js.map +1 -0
  35. package/dist/robopark/scan.js +1 -0
  36. package/dist/robopark/scan.js.map +1 -0
  37. package/dist/robopark/screen-control.js +1 -0
  38. package/dist/robopark/screen-control.js.map +1 -0
  39. package/dist/robopark/secrets.js +1 -0
  40. package/dist/robopark/secrets.js.map +1 -0
  41. package/dist/robopark/serve.js +4 -6
  42. package/dist/robopark/serve.js.map +1 -0
  43. package/dist/robopark/server-add.js +1 -0
  44. package/dist/robopark/server-add.js.map +1 -0
  45. package/dist/robopark/setup-livekit.js +53 -52
  46. package/dist/robopark/setup-livekit.js.map +1 -0
  47. package/dist/robopark/setup.js +1 -0
  48. package/dist/robopark/setup.js.map +1 -0
  49. package/dist/robopark/stop-all.js +1 -0
  50. package/dist/robopark/stop-all.js.map +1 -0
  51. package/dist/robopark/verify.js +1 -0
  52. package/dist/robopark/verify.js.map +1 -0
  53. package/dist/robopark/vision-agent-launcher.js +1 -0
  54. package/dist/robopark/vision-agent-launcher.js.map +1 -0
  55. package/dist/robopark/vision-control.js +1 -0
  56. package/dist/robopark/vision-control.js.map +1 -0
  57. package/dist/robopark-cli.js +7 -125
  58. package/dist/robopark-cli.js.map +1 -0
  59. package/package.json +16 -25
  60. package/pi-client/_install_steps.sh +29 -29
  61. package/pi-client/client.py +76 -76
  62. package/pi-client/install.sh +40 -40
  63. package/pi-client/join_convo.sh +54 -54
  64. package/pi-client/livekit_bridge.py +24 -24
  65. package/pi-client/motor_bridge.py +10 -10
  66. package/scheduler/__pycache__/main.cpython-312.pyc +0 -0
  67. package/scheduler/main.py +196 -17
  68. package/scheduler/media_lock.py +57 -57
  69. package/scheduler/preview_agent.py +1122 -1122
  70. package/scheduler/production_config.json +73 -113
  71. package/scheduler/scripts/install-robot-supervisor-linux.sh +33 -33
  72. package/{static → scheduler/static}/vendor/elevenlabs-client.iife.js +24199 -24199
  73. package/scheduler/static/voice-join.html +177 -0
  74. package/screen/screen_runtime.py +75 -75
  75. package/vision/app_pi_clean.py +297 -297
  76. package/vision/audio_server_pi.py +24 -24
  77. package/vision/install.sh +34 -34
  78. package/vision/motor_server.py +275 -275
  79. package/vision/requirements_camera.txt +6 -6
  80. package/vision/requirements_motor.txt +4 -4
  81. package/vision/requirements_pi_unified.txt +2 -2
  82. package/vision/requirements_vision_agent.txt +19 -19
  83. package/vision/run.sh +244 -244
  84. package/vision/services/services.sh +12 -12
  85. package/dist/kernel/types.js +0 -7
  86. package/dist/robopark/access.js +0 -99
  87. package/dist/robopark/control-center-html.js +0 -9191
  88. package/dist/robopark/control-server.js +0 -148
  89. package/dist/robopark/deployment-commands.js +0 -47
  90. package/dist/robopark/standalone.js +0 -562
  91. package/dist/robopark/voice-join-html.js +0 -57
  92. package/scripts/windows-shim-fix.cjs +0 -58
  93. package/static/robopark/barracuda.png +0 -0
  94. package/static/robopark/bmw.png +0 -0
  95. package/static/robopark/jaguar.png +0 -0
  96. package/static/robopark/magnus.jpg +0 -0
  97. package/static/robopark/panda.png +0 -0
  98. package/static/robopark/tesla.png +0 -0
  99. package/static/robopark/titan.jpg +0 -0
  100. package/static/robopark/volt.png +0 -0
  101. package/static/vendor/elevenlabs-client.LICENSE +0 -21
@@ -0,0 +1,177 @@
1
+ <!doctype html>
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-greeting-20260728-1">
4
+ <title>RoboPark Character Conversation</title>
5
+ <style>
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
+ .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
+ :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>
10
+ (function(){
11
+ var API_BASE='';
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);
13
+ 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);
14
+ var callControls=document.createElement('details');callControls.id='call-controls';callControls.className='call-controls';callControls.open=true;callControls.innerHTML='<summary>Microphone and speaker</summary><div class=call-panel id=call-panel></div>';audioControls.open=true;audioControls.after(callControls);
15
+ var q=new URLSearchParams(location.search),character=q.get('character')||'',agent=q.get('agent_id')||'',branch=q.get('branch_id')||'',mode=q.get('mode')==='production'?'production':'test',robot=mode==='production'?(q.get('robot')||character):'',stack=q.get('stack')||'',share=q.get('share_id')||'',session=null,eventToken=null,conversation=null,conversationId=null,keepalive=null,poller=null,retryTimer=null,cameraPublishRetry=null,ending=false,desired=false,alwaysOn=false,connecting=false,generation=0,retryAttempt=0,lastProviderError=null,history=[],seen={},micPipelines=[],micAudioContext=null,localVideoStream=null,activeCameraDevice=null,cameraRoom=null,cameraSdkPromise=null,speakerVolume=storedNumber('robopark.voice.speaker',100,0,100),micSensitivity=storedNumber('robopark.voice.micSensitivity',100,0,100),inputDeviceId='',cameraDeviceId='',outputDeviceId='';
16
+ // The panel carries two sources: a <video> for local/LiveKit camera tracks and the
17
+ // original <img> for the robot MJPEG feed, which a <video> element cannot decode.
18
+ // Both stay mounted so the ambient vision sampler can read whichever is showing.
19
+ (function(){var old=document.getElementById('robot-video'),video=document.createElement('video');video.id='robot-video';video.autoplay=true;video.muted=true;video.playsInline=true;if(old&&old.parentNode){old.id='robot-stream';old.hidden=true;old.parentNode.insertBefore(video,old)}})();
20
+ function storedNumber(key,fallback,min,max){try{var raw=localStorage.getItem(key);if(raw===null)return fallback;var value=Number(raw);return Number.isFinite(value)?Math.max(min,Math.min(max,value)):fallback}catch(_){return fallback}}
21
+ function persistNumber(key,value){try{localStorage.setItem(key,String(value))}catch(_){}}
22
+ function applySpeakerVolume(){if(conversation&&typeof conversation.setVolume==='function')try{conversation.setVolume({volume:speakerVolume/100})}catch(_){}}
23
+ function setMicSensitivity(value){var level=Number(value);micSensitivity=Math.max(0,Math.min(100,Number.isFinite(level)?level:100));micPipelines.slice().forEach(function(pipe){try{pipe.gain.gain.setTargetAtTime(micSensitivity/100,pipe.context.currentTime,.015)}catch(_){}})}
24
+ function ensureMicAudioContext(){var AudioCtor=window.AudioContext||window.webkitAudioContext;if(!AudioCtor)return null;if(!micAudioContext||micAudioContext.state==='closed')micAudioContext=new AudioCtor();if(micAudioContext.state==='suspended')micAudioContext.resume().catch(function(){});return micAudioContext}
25
+ function processMicrophone(stream){if(!stream||!stream.getAudioTracks||!stream.getAudioTracks().length)return stream;try{var context=ensureMicAudioContext();if(!context)return stream;var source=context.createMediaStreamSource(stream),gain=context.createGain(),destination=context.createMediaStreamDestination(),pipe={context:context,source:source,gain:gain,destination:destination};gain.gain.value=micSensitivity/100;source.connect(gain);gain.connect(destination);micPipelines.push(pipe);var outputTrack=destination.stream.getAudioTracks()[0],nativeStop=outputTrack.stop.bind(outputTrack),closed=false;outputTrack.stop=function(){if(closed)return;closed=true;nativeStop();stream.getTracks().forEach(function(track){try{track.stop()}catch(_){}});try{source.disconnect();gain.disconnect()}catch(_){};micPipelines=micPipelines.filter(function(item){return item!==pipe})};stream.getAudioTracks().forEach(function(track){track.addEventListener('ended',function(){try{outputTrack.stop()}catch(_){}})});return new MediaStream(stream.getVideoTracks().concat([outputTrack]))}catch(_){return stream}}
26
+ function createSilentTestMicrophone(){var AudioCtor=window.AudioContext||window.webkitAudioContext;if(!AudioCtor)throw new Error('This browser has no usable microphone or Web Audio support.');var context=new AudioCtor(),oscillator=context.createOscillator(),gain=context.createGain(),destination=context.createMediaStreamDestination();gain.gain.value=0;oscillator.connect(gain);gain.connect(destination);oscillator.start();var track=destination.stream.getAudioTracks()[0],nativeStop=track.stop.bind(track),closed=false;track.stop=function(){if(closed)return;closed=true;try{oscillator.stop()}catch(_){}nativeStop();context.close().catch(function(){})};var subtitle=document.getElementById('subtitle');if(subtitle)subtitle.textContent='No local microphone attached · agent audio and typed test enabled';return new MediaStream([track])}
27
+ function installMicrophoneRecovery(){
28
+ var media=navigator.mediaDevices;if(!media||!media.getUserMedia||media.__roboparkRecovered)return;var nativeGetUserMedia=media.getUserMedia.bind(media);media.__roboparkRecovered=true;
29
+ media.getUserMedia=function(constraints){return nativeGetUserMedia(constraints).catch(function(error){var audio=constraints&&constraints.audio,recoverable=error&&['NotFoundError','DevicesNotFoundError','OverconstrainedError'].includes(error.name);if(!audio||!recoverable)throw error;var fallback=audio===true?{echoCancellation:true,noiseSuppression:true,autoGainControl:true}:Object.assign({},audio);delete fallback.deviceId;delete fallback.groupId;delete fallback.sampleRate;delete fallback.voiceIsolation;return media.enumerateDevices().then(function(devices){var inputs=devices.filter(function(device){return device.kind==='audioinput'&&device.deviceId}),input=inputs.find(function(device){return device.deviceId!=='default'&&device.deviceId!=='communications'})||inputs[0];if(!input){if(mode==='test')return createSilentTestMicrophone();throw new Error('No browser microphone is currently attached. Connect or enable a microphone, then retry.')}fallback.deviceId={exact:input.deviceId};return nativeGetUserMedia({audio:fallback})}).catch(function(secondError){if(secondError&&String(secondError.message||'').indexOf('No browser microphone')===0)throw secondError;if(mode==='test'&&secondError&&['NotFoundError','DevicesNotFoundError','OverconstrainedError'].includes(secondError.name))return createSilentTestMicrophone();delete fallback.deviceId;return nativeGetUserMedia({audio:fallback})})}).then(function(stream){return constraints&&constraints.audio&&mode==='test'?processMicrophone(stream):stream})};
30
+ }
31
+ installMicrophoneRecovery();
32
+ function ensureElevenLabsClient(){if(window.ElevenLabsClient&&window.ElevenLabsClient.Conversation)return Promise.resolve(window.ElevenLabsClient);return new Promise(function(resolve,reject){var script=document.createElement('script');script.src='/vendor/elevenlabs-client.iife.js';script.onload=function(){if(window.ElevenLabsClient&&window.ElevenLabsClient.Conversation)resolve(window.ElevenLabsClient);else reject(new Error('ElevenLabs browser SDK loaded without a Conversation client.'))};script.onerror=function(){reject(new Error('ElevenLabs browser SDK is unavailable. Check the RoboPark control service build.'))};document.head.appendChild(script)})}
33
+ function el(id){return document.getElementById(id)}function text(id,value){var node=el(id);if(node)node.textContent=value==null?'--':String(value)}function fail(message){text('state','unable to connect');el('error').style.display='block';text('error',message);el('join').disabled=false;stage('configured','bad')}
34
+ var callPanel=el('call-panel');
35
+ callPanel.insertAdjacentHTML('beforeend','<div class=call-row hidden><label for=call-mode>Call mode</label><select id=call-mode><option value=test>Test call · browser only</option><option value=production>Production on-site · robot video and tools</option></select></div>');
36
+ callPanel.insertAdjacentHTML('beforeend','<div class=call-row id=robot-row hidden><label for=call-robot>Production robot</label><select id=call-robot><option value=none>Select a registered robot...</option></select></div>');
37
+ callPanel.insertAdjacentHTML('beforeend','<div class=call-row><label for=call-microphone>Microphone</label><select id=call-microphone><option value=default>System default microphone</option></select></div>');
38
+ callPanel.insertAdjacentHTML('beforeend','<div class=call-row hidden><label for=call-camera>Camera</label><select id=call-camera><option value=default>System default camera</option></select></div>');
39
+ callPanel.insertAdjacentHTML('beforeend','<div class=call-row><label for=call-speaker>Speaker output</label><select id=call-speaker><option value=default>System default speaker</option></select></div>');
40
+ callPanel.insertAdjacentHTML('beforeend','<div class=call-actions hidden><button class=primary id=request-hardware type=button>Request all media</button><button id=refresh-hardware type=button>Refresh devices</button></div>');
41
+ callPanel.insertAdjacentHTML('beforeend','<p class="call-note call-status" id=hardware-status>Grant browser media access to reveal device names.</p><span class=mode-badge id=mode-badge hidden>TEST ? BROWSER ONLY</span>');
42
+ function setHardwareStatus(message,state){text('hardware-status',message);el('hardware-status').className='call-note call-status '+(state||'')}
43
+ function fillHardwareSelect(id,devices,kind,label){var select=el(id),current=select.value;select.innerHTML='<option value=default>System default '+label+'</option>';devices.filter(function(device){return device.kind===kind&&device.deviceId&&device.deviceId!=='default';}).forEach(function(device,index){var option=document.createElement('option');option.value=device.deviceId;option.textContent=device.label||(label+' '+(index+1));select.appendChild(option)});if(current&&Array.from(select.options).some(function(option){return option.value===current}))select.value=current}
44
+ function refreshHardware(requestPermission){if(!navigator.mediaDevices||!navigator.mediaDevices.enumerateDevices){setHardwareStatus('Media device selection is unavailable in this browser.','bad');return Promise.reject(new Error('media devices unavailable'))}var permission=Promise.resolve();if(requestPermission){permission=navigator.mediaDevices.getUserMedia({audio:true,video:true}).catch(function(){return navigator.mediaDevices.getUserMedia({audio:true})}).then(function(stream){stream.getTracks().forEach(function(track){track.stop()})})}setHardwareStatus(requestPermission?'Requesting browser media access...':'Scanning browser hardware...','');return permission.then(function(){return navigator.mediaDevices.enumerateDevices()}).then(function(devices){fillHardwareSelect('call-microphone',devices,'audioinput','microphone');fillHardwareSelect('call-camera',devices,'videoinput','camera');fillHardwareSelect('call-speaker',devices,'audiooutput','speaker');var mic=devices.filter(function(device){return device.kind==='audioinput'}).length,cam=devices.filter(function(device){return device.kind==='videoinput'}).length,out=devices.filter(function(device){return device.kind==='audiooutput'}).length;setHardwareStatus('Ready: '+mic+' microphone(s), '+cam+' camera(s), '+out+' speaker output(s).','ok');return devices}).catch(function(error){setHardwareStatus(error&&error.message||String(error),'bad');throw error})}
45
+ function ensureCameraSdk(){if(window.LivekitClient)return Promise.resolve(window.LivekitClient);if(cameraSdkPromise)return cameraSdkPromise;cameraSdkPromise=new Promise(function(resolve,reject){var script=document.createElement('script');script.src='/vendor/livekit-client.umd.js';script.onload=function(){window.LivekitClient?resolve(window.LivekitClient):reject(new Error('LiveKit camera SDK unavailable'))};script.onerror=function(){reject(new Error('LiveKit camera SDK failed to load'))};document.head.appendChild(script)});return cameraSdkPromise}
46
+ function scheduleCameraPublishRetry(){if(cameraPublishRetry||ending||!localVideoStream)return;cameraPublishRetry=setTimeout(function(){cameraPublishRetry=null;publishLocalVideo().catch(function(){})},3000)}
47
+ function publishLocalVideo(){if(!localVideoStream||!localVideoStream.getVideoTracks().length)return Promise.resolve();if(cameraRoom)return Promise.resolve();return Promise.all([ensureCameraSdk(),fetch(API_BASE+'/api/camera-token',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({character_id:character,robot_id:robot||null})}).then(function(response){if(!response.ok)throw new Error('camera token '+response.status);return response.json()})]).then(function(parts){var LK=parts[0],info=parts[1],room=new LK.Room();cameraRoom=room;room.on(LK.RoomEvent.Disconnected,function(){if(cameraRoom===room){cameraRoom=null;scheduleCameraPublishRetry()}});return room.connect(info.url,info.token).then(function(){return room.localParticipant.publishTrack(localVideoStream.getVideoTracks()[0],{name:'camera',source:LK.Track.Source.Camera})}).then(function(){if(cameraPublishRetry){clearTimeout(cameraPublishRetry);cameraPublishRetry=null}event('camera_published','ok','Camera relay live',{})})}).catch(function(error){cameraRoom=null;event('camera_published','failed',error.message,{});scheduleCameraPublishRetry();throw error})}
48
+ function startLocalVideo(){if(!navigator.mediaDevices||!navigator.mediaDevices.getUserMedia)return Promise.resolve();if(localVideoStream&&activeCameraDevice===cameraDeviceId)return publishLocalVideo();if(localVideoStream)localVideoStream.getTracks().forEach(function(track){track.stop()});if(cameraRoom){cameraRoom.disconnect();cameraRoom=null}var video=cameraDeviceId?{deviceId:{exact:cameraDeviceId}}:true;return navigator.mediaDevices.getUserMedia({video:video,audio:false}).then(function(stream){localVideoStream=stream;activeCameraDevice=cameraDeviceId;el('robot-video').srcObject=stream;if(!robotStreamActive())el('robot-video').hidden=false;el('vision').hidden=false;return publishLocalVideo().catch(function(){})}).catch(function(error){localVideoStream=null;activeCameraDevice=null;el('robot-video').srcObject=null;if(!robotStreamActive()){el('robot-video').hidden=true;el('vision').hidden=true}throw error})}
49
+ function robotStreamActive(){var stream=el('robot-stream');return Boolean(stream&&stream.getAttribute('src'))}
50
+ // Ambient vision. The ElevenLabs contextual_update channel carries text only, so a
51
+ // frame is captioned server-side and the sentence is injected silently — a real
52
+ // image would have to go through sendMultimodalMessage, which speaks as the user
53
+ // and would make the agent answer itself every poll. Frames are only sent when the
54
+ // scene actually changes, so a static view does not grow the agent prompt by a
55
+ // caption every 10s.
56
+ var visionTimer=null,visionCanvas=null,visionProbe=null,visionHash=null,visionLastSent=0,visionBusy=false,visionFailures=0;
57
+ function visionSource(){var stream=el('robot-stream'),video=el('robot-video');if(stream&&!stream.hidden&&stream.naturalWidth)return stream;if(video&&!video.hidden&&video.videoWidth)return video;return null}
58
+ function visionCapture(){var source=visionSource();if(!source)return false;var width=source.videoWidth||source.naturalWidth,height=source.videoHeight||source.naturalHeight;if(!width||!height)return false;if(!visionCanvas)visionCanvas=document.createElement('canvas');var scale=Math.min(1,512/Math.max(width,height));visionCanvas.width=Math.max(32,Math.round(width*scale));visionCanvas.height=Math.max(32,Math.round(height*scale));try{visionCanvas.getContext('2d').drawImage(source,0,0,visionCanvas.width,visionCanvas.height);return true}catch(_){return false}}
59
+ function visionSignature(){if(!visionProbe)visionProbe=document.createElement('canvas');visionProbe.width=8;visionProbe.height=8;var ctx=visionProbe.getContext('2d');try{ctx.drawImage(visionCanvas,0,0,8,8);var data=ctx.getImageData(0,0,8,8).data}catch(_){return null}var levels=[],total=0,i;for(i=0;i<64;i++){var level=(data[i*4]*299+data[i*4+1]*587+data[i*4+2]*114)/1000;levels.push(level);total+=level}var mean=total/64,hash=[];for(i=0;i<64;i++)hash.push(levels[i]>mean?1:0);return hash}
60
+ function visionChanged(hash){if(!visionHash)return true;for(var i=0,distance=0;i<64;i++){if(hash[i]!==visionHash[i])distance++;if(distance>=6)return true}return false}
61
+ function visionTick(myGeneration){
62
+ if(visionBusy||ending||!conversation||myGeneration!==generation)return;
63
+ if(!visionCapture())return;
64
+ var hash=visionSignature();if(!hash)return;
65
+ // A still scene is still refreshed every 45s so the agent's picture of the room
66
+ // never silently ages out of the conversation.
67
+ if(!visionChanged(hash)&&Date.now()-visionLastSent<45000)return;
68
+ visionBusy=true;
69
+ visionCanvas.toBlob(function(blob){
70
+ if(!blob){visionBusy=false;return}
71
+ fetch(API_BASE+'/api/vision/caption',{method:'POST',headers:{'content-type':'image/jpeg','x-robopark-character':character||''},body:blob}).then(function(response){if(!response.ok)return response.text().then(function(body){throw new Error(body||('caption '+response.status))});return response.json()}).then(function(data){
72
+ visionBusy=false;visionFailures=0;
73
+ var caption=String(data&&data.caption||'').trim();
74
+ if(!caption||!conversation||ending||myGeneration!==generation)return;
75
+ visionHash=hash;visionLastSent=Date.now();
76
+ try{conversation.sendContextualUpdate('[live camera] '+caption)}catch(_){return}
77
+ event('vision_context','ok',caption,{model:data.model||'',elapsed_ms:data.elapsed_ms||0})
78
+ }).catch(function(error){
79
+ visionBusy=false;visionFailures++;
80
+ if(visionFailures>=3){event('vision_context','failed','Ambient vision paused: '+(error&&error.message||error),{});stopVision()}
81
+ })
82
+ },'image/jpeg',.6)
83
+ }
84
+ function startVision(myGeneration){stopVision();visionHash=null;visionLastSent=0;visionFailures=0;visionTimer=setInterval(function(){visionTick(myGeneration)},10000);setTimeout(function(){visionTick(myGeneration)},1500)}
85
+ function stopVision(){if(visionTimer){clearInterval(visionTimer);visionTimer=null}visionBusy=false}
86
+ // Motion greeting for production always-on calls. contextual_update is silent but
87
+ // never starts a turn, so the agent is prompted with a user_message carrying a
88
+ // directive; it is suppressed from the visible transcript and the ledger so the
89
+ // visitor only ever sees the greeting it produces. Guards mirror the robovoice
90
+ // wake path in voice_agent.py: cooldown, listening-only, and no firing during an
91
+ // active conversation, with the cooldown reserved before sending so a burst of
92
+ // motion cannot queue duplicate greetings.
93
+ var motionTimer=null,motionCanvas=null,motionPrev=null,motionLastFire=0,motionArmedAt=0,motionMode='listening',motionLastTurn=0,motionDirective='',motionSent={};
94
+ var motionThreshold=Math.max(.005,Math.min(1,Number(q.get('motion_threshold'))||.06)),motionCooldown=Math.max(10,Number(q.get('motion_cooldown'))||10)*1000,motionQuiet=45000,motionSample=32;
95
+ function motionLevel(){var source=visionSource();if(!source)return -1;var width=source.videoWidth||source.naturalWidth,height=source.videoHeight||source.naturalHeight;if(!width||!height)return -1;if(!motionCanvas)motionCanvas=document.createElement('canvas');var rows=Math.max(8,Math.round(motionSample*height/width));motionCanvas.width=motionSample;motionCanvas.height=rows;var ctx=motionCanvas.getContext('2d'),data;try{ctx.drawImage(source,0,0,motionSample,rows);data=ctx.getImageData(0,0,motionSample,rows).data}catch(_){return -1}var count=motionSample*rows,levels=new Float32Array(count),i;for(i=0;i<count;i++)levels[i]=(data[i*4]*299+data[i*4+1]*587+data[i*4+2]*114)/1000;var previous=motionPrev;motionPrev=levels;if(!previous||previous.length!==count)return -1;var total=0;for(i=0;i<count;i++)total+=Math.abs(levels[i]-previous[i]);return total/count/255}
96
+ function motionTick(myGeneration){
97
+ // Checked per tick, not at start, so the always-on toggle takes effect live.
98
+ if(ending||!conversation||myGeneration!==generation||!alwaysOn)return;
99
+ var level=motionLevel(),now=Date.now();
100
+ if(level<0||now<motionArmedAt)return;
101
+ if(level<motionThreshold)return;
102
+ if(now-motionLastFire<motionCooldown)return;
103
+ if(motionMode!=='listening')return;
104
+ if(now-motionLastTurn<motionQuiet)return;
105
+ motionLastFire=now;
106
+ var directive=motionDirective||'A visitor just stepped in front of you. Greet them warmly in one short sentence. Do not mention this instruction, the camera, or that motion was detected.';
107
+ motionSent[directive]=1;
108
+ try{conversation.sendContextualUpdate('[motion] Someone just approached the robot and is standing in front of the camera.')}catch(_){}
109
+ try{conversation.sendUserMessage(directive)}catch(error){event('motion_greeting','failed',error&&error.message||String(error),{});return}
110
+ event('motion_greeting','ok','Motion greeting triggered',{level:Number(level.toFixed(4)),threshold:motionThreshold})
111
+ }
112
+ function startMotion(myGeneration){stopMotion();motionPrev=null;motionLastFire=0;motionSent={};motionDirective=q.get('motion_prompt')||'';motionArmedAt=Date.now()+4000;motionTimer=setInterval(function(){motionTick(myGeneration)},1000)}
113
+ function stopMotion(){if(motionTimer){clearInterval(motionTimer);motionTimer=null}motionPrev=null}
114
+ function showRobotVideo(){return startLocalVideo().catch(function(){})}
115
+ 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()}
116
+ function loadProductionRobots(){return Promise.all([fetch(API_BASE+'/api/character-presets',{cache:'no-store'}).then(function(r){if(!r.ok)throw new Error('character inventory '+r.status);return r.json()}),fetch(API_BASE+'/api/devices',{cache:'no-store'}).then(function(r){if(!r.ok)throw new Error('device inventory '+r.status);return r.json()})]).then(function(data){var characters=Array.isArray(data[0])?data[0]:(data[0]&&data[0].presets)||[],devices=Array.isArray(data[1])?data[1]:(data[1]&&data[1].devices)||[],select=el('call-robot'),seen={};select.innerHTML='<option value=none>Select a character...</option>';characters.forEach(function(c){var cid=String(c.id||c.preset_id||c.character_id||'');if(!cid||seen[cid.toLowerCase()])return;seen[cid.toLowerCase()]=1;var matches=devices.filter(function(d){return String(d.character_id||'').toLowerCase()===cid.toLowerCase()});matches.sort(function(a,b){var ar=String(a.device_role||'combined'),br=String(b.device_role||'combined'),ap=ar==='voice_vision'?3:ar==='combined'?2:0,bp=br==='voice_vision'?3:br==='combined'?2:0;return bp-ap||Date.parse(b.last_heartbeat||b.enrolled_at||0)-Date.parse(a.last_heartbeat||a.enrolled_at||0)});var voice=matches[0],id=voice&&(voice.id||voice.device_id)||cid,option=document.createElement('option');option.value=id;option.textContent=String(c.name||cid)+' / '+(voice?(String(voice.device_role||'combined')+' '+(voice.status||'registered')):'awaiting voice/video PC');select.appendChild(option)});if(robot&&Array.from(select.options).some(function(option){return option.value===robot}))select.value=robot;else if(character){var match=Array.from(select.options).find(function(option){return option.textContent.toLowerCase().indexOf(character.toLowerCase()+' /')===0||option.value.toLowerCase()===character.toLowerCase()});if(match){select.value=match.value;robot=match.value}}applyCallMode();return characters}).catch(function(error){setHardwareStatus('Character deployment inventory unavailable: '+error.message,'bad');return []})}
117
+ el('call-mode').value=mode;el('call-robot').value=robot||'none';
118
+ el('call-mode').onchange=function(){if(desired||conversation||connecting)end('mode_changed');applyCallMode()};
119
+ el('call-robot').onchange=function(){robot=this.value==='none'?'':this.value;showRobotVideo();text('f-robot',robot||'missing robot')};
120
+ el('call-microphone').onchange=function(){inputDeviceId=this.value==='default'?'':this.value};
121
+ el('call-camera').onchange=function(){cameraDeviceId=this.value==='default'?'':this.value;startLocalVideo().catch(function(){})};
122
+ el('call-speaker').onchange=function(){outputDeviceId=this.value==='default'?'':this.value};
123
+ el('request-hardware').onclick=function(){refreshHardware(true).catch(function(){})};
124
+ el('refresh-hardware').onclick=function(){refreshHardware(false).catch(function(){})};
125
+ applyCallMode();loadProductionRobots();refreshHardware(false).catch(function(){});
126
+ function installElevenLabsHardwareRouting(){var sdk=window.ElevenLabsClient;if(!sdk||!sdk.Conversation)return false;if(sdk.Conversation.__roboparkHardware)return true;var nativeStart=sdk.Conversation.startSession.bind(sdk.Conversation);sdk.Conversation.startSession=function(options){return nativeStart(Object.assign({},options||{},{inputDeviceId:inputDeviceId||undefined,outputDeviceId:outputDeviceId||undefined}))};sdk.Conversation.__roboparkHardware=true;return true}
127
+ installElevenLabsHardwareRouting();
128
+ var hardwarePatchTimer=setInterval(function(){if(installElevenLabsHardwareRouting())clearInterval(hardwarePatchTimer)},500);
129
+ function stage(name,status){var order=['configured','permission','signed_session','connected','conversation'],index=order.indexOf(name);document.querySelectorAll('.step').forEach(function(node,i){node.className='step '+(status==='bad'&&i===index?'bad':i<index?'done':i===index?'live':'')})}
130
+ function headers(){return {'content-type':'application/json','X-RoboPark-Session-Token':eventToken||''}}
131
+ function api(path,options){options=options||{};if(path==='/api/sessions/voice-call'&&options.body){var payload=JSON.parse(options.body);payload.call_mode=mode;payload.robot_id=mode==='production'?(robot||null):null;options.body=JSON.stringify(payload)}var request=Object.assign({cache:'no-store'},options);
132
+ // Without a deadline a stalled control request hung the join forever: no
133
+ // error surfaced, so nothing ever retried.
134
+ if(!request.signal&&window.AbortSignal&&AbortSignal.timeout)request.signal=AbortSignal.timeout(25000);
135
+ return fetch(API_BASE+path,request).then(function(response){if(!response.ok)return response.text().then(function(body){var failure=new Error(body||('request '+response.status));failure.status=response.status;throw failure});return response.json()})}
136
+ function append(role,message){message=String(message||'').trim();if(!message)return;var key=role+'|'+message;if(seen[key])return;seen[key]=1;var node=document.createElement('div');node.className='turn '+role;node.textContent=message;el('transcript').appendChild(node);el('transcript').scrollTop=el('transcript').scrollHeight}
137
+ function management(path,body){if(!session||!eventToken)return Promise.resolve();return api('/api/sessions/'+encodeURIComponent(session)+path,{method:'POST',headers:headers(),body:JSON.stringify(body||{})}).catch(function(){})}
138
+ function event(stageName,status,message,details){var row=document.createElement('div');row.className='event';row.innerHTML='<span>'+String(stageName||'event').replace(/[<>]/g,'')+'</span><span class="'+String(status||'ok').replace(/[<>]/g,'')+'">'+String(status||'ok').replace(/[<>]/g,'')+'</span><span>'+String(message||'').replace(/[<>]/g,'')+'</span>';var host=el('events');if(host){host.prepend(row);while(host.children.length>9)host.lastElementChild.remove()}return Promise.resolve()}
139
+ function saveTurn(role,message){message=String(message||'').trim();if(!message)return Promise.resolve();return management('/transcript',{role:role,text:message,is_final:true,sequence:Object.keys(seen).length,source:'public_join'})}
140
+ function renderDetail(detail){var events=(detail&&detail.pipeline_events)||[],latest=events.length?events[events.length-1]:null;if(latest&&latest.status==='failed')stage('conversation','bad');el('events').innerHTML=events.slice(-9).reverse().map(function(item){return '<div class="event"><span>'+String(item.stage||'event').replace(/[<>]/g,'')+'</span><span class="'+String(item.status||'')+'">'+String(item.status||'')+'</span><span>'+String(item.message||'').replace(/[<>]/g,'')+'</span></div>'}).join('')||'<div class="event"><span>connected</span><span class="ok">ok</span><span>Waiting for conversation events.</span></div>'}
141
+ function pollDetail(){applySpeakerVolume()}
142
+ function plainDetails(value){try{return JSON.parse(JSON.stringify(value||{}))}catch(_){return {raw:String(value||'')}}}
143
+ function closeManaged(reason,details){var oldSession=session,oldToken=eventToken;if(!oldSession||!oldToken)return Promise.resolve();return api('/api/sessions/'+encodeURIComponent(oldSession)+'/end?reason='+encodeURIComponent(reason),{method:'POST',headers:{'content-type':'application/json','X-RoboPark-Session-Token':oldToken},body:JSON.stringify({details:plainDetails(details),source:'public_join'})}).catch(function(){})}
144
+ function stopTimers(){if(retryTimer){clearTimeout(retryTimer);retryTimer=null}if(keepalive){clearInterval(keepalive);keepalive=null}if(poller){clearInterval(poller);poller=null}stopVision();stopMotion()}
145
+ 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)}
146
+ 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()}
147
+ 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}
148
+ // This call lives on an ElevenLabs WebSocket and never joins a LiveKit room,
149
+ // so the control center cannot observe it. Check in on a timer, and close the
150
+ // session on unload, or the dashboard cannot tell a live call from a closed
151
+ // tab and keeps showing the character as busy.
152
+ function startKeepalive(){if(keepalive)clearInterval(keepalive);keepalive=setInterval(function(){management('/heartbeat',{source:'public_join'})},30000)}
153
+ window.addEventListener('pagehide',function(){
154
+ if(!session||!eventToken||!navigator.sendBeacon)return;
155
+ 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(_){}
156
+ });
157
+ 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}}
158
+ 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(mode==='production')startMotion(myGeneration)},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();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';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;
159
+ // A 4xx means the link itself is wrong and retrying cannot fix it. Anything
160
+ // else (5xx, signing timeout, network drop) is transient, and dead-ending
161
+ // here is what left a failed call with no reconnect until a manual re-join.
162
+ var status=Number(error&&error.status||0),permanent=status>=400&&status<500;
163
+ if(!permanent&&alwaysOn&&desired&&!ending){el('join').hidden=true;el('end').hidden=false;scheduleReconnect();return}
164
+ fail(details.message)})}
165
+ 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))}})}
166
+ 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()}};
167
+ function pollDetail(){applySpeakerVolume()}
168
+ 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()})}
169
+ 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)})}})}
170
+ 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>'})}
171
+ 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)};
172
+ 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=''}};
173
+ 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');
174
+ if(character)loadDeployment();
175
+ 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.')});
176
+ })();
177
+ </script></body></html>
@@ -1,75 +1,75 @@
1
- #!/usr/bin/env python3
2
- """Loopback-only RoboPark desktop supervisor for noVNC mesh relay."""
3
- import argparse
4
- import json
5
- import os
6
- import pwd
7
- import shutil
8
- import signal
9
- import subprocess
10
- import time
11
- from pathlib import Path
12
-
13
- running = True
14
- children = []
15
-
16
- def stop(*_):
17
- global running
18
- running = False
19
-
20
- def desktop_user(config):
21
- preferred = config.get("desktop_user") or os.environ.get("SUDO_USER")
22
- if preferred and preferred != "root":
23
- return pwd.getpwnam(preferred)
24
- candidates = [entry for entry in pwd.getpwall() if 1000 <= entry.pw_uid < 65534 and entry.pw_shell not in ("/usr/sbin/nologin", "/bin/false")]
25
- if not candidates:
26
- raise RuntimeError("no desktop user found; rerun with --desktop-user USER")
27
- return candidates[0]
28
-
29
- def command(config):
30
- user = desktop_user(config)
31
- runtime = Path(f"/run/user/{user.pw_uid}")
32
- wayland = sorted(runtime.glob("wayland-*"))
33
- if wayland and shutil.which("wayvnc"):
34
- return ["runuser", "-u", user.pw_name, "--", "env", f"XDG_RUNTIME_DIR={runtime}", f"WAYLAND_DISPLAY={wayland[0].name}", "wayvnc", "127.0.0.1", "5900"]
35
- if Path("/tmp/.X11-unix/X0").exists() and shutil.which("x11vnc"):
36
- return ["x11vnc", "-display", ":0", "-localhost", "-forever", "-shared", "-rfbport", "5900", "-nopw"]
37
- raise RuntimeError("no supported desktop found (install wayvnc for Wayland or x11vnc for X11)")
38
-
39
- def spawn_all(config):
40
- vnc = subprocess.Popen(command(config))
41
- web = shutil.which("websockify")
42
- novnc = next((p for p in ("/usr/share/novnc", "/usr/share/novnc/utils/../") if Path(p).exists()), None)
43
- if not web or not novnc:
44
- vnc.terminate()
45
- raise RuntimeError("websockify/noVNC missing; run `sudo robopark screen install`")
46
- proxy = subprocess.Popen([web, "--web", novnc, "127.0.0.1:6080", "127.0.0.1:5900"])
47
- return [vnc, proxy]
48
-
49
- def main():
50
- parser = argparse.ArgumentParser()
51
- parser.add_argument("--config", required=True)
52
- args = parser.parse_args()
53
- config = json.loads(Path(args.config).read_text())
54
- signal.signal(signal.SIGTERM, stop)
55
- signal.signal(signal.SIGINT, stop)
56
- global children
57
- while running:
58
- try:
59
- children = spawn_all(config)
60
- print(f"[screen] ready: noVNC loopback 127.0.0.1:6080, VNC loopback 127.0.0.1:5900", flush=True)
61
- while running and all(child.poll() is None for child in children):
62
- time.sleep(1)
63
- except Exception as exc:
64
- print(f"[screen] unavailable: {exc}", flush=True)
65
- for child in children:
66
- if child.poll() is None:
67
- child.terminate()
68
- for child in children:
69
- try: child.wait(timeout=5)
70
- except subprocess.TimeoutExpired: child.kill()
71
- children = []
72
- if running: time.sleep(3)
73
-
74
- if __name__ == "__main__":
75
- main()
1
+ #!/usr/bin/env python3
2
+ """Loopback-only RoboPark desktop supervisor for noVNC mesh relay."""
3
+ import argparse
4
+ import json
5
+ import os
6
+ import pwd
7
+ import shutil
8
+ import signal
9
+ import subprocess
10
+ import time
11
+ from pathlib import Path
12
+
13
+ running = True
14
+ children = []
15
+
16
+ def stop(*_):
17
+ global running
18
+ running = False
19
+
20
+ def desktop_user(config):
21
+ preferred = config.get("desktop_user") or os.environ.get("SUDO_USER")
22
+ if preferred and preferred != "root":
23
+ return pwd.getpwnam(preferred)
24
+ candidates = [entry for entry in pwd.getpwall() if 1000 <= entry.pw_uid < 65534 and entry.pw_shell not in ("/usr/sbin/nologin", "/bin/false")]
25
+ if not candidates:
26
+ raise RuntimeError("no desktop user found; rerun with --desktop-user USER")
27
+ return candidates[0]
28
+
29
+ def command(config):
30
+ user = desktop_user(config)
31
+ runtime = Path(f"/run/user/{user.pw_uid}")
32
+ wayland = sorted(runtime.glob("wayland-*"))
33
+ if wayland and shutil.which("wayvnc"):
34
+ return ["runuser", "-u", user.pw_name, "--", "env", f"XDG_RUNTIME_DIR={runtime}", f"WAYLAND_DISPLAY={wayland[0].name}", "wayvnc", "127.0.0.1", "5900"]
35
+ if Path("/tmp/.X11-unix/X0").exists() and shutil.which("x11vnc"):
36
+ return ["x11vnc", "-display", ":0", "-localhost", "-forever", "-shared", "-rfbport", "5900", "-nopw"]
37
+ raise RuntimeError("no supported desktop found (install wayvnc for Wayland or x11vnc for X11)")
38
+
39
+ def spawn_all(config):
40
+ vnc = subprocess.Popen(command(config))
41
+ web = shutil.which("websockify")
42
+ novnc = next((p for p in ("/usr/share/novnc", "/usr/share/novnc/utils/../") if Path(p).exists()), None)
43
+ if not web or not novnc:
44
+ vnc.terminate()
45
+ raise RuntimeError("websockify/noVNC missing; run `sudo robopark screen install`")
46
+ proxy = subprocess.Popen([web, "--web", novnc, "127.0.0.1:6080", "127.0.0.1:5900"])
47
+ return [vnc, proxy]
48
+
49
+ def main():
50
+ parser = argparse.ArgumentParser()
51
+ parser.add_argument("--config", required=True)
52
+ args = parser.parse_args()
53
+ config = json.loads(Path(args.config).read_text())
54
+ signal.signal(signal.SIGTERM, stop)
55
+ signal.signal(signal.SIGINT, stop)
56
+ global children
57
+ while running:
58
+ try:
59
+ children = spawn_all(config)
60
+ print(f"[screen] ready: noVNC loopback 127.0.0.1:6080, VNC loopback 127.0.0.1:5900", flush=True)
61
+ while running and all(child.poll() is None for child in children):
62
+ time.sleep(1)
63
+ except Exception as exc:
64
+ print(f"[screen] unavailable: {exc}", flush=True)
65
+ for child in children:
66
+ if child.poll() is None:
67
+ child.terminate()
68
+ for child in children:
69
+ try: child.wait(timeout=5)
70
+ except subprocess.TimeoutExpired: child.kill()
71
+ children = []
72
+ if running: time.sleep(3)
73
+
74
+ if __name__ == "__main__":
75
+ main()