infinicode 2.8.65 → 2.8.67

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.
@@ -14,10 +14,9 @@
14
14
  * for its cam / mic / speaker / hardware drawer)
15
15
  * Sessions & Telemetry — RoboPark scheduler stats + production_mode + controls
16
16
  *
17
- * The Park cam panel targets a LiveKit video track: it fetches a per-robot stream
18
- * token from /robopark/api/robots/:id/stream and, when the livekit-client SDK and
19
- * a published track are present, subscribes; otherwise it shows a styled preview.
20
- * That token/track is produced by the robot-side RoboPark plugin.
17
+ * The Park cam panel relays RoboVisionAI_PI's existing MJPEG feed through the
18
+ * authenticated mesh. LiveKit remains the voice transport but is deliberately
19
+ * not used for dashboard video, avoiding camera ownership conflicts on robots.
21
20
  *
22
21
  * Kept free of backticks and ${...} so the whole page fits inside this TS
23
22
  * template literal without escaping; the client JS uses string concatenation.
@@ -530,8 +529,34 @@ button.act:disabled{opacity:0.5;cursor:default;}
530
529
  .rp-modal-body .kv{display:grid;grid-template-columns:160px 1fr;gap:0.4rem 0.8rem;margin-bottom:0.4rem;}
531
530
  .rp-modal-body .kv .k{color:var(--muted);font-size:0.66rem;text-transform:uppercase;letter-spacing:0.05em;}
532
531
  .rp-modal-body .kv .v{color:var(--ink);word-break:break-word;white-space:pre-wrap;}
533
- .rp-modal-body pre{background:var(--panel-solid);border:1px solid var(--glass-border);border-radius:8px;padding:0.5rem 0.7rem;
534
- font-size:0.66rem;max-height:240px;overflow:auto;white-space:pre-wrap;word-break:break-word;color:var(--ink);}
532
+ .rp-modal-body pre{background:var(--panel-solid);border:1px solid var(--glass-border);border-radius:8px;padding:0.5rem 0.7rem;
533
+ font-size:0.66rem;max-height:240px;overflow:auto;white-space:pre-wrap;word-break:break-word;color:var(--ink);}
534
+
535
+ .rp-call-shell{display:grid;grid-template-columns:minmax(260px,0.8fr) minmax(320px,1.2fr);gap:0.8rem;min-height:500px;}
536
+ .rp-call-panel{border:1px solid var(--glass-border);border-radius:14px;background:var(--panel);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);padding:0.85rem;}
537
+ .rp-call-character{display:flex;align-items:center;gap:0.8rem;padding:0.7rem;border:1px solid var(--glass-border);border-radius:12px;background:var(--panel-2);margin-bottom:0.75rem;}
538
+ .rp-call-character img{width:78px;height:78px;object-fit:contain;filter:drop-shadow(0 8px 16px rgba(0,0,0,.35));}
539
+ .rp-call-character .name{font-size:1.05rem;color:var(--gold-bright);font-weight:700;}
540
+ .rp-call-state{display:flex;align-items:center;gap:0.55rem;font-family:var(--mono);font-size:0.72rem;padding:0.65rem 0;}
541
+ .rp-call-orb{width:10px;height:10px;border-radius:50%;background:var(--muted);box-shadow:0 0 0 transparent;}
542
+ .rp-call-orb.live{background:var(--ok);box-shadow:0 0 12px var(--ok);animation:pulse 1.6s infinite;}
543
+ .rp-call-orb.busy{background:var(--gold-bright);box-shadow:0 0 12px var(--gold-bright);animation:pulse 1s infinite;}
544
+ .rp-call-actions{display:flex;gap:0.45rem;flex-wrap:wrap;margin-top:0.7rem;}
545
+ .rp-call-transcript{height:430px;overflow-y:auto;display:flex;flex-direction:column;gap:0.55rem;padding:0.3rem;}
546
+ .rp-turn{max-width:86%;border:1px solid var(--glass-border);border-radius:12px;padding:0.55rem 0.7rem;font-family:var(--mono);font-size:0.72rem;line-height:1.45;background:var(--panel-2);}
547
+ .rp-turn.user{align-self:flex-end;border-color:color-mix(in srgb,var(--info) 45%,transparent);background:color-mix(in srgb,var(--info) 9%,var(--panel-2));}
548
+ .rp-turn.assistant{align-self:flex-start;border-color:color-mix(in srgb,var(--gold-bright) 42%,transparent);}
549
+ .rp-turn .who{font-size:0.57rem;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:.18rem;}
550
+ .rp-detail-hero{display:flex;align-items:center;gap:.8rem;padding:.6rem;border:1px solid var(--glass-border);border-radius:12px;background:var(--panel-2);margin-bottom:.8rem;}
551
+ .rp-detail-hero img{width:72px;height:72px;object-fit:contain;}
552
+ .rp-pipeline{display:flex;flex-direction:column;gap:.3rem;margin-top:.5rem;}
553
+ .rp-pipeline-row{display:grid;grid-template-columns:76px 125px 70px 1fr;gap:.5rem;padding:.38rem .5rem;border-left:3px solid var(--glass-border);background:var(--panel-2);border-radius:5px;font-family:var(--mono);font-size:.64rem;}
554
+ .rp-pipeline-row.ok{border-left-color:var(--ok)} .rp-pipeline-row.failed,.rp-pipeline-row.blocked{border-left-color:var(--bad)}
555
+ .node .node-hero{display:flex;align-items:center;gap:.65rem;margin:.45rem 0 .55rem;}
556
+ .node .node-avatar{width:68px;height:68px;object-fit:contain;flex:none;filter:drop-shadow(0 7px 12px rgba(0,0,0,.3));}
557
+ .node .node-health{font-family:var(--mono);font-size:.61rem;line-height:1.45;color:var(--muted);}
558
+ .node .node-health b{color:var(--ink);}
559
+ @media(max-width:820px){.rp-call-shell{grid-template-columns:1fr}.rp-call-transcript{height:320px}.rp-pipeline-row{grid-template-columns:62px 105px 58px 1fr}}
535
560
 
536
561
  /* ── TTS voice picker (used inside the generic modal) ── */
