robopark 3.3.6 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "robopark",
3
- "version": "3.3.6",
3
+ "version": "3.3.8",
4
4
  "description": "RoboPark fleet control CLI \u2014 scheduler, control UI, and character voice calls. Standalone: no infinicode runtime.",
5
5
  "type": "module",
6
6
  "bin": {
package/scheduler/main.py CHANGED
@@ -3393,6 +3393,75 @@ async def issue_livekit_token(payload: LiveKitTokenRequest,
3393
3393
  identity=payload.identity, expires_at=expires,
3394
3394
  )
3395
3395
 
3396
+ class CameraTokenRequest(BaseModel):
3397
+ robot_id: Optional[str] = None
3398
+ character_id: Optional[str] = None
3399
+
3400
+
3401
+ @app.post("/api/camera-token", response_model=LiveKitTokenResponse)
3402
+ async def issue_camera_token(payload: CameraTokenRequest):
3403
+ """Mint a PUBLISH-ONLY token so a browser can publish its camera.
3404
+
3405
+ The control center's own route signs this from LIVEKIT_* environment
3406
+ variables, which a standalone install does not have — it 503s on every
3407
+ attempt while the join page retries, which is the camera-token flood in the
3408
+ console. The keys live in this database, so sign it here instead.
3409
+ """
3410
+ stream_id = (payload.robot_id or payload.character_id or "").strip()
3411
+ if not re.match(r"^[A-Za-z0-9_\-]{1,64}$", stream_id):
3412
+ raise HTTPException(400, "A character or robot stream identity is required")
3413
+
3414
+ try:
3415
+ url, key, sec = await _lk_config_async()
3416
+ except HTTPException:
3417
+ async with aiosqlite.connect(DB_PATH) as db:
3418
+ async with db.execute(
3419
+ "SELECT url, api_key, api_secret FROM livekit_servers "
3420
+ "WHERE api_key IS NOT NULL AND api_secret IS NOT NULL "
3421
+ "ORDER BY (status = 'online') DESC, created_at ASC LIMIT 1"
3422
+ ) as c:
3423
+ row = await c.fetchone()
3424
+ if not row:
3425
+ raise HTTPException(503, "No LiveKit server is configured on the scheduler")
3426
+ url, key, sec = row[0], row[1], row[2]
3427
+
3428
+ try:
3429
+ from livekit.api import AccessToken, VideoGrants
3430
+ except ImportError:
3431
+ raise HTTPException(503, "livekit-api not installed on scheduler")
3432
+
3433
+ room = f"robopark-{stream_id}"
3434
+ identity = f"voice-vision:{stream_id}:{uuid.uuid4().hex[:8]}"
3435
+ at = (
3436
+ AccessToken(key, sec)
3437
+ .with_identity(identity)
3438
+ .with_name(f"{stream_id} voice + vision")
3439
+ # No character_id in metadata: this participant only publishes a camera,
3440
+ # and letting it advertise a character would allow one link to retag a
3441
+ # live robot so the wrong persona answers.
3442
+ .with_metadata(_json.dumps({
3443
+ "robot_id": payload.robot_id or None,
3444
+ "stream_id": stream_id,
3445
+ "participant_role": "voice_vision",
3446
+ }))
3447
+ .with_ttl(timedelta(hours=2))
3448
+ .with_grants(VideoGrants(
3449
+ room=room,
3450
+ room_join=True,
3451
+ can_publish=True,
3452
+ can_publish_data=True,
3453
+ can_subscribe=False,
3454
+ ))
3455
+ )
3456
+ return LiveKitTokenResponse(
3457
+ url=url,
3458
+ token=at.to_jwt(),
3459
+ room=room,
3460
+ identity=identity,
3461
+ expires_at=datetime.utcnow() + timedelta(hours=2),
3462
+ )
3463
+
3464
+
3396
3465
  class MonitorTokenRequest(BaseModel):
3397
3466
  room_name: str
3398
3467
 
@@ -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-greeting-20260728-1">
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);
@@ -98,25 +98,181 @@
98
98
  // active conversation, with the cooldown reserved before sending so a burst of
99
99
  // motion cannot queue duplicate greetings.
100
100
  var motionTimer=null,motionCanvas=null,motionPrev=null,motionLastFire=0,motionArmedAt=0,motionMode='listening',motionLastTurn=0,motionDirective='',motionSent={};
101
- 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;
101
+ // Presence, not just movement. Frame differencing only sees CHANGE, so a
102
+ // visitor standing still reads as zero motion; holding presence for a few
103
+ // seconds after the last movement is what stops a stationary person from
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,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.';
116
+ var motionThreshold=Math.max(.005,Math.min(1,Number(q.get('motion_threshold'))||.06)),
117
+ motionCooldown=Math.max(5,Number(q.get('motion_cooldown'))||20)*1000,
118
+ // Fast sampling so a greeting lands within a fraction of a second of
119
+ // someone arriving, rather than up to a full tick later.
120
+ motionInterval=Math.max(100,Math.min(2000,Number(q.get('motion_interval'))||250)),
121
+ motionHold=Math.max(1000,Number(q.get('motion_hold'))||8000),
122
+ // How long the frame must be empty before a return counts as a NEW
123
+ // visitor. This replaces the old blanket "stay quiet for 45s after any
124
+ // turn", which made motion useless on exactly the long always-on calls
125
+ // it was meant for.
126
+ motionAbsence=Math.max(2000,Number(q.get('motion_absence'))||25000),
127
+ motionContextGap=Math.max(5000,Number(q.get('motion_context_gap'))||20000),
128
+ motionSample=32;
102
129
  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}
