Spaces:
Sleeping
Sleeping
| """ | |
| Nav3D Agent Demo — Web Dashboard | |
| FastAPI app serving a monitoring dashboard for the voxel navigation agent. | |
| Supports both ngrok and bore tunneling. | |
| """ | |
| import os | |
| import json | |
| import subprocess | |
| import asyncio | |
| from datetime import datetime | |
| from pathlib import Path | |
| from fastapi import FastAPI, WebSocket, WebSocketDisconnect | |
| from fastapi.responses import HTMLResponse, JSONResponse | |
| from fastapi.staticfiles import StaticFiles | |
| app = FastAPI(title="Nav3D Agent Demo", version="2.0.0") | |
| # ── State ────────────────────────────────────────────────────────────────── | |
| _bot_status = { | |
| "online": False, | |
| "username": "Zelin_", | |
| "position": {"x": 0, "y": 0, "z": 0}, | |
| "health": 20, | |
| "food": 20, | |
| "activity": "starting", | |
| "last_think": "", | |
| "emotion": "neutral", | |
| "chat_log": [], | |
| "world_time": 0, | |
| "dimension": "overworld", | |
| "connected_since": "", | |
| "tunnel_url": "", | |
| "tunnel_method": "", | |
| "server_online": False, | |
| "players_online": 0, | |
| } | |
| # ── Load tunnel URL (supports both ngrok and bore) ───────────────────────── | |
| def _load_tunnel_url(): | |
| # Check bore tunnel first | |
| try: | |
| tunnel_file = Path("/app/logs/tunnel_url.txt") | |
| if tunnel_file.exists(): | |
| url = tunnel_file.read_text().strip() | |
| if url: | |
| return url | |
| except: | |
| pass | |
| # Fallback: check ngrok | |
| try: | |
| url_file = Path("/app/logs/ngrok_url.txt") | |
| if url_file.exists(): | |
| url = url_file.read_text().strip() | |
| if url: | |
| return url | |
| except: | |
| pass | |
| # Try ngrok API | |
| try: | |
| import urllib.request | |
| with urllib.request.urlopen("http://localhost:4040/api/tunnels", timeout=2) as resp: | |
| data = json.loads(resp.read()) | |
| for t in data.get("tunnels", []): | |
| url = t.get("public_url", "") | |
| if "tcp" in url: | |
| return url | |
| except: | |
| pass | |
| return "" | |
| def _load_tunnel_method(): | |
| try: | |
| method_file = Path("/app/logs/tunnel_method.txt") | |
| if method_file.exists(): | |
| return method_file.read_text().strip() | |
| except: | |
| pass | |
| # Check if ngrok URL exists | |
| try: | |
| url_file = Path("/app/logs/ngrok_url.txt") | |
| if url_file.exists() and url_file.read_text().strip(): | |
| return "ngrok" | |
| except: | |
| pass | |
| return "" | |
| def _update_bot_status(): | |
| """Read bot status from the shared JSON file""" | |
| global _bot_status | |
| try: | |
| status_file = Path("/app/logs/bot_status.json") | |
| if status_file.exists(): | |
| data = json.loads(status_file.read_text()) | |
| _bot_status.update(data) | |
| except: | |
| pass | |
| _bot_status["tunnel_url"] = _load_tunnel_url() | |
| _bot_status["tunnel_method"] = _load_tunnel_method() | |
| # ── Routes ───────────────────────────────────────────────────────────────── | |
| async def health(): | |
| _update_bot_status() | |
| return { | |
| "status": "running", | |
| "bot_online": _bot_status.get("online", False), | |
| "server_online": _bot_status.get("server_online", False), | |
| "timestamp": datetime.now().isoformat(), | |
| } | |
| async def api_status(): | |
| _update_bot_status() | |
| return _bot_status | |
| async def api_tunnel(): | |
| url = _load_tunnel_url() | |
| method = _load_tunnel_method() | |
| return {"url": url, "method": method, "active": bool(url)} | |
| async def api_debug(): | |
| """Debug endpoint with server logs and process info""" | |
| info = { | |
| "tunnel_url": _load_tunnel_url(), | |
| "tunnel_method": _load_tunnel_method(), | |
| "bot_status_exists": Path("/app/logs/bot_status.json").exists(), | |
| "paper_jar_exists": Path("/app/server/paper.jar").exists(), | |
| "plugins": [], | |
| } | |
| # Check plugins | |
| plugins_dir = Path("/app/server/plugins") | |
| if plugins_dir.exists(): | |
| info["plugins"] = [f.name for f in plugins_dir.glob("*.jar")] | |
| # Get last 50 lines of MC server log | |
| try: | |
| log_file = Path("/app/server/logs/latest.log") | |
| if log_file.exists(): | |
| lines = log_file.read_text().splitlines()[-100:] | |
| info["mc_server_log"] = lines | |
| except: | |
| pass | |
| # Get bot stdout log | |
| try: | |
| bot_log = Path("/app/logs/bot_stdout.log") | |
| if bot_log.exists(): | |
| lines = bot_log.read_text().splitlines()[-50:] | |
| info["bot_stdout"] = lines | |
| except: | |
| pass | |
| # Get tunnel log | |
| try: | |
| method = _load_tunnel_method() | |
| if method == "bore": | |
| tunnel_log = Path("/app/logs/bore.log") | |
| else: | |
| tunnel_log = Path("/app/logs/ngrok.log") | |
| if tunnel_log.exists(): | |
| info["tunnel_log"] = tunnel_log.read_text().splitlines()[-20:] | |
| except: | |
| pass | |
| return info | |
| async def dashboard(): | |
| _update_bot_status() | |
| return HTML_TEMPLATE | |
| async def chat_log(): | |
| _update_bot_status() | |
| return {"messages": _bot_status.get("chat_log", [])[-50:]} | |
| async def restart_bot(): | |
| """Restart the mineflayer bot process""" | |
| try: | |
| subprocess.run(["pkill", "-f", "node bot.js"], capture_output=True) | |
| await asyncio.sleep(2) | |
| subprocess.Popen( | |
| ["node", "/app/bot.js"], | |
| cwd="/app", | |
| stdout=open("/app/logs/bot_stdout.log", "a"), | |
| stderr=open("/app/logs/bot_stderr.log", "a"), | |
| ) | |
| return {"status": "restarting"} | |
| except Exception as e: | |
| return {"status": "error", "message": str(e)} | |
| # ── WebSocket for live updates ───────────────────────────────────────────── | |
| _ws_clients = [] | |
| async def websocket_endpoint(websocket: WebSocket): | |
| await websocket.accept() | |
| _ws_clients.append(websocket) | |
| try: | |
| while True: | |
| _update_bot_status() | |
| await websocket.send_json(_bot_status) | |
| await asyncio.sleep(2) | |
| except WebSocketDisconnect: | |
| _ws_clients.remove(websocket) | |
| except: | |
| if websocket in _ws_clients: | |
| _ws_clients.remove(websocket) | |
| # ── HTML Dashboard ───────────────────────────────────────────────────────── | |
| HTML_TEMPLATE = """<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Nav3D Agent Demo</title> | |
| <style> | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { | |
| font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; | |
| background: #0a0a0f; | |
| color: #e0e0e8; | |
| min-height: 100vh; | |
| } | |
| .header { | |
| background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); | |
| padding: 20px 30px; | |
| border-bottom: 1px solid #2a2a4a; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| } | |
| .header h1 { font-size: 1.5rem; font-weight: 700; } | |
| .header h1 span { color: #6c63ff; } | |
| .status-badge { | |
| padding: 6px 16px; | |
| border-radius: 20px; | |
| font-size: 0.85rem; | |
| font-weight: 600; | |
| } | |
| .status-online { background: #00c85320; color: #00c853; border: 1px solid #00c85340; } | |
| .status-offline { background: #ff174420; color: #ff1744; border: 1px solid #ff174440; } | |
| .dashboard { | |
| max-width: 1200px; | |
| margin: 0 auto; | |
| padding: 20px; | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 20px; | |
| } | |
| .card { | |
| background: #12121a; | |
| border: 1px solid #2a2a4a; | |
| border-radius: 12px; | |
| padding: 20px; | |
| } | |
| .card h2 { | |
| font-size: 1rem; | |
| color: #8888aa; | |
| text-transform: uppercase; | |
| letter-spacing: 1px; | |
| margin-bottom: 12px; | |
| } | |
| .card.full-width { grid-column: 1 / -1; } | |
| .stat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } | |
| .stat-item { | |
| background: #1a1a2e; | |
| padding: 12px; | |
| border-radius: 8px; | |
| } | |
| .stat-label { font-size: 0.75rem; color: #6a6a8a; text-transform: uppercase; } | |
| .stat-value { font-size: 1.5rem; font-weight: 700; color: #6c63ff; margin-top: 4px; } | |
| .health-bar, .food-bar { | |
| height: 8px; | |
| border-radius: 4px; | |
| background: #1a1a2e; | |
| margin-top: 8px; | |
| overflow: hidden; | |
| } | |
| .health-bar-fill { height: 100%; background: #00c853; border-radius: 4px; transition: width 0.5s; } | |
| .food-bar-fill { height: 100%; background: #ff9800; border-radius: 4px; transition: width 0.5s; } | |
| .chat-log { | |
| max-height: 300px; | |
| overflow-y: auto; | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 0.85rem; | |
| } | |
| .chat-msg { | |
| padding: 6px 10px; | |
| border-bottom: 1px solid #1a1a2e; | |
| } | |
| .chat-msg .time { color: #6a6a8a; margin-right: 8px; } | |
| .chat-msg .sender { color: #6c63ff; font-weight: 600; } | |
| .thought-box { | |
| background: #1a1a2e; | |
| padding: 12px; | |
| border-radius: 8px; | |
| border-left: 3px solid #6c63ff; | |
| font-style: italic; | |
| color: #a0a0c0; | |
| } | |
| .connect-info { | |
| background: #1a1a2e; | |
| padding: 16px; | |
| border-radius: 8px; | |
| text-align: center; | |
| } | |
| .connect-info code { | |
| display: block; | |
| margin-top: 8px; | |
| padding: 10px; | |
| background: #0a0a0f; | |
| border-radius: 6px; | |
| font-size: 1.1rem; | |
| color: #6c63ff; | |
| font-family: 'JetBrains Mono', monospace; | |
| user-select: all; | |
| } | |
| .restart-btn { | |
| background: #6c63ff; | |
| color: white; | |
| border: none; | |
| padding: 10px 24px; | |
| border-radius: 8px; | |
| cursor: pointer; | |
| font-size: 0.9rem; | |
| font-weight: 600; | |
| margin-top: 10px; | |
| transition: background 0.2s; | |
| } | |
| .restart-btn:hover { background: #5a52e0; } | |
| .debug-section { | |
| margin-top: 12px; | |
| background: #0a0a0f; | |
| padding: 12px; | |
| border-radius: 8px; | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 0.75rem; | |
| max-height: 400px; | |
| overflow-y: auto; | |
| color: #6a6a8a; | |
| white-space: pre-wrap; | |
| word-break: break-all; | |
| } | |
| .debug-btn { | |
| background: #2a2a4a; | |
| color: #8888aa; | |
| border: 1px solid #3a3a5a; | |
| padding: 8px 16px; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| font-size: 0.8rem; | |
| margin-top: 8px; | |
| } | |
| .debug-btn:hover { background: #3a3a5a; color: #e0e0e8; } | |
| @keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:0.5; } } | |
| .pulse { animation: pulse 2s infinite; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="header"> | |
| <h1><span>Nav3D</span> Agent Demo</h1> | |
| <div id="statusBadge" class="status-badge status-offline">OFFLINE</div> | |
| </div> | |
| <div class="dashboard"> | |
| <div class="card"> | |
| <h2>Agent Status</h2> | |
| <div class="stat-grid"> | |
| <div class="stat-item"> | |
| <div class="stat-label">Position</div> | |
| <div class="stat-value" id="posValue">0, 0, 0</div> | |
| </div> | |
| <div class="stat-item"> | |
| <div class="stat-label">Activity</div> | |
| <div class="stat-value" id="activityValue">--</div> | |
| </div> | |
| <div class="stat-item"> | |
| <div class="stat-label">Emotion</div> | |
| <div class="stat-value" id="emotionValue">--</div> | |
| </div> | |
| <div class="stat-item"> | |
| <div class="stat-label">Dimension</div> | |
| <div class="stat-value" id="dimValue">--</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <h2>Vitals</h2> | |
| <div class="stat-grid"> | |
| <div class="stat-item"> | |
| <div class="stat-label">Health <span id="healthNum">0</span>/20</div> | |
| <div class="health-bar"><div class="health-bar-fill" id="healthBar" style="width:0%"></div></div> | |
| </div> | |
| <div class="stat-item"> | |
| <div class="stat-label">Hunger <span id="foodNum">0</span>/20</div> | |
| <div class="food-bar"><div class="food-bar-fill" id="foodBar" style="width:0%"></div></div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="card full-width"> | |
| <h2>Current Thought</h2> | |
| <div class="thought-box" id="thoughtBox">Waiting for agent to connect...</div> | |
| </div> | |
| <div class="card"> | |
| <h2>Connect to Server</h2> | |
| <div class="connect-info"> | |
| <div>Add this server in Minecraft (any version 1.8-1.21+):</div> | |
| <code id="serverAddr">Loading...</code> | |
| <div style="margin-top:12px; font-size:0.8rem; color:#6a6a8a;" id="tunnelNote"></div> | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <h2>Server Info</h2> | |
| <div class="stat-grid"> | |
| <div class="stat-item"> | |
| <div class="stat-label">Players Online</div> | |
| <div class="stat-value" id="playersValue">0</div> | |
| </div> | |
| <div class="stat-item"> | |
| <div class="stat-label">World Time</div> | |
| <div class="stat-value" id="worldTimeValue">--</div> | |
| </div> | |
| <div class="stat-item"> | |
| <div class="stat-label">Connected Since</div> | |
| <div class="stat-value" id="connSinceValue" style="font-size:0.9rem">--</div> | |
| </div> | |
| <div class="stat-item"> | |
| <button class="restart-btn" onclick="restartBot()">Restart Agent</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="card full-width"> | |
| <h2>Chat Log</h2> | |
| <div class="chat-log" id="chatLog"> | |
| <div class="chat-msg"><span class="time">--:--</span> Waiting for messages...</div> | |
| </div> | |
| </div> | |
| <div class="card full-width"> | |
| <h2>Debug Logs</h2> | |
| <button class="debug-btn" onclick="loadDebug()">Refresh</button> | |
| <div class="debug-section" id="debugLog">Click Refresh to load debug info...</div> | |
| </div> | |
| </div> | |
| <script> | |
| let ws; | |
| function connect() { | |
| const proto = location.protocol === 'https:' ? 'wss' : 'ws'; | |
| ws = new WebSocket(proto + '://' + location.host + '/ws'); | |
| ws.onmessage = (e) => { | |
| try { | |
| const d = JSON.parse(e.data); | |
| updateUI(d); | |
| } catch {} | |
| }; | |
| ws.onclose = () => setTimeout(connect, 3000); | |
| ws.onerror = () => ws.close(); | |
| } | |
| function updateUI(d) { | |
| const badge = document.getElementById('statusBadge'); | |
| if (d.online) { | |
| badge.textContent = 'ONLINE'; | |
| badge.className = 'status-badge status-online'; | |
| } else { | |
| badge.textContent = 'OFFLINE'; | |
| badge.className = 'status-badge status-offline'; | |
| } | |
| document.getElementById('posValue').textContent = | |
| Math.round(d.position?.x||0) + ', ' + Math.round(d.position?.y||0) + ', ' + Math.round(d.position?.z||0); | |
| document.getElementById('activityValue').textContent = d.activity || '--'; | |
| document.getElementById('emotionValue').textContent = d.emotion || '--'; | |
| document.getElementById('dimValue').textContent = d.dimension || '--'; | |
| const hp = d.health || 0; | |
| const food = d.food || 0; | |
| document.getElementById('healthNum').textContent = hp; | |
| document.getElementById('foodNum').textContent = food; | |
| document.getElementById('healthBar').style.width = (hp/20*100)+'%'; | |
| document.getElementById('foodBar').style.width = (food/20*100)+'%'; | |
| document.getElementById('thoughtBox').textContent = d.last_think || 'Waiting...'; | |
| document.getElementById('playersValue').textContent = d.players_online || 0; | |
| const wt = d.world_time || 0; | |
| const hours = Math.floor(wt / 1000) % 24; | |
| const mins = Math.floor((wt % 1000) / 1000 * 60); | |
| document.getElementById('worldTimeValue').textContent = String(hours).padStart(2,'0') + ':' + String(mins).padStart(2,'0'); | |
| document.getElementById('connSinceValue').textContent = d.connected_since || '--'; | |
| // Tunnel URL - supports both ngrok and bore | |
| const tunnelUrl = d.tunnel_url || d.ngrok_url || ''; | |
| const tunnelMethod = d.tunnel_method || ''; | |
| if (tunnelUrl) { | |
| const addr = tunnelUrl.replace('tcp://', ''); | |
| document.getElementById('serverAddr').textContent = addr; | |
| if (tunnelMethod === 'bore') { | |
| document.getElementById('tunnelNote').textContent = '✓ Tunnel active via bore (any MC version 1.8-1.21+)'; | |
| } else { | |
| document.getElementById('tunnelNote').textContent = 'Connect via ngrok TCP tunnel'; | |
| } | |
| } else { | |
| document.getElementById('serverAddr').textContent = 'Direct: (check Space logs)'; | |
| document.getElementById('tunnelNote').textContent = 'No tunnel active'; | |
| } | |
| const chatLog = document.getElementById('chatLog'); | |
| if (d.chat_log && d.chat_log.length) { | |
| chatLog.innerHTML = d.chat_log.slice(-30).map(m => | |
| '<div class="chat-msg"><span class="time">' + (m.time||'') + '</span> <span class="sender">' + (m.sender||'') + '</span> ' + (m.text||'') + '</div>' | |
| ).join(''); | |
| chatLog.scrollTop = chatLog.scrollHeight; | |
| } | |
| } | |
| async function restartBot() { | |
| try { await fetch('/api/bot/restart', {method:'POST'}); } catch {} | |
| } | |
| async function loadDebug() { | |
| try { | |
| const r = await fetch('/api/debug'); | |
| const d = await r.json(); | |
| let out = ''; | |
| out += 'Paper JAR: ' + (d.paper_jar_exists ? 'exists' : 'MISSING') + '\\n'; | |
| out += 'Tunnel URL: ' + (d.tunnel_url || 'none') + '\\n'; | |
| out += 'Tunnel Method: ' + (d.tunnel_method || 'none') + '\\n'; | |
| out += 'Plugins: ' + JSON.stringify(d.plugins || []) + '\\n'; | |
| out += '\\n=== MC SERVER LOG (last 100 lines) ===\\n'; | |
| out += (d.mc_server_log || ['No log available']).join('\\n'); | |
| out += '\\n\\n=== BOT STDOUT ===\\n'; | |
| out += (d.bot_stdout || ['No log available']).join('\\n'); | |
| out += '\\n\\n=== TUNNEL LOG ===\\n'; | |
| out += (d.tunnel_log || ['No log available']).join('\\n'); | |
| out += '\\n\\n=== PROCESSES ===\\n'; | |
| document.getElementById('debugLog').textContent = out; | |
| } catch(e) { | |
| document.getElementById('debugLog').textContent = 'Error: ' + e.message; | |
| } | |
| } | |
| connect(); | |
| setInterval(async () => { | |
| try { | |
| const r = await fetch('/api/status'); | |
| const d = await r.json(); | |
| updateUI(d); | |
| } catch {} | |
| }, 5000); | |
| </script> | |
| </body> | |
| </html>""" | |