537
562
  .rp-voice-picker{display:flex;flex-direction:column;gap:0.6rem;min-height:320px;}
@@ -659,7 +684,8 @@ button.act:disabled{opacity:0.5;cursor:default;}
659
684
  <div class="rp-subtabs" id="rp-subtabs" role="tablist">
660
685
  <button class="rp-subtab active" data-sub="overview" id="rpsbtn-overview" role="tab" aria-selected="true">Overview</button>
661
686
  <button class="rp-subtab" data-sub="incidents" id="rpsbtn-incidents" role="tab" aria-selected="false">Incidents <span class="badge" id="rps-incidents-badge">0</span></button>
662
- <button class="rp-subtab" data-sub="live" id="rpsbtn-live" role="tab" aria-selected="false">Live Sessions <span class="badge" id="rps-live-badge">0</span></button>
687
+ <button class="rp-subtab" data-sub="live" id="rpsbtn-live" role="tab" aria-selected="false">Live Sessions <span class="badge" id="rps-live-badge">0</span></button>
688
+ <button class="rp-subtab" data-sub="voice" id="rpsbtn-voice" role="tab" aria-selected="false">Voice Call</button>
663
689
  <button class="rp-subtab" data-sub="history" id="rpsbtn-history" role="tab" aria-selected="false">History</button>
664
690
  <button class="rp-subtab" data-sub="audit" id="rpsbtn-audit" role="tab" aria-selected="false">Audit Log</button>
665
691
  <button class="rp-subtab" data-sub="fleet" id="rpsbtn-fleet" role="tab" aria-selected="false">Fleet</button>
@@ -701,7 +727,30 @@ button.act:disabled{opacity:0.5;cursor:default;}
701
727
  </div>
702
728
 
703
729
  <!-- ═══ History ═══ -->
704
- <div class="rp-sub" id="rp-sub-history">
730
+ <div class="rp-sub" id="rp-sub-voice">
731
+ <div class="rp-call-shell">
732
+ <div class="rp-call-panel">
733
+ <div class="rp-section-h">Operator voice call <span class="meta">RoboVoice pipeline</span></div>
734
+ <div class="rp-call-character" id="rp-call-character"><div><div class="name">Choose a character</div><div class="meta">The selected preset and stack are frozen into session history.</div></div></div>
735
+ <div class="rp-field"><label>Character preset</label><select id="rp-call-character-select"><option value="">select character...</option></select></div>
736
+ <div class="rp-field" style="margin-top:.55rem"><label>Voice stack</label><select id="rp-call-stack-select"><option value="">character default</option></select></div>
737
+ <div class="rp-call-state"><span class="rp-call-orb" id="rp-call-orb"></span><span id="rp-call-status">ready to start</span></div>
738
+ <div class="rp-call-actions">
739
+ <button class="rp-btn primary" id="rp-call-start">Start voice call</button>
740
+ <button class="rp-btn" id="rp-call-mute" disabled>Mute microphone</button>
741
+ <button class="rp-btn danger" id="rp-call-end" disabled>End call</button>
742
+ </div>
743
+ <div class="pk-note" style="margin-top:.7rem">Uses your browser microphone. Agent audio plays here and every final transcript turn is saved to session history.</div>
744
+ </div>
745
+ <div class="rp-call-panel">
746
+ <div class="rp-section-h">Live transcript <span class="meta" id="rp-call-session">not connected</span></div>
747
+ <div class="rp-call-transcript" id="rp-call-transcript"><div class="rp-tbl-empty">Transcript appears when the call starts.</div></div>
748
+ <div id="rp-call-audio" hidden></div>
749
+ </div>
750
+ </div>
751
+ </div>
752
+
753
+ <div class="rp-sub" id="rp-sub-history">
705
754
  <div class="rp-filterbar">
706
755
  <div class="rp-field"><label>Search</label><input id="rp-hist-search" placeholder="session id, robot…"></div>
707
756
  <div class="rp-field"><label>Robot</label><select id="rp-hist-robot"><option value="">all robots</option></select></div>
@@ -725,7 +774,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
725
774
  <div class="rp-tbl-wrap" id="rp-hist-wrap">
726
775
  <table class="rp-history-table" id="rp-hist-table">
727
776
  <thead><tr>
728
- <th>Session</th><th>Robot</th><th>Started</th><th>Ended</th><th>Duration</th><th>End reason</th><th>Triggers</th><th>Latency</th><th>Actions</th>
777
+ <th>Session</th><th>Robot</th><th>Started</th><th>Ended</th><th>Duration</th><th>End reason</th><th>Turns</th><th>Pipeline</th><th>Latency</th><th>Actions</th>
729
778
  </tr></thead>
730
779
  <tbody id="rp-hist-tbody"></tbody>
731
780
  </table>
@@ -1143,7 +1192,34 @@ button.act:disabled{opacity:0.5;cursor:default;}
1143
1192
  + '<div class="bar-track"><div class="bar-fill '+loadClass(v)+'" style="width:'+v+'%"></div></div>'
1144
1193
  + '<span class="mv '+(cls||'')+'">'+esc(valTxt)+'</span></div>';
1145
1194
  }