130
+ // Silent channel: contextual updates never start a turn, so these are safe to
131
+ // send while the character is mid-sentence. Rate-limited because every one of
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
+ }
213
+ function motionContext(now,text){
214
+ if(now-motionLastContext<motionContextGap)return;
215
+ motionLastContext=now;
216
+ try{conversation.sendContextualUpdate(text)}catch(_){}
217
+ }
103
218
  function motionTick(myGeneration){
104
- // Checked per tick, not at start, so the always-on toggle takes effect live.
105
- if(ending||!conversation||myGeneration!==generation||!alwaysOn)return;
219
+ if(ending||!conversation||myGeneration!==generation)return;
106
220
  var level=motionLevel(),now=Date.now();
107
- if(level<0||now<motionArmedAt)return;
108
- if(level<motionThreshold)return;
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}
230
+ // Normalise to change-per-second. The raw difference between two frames
231
+ // scales with the gap between them, so without this a faster sample rate
232
+ // would silently make the configured threshold four times harder to hit.
233
+ var elapsed=motionLastSampleAt?Math.max(1,now-motionLastSampleAt):motionInterval;
234
+ motionLastSampleAt=now;
235
+ if(level*1000/elapsed>=motionThreshold)motionLastSeen=now;
236
+
237
+ var present=motionLastSeen>0&&(now-motionLastSeen)<motionHold;
238
+ if(present===motionPresent)return;
239
+ motionPresent=present;
240
+
241
+ if(!present){
242
+ motionAbsentSince=now;
243
+ motionContext(now,'[presence] The person in front of the camera has moved away.');
244
+ event('motion_presence','ok','Visitor left the frame',{});
245
+ return;
246
+ }
247
+
248
+ var absence=motionAbsentSince?now-motionAbsentSince:Infinity;
249
+ motionContext(now,'[presence] Someone is now standing in front of the camera.');
250
+
251
+ // Speaking: never cut in. The silent update above already told the model
252
+ // someone arrived, so it can react on its own next turn.
253
+ if(motionMode==='speaking')return;
254
+ // Too brief a gap means the same visitor shifted or stepped out of frame
255
+ // for a moment — greeting them again would be the annoying behaviour.
256
+ if(absence<motionAbsence)return;
109
257
  if(now-motionLastFire<motionCooldown)return;
110
- if(motionMode!=='listening')return;
111
- if(now-motionLastTurn<motionQuiet)return;
258
+
112
259
  motionLastFire=now;
113
260
  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.';
114
261
  motionSent[directive]=1;
115
- try{conversation.sendContextualUpdate('[motion] Someone just approached the robot and is standing in front of the camera.')}catch(_){}
116
262
  try{conversation.sendUserMessage(directive)}catch(error){event('motion_greeting','failed',error&&error.message||String(error),{});return}
117
- event('motion_greeting','ok','Motion greeting triggered',{level:Number(level.toFixed(4)),threshold:motionThreshold})
263
+ event('motion_greeting','ok','Motion greeting triggered',{absence_ms:absence===Infinity?null:Math.round(absence),threshold:motionThreshold})
264
+ }
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})
118
275
  }
119
- function startMotion(myGeneration){stopMotion();motionPrev=null;motionLastFire=0;motionSent={};motionDirective=q.get('motion_prompt')||'';motionArmedAt=Date.now()+4000;motionTimer=setInterval(function(){motionTick(myGeneration)},1000)}
120
276
  function stopMotion(){if(motionTimer){clearInterval(motionTimer);motionTimer=null}motionPrev=null}
121
277
  function showRobotVideo(){return startLocalVideo().catch(function(){})}
122
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()}
@@ -151,7 +307,9 @@
151
307
  function stopTimers(){if(retryTimer){clearTimeout(retryTimer);retryTimer=null}if(keepalive){clearInterval(keepalive);keepalive=null}if(poller){clearInterval(poller);poller=null}stopVision();stopMotion()}
152
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)}
153
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()}
154
- 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}
155
313
  // This call lives on an ElevenLabs WebSocket and never joins a LiveKit room,
156
314
  // so the control center cannot observe it. Check in on a timer, and close the
157
315
  // session on unload, or the dashboard cannot tell a live call from a closed
@@ -162,7 +320,7 @@
162
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(_){}
163
321
  });
164
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}}
165
- 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;
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;
166
324
  // A 4xx means the link itself is wrong and retrying cannot fix it. Anything
167
325
  // else (5xx, signing timeout, network drop) is transient, and dead-ending
168
326
  // here is what left a failed call with no reconnect until a manual re-join.
@@ -174,13 +332,18 @@
174
332
  if(!permanent&&alwaysOn&&desired&&!ending){el('join').hidden=true;el('end').hidden=false;scheduleReconnect();return}
175
333
  fail(details.message)})}
176
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
+ };
177
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()}};
178
341
  function pollDetail(){applySpeakerVolume()}
179
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()})}
180
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)})}})}
181
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>'})}
182
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)};
183
- 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=''}};
184
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');
185
348
  if(character)loadDeployment();
186
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 +1 @@
1
- 3hp0A6CE4VTmuh3NC6Bep
1
+ 7hosI6Tk4PpkcjmN3-s_F