feat: add Orch tab to miniapp — orchestration room canvas with real WS

Adds a 7th "Orch" tab to the Telegram Mini App that displays the
orchestration room canvas live via WebSocket.

- Tab indicator pill updated from 100%/6 to 100%/7
- Orch-specific CSS: badge styles, canvas-wrap, status dot + blink animation
- Silkscreen font + map.js loaded for pixel-font and map rendering
- Canvas panel: conductor/secretary badges (left), 320×320 canvas (center),
  s0–s4 subagent slot badges (right), WS status bar (bottom)
- Full JS orchestration engine (_orch* namespace):
  - Character system with bob animation, state rings, speech bubbles
  - _orchCharForId(): heartbeat→secretary, subagent-N→slot, else→conductor
  - Spawn/GC lifecycle: subagents walk door↔station; main sessions toggle alive
  - Conversation: characters move toward each other, say text, return home
  - requestAnimationFrame render loop with dt capped at 80ms
- connectOrchWs(): opens /miniapp/api/orchestration/ws, handles init
  (snapshot replay) and event messages; auto-reconnects while tab active
- disconnectOrchWs(): closes WS, clears reconnect timer
- Lazy orchInit(): canvas initialised on first Orch tab open only

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-02-25 04:46:07 +09:00
parent 581578aa0c
commit 9649fba2da

View file