1146
- function renderFleet(nodes){
1195
+ function rpAvatarFor(label){
1196
+ var key=String(label||'').toLowerCase();
1197
+ var presets=(typeof characterPresets!=='undefined' && Array.isArray(characterPresets))?characterPresets:[];
1198
+ for(var i=0;i<presets.length;i++){
1199
+ var p=presets[i], aliases=[p.id,p.name];
1200
+ for(var j=0;j<aliases.length;j++){
1201
+ var a=String(aliases[j]||'').toLowerCase();
1202
+ if(a && (key===a || key.indexOf(a)>=0 || a.indexOf(key)>=0)) return p.img||'';
1203
+ }
1204
+ }
1205
+ if(key.indexOf('bmw')>=0 || key.indexOf('vixen')>=0) return '/static/robopark/bmw.png';
1206
+ var known=['volt','tesla','jaguar','panda'];
1207
+ for(var k=0;k<known.length;k++) if(key.indexOf(known[k])>=0) return '/static/robopark/'+known[k]+'.png';
1208
+ return '';
1209
+ }
1210
+ function rpTelemetryForNode(n){
1211
+ if(typeof roboparkByName==='undefined' || !roboparkByName) return null;
1212
+ var aliases=[n&&n.displayName,n&&n.name,n&&n.nodeId];
1213
+ for(var i=0;i<aliases.length;i++){
1214
+ var key=String(aliases[i]||'').toLowerCase();
1215
+ if(roboparkByName[key]) return roboparkByName[key];
1216
+ for(var existing in roboparkByName){
1217
+ if(existing===key || (key && existing.indexOf(key)>=0) || (existing && key.indexOf(existing)>=0)) return roboparkByName[existing];
1218
+ }
1219
+ }
1220
+ return null;
1221
+ }
1222
+ function renderFleet(nodes){
1147
1223
  var el = $('fleet');
1148
1224
  if(!nodes || !nodes.length){ el.innerHTML = '<div class="empty">No nodes reporting yet. Start peers with <span class="mono">infinicode serve</span> or <span class="mono">robopark setup</span>.</div>'; return; }
1149
1225
  // connected first, then robots/satellites above hubs so the fleet reads robot-first
@@ -1164,13 +1240,19 @@ button.act:disabled{opacity:0.5;cursor:default;}
1164
1240
  var disk = (hw&&hw.disk&&hw.disk.mounts&&hw.disk.mounts[0]) ? hw.disk.mounts[0].percent : null;
1165
1241
  var nt = netRxTx(hw);
1166
1242
  var upt = hw&&hw.system ? hw.system.uptimeSec : null;
1167
- var caps = (n.capabilities||[]).filter(function(c){ return c.indexOf('backend:')!==0; }).slice(0,5);
1168
- var capHtml=''; for(var c=0;c<caps.length;c++) capHtml += '<span class="cap">'+esc(caps[c])+'</span>';
1243
+ var caps = (n.capabilities||[]).filter(function(c){ return c.indexOf('backend:')!==0; }).slice(0,5);
1244
+ var capHtml=''; for(var c=0;c<caps.length;c++) capHtml += '<span class="cap">'+esc(caps[c])+'</span>';
1245
+ var tele=rpTelemetryForNode(n);
1246
+ var avatar=rpAvatarFor(tele&&(tele.character_name||tele.character_id)||n.displayName);
1247
+ var readiness=tele&&(tele.readiness_code||tele.status)||(!up?'offline':'mesh online');
1248
+ var hb=tele&&tele.heartbeat_age_seconds!=null?tele.heartbeat_age_seconds:null;
1169
1249
  var site = n.site?(' · '+esc(n.site)):'';
1170
1250
 
1171
1251
  html += '<div class="node '+(up?'up':'down')+(selected===n.nodeId?' sel':'')+'" tabindex="0" role="button" data-id="'+esc(n.nodeId)+'" data-name="'+esc(n.displayName)+'">'
1172
1252
  + '<div class="top"><span class="name"><span class="statdot '+(up?'on':'off')+'"></span>'+esc(n.displayName)+(isSelf?' <span class="selfbadge">this</span>':'')+'</span><span class="role">'+esc(n.role)+'</span></div>'
1173
- + '<div class="meta">'+esc(n.platform||'')+' '+esc((hw&&hw.cpu&&hw.cpu.model)?hw.cpu.model.replace(/\\s+/g,' ').slice(0,22):'')+site+' <span class="mono">v'+esc(n.version||'?')+'</span></div>';
1253
+ + '<div class="meta">'+esc(n.platform||'')+' '+esc((hw&&hw.cpu&&hw.cpu.model)?hw.cpu.model.replace(/\\s+/g,' ').slice(0,22):'')+site+' <span class="mono">v'+esc(n.version||'?')+'</span></div>'
1254
+ + (avatar?('<div class="node-hero"><img class="node-avatar" alt="'+esc(n.displayName)+'" src="'+esc(avatar+QS)+'"><div class="node-health"><b>'+esc(String(readiness).replace(/_/g,' '))+'</b><br>'
1255
+ +(tele?('sessions '+esc(tele.sessions_total||0)+' / triggers '+esc(tele.trigger_count||0)+'<br>latency '+esc(tele.avg_latency_ms!=null?Math.round(tele.avg_latency_ms)+'ms':'n/a')+' / drops '+esc(tele.drop_rate!=null?Math.round(tele.drop_rate*100)+'%':'n/a')+'<br>heartbeat '+esc(hb!=null?hb+'s ago':'unavailable')):'scheduler telemetry pending')+'</div></div>'):'');
1174
1256
 
1175
1257
  if(up){
1176
1258
  html += mrow('CPU', cpu, cpu+'%'+(cores?(' /'+cores+'c'):''), loadClass(cpu))
@@ -1611,12 +1693,12 @@ button.act:disabled{opacity:0.5;cursor:default;}
1611
1693
  });
1612
1694
 
1613
1695
  var characterPresets=[];
1614
- function loadCharacterPresets(){
1615
- fetch('/robopark/api/character-presets'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){ characterPresets=Array.isArray(d)?d:(d&&d.presets)||[]; fillCharacterDropdowns(); fillTemplateCharacterList(); refreshDrawerPresetOptions(); }).catch(function(){});
1616
- }
1617
- function loadVoiceStacks(){
1618
- fetch('/robopark/api/voice-stacks'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){ voiceStacks=Array.isArray(d)?d:(d&&d.stacks)||[]; fillVoiceStackDropdowns(); fillTemplateVoiceList(); refreshDrawerPresetOptions(); }).catch(function(){});
1619
- }
1696
+ function loadCharacterPresets(){
1697
+ return fetch('/robopark/api/character-presets'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){ characterPresets=Array.isArray(d)?d:(d&&d.presets)||[]; fillCharacterDropdowns(); fillTemplateCharacterList(); refreshDrawerPresetOptions(); refreshVoiceCallOptions(); return characterPresets; }).catch(function(){ return []; });
1698
+ }
1699
+ function loadVoiceStacks(){
1700
+ return fetch('/robopark/api/voice-stacks'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){ voiceStacks=Array.isArray(d)?d:(d&&d.stacks)||[]; fillVoiceStackDropdowns(); fillTemplateVoiceList(); refreshDrawerPresetOptions(); refreshVoiceCallOptions(); return voiceStacks; }).catch(function(){ return []; });
1701
+ }
1620
1702
 
