agent-nav-3d / app.py
TomatitoToho's picture
Fix invalid movement kicks: spigot.yml thresholds, bore tunnel, bot movement fixes
0c905c8 verified
Raw
History Blame Contribute Delete
18.6 kB
"""
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 ─────────────────────────────────────────────────────────────────
@app.get("/health")
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(),
}
@app.get("/api/status")
async def api_status():
_update_bot_status()
return _bot_status
@app.get("/api/tunnel")
async def api_tunnel():
url = _load_tunnel_url()
method = _load_tunnel_method()
return {"url": url, "method": method, "active": bool(url)}
@app.get("/api/debug")
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
@app.get("/", response_class=HTMLResponse)
async def dashboard():
_update_bot_status()
return HTML_TEMPLATE
@app.get("/api/chat-log")
async def chat_log():
_update_bot_status()
return {"messages": _bot_status.get("chat_log", [])[-50:]}
@app.post("/api/bot/restart")
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 = []
@app.websocket("/ws")
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>"""