Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <title>Tabletop Planning Env</title> | |
| <style> | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { | |
| background: #0f0f13; | |
| color: #e0e0e0; | |
| font-family: 'JetBrains Mono', 'Fira Code', monospace; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| padding: 24px; | |
| gap: 20px; | |
| } | |
| h1 { color: #7eb8f7; font-size: 1.2rem; letter-spacing: 0.05em; } | |
| .layout { display: flex; gap: 24px; align-items: flex-start; width: 100%; max-width: 1280px; } | |
| .canvas-wrap { | |
| background: #1a1a24; border: 1px solid #333; border-radius: 8px; | |
| padding: 16px; flex-shrink: 0; | |
| } | |
| canvas { display: block; border-radius: 4px; } | |
| .panel { | |
| background: #1a1a24; border: 1px solid #333; border-radius: 8px; | |
| padding: 16px; flex: 1; min-width: 260px; | |
| display: flex; flex-direction: column; gap: 14px; | |
| } | |
| .panel h2 { font-size: 0.7rem; color: #777; text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 2px; } | |
| .instruction { color: #f5ce72; font-size: 0.92rem; line-height: 1.5; } | |
| .obj-list { display: flex; flex-direction: column; gap: 5px; } | |
| .obj-row { display: flex; gap: 8px; align-items: center; font-size: 0.8rem; } | |
| .dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } | |
| .tag { background: #2a2a36; border-radius: 4px; padding: 1px 6px; font-size: 0.68rem; color: #aaa; } | |
| .tag.blocked { background: #3a1a1a; color: #e06060; } | |
| .tag.hidden { background: #2a2a1a; color: #b8a060; } | |
| .tag.held { background: #1a3a1a; color: #60e090; } | |
| .tag.fragile { background: #2a1a3a; color: #c080f0; } | |
| .tag.heavy { background: #1a2a3a; color: #6090d0; } | |
| .reasoning-box { background: #131320; border-left: 5px solid #5a8fd5; border-radius: 4px; padding: 10px 12px; font-size: 0.82rem; color: #a8ccee; line-height: 1.6; font-style: italic; margin-bottom: 8px; white-space: pre-wrap; word-break: break-word; } | |
| .action-box { background: #0e2a0e; border-left: 5px solid #3a9a3a; border-radius: 4px; padding: 8px 12px; font-size: 0.92rem; color: #80e880; font-family: monospace; font-weight: bold; letter-spacing: 0.08em; } | |
| .log { display: flex; flex-direction: column; gap: 3px; max-height: 180px; overflow-y: auto; } | |
| .log-row { font-size: 0.73rem; display: flex; gap: 8px; } | |
| .log-row .sn { color: #555; width: 26px; flex-shrink: 0; } | |
| .log-row .act { color: #7eb8f7; flex: 1; } | |
| .log-row .res.ok { color: #60e090; } | |
| .log-row .res.fail { color: #e06060; } | |
| .log-row .rew { color: #f0c36d; } | |
| .stats { display: flex; gap: 16px; flex-wrap: wrap; } | |
| .stat .label { font-size: 0.65rem; color: #888; text-transform: uppercase; letter-spacing: 0.06em; } | |
| .stat .value { font-size: 1.4rem; color: #d4eaff; font-weight: bold; } | |
| .controls { display: flex; gap: 7px; flex-wrap: wrap; margin-top: 4px; } | |
| .diff-controls { display: flex; gap: 6px; margin-top: 8px; } | |
| .diff-btn.active { background: #2a5a8a; border-color: #4a8ad0; color: #cfe6ff; } | |
| button { | |
| background: #252535; border: 1px solid #444; color: #ccc; | |
| padding: 5px 12px; border-radius: 6px; cursor: pointer; | |
| font-size: 0.75rem; font-family: inherit; transition: background 0.12s; | |
| } | |
| button:hover { background: #33334a; } | |
| button.primary { background: #2a3a5a; border-color: #4a6a9a; color: #7eb8f7; } | |
| button.danger { background: #3a1a1a; border-color: #6a2a2a; color: #e06060; } | |
| .list-items .item { font-size: 0.75rem; line-height: 1.6; } | |
| .item.sg { color: #60e090; } .item.sg::before { content: "✓ "; } | |
| .item.fai { color: #e06060; } .item.fai::before { content: "✗ "; } | |
| .item.con { color: #f0c36d; } .item.con::before { content: "⚠ "; } | |
| .none { font-size: 0.72rem; color: #444; } | |
| .holding-banner { | |
| background: #1a3a1a; border: 1px solid #2a6a2a; border-radius: 6px; | |
| padding: 5px 12px; font-size: 0.8rem; color: #60e090; | |
| } | |
| .done-banner { | |
| background: #1a2a3a; border: 1px solid #2a5a8a; border-radius: 6px; | |
| padding: 7px 14px; font-size: 0.88rem; color: #60c0ff; text-align: center; | |
| } | |
| .mid-task-banner { | |
| background: #2a1505; border: 2px solid #f07828; border-radius: 6px; | |
| padding: 10px 16px; font-size: 0.92rem; color: #ffb060; text-align: center; | |
| font-weight: bold; letter-spacing: 0.03em; | |
| animation: pulse-border 1.2s ease-in-out infinite; | |
| } | |
| @keyframes pulse-border { | |
| 0%,100% { border-color: #f07828; box-shadow: none; } | |
| 50% { border-color: #ffd080; box-shadow: 0 0 18px #f0782888; } | |
| } | |
| .rbar-bg { background: #222; border-radius: 4px; height: 10px; overflow: hidden; margin-top: 6px; } | |
| .rbar { height: 100%; border-radius: 4px; transition: width 0.3s, background 0.3s; } | |
| select { background:#252535; color:#ccc; border:1px solid #444; border-radius:4px; padding:4px; font-family:inherit; } | |
| .manual-grid { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 4px; } | |
| </style> | |
| </head> | |
| <body> | |
| <h1>RoboReplan — Tabletop Planning & Replanning Environment</h1> | |
| <div class="layout"> | |
| <div style="display:flex;flex-direction:column;gap:10px;"> | |
| <div class="canvas-wrap"> | |
| <div style="font-size:0.7rem;color:#888;margin-bottom:6px;letter-spacing:0.05em;">TOP-DOWN VIEW • <span style="color:#f0c36d">⊕</span> gripper • <span style="color:#e06060">×</span> blocked</div> | |
| <canvas id="canvas" width="400" height="340"></canvas> | |
| </div> | |
| <div class="canvas-wrap"> | |
| <div style="font-size:0.7rem;color:#888;margin-bottom:6px;letter-spacing:0.05em;">SIDE VIEW • stacking & blocking</div> | |
| <canvas id="canvas-side" width="400" height="140"></canvas> | |
| </div> | |
| </div> | |
| <div class="panel"> | |
| <div> | |
| <h2>Instruction</h2> | |
| <div class="instruction" id="instruction">—</div> | |
| </div> | |
| <div id="mid-task-sec" style="display:none"> | |
| <div class="mid-task-banner" id="mid-task-txt">⚡ Mid-task instruction change!</div> | |
| </div> | |
| <div> | |
| <h2>Scene Objects</h2> | |
| <div style="font-size:0.65rem;color:#666;margin-bottom:4px;"><span class="tag blocked">blocked</span> = something in the way <span class="tag hidden">hidden</span> = not yet scanned</div> | |
| <div class="obj-list" id="obj-list"></div> | |
| </div> | |
| <div id="holding-sec" style="display:none"> | |
| <div class="holding-banner" id="holding-txt"></div> | |
| </div> | |
| <div> | |
| <h2>Constraints</h2> | |
| <div class="list-items" id="constraints"></div> | |
| </div> | |
| <div> | |
| <h2>Completed Subgoals</h2> | |
| <div class="list-items" id="subgoals"></div> | |
| </div> | |
| <div> | |
| <h2>Known Failures</h2> | |
| <div class="list-items" id="failures"></div> | |
| </div> | |
| <div> | |
| <h2>Stats</h2> | |
| <div class="stats"> | |
| <div class="stat"><div class="label">Steps left</div><div class="value" id="steps">—</div></div> | |
| <div class="stat"><div class="label">Reward</div><div class="value" id="reward">0.00</div></div> | |
| <div class="stat"><div class="label">Episode</div><div class="value" id="episode">1</div></div> | |
| <div class="stat"><div class="label">Success</div><div class="value" id="successes">0</div></div> | |
| </div> | |
| <div class="rbar-bg"><div class="rbar" id="rbar" style="width:50%;background:#4a8a4a"></div></div> | |
| </div> | |
| <div> | |
| <h2>Model Diagnostics</h2> | |
| <div class="list-items" id="diag"></div> | |
| </div> | |
| <div> | |
| <h2>Model Reasoning</h2> | |
| <div class="list-items" id="raw-model"><div class="none">none</div></div> | |
| </div> | |
| <div id="done-sec" style="display:none"> | |
| <div class="done-banner" id="done-txt"></div> | |
| </div> | |
| <div> | |
| <h2>Action Log</h2> | |
| <div class="log" id="log"></div> | |
| </div> | |
| <div> | |
| <h2>Controls</h2> | |
| <div class="controls"> | |
| <button class="primary" onclick="resetEp()" title="Randomizes a new scenario each time">Reset 🎲</button> | |
| <button onclick="togglePlay()" id="play-btn">▶ Run Agent</button> | |
| <button onclick="modelStep()" id="model-btn">🤖 Model Step</button> | |
| <button onclick="oracleStep()" id="oracle-btn" style="background:#1a2a3a;border-color:#3a6a9a;color:#7ec8f7">🎯 Oracle Step</button> | |
| <button onclick="toggleOraclePlay()" id="oracle-play-btn" style="background:#1a2a3a;border-color:#3a6a9a;color:#7ec8f7">🎯 Run Oracle</button> | |
| <button class="danger" onclick="stopAll()">■ Stop</button> | |
| <select id="speed"> | |
| <option value="700">Slow</option> | |
| <option value="350" selected>Normal</option> | |
| <option value="120">Fast</option> | |
| <option value="20">Max</option> | |
| </select> | |
| </div> | |
| <div class="diff-controls"> | |
| <button id="diff-easy" class="diff-btn" onclick="setDifficulty('easy')">Easy</button> | |
| <button id="diff-medium" class="diff-btn" onclick="setDifficulty('medium')">Medium</button> | |
| <button id="diff-hard" class="diff-btn" onclick="setDifficulty('hard')">Hard</button> | |
| </div> | |
| <div style="display:flex;gap:6px;margin-top:8px;align-items:center;"> | |
| <span style="font-size:0.65rem;color:#666;text-transform:uppercase;letter-spacing:.08em;">Scene</span> | |
| <select id="pack-select" onchange="setScenarioPack(this.value)" style="flex:1"> | |
| <option value="default">🟥 Colored blocks (default)</option> | |
| <option value="pharmacy">💊 Pharmacy (PS 3.1)</option> | |
| <option value="warehouse">📦 Warehouse (PS 3.1)</option> | |
| <option value="lab">🔬 Lab (PS 3.1)</option> | |
| </select> | |
| </div> | |
| <h2 style="margin-top:10px">Manual Actions</h2> | |
| <div style="font-size:0.65rem;color:#666;margin-bottom:6px;">Tip: MOVE_TO_<color> first, then PICK. Disabled buttons = invalid for current state.</div> | |
| <div class="manual-grid" id="manual-grid"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| // ───────────────────────────────────────────────────────────────────── | |
| // Environment (mirrors Python logic exactly) | |
| // ───────────────────────────────────────────────────────────────────── | |
| const INSTRUCTIONS = [ | |
| "Place the red block in bin A.", | |
| "Place the red block in bin A. Fragile items first.", | |
| "Place the red block in bin A, then the green block in bin B.", | |
| "Place the yellow block in bin B.", | |
| "Place the purple block in bin A, then the red block in bin B.", | |
| ]; | |
| const MAX_STEPS = 20; | |
| const URL_PARAMS = new URLSearchParams(window.location.search); | |
| let ACTIVE_DIFFICULTY = (URL_PARAMS.get('difficulty') || 'medium').toLowerCase(); | |
| let ACTIVE_PACK = (URL_PARAMS.get('pack') || 'default').toLowerCase(); | |
| function makeObject(name, x, y, reachable, blocking) { | |
| return { name, x, y, reachable, blocking: blocking||null, inBin: null, isHeld: false }; | |
| } | |
| class Env { | |
| constructor() { this.reset(); } | |
| reset() { | |
| this.navMode = (ACTIVE_DIFFICULTY === 'hard'); | |
| const blocked = Math.random() < 0.5; | |
| this.objects = { | |
| red_block: makeObject('red_block', 0.10, 0.00, !blocked, null), | |
| blue_block: makeObject('blue_block', 0.00, 0.00, true, blocked ? 'red_block' : null), | |
| green_block: makeObject('green_block',-0.10, 0.00, true, null), | |
| yellow_block: makeObject('yellow_block', 0.20, 0.06, true, null), | |
| purple_block: makeObject('purple_block', -0.20, 0.06, true, null), | |
| }; | |
| this.gripperX = 0.00; | |
| this.gripperY = 0.25; | |
| this.gripperFacing = 'N'; | |
| this.holding = null; | |
| this.steps = 0; | |
| this.done = false; | |
| this.actionHistory = []; | |
| this.completedSubgoals = []; | |
| this.knownFailures = []; | |
| this.activeConstraints = []; | |
| this.instruction = INSTRUCTIONS[Math.floor(Math.random() * INSTRUCTIONS.length)]; | |
| if (this.instruction.includes('Fragile')) this.activeConstraints.push('fragile_first'); | |
| this.requiredPlacements = {}; | |
| if (this.instruction.includes('red block in bin A')) this.requiredPlacements['red_block'] = 'A'; | |
| if (this.instruction.includes('red block in bin B')) this.requiredPlacements['red_block'] = 'B'; | |
| if (this.instruction.includes('green block in bin B')) this.requiredPlacements['green_block'] = 'B'; | |
| if (this.instruction.includes('yellow block in bin B')) this.requiredPlacements['yellow_block'] = 'B'; | |
| if (this.instruction.includes('purple block in bin A')) this.requiredPlacements['purple_block'] = 'A'; | |
| return this._obs(null, null); | |
| } | |
| step(action) { | |
| if (this.done) throw new Error('Episode done. Call reset().'); | |
| const result = this._execute(action); | |
| this.actionHistory.push(action); | |
| this.steps++; | |
| const reward = this._reward(action, result); | |
| this._updateState(action, result); | |
| const done = this.done || this.steps >= MAX_STEPS; | |
| if (this.steps >= MAX_STEPS) this.done = true; | |
| return { observation: this._obs(action, result), reward, done, info: { result } }; | |
| } | |
| _execute(action) { | |
| const o = this.objects; | |
| if (action === 'SCAN_SCENE') return 'SUCCESS'; | |
| if (action === 'MOVE_NORTH') { this.gripperY = Math.min(0.35, this.gripperY + 0.1); return 'SUCCESS'; } | |
| if (action === 'MOVE_SOUTH') { this.gripperY = Math.max(-0.25, this.gripperY - 0.1); return 'SUCCESS'; } | |
| if (action === 'MOVE_EAST') { this.gripperX = Math.min(0.35, this.gripperX + 0.1); return 'SUCCESS'; } | |
| if (action === 'MOVE_WEST') { this.gripperX = Math.max(-0.35, this.gripperX - 0.1); return 'SUCCESS'; } | |
| if (action === 'ROTATE_LEFT') { | |
| const dirs = ['N','W','S','E']; | |
| this.gripperFacing = dirs[(dirs.indexOf(this.gripperFacing) + 1) % 4]; | |
| return 'SUCCESS'; | |
| } | |
| if (action === 'ROTATE_RIGHT') { | |
| const dirs = ['N','E','S','W']; | |
| this.gripperFacing = dirs[(dirs.indexOf(this.gripperFacing) + 1) % 4]; | |
| return 'SUCCESS'; | |
| } | |
| if (['MOVE_TO_RED','MOVE_TO_BLUE','MOVE_TO_GREEN','MOVE_TO_YELLOW','MOVE_TO_PURPLE'].includes(action)) { | |
| if (this.navMode) return 'FAILED_INVALID'; | |
| const map = { | |
| MOVE_TO_RED:'red_block', | |
| MOVE_TO_BLUE:'blue_block', | |
| MOVE_TO_GREEN:'green_block', | |
| MOVE_TO_YELLOW:'yellow_block', | |
| MOVE_TO_PURPLE:'purple_block' | |
| }; | |
| const name = map[action]; | |
| if (!o[name]) return 'FAILED_INVALID'; | |
| if (!o[name].reachable) return 'FAILED_BLOCKED'; | |
| this.gripperX = o[name].x; | |
| this.gripperY = o[name].y + 0.05; | |
| return 'SUCCESS'; | |
| } | |
| if (action === 'PICK') { | |
| if (this.holding) return 'FAILED_INVALID'; | |
| for (const obj of Object.values(o)) { | |
| if (obj.reachable && !obj.isHeld && !obj.inBin) { | |
| const dist = Math.hypot(this.gripperX - obj.x, this.gripperY - obj.y); | |
| if (dist < 0.15) { | |
| obj.isHeld = true; | |
| this.holding = obj.name; | |
| return 'SUCCESS'; | |
| } | |
| } | |
| } | |
| return 'FAILED_EMPTY'; | |
| } | |
| if (action === 'PLACE_BIN_A' || action === 'PLACE_BIN_B') { | |
| if (!this.holding) return 'FAILED_EMPTY'; | |
| const bin = action === 'PLACE_BIN_A' ? 'A' : 'B'; | |
| const obj = o[this.holding]; | |
| obj.inBin = bin; obj.isHeld = false; obj.reachable = false; | |
| this.holding = null; | |
| return 'SUCCESS'; | |
| } | |
| if (action === 'CLEAR_BLOCKER') { | |
| for (const obj of Object.values(o)) { | |
| if (obj.blocking && obj.reachable) { | |
| const target = obj.blocking; | |
| obj.blocking = null; | |
| obj.x += 0.28; | |
| if (o[target]) o[target].reachable = true; | |
| return 'SUCCESS'; | |
| } | |
| } | |
| return 'FAILED_INVALID'; | |
| } | |
| return 'FAILED_INVALID'; | |
| } | |
| _reward(action, result) { | |
| let r = -0.05; | |
| if (result !== 'SUCCESS') { | |
| const key = action + ':' + result; | |
| r += this.knownFailures.includes(key) ? -2.0 : -1.0; | |
| return r; | |
| } | |
| if (action === 'CLEAR_BLOCKER') r += 2.0; | |
| if (action === 'PICK') r += 2.0; | |
| if (action === 'PLACE_BIN_A' || action === 'PLACE_BIN_B') { | |
| // only reward correct placement — wrong bin gets a penalty instead | |
| const placedObj = Object.values(this.objects).find(o => o.inBin !== null && o.isHeld === false && result === 'SUCCESS'); | |
| // check via requiredPlacements: was this the right bin? | |
| const bin = action === 'PLACE_BIN_A' ? 'A' : 'B'; | |
| // find what we just placed (holding was cleared in _execute, check inBin) | |
| let correct = false; | |
| for (const [name, reqBin] of Object.entries(this.requiredPlacements)) { | |
| if (this.objects[name]?.inBin === bin) { correct = (reqBin === bin); break; } | |
| } | |
| r += correct ? 2.0 : -3.0; // correct bin +2, wrong bin -3 | |
| } | |
| if (this.knownFailures.length && result === 'SUCCESS' && action !== 'SCAN_SCENE') { | |
| if (!this.completedSubgoals.includes('recovery')) r += 1.0; | |
| } | |
| if (this._allGoalsDone()) { r += 10.0; this.done = true; } | |
| return r; | |
| } | |
| _updateState(action, result) { | |
| if (result !== 'SUCCESS') { | |
| const key = action + ':' + result; | |
| if (!this.knownFailures.includes(key)) this.knownFailures.push(key); | |
| } else { | |
| if (action === 'CLEAR_BLOCKER' && !this.completedSubgoals.includes('cleared_blocker')) | |
| this.completedSubgoals.push('cleared_blocker'); | |
| if (action === 'PICK' && !this.completedSubgoals.includes('recovery') && this.knownFailures.length) | |
| this.completedSubgoals.push('recovery'); | |
| } | |
| for (const [name, bin] of Object.entries(this.requiredPlacements)) { | |
| const key = `placed_${name}_in_bin_${bin}`; | |
| if (!this.completedSubgoals.includes(key) && this.objects[name]?.inBin === bin) | |
| this.completedSubgoals.push(key); | |
| } | |
| } | |
| _allGoalsDone() { | |
| for (const [name, bin] of Object.entries(this.requiredPlacements)) | |
| if (this.objects[name]?.inBin !== bin) return false; | |
| return true; | |
| } | |
| _validActions() { | |
| const actions = ['SCAN_SCENE']; | |
| const o = this.objects; | |
| if (this.navMode) actions.push('MOVE_NORTH','MOVE_SOUTH','MOVE_EAST','MOVE_WEST','ROTATE_LEFT','ROTATE_RIGHT'); | |
| if (!this.holding) { | |
| if (!this.navMode) { | |
| const moveMap = [ | |
| ['MOVE_TO_RED', 'red_block'], | |
| ['MOVE_TO_BLUE', 'blue_block'], | |
| ['MOVE_TO_GREEN', 'green_block'], | |
| ['MOVE_TO_YELLOW', 'yellow_block'], | |
| ['MOVE_TO_PURPLE', 'purple_block'], | |
| ]; | |
| for (const [action, name] of moveMap) { | |
| const obj = o[name]; | |
| if (obj && obj.reachable && !obj.inBin) actions.push(action); | |
| } | |
| } | |
| let canPick = false; | |
| for (const obj of Object.values(o)) { | |
| if (!obj.reachable || obj.isHeld || obj.inBin) continue; | |
| const dist = Math.hypot(this.gripperX - obj.x, this.gripperY - obj.y); | |
| if (dist < 0.15) { canPick = true; break; } | |
| } | |
| if (canPick) actions.push('PICK'); | |
| } else { | |
| actions.push('PLACE_BIN_A', 'PLACE_BIN_B'); | |
| } | |
| for (const obj of Object.values(o)) { | |
| if (obj.blocking && obj.reachable) { | |
| actions.push('CLEAR_BLOCKER'); | |
| break; | |
| } | |
| } | |
| return actions; | |
| } | |
| _obs(lastAction, lastResult) { | |
| const totalGoals = Object.keys(this.requiredPlacements).length; | |
| const completedGoals = Object.entries(this.requiredPlacements) | |
| .filter(([name, bin]) => this.objects[name]?.inBin === bin).length; | |
| const goalProgress = totalGoals ? (completedGoals / totalGoals) : 1.0; | |
| return { | |
| instruction: this.instruction, | |
| stepsRemaining: MAX_STEPS - this.steps, | |
| visibleObjects: Object.values(this.objects).map(o => ({ | |
| name: o.name, reachable: o.reachable, blocking: o.blocking, inBin: o.inBin | |
| })), | |
| holding: this.holding, | |
| completedSubgoals: [...this.completedSubgoals], | |
| knownFailures: [...this.knownFailures], | |
| activeConstraints: [...this.activeConstraints], | |
| actionHistory: [...this.actionHistory], | |
| validActions: this._validActions(), | |
| navMode: this.navMode, | |
| gripperFacing: this.gripperFacing, | |
| goalProgress, | |
| goalsRemaining: Math.max(0, totalGoals - completedGoals), | |
| lastAction, lastResult, | |
| // expose raw sim state for canvas | |
| _objects: this.objects, | |
| _gripperX: this.gripperX, | |
| _gripperY: this.gripperY, | |
| }; | |
| } | |
| } | |
| // ───────────────────────────────────────────────────────────────────── | |
| // Scripted agent | |
| // ───────────────────────────────────────────────────────────────────── | |
| function scriptedAgent(obs) { | |
| const valid = new Set(obs.validActions || []); | |
| const pickFirst = ['PICK','CLEAR_BLOCKER','PLACE_BIN_A','PLACE_BIN_B', | |
| 'MOVE_TO_RED','MOVE_TO_BLUE','MOVE_TO_GREEN','MOVE_TO_YELLOW','MOVE_TO_PURPLE', | |
| 'MOVE_NORTH','MOVE_SOUTH','MOVE_EAST','MOVE_WEST','ROTATE_LEFT','ROTATE_RIGHT','SCAN_SCENE']; | |
| // In backend-driven mode, always stay within valid actions. | |
| if (obs.validActions && obs.validActions.length) { | |
| for (const a of pickFirst) { | |
| if (valid.has(a)) return a; | |
| } | |
| return obs.validActions[0]; | |
| } | |
| const failures = new Set(obs.knownFailures); | |
| const reachable = {}; | |
| obs.visibleObjects.forEach(o => reachable[o.name] = o.reachable); | |
| const completed = new Set(obs.completedSubgoals); | |
| if (obs.lastAction?.startsWith('MOVE_TO') && obs.lastResult === 'SUCCESS') return 'PICK'; | |
| if (obs.holding === 'red_block') return 'PLACE_BIN_A'; | |
| if (obs.holding === 'green_block') return 'PLACE_BIN_B'; | |
| if (failures.has('MOVE_TO_RED:FAILED_BLOCKED') || failures.has('PICK:FAILED_EMPTY')) return 'CLEAR_BLOCKER'; | |
| if (!completed.has('placed_red_block_in_bin_A')) { | |
| if (reachable['red_block']) return 'MOVE_TO_RED'; | |
| return 'CLEAR_BLOCKER'; | |
| } | |
| if (!completed.has('placed_green_block_in_bin_B') && reachable['green_block']) return 'MOVE_TO_GREEN'; | |
| return 'SCAN_SCENE'; | |
| } | |
| // ───────────────────────────────────────────────────────────────────── | |
| // Canvas rendering | |
| // ───────────────────────────────────────────────────────────────────── | |
| const canvas = document.getElementById('canvas'); | |
| const ctx = canvas.getContext('2d'); | |
| const W = canvas.width, H = canvas.height; | |
| const COLORS = { | |
| red_block: '#e05555', | |
| blue_block: '#5599e0', | |
| green_block: '#55c055', | |
| yellow_block:'#d2c343', | |
| purple_block:'#9a68d8', | |
| gripper: '#f0c36d', | |
| // pharmacy pack | |
| morphine_vial: '#e05580', | |
| saline_bag: '#55b8e0', | |
| insulin_pen: '#55e090', | |
| blood_sample: '#c03030', | |
| contrast_agent: '#b068d8', | |
| // warehouse pack | |
| fragile_package:'#e0a055', | |
| heavy_pallet: '#7070a0', | |
| urgent_parcel: '#e05555', | |
| standard_box: '#8080a0', | |
| hazmat_drum: '#d2c343', | |
| // lab pack | |
| reagent_alpha: '#e05555', | |
| catalyst_beta: '#5599e0', | |
| sample_gamma: '#55c055', | |
| solvent_delta: '#d2c343', | |
| enzyme_epsilon: '#9a68d8', | |
| }; | |
| const BIN_POS = { A: {x:0.18,y:-0.22}, B: {x:-0.18,y:-0.22} }; | |
| const BIN_COL = { A: '#9955ee', B: '#ee9955' }; | |
| function w2c(wx, wy) { | |
| return [W/2 + wx * 580, H/2 - wy * 580]; | |
| } | |
| function drawScene(obs, blockedByMap) { | |
| blockedByMap = blockedByMap || {}; | |
| if (!obs) return; | |
| ctx.clearRect(0,0,W,H); | |
| // Table bg | |
| ctx.fillStyle = '#13131e'; | |
| ctx.fillRect(0,0,W,H); | |
| // Grid | |
| ctx.strokeStyle = '#2a2a3a'; ctx.lineWidth = 1; | |
| for (let i = -6; i <= 6; i++) { | |
| let [x1,y1] = w2c(i*0.07,-0.42), [x2,y2] = w2c(i*0.07,0.42); | |
| ctx.beginPath(); ctx.moveTo(x1,y1); ctx.lineTo(x2,y2); ctx.stroke(); | |
| let [x3,y3] = w2c(-0.42,i*0.07), [x4,y4] = w2c(0.42,i*0.07); | |
| ctx.beginPath(); ctx.moveTo(x3,y3); ctx.lineTo(x4,y4); ctx.stroke(); | |
| } | |
| // Bins | |
| for (const [label, pos] of Object.entries(BIN_POS)) { | |
| const [cx,cy] = w2c(pos.x, pos.y); | |
| const col = BIN_COL[label]; | |
| ctx.strokeStyle = col; ctx.lineWidth = 2.5; | |
| ctx.strokeRect(cx-32,cy-32,64,64); | |
| ctx.fillStyle = col+'18'; ctx.fillRect(cx-32,cy-32,64,64); | |
| ctx.fillStyle = col; | |
| ctx.font = 'bold 13px monospace'; ctx.textAlign = 'center'; | |
| ctx.fillText('Bin '+label, cx, cy+5); | |
| // show what's in it | |
| const inBin = obs.visibleObjects.filter(o => o.inBin === label); | |
| inBin.forEach((o,i) => { | |
| const c2 = COLORS[o.name] || '#aaa'; | |
| ctx.fillStyle = c2+'bb'; | |
| ctx.fillRect(cx - 14 + i*12, cy-26, 12, 12); | |
| }); | |
| } | |
| // Helper: draw a 3-D bevel cube centered at (cx,cy) | |
| function drawCube(cx, cy, col, held, blocked, label) { | |
| const S = 18; // half-size of front face | |
| const BX = 9; // bevel offset x (right face width) | |
| const BY = 7; // bevel offset y (top face height) | |
| // Lighten / darken helpers | |
| function tint(hex, amt) { | |
| const n = parseInt(hex.slice(1), 16); | |
| const r = Math.min(255, Math.max(0, (n>>16) + amt)); | |
| const g = Math.min(255, Math.max(0, ((n>>8)&0xff) + amt)); | |
| const b = Math.min(255, Math.max(0, (n&0xff) + amt)); | |
| return '#' + [r,g,b].map(v=>v.toString(16).padStart(2,'0')).join(''); | |
| } | |
| const alpha = blocked ? '55' : held ? 'ee' : 'cc'; | |
| const baseCol = col + alpha; | |
| const topCol = tint(col, 55) + alpha; | |
| const sideCol = tint(col, -50) + alpha; | |
| // Drop shadow | |
| ctx.fillStyle = 'rgba(0,0,0,0.4)'; | |
| ctx.beginPath(); | |
| ctx.ellipse(cx + BX/2, cy + S + BY/2, S + 4, 5, 0, 0, Math.PI*2); | |
| ctx.fill(); | |
| // Right face (darkest) | |
| ctx.beginPath(); | |
| ctx.moveTo(cx+S, cy-S+BY); | |
| ctx.lineTo(cx+S+BX, cy-S); | |
| ctx.lineTo(cx+S+BX, cy+S); | |
| ctx.lineTo(cx+S, cy+S+BY); | |
| ctx.closePath(); | |
| ctx.fillStyle = sideCol; | |
| ctx.fill(); | |
| if (!blocked) { ctx.strokeStyle = tint(col,-70)+alpha; ctx.lineWidth=0.8; ctx.stroke(); } | |
| // Top face (lightest) | |
| ctx.beginPath(); | |
| ctx.moveTo(cx-S, cy-S+BY); | |
| ctx.lineTo(cx-S+BX, cy-S); | |
| ctx.lineTo(cx+S+BX, cy-S); | |
| ctx.lineTo(cx+S, cy-S+BY); | |
| ctx.closePath(); | |
| ctx.fillStyle = topCol; | |
| ctx.fill(); | |
| if (!blocked) { ctx.strokeStyle = tint(col,20)+alpha; ctx.lineWidth=0.8; ctx.stroke(); } | |
| // Front face | |
| ctx.beginPath(); | |
| ctx.moveTo(cx-S, cy-S+BY); | |
| ctx.lineTo(cx+S, cy-S+BY); | |
| ctx.lineTo(cx+S, cy+S+BY); | |
| ctx.lineTo(cx-S, cy+S+BY); | |
| ctx.closePath(); | |
| ctx.fillStyle = baseCol; | |
| ctx.fill(); | |
| // Front face border | |
| ctx.strokeStyle = held ? '#ffffff' : blocked ? '#555' : tint(col, 40); | |
| ctx.lineWidth = held ? 2.5 : 1.5; | |
| ctx.stroke(); | |
| // Blocked overlay: × for physically blocked, ? for hidden/unscanned | |
| if (blocked) { | |
| ctx.fillStyle = 'rgba(0,0,0,0.35)'; | |
| ctx.fillRect(cx-S, cy-S+BY, S*2, S*2); | |
| if (arguments[5]) { | |
| // hidden — amber question mark | |
| ctx.fillStyle = '#c8a040'; ctx.font = 'bold 16px monospace'; ctx.textAlign = 'center'; | |
| ctx.fillText('?', cx, cy + BY + 6); | |
| } else { | |
| // physically blocked — red X | |
| ctx.strokeStyle = '#e06060cc'; ctx.lineWidth = 2; | |
| ctx.beginPath(); ctx.moveTo(cx-10,cy-10+BY); ctx.lineTo(cx+10,cy+10+BY); ctx.stroke(); | |
| ctx.beginPath(); ctx.moveTo(cx+10,cy-10+BY); ctx.lineTo(cx-10,cy+10+BY); ctx.stroke(); | |
| } | |
| } | |
| // Held glow | |
| if (held) { | |
| ctx.shadowColor = col; ctx.shadowBlur = 18; | |
| ctx.strokeStyle = '#ffffff88'; ctx.lineWidth = 1; | |
| ctx.strokeRect(cx-S-2, cy-S+BY-2, S*2+4, S*2+4); | |
| ctx.shadowBlur = 0; | |
| } | |
| // Label on front face | |
| ctx.fillStyle = held ? '#fff' : blocked ? '#999' : '#fff'; | |
| ctx.font = (held ? 'bold ' : '') + '9px monospace'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText(label, cx, cy + BY + 5); | |
| if (held) { | |
| ctx.fillStyle = '#90ffb0'; ctx.font = '7px monospace'; | |
| ctx.fillText('HELD', cx, cy + BY + 15); | |
| } | |
| } | |
| // Sort: draw blockers AFTER their targets so blockers appear on top visually | |
| const allObjs = Object.entries(obs._objects).filter(([,o]) => !o.inBin); | |
| const blockerNames = new Set(allObjs.filter(([,o]) => o.blocking).map(([n]) => n)); | |
| const drawOrder = [...allObjs.filter(([n]) => !blockerNames.has(n)), | |
| ...allObjs.filter(([n]) => blockerNames.has(n))]; | |
| for (const [name, obj] of drawOrder) { | |
| const [cx,cy] = w2c(obj.x, obj.y); | |
| const col = COLORS[name] || '#aaa'; | |
| const held = obj.isHeld; | |
| const blocked = !obj.reachable; | |
| const shortLabel = name.replace('_block','').replace('_vial','').replace('_bag','').replace('_pen','').replace('_sample','').replace('_agent','').replace('_package','').replace('_pallet','').replace('_parcel','').replace('_box','').replace('_drum','').replace('_alpha','α').replace('_beta','β').replace('_gamma','γ').replace('_delta','δ').replace('_epsilon','ε').toUpperCase().slice(0, 6); | |
| // hidden = unreachable but NOT because something is physically on top | |
| const isHidden = blocked && !blockedByMap[name]; | |
| // Append trait icon to label if revealed | |
| const trait = (blockedByMap && obs.discoveredTraits) ? obs.discoveredTraits[name] : null; | |
| const traitSuffix = trait === 'fragile' ? ' 💜' : trait === 'heavy' ? ' 🔵' : ''; | |
| // For blockers: draw slightly above the blocked object (offset up to show "on top") | |
| const yOff = obj.blocking ? -6 : 0; | |
| drawCube(cx, cy + yOff, col, held, blocked, shortLabel + traitSuffix, isHidden); | |
| } | |
| // Gripper | |
| const [gx,gy] = w2c(obs._gripperX, obs._gripperY); | |
| ctx.shadowColor = COLORS.gripper; ctx.shadowBlur = 10; | |
| ctx.strokeStyle = COLORS.gripper; ctx.lineWidth = 2; | |
| ctx.beginPath(); ctx.arc(gx,gy,13,0,Math.PI*2); ctx.stroke(); | |
| ctx.fillStyle = COLORS.gripper+'22'; ctx.fill(); | |
| ctx.shadowBlur = 0; | |
| ctx.strokeStyle = COLORS.gripper; ctx.lineWidth = 1.5; | |
| ctx.beginPath(); ctx.moveTo(gx-8,gy); ctx.lineTo(gx+8,gy); ctx.stroke(); | |
| ctx.beginPath(); ctx.moveTo(gx,gy-8); ctx.lineTo(gx,gy+8); ctx.stroke(); | |
| } | |
| // ── Side view ───────────────────────────────────────────────────────── | |
| const canvasSide = document.getElementById('canvas-side'); | |
| const ctxS = canvasSide.getContext('2d'); | |
| const SW = canvasSide.width, SH = canvasSide.height; | |
| function drawSideView(obs) { | |
| if (!obs) return; | |
| ctxS.clearRect(0,0,SW,SH); | |
| ctxS.fillStyle = '#13131e'; ctxS.fillRect(0,0,SW,SH); | |
| // Table surface | |
| const tableY = SH - 28; | |
| ctxS.fillStyle = '#2a2a1a'; ctxS.fillRect(0, tableY, SW, 6); | |
| ctxS.strokeStyle = '#554'; ctxS.lineWidth = 1.5; | |
| ctxS.beginPath(); ctxS.moveTo(0, tableY); ctxS.lineTo(SW, tableY); ctxS.stroke(); | |
| // Group objects by x position to show stacking | |
| const onTable = Object.entries(obs._objects).filter(([n,o]) => !o.inBin && !o.isHeld); | |
| // Objects that are drawn as the base of a blocker stack — skip them in the main loop | |
| const drawnAsBase = new Set(onTable.filter(([,o]) => o.blocking).map(([,o]) => o.blocking)); | |
| const BSIZE = 34; | |
| const visibleItems = onTable.filter(([n]) => !drawnAsBase.has(n)); | |
| const spacing = Math.min(70, (SW - 40) / Math.max(visibleItems.length, 1)); | |
| const startX = SW/2 - (visibleItems.length-1) * spacing/2; | |
| visibleItems.forEach(([name, obj], i) => { | |
| const col = COLORS[name] || '#aaa'; | |
| const blocked = !obj.reachable; | |
| const cx = startX + i * spacing; | |
| // Check if this block is blocking something (draw blocker on top) | |
| const isBlocker = obj.blocking; | |
| const blockH = BSIZE; | |
| let stackY = tableY - blockH; | |
| if (isBlocker) { | |
| // Draw the blocked block first (bottom) | |
| const targetCol = COLORS[obj.blocking] || '#aaa'; | |
| ctxS.fillStyle = targetCol + '44'; | |
| ctxS.fillRect(cx - BSIZE/2, stackY, BSIZE, blockH); | |
| ctxS.strokeStyle = '#444'; ctxS.lineWidth = 1; | |
| ctxS.strokeRect(cx - BSIZE/2, stackY, BSIZE, blockH); | |
| ctxS.fillStyle = '#888'; ctxS.font = '8px monospace'; ctxS.textAlign = 'center'; | |
| ctxS.fillText(obj.blocking.replace('_block','').toUpperCase(), cx, stackY + blockH/2 + 3); | |
| // X mark | |
| ctxS.strokeStyle = '#e06060'; ctxS.lineWidth = 1.5; | |
| ctxS.beginPath(); ctxS.moveTo(cx-10, stackY+8); ctxS.lineTo(cx+10, stackY+blockH-8); ctxS.stroke(); | |
| ctxS.beginPath(); ctxS.moveTo(cx+10, stackY+8); ctxS.lineTo(cx-10, stackY+blockH-8); ctxS.stroke(); | |
| stackY -= blockH; // blocker goes on top | |
| } | |
| // Draw this block | |
| ctxS.fillStyle = blocked ? col+'44' : col+'aa'; | |
| ctxS.fillRect(cx - BSIZE/2, stackY, BSIZE, blockH); | |
| ctxS.strokeStyle = isBlocker ? '#f09040' : blocked ? '#444' : col; | |
| ctxS.lineWidth = isBlocker ? 2.5 : 1.5; | |
| ctxS.strokeRect(cx - BSIZE/2, stackY, BSIZE, blockH); | |
| // Label | |
| ctxS.fillStyle = '#fff'; ctxS.font = 'bold 9px monospace'; ctxS.textAlign = 'center'; | |
| ctxS.fillText(name.replace('_block','').toUpperCase(), cx, stackY + blockH/2 + 3); | |
| if (isBlocker) { | |
| ctxS.fillStyle = '#f09040'; ctxS.font = '7px monospace'; | |
| ctxS.fillText('ON TOP', cx, stackY - 4); | |
| } | |
| }); | |
| // Held block (floating above) | |
| const held = Object.entries(obs._objects).find(([n,o]) => o.isHeld); | |
| if (held) { | |
| const [name] = held; | |
| const col = COLORS[name] || '#aaa'; | |
| ctxS.fillStyle = col+'cc'; | |
| ctxS.fillRect(SW/2 - BSIZE/2, 8, BSIZE, BSIZE); | |
| ctxS.strokeStyle = '#fff'; ctxS.lineWidth = 2; | |
| ctxS.strokeRect(SW/2 - BSIZE/2, 8, BSIZE, BSIZE); | |
| ctxS.fillStyle = '#fff'; ctxS.font = 'bold 9px monospace'; ctxS.textAlign = 'center'; | |
| ctxS.fillText(name.replace('_block','').toUpperCase(), SW/2, 8 + BSIZE/2 + 3); | |
| ctxS.fillStyle = '#90ffb0'; ctxS.font = '7px monospace'; | |
| ctxS.fillText('HELD BY GRIPPER', SW/2, 8 + BSIZE + 10); | |
| } | |
| // Bin labels | |
| ctxS.fillStyle = BIN_COL.A + 'aa'; ctxS.font = 'bold 10px monospace'; ctxS.textAlign = 'left'; | |
| ctxS.fillText('BIN A', 6, tableY + 18); | |
| ctxS.fillStyle = BIN_COL.B + 'aa'; ctxS.textAlign = 'right'; | |
| ctxS.fillText('BIN B', SW - 6, tableY + 18); | |
| } | |
| // ───────────────────────────────────────────────────────────────────── | |
| // UI | |
| // ───────────────────────────────────────────────────────────────────── | |
| const ACTIONS = [ | |
| 'SCAN_SCENE', | |
| 'MOVE_TO_RED','MOVE_TO_BLUE','MOVE_TO_GREEN','MOVE_TO_YELLOW','MOVE_TO_PURPLE', | |
| 'MOVE_NORTH','MOVE_SOUTH','MOVE_EAST','MOVE_WEST','ROTATE_LEFT','ROTATE_RIGHT', | |
| 'PICK','PLACE_BIN_A','PLACE_BIN_B','CLEAR_BLOCKER' | |
| ]; | |
| let env = new Env(); // fallback only if backend unavailable | |
| let currentObs = null; | |
| let currentDone = false; | |
| let totalReward = 0; | |
| let stepCount = 0; | |
| let episodeCount = 1; | |
| let successCount = 0; | |
| let playing = false; | |
| let oraclePlaying = false; | |
| let playTimer = null; | |
| let modelBusy = false; | |
| let lastModelRaw = ''; | |
| let lastModelReasoning = ''; | |
| let lastModelAction = ''; | |
| let lastOracleReasoning = ''; | |
| let isOracleMode = false; | |
| let lastStepInfo = {}; | |
| let recentActions = []; | |
| let recentRewards = []; | |
| let backendHealthy = true; | |
| const OBJECT_LAYOUT = { | |
| red_block: { x: 0.10, y: 0.00 }, | |
| blue_block: { x: 0.00, y: 0.00 }, | |
| green_block: { x: -0.10, y: 0.00 }, | |
| yellow_block: { x: 0.20, y: 0.06 }, | |
| purple_block: { x: -0.20, y: 0.06 }, | |
| }; | |
| function parseCell(cellStr) { | |
| if (!cellStr || typeof cellStr !== 'string' || !cellStr.includes(',')) return [0, 0.25]; | |
| const [xs, ys] = cellStr.split(','); | |
| const x = Number(xs), y = Number(ys); | |
| if (!Number.isFinite(x) || !Number.isFinite(y)) return [0, 0.25]; | |
| return [x * 0.1, y * 0.1]; | |
| } | |
| function normalizeObs(raw) { | |
| const visible = (raw.visible_objects || []).map((o) => ({ | |
| name: o.name, | |
| reachable: !!o.reachable, | |
| blocking: o.blocking || null, | |
| inBin: o.in_bin || null, | |
| isHeld: !!o.is_held, | |
| })); | |
| const obs = { | |
| instruction: raw.instruction || '—', | |
| stepsRemaining: raw.steps_remaining ?? 0, | |
| visibleObjects: visible, | |
| holding: raw.holding || null, | |
| completedSubgoals: raw.completed_subgoals || [], | |
| knownFailures: raw.known_failures || [], | |
| activeConstraints: raw.active_constraints || [], | |
| actionHistory: raw.action_history || [], | |
| lastAction: raw.last_action || null, | |
| lastResult: raw.last_result || null, | |
| validActions: raw.valid_actions || [], | |
| actionPreconditions: raw.action_preconditions || {}, | |
| goalProgress: raw.goal_progress ?? 0, | |
| goalsRemaining: raw.goals_remaining ?? 0, | |
| oracleHint: raw.oracle_hint || null, | |
| navMode: !!raw.nav_mode, | |
| midTaskChanged: !!raw.mid_task_changed, | |
| gripperCell: raw.gripper_cell || null, | |
| gripperFacing: raw.gripper_facing || null, | |
| nextTargetCell: raw.next_target_cell || null, | |
| distanceToNextGoal: raw.distance_to_next_goal ?? null, | |
| deadlineStatus: raw.deadline_status || {}, | |
| discoveredTraits: raw.discovered_traits || {}, | |
| _objects: {}, | |
| _gripperX: 0, | |
| _gripperY: 0.25, | |
| }; | |
| const [gx, gy] = parseCell(obs.gripperCell); | |
| obs._gripperX = gx; | |
| obs._gripperY = gy; | |
| for (const o of obs.visibleObjects) { | |
| const base = OBJECT_LAYOUT[o.name] || { x: 0, y: 0 }; | |
| obs._objects[o.name] = { | |
| name: o.name, | |
| x: base.x, | |
| y: base.y, | |
| reachable: !!o.reachable, | |
| blocking: o.blocking || null, | |
| inBin: o.inBin || null, | |
| isHeld: !!o.isHeld || (obs.holding === o.name), | |
| }; | |
| } | |
| return obs; | |
| } | |
| async function apiReset() { | |
| const url = `/demo/reset?difficulty=${encodeURIComponent(ACTIVE_DIFFICULTY)}&scenario_pack=${encodeURIComponent(ACTIVE_PACK)}`; | |
| const resp = await fetch(url, { method: 'POST' }); | |
| if (!resp.ok) throw new Error(`reset failed ${resp.status}`); | |
| const data = await resp.json(); | |
| return { observation: normalizeObs(data.observation), reward: data.reward ?? 0, done: !!data.done, info: data.info || {} }; | |
| } | |
| async function apiStep(action) { | |
| const resp = await fetch(`/demo/step?action=${encodeURIComponent(action)}`, { method: 'POST' }); | |
| if (!resp.ok) throw new Error(`step failed ${resp.status}`); | |
| const data = await resp.json(); | |
| return { observation: normalizeObs(data.observation), reward: data.reward ?? 0, done: !!data.done, info: data.info || {} }; | |
| } | |
| async function apiOracle() { | |
| const resp = await fetch('/demo/oracle', { method: 'GET' }); | |
| if (!resp.ok) throw new Error(`oracle failed ${resp.status}`); | |
| const data = await resp.json(); | |
| return { | |
| observation: normalizeObs(data.observation), | |
| reward: data.reward ?? 0, | |
| done: !!data.done, | |
| info: data.info || {}, | |
| action_taken: data.action_taken || null, | |
| reasoning: data.reasoning || '', | |
| }; | |
| } | |
| function renderManualButtons(validActions) { | |
| const grid = document.getElementById('manual-grid'); | |
| grid.innerHTML = ''; | |
| const valid = new Set(validActions || []); | |
| for (const a of ACTIONS) { | |
| const b = document.createElement('button'); | |
| b.textContent = a.replace(/_/g,' '); | |
| b.disabled = !valid.has(a); | |
| b.style.opacity = b.disabled ? '0.45' : '1'; | |
| b.onclick = () => manualStep(a); | |
| grid.appendChild(b); | |
| } | |
| } | |
| function updateUI(obs, reward, done) { | |
| currentObs = obs; | |
| currentDone = !!done; | |
| document.getElementById('instruction').textContent = obs.instruction; | |
| // Mid-task instruction change banner | |
| const midSec = document.getElementById('mid-task-sec'); | |
| const midTxt = document.getElementById('mid-task-txt'); | |
| if (obs.midTaskChanged) { | |
| midSec.style.display = ''; | |
| // Re-trigger animation by cloning the element | |
| const clone = midTxt.cloneNode(true); | |
| midSec.replaceChild(clone, midTxt); | |
| // Extract the [UPDATE: ...] part from instruction if present | |
| const updateMatch = obs.instruction.match(/\[UPDATE:(.*?)\]/); | |
| clone.textContent = updateMatch | |
| ? `⚡ Instruction changed! ${updateMatch[0]}` | |
| : '⚡ Mid-task instruction change!'; | |
| } else { | |
| midSec.style.display = 'none'; | |
| } | |
| document.getElementById('steps').textContent = obs.stepsRemaining; | |
| document.getElementById('reward').textContent = totalReward.toFixed(2); | |
| document.getElementById('episode').textContent = episodeCount; | |
| document.getElementById('successes').textContent = successCount; | |
| // reward bar | |
| const norm = Math.min(1, Math.max(0, (totalReward + 5) / 22)); | |
| const bar = document.getElementById('rbar'); | |
| bar.style.width = (norm*100)+'%'; | |
| bar.style.background = totalReward >= 0 ? '#4a8a4a' : '#8a3a3a'; | |
| // objects — distinguish physically blocked (has a blocker object) from hidden (perception noise) | |
| const blockedByMap = {}; | |
| for (const o of obs.visibleObjects) { | |
| if (o.blocking) blockedByMap[o.blocking] = o.name; | |
| } | |
| document.getElementById('obj-list').innerHTML = obs.visibleObjects.map(o => { | |
| const col = COLORS[o.name] || '#aaa'; | |
| const tags = []; | |
| if (!o.reachable && !o.inBin) { | |
| if (blockedByMap[o.name]) tags.push('<span class="tag blocked">blocked</span>'); | |
| else tags.push('<span class="tag hidden">🔍 scan to reveal</span>'); | |
| } | |
| const trait = obs.discoveredTraits[o.name]; | |
| if (trait === 'fragile') tags.push('<span class="tag fragile">💜 fragile</span>'); | |
| else if (trait === 'heavy') tags.push('<span class="tag heavy">🔵 heavy</span>'); | |
| if (o.name === obs.holding) tags.push('<span class="tag held">held</span>'); | |
| if (o.inBin) tags.push(`<span class="tag">in bin ${o.inBin}</span>`); | |
| return `<div class="obj-row"><div class="dot" style="background:${col}"></div><span>${o.name}</span>${tags.join('')}</div>`; | |
| }).join(''); | |
| // holding | |
| const hs = document.getElementById('holding-sec'); | |
| if (obs.holding) { hs.style.display=''; document.getElementById('holding-txt').textContent='Holding: '+obs.holding; } | |
| else hs.style.display='none'; | |
| // constraints / subgoals / failures | |
| const mkItems = (arr, cls) => arr.length | |
| ? arr.map(s=>`<div class="item ${cls}">${s}</div>`).join('') | |
| : '<div class="none">none</div>'; | |
| document.getElementById('constraints').innerHTML = mkItems(obs.activeConstraints,'con'); | |
| document.getElementById('subgoals').innerHTML = mkItems(obs.completedSubgoals,'sg'); | |
| document.getElementById('failures').innerHTML = mkItems(obs.knownFailures,'fai'); | |
| // log row | |
| if (obs.lastAction) { | |
| recentActions.push(obs.lastAction); | |
| recentRewards.push(reward); | |
| if (recentActions.length > 30) recentActions.shift(); | |
| if (recentRewards.length > 30) recentRewards.shift(); | |
| const ok = obs.lastResult?.startsWith('SUCCESS'); | |
| const row = document.createElement('div'); | |
| row.className = 'log-row'; | |
| row.innerHTML = `<span class="sn">#${stepCount}</span><span class="act">${obs.lastAction}</span><span class="res ${ok?'ok':'fail'}">${obs.lastResult}</span><span class="rew">${reward>=0?'+':''}${reward?.toFixed(2)}</span>`; | |
| const log = document.getElementById('log'); | |
| log.prepend(row); | |
| if (log.children.length > 40) log.removeChild(log.lastChild); | |
| } | |
| // diagnostics | |
| const counts = {}; | |
| for (const a of recentActions) counts[a] = (counts[a] || 0) + 1; | |
| const sorted = Object.entries(counts).sort((a,b) => b[1]-a[1]); | |
| const top = sorted.length ? `${sorted[0][0]} (${sorted[0][1]}/${recentActions.length})` : 'none'; | |
| const avgRecentReward = recentRewards.length | |
| ? (recentRewards.reduce((x,y)=>x+y,0)/recentRewards.length).toFixed(3) | |
| : '0.000'; | |
| const loopRisk = (sorted.length && (sorted[0][1] / Math.max(1, recentActions.length)) > 0.7) | |
| ? 'HIGH' | |
| : 'LOW'; | |
| const loopColor = loopRisk === 'HIGH' ? 'color:#e06060' : 'color:#60e090'; | |
| document.getElementById('diag').innerHTML = ` | |
| <div class="item con">Top recent action: ${top}</div> | |
| <div class="item con">Avg recent reward: ${avgRecentReward}</div> | |
| <div class="item" style="${loopColor}">Loop risk: ${loopRisk}</div> | |
| `; | |
| const rawEl = document.getElementById('raw-model'); | |
| const esc = s => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); | |
| const activeReasoning = isOracleMode ? lastOracleReasoning : lastModelReasoning; | |
| const activeAction = isOracleMode ? (obs.lastAction || '') : lastModelAction; | |
| if (activeReasoning || activeAction) { | |
| rawEl.innerHTML = | |
| (activeReasoning ? `<div class="reasoning-box">💭 ${esc(activeReasoning)}</div>` : '') + | |
| (activeAction ? `<div class="action-box">▶ ${esc(activeAction)}</div>` : ''); | |
| } else if (lastModelRaw) { | |
| rawEl.innerHTML = `<div class="item con">${esc(lastModelRaw)}</div>`; | |
| } else { | |
| rawEl.innerHTML = '<div class="none">none</div>'; | |
| } | |
| renderManualButtons(obs.validActions); | |
| // done | |
| const ds = document.getElementById('done-sec'); | |
| if (done) { | |
| ds.style.display = ''; | |
| const win = (obs.goalProgress || 0) >= 1.0; | |
| if (win) successCount++; | |
| document.getElementById('successes').textContent = successCount; | |
| document.getElementById('done-txt').textContent = | |
| win ? `✓ Task Complete! Reward: ${totalReward.toFixed(2)}` | |
| : `✗ Episode ended. Reward: ${totalReward.toFixed(2)}`; | |
| stopAll(); | |
| } else { | |
| ds.style.display = 'none'; | |
| } | |
| // blockedByMap already built above for obj-list; reuse for canvas | |
| drawScene(obs, blockedByMap); | |
| drawSideView(obs); | |
| } | |
| function resetEp() { | |
| resetEpAsync(); | |
| } | |
| async function resetEpAsync() { | |
| stopAll(); | |
| totalReward = 0; stepCount = 0; episodeCount++; | |
| recentActions = []; | |
| recentRewards = []; | |
| lastModelRaw = ''; lastModelReasoning = ''; lastModelAction = ''; | |
| lastOracleReasoning = ''; isOracleMode = false; | |
| document.getElementById('raw-model').innerHTML = '<div class="none">none</div>'; | |
| document.getElementById('log').innerHTML = ''; | |
| try { | |
| const r = await apiReset(); | |
| backendHealthy = true; | |
| document.getElementById('done-sec').style.display = 'none'; | |
| updateUI(r.observation, 0, false); | |
| } catch (e) { | |
| backendHealthy = false; | |
| const obs = env.reset(); | |
| document.getElementById('done-sec').style.display = 'none'; | |
| updateUI(obs, 0, false); | |
| } | |
| } | |
| function manualStep(action) { | |
| manualStepAsync(action); | |
| } | |
| async function manualStepAsync(action) { | |
| if (currentDone) { await resetEpAsync(); return; } | |
| let r; | |
| if (backendHealthy) { | |
| try { | |
| r = await apiStep(action); | |
| } catch (e) { | |
| backendHealthy = false; | |
| } | |
| } | |
| if (!backendHealthy) { | |
| r = env.step(action); | |
| } | |
| totalReward += r.reward; | |
| stepCount++; | |
| lastStepInfo = r.info || {}; | |
| updateUI(r.observation, r.reward, r.done); | |
| } | |
| function buildPolicyPrompt(obs) { | |
| const objects = obs.visibleObjects | |
| .map(o => { | |
| const tags = []; | |
| tags.push(o.reachable ? 'reachable' : 'BLOCKED'); | |
| if (o.inBin) tags.push(`in_bin=${o.inBin}`); | |
| if (o.name === obs.holding) tags.push('HELD'); | |
| if (o.blocking) tags.push(`blocking=${o.blocking}`); | |
| return `${o.name}(${tags.join(',')})`; | |
| }) | |
| .join(', '); | |
| const valid = (obs.validActions && obs.validActions.length) ? obs.validActions.join(', ') : 'any'; | |
| const preconditions = obs.actionPreconditions ? JSON.stringify(obs.actionPreconditions) : '{}'; | |
| const history = (obs.actionHistory && obs.actionHistory.length) | |
| ? obs.actionHistory.slice(-5).join(' -> ') | |
| : 'none'; | |
| const constraints = (obs.activeConstraints && obs.activeConstraints.length) | |
| ? obs.activeConstraints.join('; ') | |
| : 'none'; | |
| const failures = (obs.knownFailures && obs.knownFailures.length) ? obs.knownFailures.join('; ') : 'none'; | |
| const subgoals = (obs.completedSubgoals && obs.completedSubgoals.length) ? obs.completedSubgoals.join('; ') : 'none yet'; | |
| return ( | |
| "You are a robot planning agent on a tabletop. Choose ONE action.\n" | |
| + "Actions: SCAN_SCENE | MOVE_TO_RED | MOVE_TO_BLUE | MOVE_TO_GREEN | MOVE_TO_YELLOW | MOVE_TO_PURPLE | MOVE_NORTH | MOVE_SOUTH | MOVE_EAST | MOVE_WEST | ROTATE_LEFT | ROTATE_RIGHT | PICK | PLACE_BIN_A | PLACE_BIN_B | CLEAR_BLOCKER\n\n" | |
| + `Instruction: ${obs.instruction}\n` | |
| + `Scene: ${objects}\n` | |
| + `Holding: ${obs.holding || 'nothing'}\n` | |
| + `Goal progress: ${Math.round((obs.goalProgress || 0) * 100)}% Goals remaining: ${obs.goalsRemaining ?? '?'}\n` | |
| + `Completed: ${subgoals}\n` | |
| + `Failures: ${failures}\n` | |
| + `Constraints: ${constraints}\n` | |
| + `History: ${history}\n` | |
| + `Last: ${obs.lastAction || 'none'} -> ${obs.lastResult || 'n/a'}\n` | |
| + `Valid now: ${valid}\n` | |
| + `Preconditions: ${preconditions}\n` | |
| + `Distance to next goal: ${obs.distanceToNextGoal ?? 'n/a'}\n` | |
| + `Deadlines: ${JSON.stringify(obs.deadlineStatus || {})}\n` | |
| + `Steps left: ${obs.stepsRemaining}\n\n` | |
| + "Pick one action from Valid now. Output only the action name." | |
| ); | |
| } | |
| function chooseNonScanFallback(validActions) { | |
| const valid = new Set(validActions || []); | |
| const priority = [ | |
| 'PLACE_BIN_A','PLACE_BIN_B', | |
| 'PICK', | |
| 'CLEAR_BLOCKER', | |
| 'MOVE_TO_RED','MOVE_TO_BLUE','MOVE_TO_GREEN','MOVE_TO_YELLOW','MOVE_TO_PURPLE', | |
| 'MOVE_NORTH','MOVE_SOUTH','MOVE_EAST','MOVE_WEST', | |
| 'ROTATE_LEFT','ROTATE_RIGHT', | |
| 'SCAN_SCENE', | |
| ]; | |
| for (const a of priority) { | |
| if (valid.has(a)) return a; | |
| } | |
| return 'SCAN_SCENE'; | |
| } | |
| async function modelStep() { | |
| if (modelBusy || !currentObs) return; | |
| modelBusy = true; | |
| const btn = document.getElementById('model-btn'); | |
| const prev = btn.textContent; | |
| btn.textContent = '…Model'; | |
| try { | |
| const resp = await fetch('/demo/policy_action', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| prompt: buildPolicyPrompt(currentObs), | |
| valid_actions: currentObs.validActions || [], | |
| }), | |
| }); | |
| const data = await resp.json(); | |
| const raw = data.raw_output || ''; | |
| const rawAction = data.raw_action || 'n/a'; | |
| const chosen = data.action || 'n/a'; | |
| const valid = (data.valid_actions_used || []).join(', '); | |
| // Use structured reasoning field if available, otherwise parse <think> tags | |
| lastModelReasoning = data.reasoning || (() => { | |
| const m = raw.match(/<think>([\s\S]*?)<\/think>/i); | |
| return m ? m[1].trim() : ''; | |
| })(); | |
| lastModelAction = chosen; | |
| lastModelRaw = raw; | |
| if (data.error) lastModelRaw += ` [error=${data.error}]`; | |
| let action = data.action || 'SCAN_SCENE'; | |
| await manualStepAsync(action); | |
| } catch (e) { | |
| lastModelRaw = `ERROR: ${String(e)}`; | |
| await manualStepAsync('SCAN_SCENE'); | |
| } finally { | |
| modelBusy = false; | |
| btn.textContent = prev; | |
| } | |
| } | |
| async function oracleStep() { | |
| if (currentDone) { await resetEpAsync(); return; } | |
| isOracleMode = true; | |
| try { | |
| const r = await apiOracle(); | |
| lastOracleReasoning = r.reasoning || ''; | |
| totalReward += r.reward; | |
| stepCount++; | |
| lastStepInfo = r.info || {}; | |
| updateUI(r.observation, r.reward, r.done); | |
| } catch (e) { | |
| isOracleMode = false; | |
| console.error('Oracle step failed:', e); | |
| } | |
| } | |
| async function playLoop() { | |
| if (!playing) return; | |
| if (currentDone) { await new Promise(res=>setTimeout(res,600)); await resetEpAsync(); return; } | |
| await modelStep(); | |
| if (currentDone) { | |
| if (playing) playTimer = setTimeout(playLoop, 1000); | |
| return; | |
| } | |
| if (playing) { | |
| const delay = parseInt(document.getElementById('speed').value); | |
| playTimer = setTimeout(playLoop, delay); | |
| } | |
| } | |
| function togglePlay() { | |
| const btn = document.getElementById('play-btn'); | |
| if (playing) { playing=false; clearTimeout(playTimer); btn.textContent='▶ Run Agent'; } | |
| else { oraclePlaying=false; document.getElementById('oracle-play-btn').textContent='🎯 Run Oracle'; playing=true; btn.textContent='⏸ Pause'; playLoop(); } | |
| } | |
| async function oraclePlayLoop() { | |
| if (!oraclePlaying) return; | |
| if (currentDone) { await new Promise(res=>setTimeout(res,600)); await resetEpAsync(); } | |
| else await oracleStep(); | |
| if (oraclePlaying) { | |
| const delay = parseInt(document.getElementById('speed').value); | |
| playTimer = setTimeout(oraclePlayLoop, delay); | |
| } | |
| } | |
| function toggleOraclePlay() { | |
| const btn = document.getElementById('oracle-play-btn'); | |
| if (oraclePlaying) { oraclePlaying=false; clearTimeout(playTimer); btn.textContent='🎯 Run Oracle'; } | |
| else { playing=false; document.getElementById('play-btn').textContent='▶ Run Agent'; oraclePlaying=true; btn.textContent='⏸ Pause Oracle'; oraclePlayLoop(); } | |
| } | |
| function stopAll() { | |
| playing=false; oraclePlaying=false; clearTimeout(playTimer); | |
| document.getElementById('play-btn').textContent='▶ Run Agent'; | |
| document.getElementById('oracle-play-btn').textContent='🎯 Run Oracle'; | |
| } | |
| function renderDifficultyButtons() { | |
| ['easy', 'medium', 'hard'].forEach((d) => { | |
| const el = document.getElementById(`diff-${d}`); | |
| if (!el) return; | |
| if (d === ACTIVE_DIFFICULTY) el.classList.add('active'); | |
| else el.classList.remove('active'); | |
| }); | |
| } | |
| async function setDifficulty(level) { | |
| ACTIVE_DIFFICULTY = level; | |
| renderDifficultyButtons(); | |
| await resetEpAsync(); | |
| } | |
| async function setScenarioPack(pack) { | |
| ACTIVE_PACK = pack; | |
| await resetEpAsync(); | |
| } | |
| // init | |
| renderDifficultyButtons(); | |
| const packSel = document.getElementById('pack-select'); | |
| if (packSel) packSel.value = ACTIVE_PACK; | |
| resetEpAsync(); | |
| </script> | |
| </body> | |
| </html> | |