1621
1703
  // ── TTS voice catalog (per provider) ──
1622
1704
  // Holds the dropdown options for whichever TTS provider is currently
@@ -2081,7 +2163,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
2081
2163
  for (var i=0;i<robots.length;i++){
2082
2164
  var x = robots[i];
2083
2165
  var name = robotName(x);
2084
- var avatar = avatarSrcForNode({label:(x.character_name||x.character_id||x.character_preset_id||name)});
2166
+ var avatar = rpAvatarFor(x.character_name||x.character_id||x.character_preset_id||name);
2085
2167
  var lat = x.avg_latency_ms!=null?Math.round(x.avg_latency_ms)+'ms':'—';
2086
2168
  var dcls = x.drop_rate!=null?(x.drop_rate>=0.2?'bad':(x.drop_rate>=0.05?'warn':'ok')):'';
2087
2169
  var drop = x.drop_rate!=null?Math.round(x.drop_rate*100)+'%':'—';
@@ -2193,14 +2275,15 @@ button.act:disabled{opacity:0.5;cursor:default;}
2193
2275
  s.classList.toggle('active', s.id==='rp-sub-'+name);
2194
2276
  }
2195
2277
  // stop every sub-tab interval, then start only the active one
2196
- var names = ['overview','incidents','live','history','audit','fleet','setup'];
2278
+ var names = ['overview','incidents','live','voice','history','audit','fleet','setup'];
2197
2279
  for(var k=0;k<names.length;k++){
2198
2280
  var n=names[k];
2199
2281
  if(rpIntervals[n]){ clearInterval(rpIntervals[n]); rpIntervals[n]=null; }
2200
2282
  }
2201
2283
  if(name==='overview'){ pollOverview(); rpIntervals.overview = setInterval(pollOverview, 4000); }
2202
2284
  if(name==='incidents'){ pollIncidents(); rpIntervals.incidents = setInterval(pollIncidents, 4000); }
2203
- if(name==='live'){ pollLiveSessions(); startLiveAutoRefresh(); }
2285
+ if(name==='live'){ pollLiveSessions(); startLiveAutoRefresh(); }
2286
+ if(name==='voice'){ refreshVoiceCallOptions(); }
2204
2287
  if(name==='history'){ rpHistoryPage=0; pollHistory(); }
2205
2288
  if(name==='audit'){ pollAuditLog(); rpIntervals.audit = setInterval(pollAuditLog, 6000); }
2206
2289
  if(name==='fleet'){ pollFleet(); rpIntervals.fleet = setInterval(pollFleet, 5000); }
@@ -2219,7 +2302,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
2219
2302
  var h=String(location.hash||'').replace('#','');
2220
2303
  var m = h.match(/^robopark\\/(.+)$/);
2221
2304
  var initial = (m && m[1]) ? m[1] : 'overview';
2222
- if(['overview','incidents','live','history','audit','fleet','setup'].indexOf(initial)<0) initial='overview';
2305
+ if(['overview','incidents','live','voice','history','audit','fleet','setup'].indexOf(initial)<0) initial='overview';
2223
2306
  setRpSubTab(initial);
2224
2307
  }
2225
2308
 
@@ -2514,7 +2597,111 @@ button.act:disabled{opacity:0.5;cursor:default;}
2514
2597
  })();
2515
2598
 
2516
2599
  // ── History sub-tab ──