@ -84,7 +84,7 @@
top: 2px; top: 2px;
bottom: 2px; bottom: 2px;
left: 2px; left: 2px;
width: calc(100%/6 - 2px); width: calc(100%/7 - 2px);
border-radius: 8px; border-radius: 8px;
background: var(--tab-pill-bg); background: var(--tab-pill-bg);
box-shadow: 0 0.5px 2px rgba(0,0,0,0.12), 0 0.5px 1px rgba(0,0,0,0.08); box-shadow: 0 0.5px 2px rgba(0,0,0,0.12), 0 0.5px 1px rgba(0,0,0,0.08);
@ -768,7 +768,30 @@
cursor: pointer; cursor: pointer;
} }
/* ── Orch panel ── */
.orch-room-row { display:flex; align-items:flex-start; padding:12px 0; }
.orch-side { display:flex; flex-direction:column; align-items:center; gap:12px; padding-top:20px; width:40px; flex-shrink:0; }
.orch-badge { display:flex; flex-direction:column; align-items:center; gap:3px; opacity:0.3; transition:opacity 0.3s; }
.orch-badge.alive { opacity:1; }
.orch-badge.alive .orch-badge-label { color:#4a6ac0; }
.orch-badge.alive .orch-badge-dot { background:#4ade80; }
.orch-badge.toolcall .orch-badge-dot { background:#fb923c; animation:orch-blink 0.2s step-end infinite; }
.orch-badge.waiting .orch-badge-dot { background:#60a5fa; }
.orch-badge.talking .orch-badge-label{ color:#facc15; }
.orch-badge.talking .orch-badge-dot { background:#facc15; animation:orch-blink 0.6s step-end infinite; }
.orch-badge-emoji { font-size:16px; line-height:1; }
.orch-badge-label { font-size:7px; color:var(--hint); letter-spacing:0.05em; text-transform:uppercase; }
.orch-badge-dot { width:4px; height:4px; border-radius:50%; background:var(--hint); }
@keyframes orch-blink { 50% { opacity:0; } }
.orch-canvas-wrap { flex:1; min-width:0; border-radius:12px; overflow:hidden; background:#060810; }
.orch-canvas-wrap canvas { image-rendering:pixelated; image-rendering:crisp-edges; display:block; width:100%; aspect-ratio:1/1; }
.orch-status { text-align:center; font-size:11px; color:var(--hint); padding:6px 0 4px; }
.orch-dot { display:inline-block; width:6px; height:6px; border-radius:50%; background:var(--hint); margin-right:4px; vertical-align:middle; }
.orch-dot.on { background:#4ade80; }
</style> </style>
<link href="https://fonts.googleapis.com/css2?family=Silkscreen&display=swap" rel="stylesheet">
<script src="map.js"></script>
</head> </head>
<body> <body>
@ -781,6 +804,7 @@
<button class="tab" data-panel="session">Session</button> <button class="tab" data-panel="session">Session</button>
<button class="tab" data-panel="config">Config</button> <button class="tab" data-panel="config">Config</button>
<button class="tab" data-panel="dev">Dev</button> <button class="tab" data-panel="dev">Dev</button>
<button class="tab" data-panel="orch">Orch</button>
</div> </div>
</div> </div>
@ -852,6 +876,57 @@
</div> </div>
</div> </div>
<div id="orch" class="panel">
<div class="orch-room-row">
<div class="orch-side" id="orch-panel-left">
<div class="orch-badge alive" id="orch-badge-conductor">
<div class="orch-badge-emoji">👑</div>
<div class="orch-badge-label">CNDR</div>
<div class="orch-badge-dot"></div>
</div>
<div class="orch-badge alive" id="orch-badge-secretary">
<div class="orch-badge-emoji">👩‍💼</div>
<div class="orch-badge-label">SEC</div>
<div class="orch-badge-dot"></div>
</div>
</div>
<div class="orch-canvas-wrap">
<canvas id="orch-canvas" width="320" height="320"></canvas>
</div>
<div class="orch-side" id="orch-panel-right">
<div class="orch-badge" id="orch-badge-s0">
<div class="orch-badge-emoji">🔍</div>
<div class="orch-badge-label">SCOUT</div>
<div class="orch-badge-dot"></div>
</div>
<div class="orch-badge" id="orch-badge-s1">
<div class="orch-badge-emoji">📊</div>
<div class="orch-badge-label">ANLY</div>
<div class="orch-badge-dot"></div>
</div>
<div class="orch-badge" id="orch-badge-s2">
<div class="orch-badge-emoji">💻</div>
<div class="orch-badge-label">CODE</div>
<div class="orch-badge-dot"></div>
</div>
<div class="orch-badge" id="orch-badge-s3">
<div class="orch-badge-emoji">🔧</div>
<div class="orch-badge-label">WRKR</div>
<div class="orch-badge-dot"></div>
</div>
<div class="orch-badge" id="orch-badge-s4">
<div class="orch-badge-emoji">🎯</div>
<div class="orch-badge-label">CORD</div>
<div class="orch-badge-dot"></div>
</div>
</div>
</div>
<div class="orch-status">
<span class="orch-dot" id="orch-status-dot"></span>
<span id="orch-status-text">Connecting…</span>
</div>
</div>
<div class="send-bar hidden" id="send-bar"> <div class="send-bar hidden" id="send-bar">
<input id="skill-msg" class="send-input glass glass-interactive" placeholder="Message for skill..."> <input id="skill-msg" class="send-input glass glass-interactive" placeholder="Message for skill...">
<button class="send-btn" id="send-skill-btn" onclick="sendSkillCommand()">Send</button> <button class="send-btn" id="send-skill-btn" onclick="sendSkillCommand()">Send</button>
@ -894,6 +969,8 @@ tabs.forEach((tab, index) => {
if (p === 'dev' && !fresh) loadDev(); if (p === 'dev' && !fresh) loadDev();
if (p === 'config') connectLogsWs(); if (p === 'config') connectLogsWs();
else disconnectLogsWs(); else disconnectLogsWs();
if (p === 'orch') connectOrchWs();
else disconnectOrchWs();
}); });
}); });
@ -1717,6 +1794,190 @@ document.querySelector('.log-filter-chips').addEventListener('click', function(e
connectLogsWs(); connectLogsWs();
}); });
// ─── Orchestration room ───────────────────────────────────────────────────
var orchCanvas = null, orchCtx = null, orchInited = false;
var orchWs = null, orchReconnectTimer = null;
var _orchLastTs = null;
var _orchBOB = [0, -1, -2, -1];
var _orchFRAME_MS = {idle:450, waiting:650, toolcall:90, talking:280, entering:220, exiting:220};
var _orchWALK = 55;
var _orchConductor, _orchSecretary, _orchSubagents, _orchSlots, _orchFreeSlots;
function _orchMakeChar(id, emoji, home) {
return {id:id, emoji:emoji, x:home.x, y:home.y, home:home, target:null, state:'idle',
frame:0, frameTimer:0, bubble:null, alive:false, _onArrive:null};
}
function _orchInitChars() {
_orchConductor = _orchMakeChar('conductor', '👑', MAP_POSITIONS.conductor);
_orchSecretary = _orchMakeChar('secretary', '👩‍💼', MAP_POSITIONS.secretary);
_orchConductor.alive = true; _orchSecretary.alive = true;
var ps = [{id:'s0',emoji:'🔍'},{id:'s1',emoji:'📊'},{id:'s2',emoji:'💻'},
{id:'s3',emoji:'🔧'},{id:'s4',emoji:'🎯'}];
_orchSubagents = ps.map(function(p,i){
var c = _orchMakeChar(p.id, p.emoji, MAP_POSITIONS.stations[i]);
c.x = MAP_POSITIONS.door.x; c.y = MAP_POSITIONS.door.y; return c;
});
_orchSlots = {}; _orchFreeSlots = _orchSubagents.slice();
}
function _orchAllChars() { return [_orchConductor, _orchSecretary].concat(_orchSubagents); }
function _orchSyncBadge(id, state, alive) {
var el = document.getElementById('orch-badge-' + id); if (!el) return;
el.className = 'orch-badge'
+ (alive ? ' alive' : '')
+ (state==='talking' ? ' talking' : '')
+ (state==='toolcall'? ' toolcall' : '')
+ (state==='waiting' ? ' waiting' : '');
}
function _orchSetState(c, state) { c.state=state; _orchSyncBadge(c.id, state, c.alive); }
function _orchMoveTo(c, pos, cb) { c.target=pos; c._onArrive=cb||null; }
function _orchSay(c, text, ttl) { c.bubble={text:text, ttl:ttl||2200}; }
function _orchCharForId(id) {
if (id === 'heartbeat') return _orchSecretary;
if (_orchSlots[id]) return _orchSlots[id];
return _orchConductor;
}
function _orchSpawn(id) {
if (/^subagent-/.test(id)) {
var c = _orchFreeSlots.shift(); if (!c) return;
_orchSlots[id]=c; c.alive=true;
c.x=MAP_POSITIONS.door.x; c.y=MAP_POSITIONS.door.y;
_orchSetState(c,'entering');
_orchMoveTo(c, c.home, function(){ _orchSetState(c,'idle'); });
} else {
var ch = _orchCharForId(id); ch.alive=true; _orchSetState(ch,'waiting');
}
}
function _orchGC(id) {
if (/^subagent-/.test(id)) {
var c=_orchSlots[id]; if (!c) return;
delete _orchSlots[id]; _orchFreeSlots.push(c);
_orchSetState(c,'exiting');
_orchMoveTo(c, MAP_POSITIONS.door, function(){ c.alive=false; _orchSetState(c,'idle'); });
} else {
var ch=_orchCharForId(id); ch.alive=false; _orchSetState(ch,'idle');
}
}
function _orchConverse(fromId, toId, text) {
var from=_orchCharForId(fromId), to=_orchCharForId(toId);
if (!from||!to||from===to) return;
var label=(text||'').slice(0,18);
var mid={x:(from.x+to.x)/2, y:(from.y+to.y)/2};
_orchSetState(from,'talking'); _orchSetState(to,'talking');
_orchMoveTo(from, {x:mid.x-18,y:mid.y}, function(){ _orchSay(from,label,2400); });
_orchMoveTo(to, {x:mid.x+18,y:mid.y}, function(){
setTimeout(function(){
_orchMoveTo(from, from.home, function(){ _orchSetState(from,'idle'); });
_orchMoveTo(to, to.home, function(){ _orchSetState(to, 'idle'); });
}, 2600);
});
}
function _orchUpdate(dt) {
_orchAllChars().forEach(function(c){
if (!c.alive && c.state!=='entering') return;
c.frameTimer+=dt;
var dur=_orchFRAME_MS[c.state]||450;
if (c.frameTimer>=dur){ c.frame=(c.frame+1)%4; c.frameTimer-=dur; }
if (c.target){
var dx=c.target.x-c.x, dy=c.target.y-c.y, dist=Math.sqrt(dx*dx+dy*dy);
if (dist>1.5){ var spd=_orchWALK*dt/1000; c.x+=dx/dist*spd; c.y+=dy/dist*spd; }
else { c.x=c.target.x; c.y=c.target.y; c.target=null; if(c._onArrive){c._onArrive();c._onArrive=null;} }
}
if (c.bubble){ c.bubble.ttl-=dt; if(c.bubble.ttl<=0) c.bubble=null; }
});
}
function _orchDrawBubble(c) {
if (!c.bubble) return;
var yOff=_orchBOB[c.frame], bx=c.x, by=c.y+yOff-18;
orchCtx.font='7px Silkscreen,monospace';
var tw=orchCtx.measureText(c.bubble.text).width, pw=tw+8, ph=12;
var lx=Math.max(4,Math.min(316-pw, bx-pw/2));
orchCtx.fillStyle='#facc15';
orchCtx.fillRect(Math.floor(lx),Math.floor(by-ph),Math.ceil(pw),Math.ceil(ph));
orchCtx.fillRect(Math.floor(bx)-1,Math.floor(by),3,3);
orchCtx.fillStyle='#0a0a00'; orchCtx.textAlign='left'; orchCtx.textBaseline='middle';
orchCtx.fillText(c.bubble.text, Math.floor(lx+4), Math.floor(by-ph/2));
}
function _orchDrawChar(c) {
if (!c.alive && c.state!=='entering' && c.state!=='exiting') return;
var yOff=_orchBOB[c.frame], cx=Math.floor(c.x), cy=Math.floor(c.y+yOff);
if (c.state==='toolcall'){
orchCtx.fillStyle='rgba(251,146,60,0.35)'; orchCtx.beginPath();
orchCtx.arc(cx,cy,13,0,Math.PI*2); orchCtx.fill();
} else if (c.state==='waiting'){
orchCtx.fillStyle='rgba(96,165,250,0.25)'; orchCtx.beginPath();
orchCtx.arc(cx,cy,11,0,Math.PI*2); orchCtx.fill();
}
orchCtx.font='18px serif'; orchCtx.textAlign='center'; orchCtx.textBaseline='middle';
orchCtx.fillText(c.emoji, cx, cy);
orchCtx.font='6px Silkscreen,monospace'; orchCtx.textAlign='center'; orchCtx.textBaseline='top';
orchCtx.fillStyle=c.state==='talking'?'#facc15':'#3a4a7a';
orchCtx.fillText(c.id.toUpperCase(), cx, cy+11);
_orchDrawBubble(c);
}
function _orchRender(ts) {
if (_orchLastTs===null) _orchLastTs=ts;
var dt=Math.min(ts-_orchLastTs,80); _orchLastTs=ts;
_orchUpdate(dt);
orchCtx.imageSmoothingEnabled=false;
drawMap(orchCtx);
_orchAllChars().forEach(_orchDrawChar);
requestAnimationFrame(_orchRender);
}
function orchInit() {
if (orchInited) return; orchInited=true;
orchCanvas=document.getElementById('orch-canvas');
orchCtx=orchCanvas.getContext('2d');
orchCtx.imageSmoothingEnabled=false;
_orchInitChars();
loadMapAsset(function(){ _orchLastTs=null; requestAnimationFrame(_orchRender); });
}
function connectOrchWs() {
orchInit();
if (orchWs && orchWs.readyState<=1) return;
var proto=location.protocol==='https:'?'wss:':'ws:';
var url=proto+'//'+location.host+'/miniapp/api/orchestration/ws?initData='+encodeURIComponent(initData);
orchWs=new WebSocket(url);
orchWs.onopen=function(){
document.getElementById('orch-status-dot').classList.add('on');
document.getElementById('orch-status-text').textContent='Live';
};
orchWs.onmessage=function(e){
var msg; try{ msg=JSON.parse(e.data); }catch(_){ return; }
if (msg.type==='init') {
(msg.agents||[]).forEach(function(info){
_orchSpawn(info.id);
if (info.state && info.state!=='idle'){
var c=_orchCharForId(info.id); if(c) _orchSetState(c, info.state);
}
});
} else if (msg.type==='event') {
var ev=msg.event||{};
if (ev.type==='agent_spawn') _orchSpawn(ev.id);
if (ev.type==='agent_state') { var c=_orchCharForId(ev.id); if(c) _orchSetState(c,ev.state); }
if (ev.type==='agent_gc') _orchGC(ev.id);
if (ev.type==='conversation') _orchConverse(ev.from, ev.to, ev.text);
}
};
orchWs.onclose=function(){
document.getElementById('orch-status-dot').classList.remove('on');
document.getElementById('orch-status-text').textContent='Disconnected';
orchWs=null;
var at=document.querySelector('.tab.active');
if (at && at.dataset.panel==='orch') orchReconnectTimer=setTimeout(connectOrchWs,3000);
};
orchWs.onerror=function(){};
}
function disconnectOrchWs() {
if (orchReconnectTimer){ clearTimeout(orchReconnectTimer); orchReconnectTimer=null; }
if (orchWs){ orchWs.close(); orchWs=null; }
var dot=document.getElementById('orch-status-dot');
var txt=document.getElementById('orch-status-text');
if (dot) dot.classList.remove('on');
if (txt) txt.textContent='Offline';
}
async function saveLogSnapshot() { async function saveLogSnapshot() {
try { try {
var res = await fetch(API_BASE + '/miniapp/api/logs/snapshot?initData=' + encodeURIComponent(initData), { var res = await fetch(API_BASE + '/miniapp/api/logs/snapshot?initData=' + encodeURIComponent(initData), {