2517
- var rpHistoryFilter = { robot: '', endReason: '', range: '7d', search: '' };
2600
+ var rpVoiceCall={room:null,session:null,eventToken:null,muted:false,ending:false,seen:{}};
2601
+ function refreshVoiceCallOptions(){
2602
+ var cs=$('rp-call-character-select'), ss=$('rp-call-stack-select');
2603
+ if(!cs||!ss) return;
2604
+ var cv=cs.value, sv=ss.value;
2605
+ cs.innerHTML='<option value="">select character...</option>'+(characterPresets||[]).map(function(c){return '<option value="'+esc(c.id)+'">'+esc(c.name||c.id)+'</option>';}).join('');
2606
+ ss.innerHTML='<option value="">character default</option>'+(voiceStacks||[]).map(function(s){return '<option value="'+esc(s.id)+'">'+esc(s.name||s.id)+' - '+esc(s.stt_provider)+' / '+esc(s.llm_model)+' / '+esc(s.tts_provider)+'</option>';}).join('');
2607
+ if(cv) cs.value=cv; if(sv) ss.value=sv;
2608
+ paintVoiceCallCharacter();
2609
+ }
2610
+ function paintVoiceCallCharacter(){
2611
+ var sel=$('rp-call-character-select'), box=$('rp-call-character'); if(!sel||!box) return;
2612
+ var chosen=null; for(var i=0;i<characterPresets.length;i++) if(characterPresets[i].id===sel.value){chosen=characterPresets[i];break;}
2613
+ if(!chosen){ box.innerHTML='<div><div class="name">Choose a character</div><div class="meta">The selected preset and stack are frozen into session history.</div></div>'; return; }
2614
+ var img=chosen.img||rpAvatarFor(chosen.id||chosen.name);
2615
+ box.innerHTML=(img?'<img alt="'+esc(chosen.name)+'" src="'+esc(img+QS)+'">':'')+'<div><div class="name">'+esc(chosen.name||chosen.id)+'</div><div class="meta">'+esc(chosen.description||'Ready for an operator voice call')+'</div></div>';
2616
+ var stack=$('rp-call-stack-select'); if(stack && !stack.value && chosen.voice_stack_id) stack.value=chosen.voice_stack_id;
2617
+ }
2618
+ function setVoiceCallStatus(text,state){
2619
+ var s=$('rp-call-status'), o=$('rp-call-orb'); if(s)s.textContent=text; if(o)o.className='rp-call-orb '+(state||'');
2620
+ }
2621
+ function ensureVoiceLiveKit(){
2622
+ if(window.LivekitClient) return Promise.resolve(window.LivekitClient);
2623
+ return new Promise(function(resolve,reject){
2624
+ var old=document.querySelector('script[data-rp-voice-livekit]');
2625
+ if(old){ old.addEventListener('load',function(){resolve(window.LivekitClient);}); old.addEventListener('error',reject); return; }
2626
+ var script=document.createElement('script'); script.dataset.rpVoiceLivekit='1'; script.src='/vendor/livekit-client.umd.js'+QS;
2627
+ script.onload=function(){resolve(window.LivekitClient);}; script.onerror=function(){reject(new Error('LiveKit browser library failed to load'));}; document.head.appendChild(script);
2628
+ });
2629
+ }
2630
+ function appendVoiceTurn(role,text){
2631
+ var wrap=$('rp-call-transcript'); if(!wrap)return;
2632
+ if(wrap.querySelector('.rp-tbl-empty')) wrap.innerHTML='';
2633
+ var div=document.createElement('div'); div.className='rp-turn '+role;
2634
+ div.innerHTML='<div class="who">'+esc(role==='user'?'operator':'character')+'</div><div>'+esc(text)+'</div>';
2635
+ wrap.appendChild(div); wrap.scrollTop=wrap.scrollHeight;
2636
+ }
2637
+ function saveVoiceTurn(role,text,sequence){
2638
+ if(!rpVoiceCall.session||!rpVoiceCall.eventToken)return;
2639
+ fetch('/robopark/api/sessions/'+encodeURIComponent(rpVoiceCall.session)+'/transcript'+QS,{
2640
+ method:'POST',headers:Object.assign({'content-type':'application/json','X-RoboPark-Session-Token':rpVoiceCall.eventToken},operatorHeaders()),
2641
+ body:JSON.stringify({role:role,text:text,is_final:true,sequence:sequence,source:'control_center'})
2642
+ }).catch(function(){});
2643
+ }
2644
+ function startVoiceCall(){
2645
+ var cid=$('rp-call-character-select').value, sid=$('rp-call-stack-select').value;
2646
+ if(!cid){setVoiceCallStatus('select a character first','');return;}
2647
+ if(rpVoiceCall.room)return;
2648
+ setVoiceCallStatus('creating scheduled session...','busy'); $('rp-call-start').disabled=true;
2649
+ rpAction('POST','/robopark/api/sessions/voice-call',{character_preset_id:cid,voice_stack_id:sid||null,client_name:getOperatorName()||'RoboPark Operator'})
2650
+ .then(function(call){
2651
+ if(!call)throw new Error('scheduler returned no call');
2652
+ rpVoiceCall.session=call.session_id; rpVoiceCall.eventToken=call.event_token; rpVoiceCall.seen={}; rpVoiceCall.ending=false;
2653
+ $('rp-call-session').textContent=call.session_id; $('rp-call-transcript').innerHTML='<div class="rp-tbl-empty">Connecting to '+esc(call.character&&call.character.name||cid)+'...</div>';
2654
+ return ensureVoiceLiveKit().then(function(LK){
2655
+ var room=new LK.Room({adaptiveStream:true,dynacast:true}); rpVoiceCall.room=room;
2656
+ room.on(LK.RoomEvent.TrackSubscribed,function(track){ if(track.kind==='audio'){var el=track.attach();el.autoplay=true;$('rp-call-audio').appendChild(el);} });
2657
+ room.on(LK.RoomEvent.TranscriptionReceived,function(segments,participant){
2658
+ for(var i=0;i<(segments||[]).length;i++){
2659
+ var seg=segments[i], final=seg.final===true||seg.isFinal===true; if(!final||!String(seg.text||'').trim())continue;
2660
+ var key=String(seg.id||'')+'|'+String(seg.text); if(rpVoiceCall.seen[key])continue; rpVoiceCall.seen[key]=1;
2661
+ var identity=participant&&participant.identity||''; var role=identity.indexOf('operator:')===0?'user':'assistant';
2662
+ appendVoiceTurn(role,String(seg.text)); saveVoiceTurn(role,String(seg.text),Object.keys(rpVoiceCall.seen).length);
2663
+ }
2664
+ });
2665
+ room.on(LK.RoomEvent.DataReceived,function(payload,participant,kind,topic){
2666
+ if(topic && topic!=='lk-chat-topic')return;
2667
+ try{
2668
+ var msg=JSON.parse(new TextDecoder().decode(payload));
2669
+ var text=String(msg.message||msg.text||'').trim(); if(!text)return;
2670
+ var role=msg.role==='user'?'user':'assistant';
2671
+ var key=role+'|'+text; if(rpVoiceCall.seen[key])return; rpVoiceCall.seen[key]=1;
2672
+ appendVoiceTurn(role,text); saveVoiceTurn(role,text,Object.keys(rpVoiceCall.seen).length);
2673
+ }catch(_){}
2674
+ });
2675
+ room.on(LK.RoomEvent.Disconnected,function(){ if(!rpVoiceCall.ending) finishVoiceCall('connection_lost'); });
2676
+ return room.connect(call.server_url,call.token).then(function(){return room.localParticipant.setMicrophoneEnabled(true);});
2677
+ }).then(function(){
2678
+ setVoiceCallStatus('connected - listening','live'); $('rp-call-mute').disabled=false; $('rp-call-end').disabled=false;
2679
+ return fetch('/robopark/api/sessions/'+encodeURIComponent(rpVoiceCall.session)+'/joined'+QS,{method:'POST',headers:{'X-RoboPark-Session-Token':rpVoiceCall.eventToken}}).catch(function(){});
2680
+ });
2681
+ }).catch(function(e){
2682
+ setVoiceCallStatus('call failed: '+(e&&e.message||e),''); $('rp-call-start').disabled=false;
2683
+ if(rpVoiceCall.room){try{rpVoiceCall.room.disconnect();}catch(_){} rpVoiceCall.room=null;}
2684
+ });
2685
+ }
2686
+ function toggleVoiceMute(){
2687
+ if(!rpVoiceCall.room)return; rpVoiceCall.muted=!rpVoiceCall.muted;
2688
+ rpVoiceCall.room.localParticipant.setMicrophoneEnabled(!rpVoiceCall.muted);
2689
+ $('rp-call-mute').textContent=rpVoiceCall.muted?'Unmute microphone':'Mute microphone'; setVoiceCallStatus(rpVoiceCall.muted?'connected - microphone muted':'connected - listening','live');
2690
+ }
2691
+ function finishVoiceCall(reason){
2692
+ if(rpVoiceCall.ending)return; rpVoiceCall.ending=true; var session=rpVoiceCall.session, room=rpVoiceCall.room;
2693
+ rpVoiceCall.room=null; if(room){try{room.disconnect();}catch(_){}}
2694
+ if(session) rpAction('POST','/robopark/api/sessions/'+encodeURIComponent(session)+'/end?reason='+encodeURIComponent(reason||'operator'));
2695
+ setVoiceCallStatus('call ended',''); $('rp-call-start').disabled=false; $('rp-call-mute').disabled=true; $('rp-call-end').disabled=true;
2696
+ $('rp-call-mute').textContent='Mute microphone'; rpVoiceCall.session=null; rpVoiceCall.eventToken=null; rpVoiceCall.muted=false; setTimeout(function(){rpVoiceCall.ending=false;},300);
2697
+ }
2698
+ (function(){
2699
+ var cs=$('rp-call-character-select'), start=$('rp-call-start'), mute=$('rp-call-mute'), end=$('rp-call-end');
2700
+ if(cs)cs.addEventListener('change',paintVoiceCallCharacter); if(start)start.addEventListener('click',startVoiceCall);
2701
+ if(mute)mute.addEventListener('click',toggleVoiceMute); if(end)end.addEventListener('click',function(){finishVoiceCall('operator');});
2702
+ })();
2703
+
2704
+ var rpHistoryFilter = { robot: '', endReason: '', range: '7d', search: '' };
2518
2705
  var rpHistoryPage = 0;
2519
2706
  var RP_HISTORY_PAGE_SIZE = 25;
2520
2707
  function pollHistory(){
@@ -2588,7 +2775,8 @@ button.act:disabled{opacity:0.5;cursor:default;}
2588
2775
  var dur = s.duration_seconds!=null ? s.duration_seconds : (started&&ended?Math.floor((ended-started)/1000):null);
2589
2776
  var reason = s.end_reason || '';
2590
2777
  var reasonCls = rpEndReasonClass(reason);
2591
- var trig = s.trigger_count!=null ? s.trigger_count : '—';
2778
+ var turns = s.transcript_count!=null ? s.transcript_count : 0;
2779
+ var pipe = s.error_count ? (s.error_count+' errors') : ((s.pipeline_event_count||0)+' events');
2592
2780
  var lat = s.avg_latency_ms!=null ? Math.round(s.avg_latency_ms)+'ms' : '—';
2593
2781
  html += '<tr data-sid="'+esc(sid)+'">'
2594
2782
  + '<td><span class="sid" title="'+esc(sid)+'">'+esc(sid)+'</span></td>'
@@ -2597,7 +2785,8 @@ button.act:disabled{opacity:0.5;cursor:default;}
2597
2785
  + '<td><span class="t-rel">'+esc(rpFmtRelative(ended))+'</span><span class="t-abs">'+esc(rpFmtAbs(ended))+'</span></td>'
2598
2786
  + '<td>'+esc(rpFmtDuration(dur))+'</td>'
2599
2787
  + '<td><span class="rp-end-badge '+(reasonCls||'')+'">'+esc(reason||'—')+'</span></td>'
2600
- + '<td>'+esc(trig)+'</td>'
2788
+ + '<td>'+esc(turns)+'</td>'
2789
+ + '<td class="'+(s.error_count?'bad':'ok')+'">'+esc(pipe)+'</td>'
2601
2790
  + '<td>'+esc(lat)+'</td>'
2602
2791
  + '<td><button class="rp-btn" data-act="view" data-sid="'+esc(sid)+'">View</button></td>'
2603
2792
  + '</tr>';
@@ -2608,29 +2797,27 @@ button.act:disabled{opacity:0.5;cursor:default;}
2608
2797
  if(prev) prev.disabled = rpHistoryPage<=0;
2609
2798
  if(next) next.disabled = rpHistoryPage>=totalPages-1;
2610
2799
  }
2611
- function openSessionDetailModal(s){
2612
- if(!s) return;
2613
- function row(k,v){ return '<div class="kv"><div class="k">'+esc(k)+'</div><div class="v">'+esc(v==null?'—':v)+'</div></div>'; }
2614
- var html = '';
2615
- html += row('Session ID', s.session_id);
2616
- html += row('Robot', s.robot_id);
2617
- html += row('Room', s.room_name);
2618
- html += row('Started', rpFmtAbs(s.started_at) + ' ('+rpFmtRelative(s.started_at)+')');
2619
- html += row('Ended', rpFmtAbs(s.ended_at) + ' ('+rpFmtRelative(s.ended_at)+')');
2620
- html += row('Duration', rpFmtDuration(s.duration_seconds));
2621
- html += row('End reason', s.end_reason || '—');
2622
- html += row('Trigger count', s.trigger_count!=null?s.trigger_count:'—');
2623
- html += row('Avg latency', s.avg_latency_ms!=null?Math.round(s.avg_latency_ms)+'ms':'');
2624
- html += row('Drop rate', s.drop_rate!=null?Math.round(s.drop_rate*100)+'%':'—');
2625
- html += row('Operator', s.operator_id || s.ended_by || '');
2626
- html += row('Character', s.character_id || s.character_name || '—');
2627
- html += row('Voice stack', s.voice_stack_id || '—');
2628
- if(s.notes) html += row('Notes', s.notes);
2629
- if(s.metadata){
2630
- html += '<div class="kv"><div class="k">Metadata</div><div class="v"><pre>'+esc(typeof s.metadata==='string'?s.metadata:JSON.stringify(s.metadata,null,2))+'</pre></div></div>';
2631
- }
2632
- showModal('Session '+(s.session_id||''), html);
2633
- }
2800
+ function renderSessionDetail(s){
2801
+ function row(k,v){ return '<div class="kv"><div class="k">'+esc(k)+'</div><div class="v">'+esc(v==null?'—':v)+'</div></div>'; }
2802
+ var avatar=s.character_img||rpAvatarFor(s.character_id||s.character_name||s.robot_id), summary=s.summary||{};
2803
+ var html='<div class="rp-detail-hero">'+(avatar?'<img alt="'+esc(s.character_name||s.robot_id)+'" src="'+esc(avatar+QS)+'">':'')
2804
+ +'<div><div style="font-size:1rem;color:var(--gold-bright);font-weight:700">'+esc(s.character_name||s.robot_id||'Session')+'</div>'
2805
+ +'<div class="meta">'+esc(summary.turns||0)+' turns · '+esc(summary.pipeline_errors||0)+' pipeline errors</div></div></div>';
2806
+ html += row('Session ID', s.session_id)+row('Robot / source', s.robot_id)+row('Room', s.room_name);
2807
+ html += row('Started', rpFmtAbs(s.started_at)+' ('+rpFmtRelative(s.started_at)+')')+row('Ended',rpFmtAbs(s.ended_at)+' ('+rpFmtRelative(s.ended_at)+')');
2808
+ html += row('Duration',rpFmtDuration(s.duration_seconds))+row('End reason',s.end_reason||'active')+row('Join latency',s.latency_ms!=null?s.latency_ms+'ms':'—');
2809
+ html += row('Operator',s.initiated_by||'robot motion')+row('Character',s.character_name||s.character_id||'—')+row('Voice stack',s.voice_stack_name||s.voice_stack_id||'—');
2810
+ html += '<div class="rp-section-h" style="margin-top:1rem">Transcript <span class="meta">'+esc(summary.user_turns||0)+' user / '+esc(summary.assistant_turns||0)+' assistant</span></div>';
2811
+ var turns=(s.transcript||[]).filter(function(t){return t.is_final;});
2812
+ html += '<div class="rp-call-transcript" style="height:auto;max-height:320px">'+(turns.length?turns.map(function(t){return '<div class="rp-turn '+esc(t.role)+'"><div class="who">'+esc(t.speaker||t.role)+' · '+esc(clock(t.timestamp))+'</div><div>'+esc(t.text)+'</div></div>';}).join(''):'<div class="rp-tbl-empty">No transcript was recorded for this session.</div>')+'</div>';
2813
+ html += '<div class="rp-section-h" style="margin-top:1rem">Pipeline timeline <span class="meta">'+esc((s.pipeline_events||[]).length)+' events</span></div>';
2814
+ html += '<div class="rp-pipeline">'+((s.pipeline_events||[]).length?s.pipeline_events.map(function(e){return '<div class="rp-pipeline-row '+esc(e.status)+'"><span>'+esc(clock(e.timestamp))+'</span><b>'+esc(e.stage)+'</b><span>'+esc(e.status)+'</span><span>'+esc(e.message||'')+'</span></div>';}).join(''):'<div class="rp-tbl-empty">No pipeline events were recorded.</div>')+'</div>';
2815
+ return html;
2816
+ }
2817
+ function openSessionDetailModal(s){
2818
+ if(!s)return; showModal('Session '+(s.session_id||''),'<div class="rp-tbl-empty">Loading transcript and pipeline timeline...</div>');
2819
+ getJson('/robopark/api/sessions/'+encodeURIComponent(s.session_id)+'/detail').then(function(detail){$('rp-modal-body').innerHTML=renderSessionDetail(detail);}).catch(function(e){$('rp-modal-body').innerHTML='<div class="rp-tbl-empty">Session detail unavailable: '+esc(e&&e.message||e)+'</div>';});
2820
+ }
2634
2821
  (function(){
2635
2822
  var s=$('rp-hist-search'); if(s) s.addEventListener('input', function(){ rpHistoryFilter.search = String(s.value||'').toLowerCase(); rpHistoryPage=0; renderHistory(); });
2636
2823
  var r=$('rp-hist-robot'); if(r) r.addEventListener('change', function(){ rpHistoryFilter.robot = r.value||''; rpHistoryPage=0; renderHistory(); });
@@ -3175,7 +3362,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
3175
3362
  for(var i=0;i<top3.length;i++){
3176
3363
  var x = top3[i];
3177
3364
  var name = x.name || x.id || 'robot';
3178
- var avatar = avatarSrcForNode({label:(x.character_name||x.character_id||name)});
3365
+ var avatar = rpAvatarFor(x.character_name||x.character_id||name);
3179
3366
  thtml += '<div class="rp-card" style="padding:0.55rem 0.7rem;">'
3180
3367
  + '<div class="rp-card-top">'
3181
3368
  + (avatar?'<div class="rp-card-img"><img alt="'+esc(name)+'" src="'+esc(avatar+QS)+'"></div>':'')
@@ -5446,7 +5633,12 @@ button.act:disabled{opacity:0.5;cursor:default;}
5446
5633
  // ── on-demand operator preview: open the robot's cam without a scene-detection session ──
5447
5634
  function updateGoLive(){ var b=$('pk-golive'); if(b){ b.textContent = previewOn?'■ Stop live':'▶ Go live'; b.classList.toggle('danger', previewOn); } }
5448
5635
  function goLive(n){ if(previewOn) stopPreview(n); else startPreview(n); }
5449
- function startPreview(n){
5636
+ function startPreview(n){
5637
+ previewOn=true; updateGoLive();
5638
+ if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; }
5639
+ idleCam('connecting');
5640
+ tryVisionOverlay(n, true);
5641
+ return;
5450
5642
  previewOn=true; updateGoLive();
5451
5643
  var note=$('pk-golivenote'); if(note) note.textContent='requesting preview…';
5452
5644
  if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; } // drop any session cam first
@@ -5463,7 +5655,14 @@ button.act:disabled{opacity:0.5;cursor:default;}
5463
5655
  })
5464
5656
  .catch(function(e){ idleCam('unreachable'); if(note) note.textContent='preview request failed'; if(window.console) console.error('RoboPark preview request failed',e); previewOn=false; updateGoLive(); });
5465
5657
  }
5466
- function stopPreview(n){
5658
+ function stopPreview(n){
5659
+ previewOn=false; updateGoLive();
5660
+ if(previewTimer){ clearInterval(previewTimer); previewTimer=null; }
5661
+ if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; }
5662
+ stopVision();
5663
+ var directNote=$('pk-golivenote'); if(directNote) directNote.textContent='';
5664
+ idleCam('idle');
5665
+ return;
5467
5666
  previewOn=false; updateGoLive();
5468
5667
  if(previewTimer){ clearInterval(previewTimer); previewTimer=null; }
5469
5668
  if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; }
@@ -5478,6 +5677,17 @@ button.act:disabled{opacity:0.5;cursor:default;}
5478
5677
  // server is opt-in because running it beside preview_agent would open the
5479
5678
  // same Windows camera twice and make both feeds unreliable.
5480
5679
  function tryVisionOverlay(n, force){
5680
+ var directImg=$('pk-vision-img'); if(!directImg) return;
5681
+ // Names stay stable when a robot is re-enrolled and receives a new mesh
5682
+ // node id. The hub resolves the name to the currently connected peer.
5683
+ var directRef=n.displayName||n.name||n.nodeId||n.id;
5684
+ if(!directRef){ visionOn=false; directImg.src=''; directImg.style.display='none'; return; }
5685
+ var directCamNote=$('pk-camnote'), directLiveNote=$('pk-golivenote'), directRes=$('pk-camres');
5686
+ visionOn=true;
5687
+ directImg.onerror=function(){ visionOn=false; directImg.style.display='none'; directImg.src=''; if(directCamNote) directCamNote.textContent='RoboVision feed unavailable on robot'; if(directLiveNote) directLiveNote.textContent='camera unavailable'; if(directRes) directRes.textContent='OFFLINE'; };
5688
+ directImg.onload=function(){ directImg.style.display='block'; if(directCamNote) directCamNote.textContent='live camera via RoboVision mesh relay'; if(directLiveNote) directLiveNote.textContent='live'; if(directRes) directRes.textContent='LIVE'; };
5689
+ directImg.src='/fed/media/robots/'+encodeURIComponent(directRef)+'/video_feed'+QS+(QS?'&':'?')+'_='+Date.now();
5690
+ return;
5481
5691
  var img=$('pk-vision-img'); if(!img) return;
5482
5692
  if(!force && String(window.localStorage.getItem('robopark.visionOverlay')||'').toLowerCase()!=='true'){
5483
5693
  visionOn=false; img.src=''; img.style.display='none'; return;
@@ -6084,7 +6294,10 @@ button.act:disabled{opacity:0.5;cursor:default;}
6084
6294
  function setFill(id,pct){ var e=$(id); if(!e||pct==null) return; e.style.width=Math.max(2,Math.min(100,pct))+'%'; e.style.background=pct>=75?'var(--bad)':pct>=45?'var(--warn)':'var(--gold-bright)'; }
6085
6295
 
6086
6296
  // ── cam: target a LiveKit track; fall back to a styled preview ──
6087
- function startCam(n){
6297
+ function startCam(n){
6298
+ idleCam('connecting');
6299
+ tryVisionOverlay(n, true);
6300
+ return;
6088
6301
  var note=$('pk-camnote');
6089
6302
  camAbort = ('AbortController' in window)?new AbortController():null;
6090
6303
  idleCam('connecting');
@@ -86,6 +86,7 @@ export class Federation {
86
86
  logger,
87
87
  getManifest: () => buildManifest(identity, this.describeWithBackends()),
88
88
  getNodes: () => this.nodeStatuses(),
89
+ getPeers: () => this.peers(),
89
90
  getStatus: () => this.statusSnapshot(),
90
91
  runCommand: (input) => this.deps.kernel.command(input),
91
92
  commandGate: options.commandGate,
@@ -37,6 +37,18 @@ export class PeerMesh {
37
37
  manifest,
38
38
  version: manifest.version,
39
39
  };
40
+ const endpoint = url.replace(/\/+$/, '').toLowerCase();
41
+ const name = manifest.displayName.trim().toLowerCase();
42
+ for (const existing of this.peers.values()) {
43
+ if (existing.nodeId === peer.nodeId)
44
+ continue;
45
+ const sameEndpoint = existing.url.replace(/\/+$/, '').toLowerCase() === endpoint;
46
+ const sameNamedDevice = existing.role === manifest.role && existing.displayName.trim().toLowerCase() === name;
47
+ if (!sameEndpoint && !sameNamedDevice)
48
+ continue;
49
+ this.opts.logger.warn(`[federation] replacing stale peer ${existing.displayName} (${existing.nodeId}) with ${peer.nodeId}`);
50
+ this.remove(existing.nodeId);
51
+ }
40
52
  this.peers.set(peer.nodeId, peer);
41
53
  this.openStream(peer);
42
54
  // Immediate heartbeat so the peer learns us (nodeId + port) right away —
@@ -1,5 +1,5 @@
1
1
  import type { Logger } from '../types.js';
2
- import type { FederationEnvelope, NodeManifest, StreamFrame } from './types.js';
2
+ import type { FederationEnvelope, NodeManifest, PeerInfo, StreamFrame } from './types.js';
3
3
  export type RpcHandler = (env: FederationEnvelope, remote: string) => Promise<FederationEnvelope | void>;
4
4
  export interface MeshServerOptions {
5
5
  host?: string;
@@ -9,6 +9,8 @@ export interface MeshServerOptions {
9
9
  getManifest: () => NodeManifest;
10
10
  /** Optional: full mesh view (self + peers) for the GET /fed/nodes probe. */
11
11
  getNodes?: () => unknown;
12
+ /** Connected peers and their reachable mesh URLs. */
13
+ getPeers?: () => PeerInfo[];
12
14
  /** Optional: consolidated fleet snapshot (self + nodes + hardware + roles +
13
15
  * runs + recent activity) for the dashboard and `robopark fleet` (GET /fed/status). */
14
16
  getStatus?: () => unknown;
@@ -50,6 +52,9 @@ export declare class MeshServer {
50
52
  private seq;
51
53
  constructor(opts: MeshServerOptions);
52
54
  get streamCount(): number;
55
+ private streamHttp;
56
+ private streamLocalRoboVision;
57
+ private streamPeerRoboVision;
53
58
  start(): Promise<void>;
54
59
  /** Proxy a dashboard request to the RoboPark scheduler (product data). */
55
60
  private proxyToScheduler;