add full listeners for UI, auto open browser

This commit is contained in:
MaxRobinsonTheGreat 2025-08-26 14:21:24 -05:00
parent 28c2143e73
commit 70c34c79f9
9 changed files with 366 additions and 52 deletions

View file

@ -63,7 +63,7 @@ if (process.env.LOG_ALL) {
settings.log_all_prompts = process.env.LOG_ALL;
}
Mindcraft.init(false, settings.mindserver_port);
Mindcraft.init(false, settings.mindserver_port, settings.auto_open_browser);
for (let profile of settings.profiles) {
const profile_json = JSON.parse(readFileSync(profile, 'utf8'));

View file

@ -19,6 +19,7 @@
"mineflayer-pathfinder": "^2.4.5",
"mineflayer-pvp": "^1.3.2",
"node-canvas-webgl": "PrismarineJS/node-canvas-webgl",
"open": "^10.2.0",
"openai": "^4.4.0",
"patch-package": "^8.0.0",
"prismarine-item": "^1.15.0",

View file

@ -6,6 +6,7 @@ const settings = {
// the mindserver manages all agents and hosts the UI
"mindserver_port": 8080,
"auto_open_browser": true,
"base_profile": "assistant", // survival, assistant, creative, or god_mode
"profiles": [

View file

@ -0,0 +1,89 @@
import {
getPosition,
getBiomeName,
getNearbyPlayerNames,
getInventoryCounts,
getNearbyEntityTypes,
getNearbyBlockTypes,
getBlockAtPosition,
getFirstBlockAboveHead
} from "./world.js";
import convoManager from '../conversation.js';
export function getFullState(agent) {
const bot = agent.bot;
const pos = getPosition(bot);
const position = {
x: Number(pos.x.toFixed(2)),
y: Number(pos.y.toFixed(2)),
z: Number(pos.z.toFixed(2))
};
let weather = 'Clear';
if (bot.thunderState > 0) weather = 'Thunderstorm';
else if (bot.rainState > 0) weather = 'Rain';
let timeLabel = 'Night';
if (bot.time.timeOfDay < 6000) timeLabel = 'Morning';
else if (bot.time.timeOfDay < 12000) timeLabel = 'Afternoon';
const below = getBlockAtPosition(bot, 0, -1, 0).name;
const legs = getBlockAtPosition(bot, 0, 0, 0).name;
const head = getBlockAtPosition(bot, 0, 1, 0).name;
let players = getNearbyPlayerNames(bot);
let bots = convoManager.getInGameAgents().filter(b => b !== agent.name);
players = players.filter(p => !bots.includes(p));
const helmet = bot.inventory.slots[5];
const chestplate = bot.inventory.slots[6];
const leggings = bot.inventory.slots[7];
const boots = bot.inventory.slots[8];
const state = {
name: agent.name,
gameplay: {
position,
dimension: bot.game.dimension,
gamemode: bot.game.gameMode,
health: Math.round(bot.health),
hunger: Math.round(bot.food),
biome: getBiomeName(bot),
weather,
timeOfDay: bot.time.timeOfDay,
timeLabel
},
action: {
current: agent.isIdle() ? 'Idle' : agent.actions.currentActionLabel,
isIdle: agent.isIdle()
},
surroundings: {
below,
legs,
head,
firstBlockAboveHead: getFirstBlockAboveHead(bot, null, 32)
},
inventory: {
counts: getInventoryCounts(bot),
equipment: {
helmet: helmet ? helmet.name : null,
chestplate: chestplate ? chestplate.name : null,
leggings: leggings ? leggings.name : null,
boots: boots ? boots.name : null,
mainHand: bot.heldItem ? bot.heldItem.name : null
}
},
nearby: {
humanPlayers: players,
botPlayers: bots,
entityTypes: getNearbyEntityTypes(bot).filter(t => t !== 'player' && t !== 'item'),
blockTypes: getNearbyBlockTypes(bot)
},
modes: {
summary: bot.modes.getMiniDocs()
}
};
return state;
}

View file

@ -1,6 +1,7 @@
import { io } from 'socket.io-client';
import convoManager from './conversation.js';
import { setSettings } from './settings.js';
import { getFullState } from './library/full_state.js';
// agent's individual connection to the mindserver
// always connect to localhost
@ -65,6 +66,16 @@ class MindServerProxy {
}
});
this.socket.on('get-full-state', (callback) => {
try {
const state = getFullState(this.agent);
callback(state);
} catch (error) {
console.error('Error getting full state:', error);
callback(null);
}
});
// Request settings and wait for response
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {

View file

@ -1,6 +1,7 @@
import { createMindServer, registerAgent } from './mindserver.js';
import { createMindServer, registerAgent, numStateListeners } from './mindserver.js';
import { AgentProcess } from '../process/agent_process.js';
import { getServer } from './mcserver.js';
import open from 'open';
let mindserver;
let connected = false;
@ -8,7 +9,7 @@ let agent_processes = {};
let agent_count = 0;
let port = 8080;
export async function init(host_public=false, port=8080) {
export async function init(host_public=false, port=8080, auto_open_browser=true) {
if (connected) {
console.error('Already initiliazed!');
return;
@ -16,6 +17,14 @@ export async function init(host_public=false, port=8080) {
mindserver = createMindServer(host_public, port);
port = port;
connected = true;
if (auto_open_browser) {
setTimeout(() => {
// check if browser listener is already open
if (numStateListeners() === 0) {
open('http://localhost:'+port);
}
}, 2000);
}
}
export async function createAgent(settings) {

View file

@ -15,6 +15,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
let io;
let server;
const agent_connections = {};
const agent_listeners = [];
const settings_spec = JSON.parse(readFileSync(path.join(__dirname, 'public/settings_spec.json'), 'utf8'));
@ -23,8 +24,8 @@ class AgentConnection {
this.socket = null;
this.settings = settings;
this.in_game = false;
this.full_state = null;
}
}
export function registerAgent(settings) {
@ -114,6 +115,9 @@ export function createMindServer(host_public = false, port = 8080) {
agent_connections[curAgentName].in_game = false;
agentsUpdate();
}
if (agent_listeners.includes(socket)) {
removeListener(socket);
}
});
socket.on('chat-message', (agentName, json) => {
@ -174,6 +178,10 @@ export function createMindServer(host_public = false, port = 8080) {
socket.on('bot-output', (agentName, message) => {
io.emit('bot-output', agentName, message);
});
socket.on('listen-to-agents', () => {
addListener(socket);
});
});
let host = host_public ? '0.0.0.0' : 'localhost';
@ -195,6 +203,42 @@ function agentsUpdate(socket) {
socket.emit('agents-update', agents);
}
let listenerInterval = null;
function addListener(listener_socket) {
agent_listeners.push(listener_socket);
if (agent_listeners.length === 1) {
listenerInterval = setInterval(async () => {
const states = {};
for (let agentName in agent_connections) {
let agent = agent_connections[agentName];
if (agent.in_game) {
try {
const state = await new Promise((resolve) => {
agent.socket.emit('get-full-state', (s) => resolve(s));
});
states[agentName] = state;
} catch (e) {
states[agentName] = { error: String(e) };
}
}
}
for (let listener of agent_listeners) {
listener.emit('state-update', states);
}
}, 1000);
}
}
function removeListener(listener_socket) {
agent_listeners.splice(agent_listeners.indexOf(listener_socket), 1);
if (agent_listeners.length === 0) {
clearInterval(listenerInterval);
listenerInterval = null;
}
}
// Optional: export these if you need access to them from other files
export const getIO = () => io;
export const getServer = () => server;
export const numStateListeners = () => agent_listeners.length;

View file

@ -118,22 +118,115 @@
display: flex;
justify-content: flex-start;
}
.status-badge {
font-size: 0.75em;
margin-left: 8px;
padding: 2px 6px;
border-radius: 4px;
background: #3a3a3a;
color: #cccccc;
text-transform: lowercase;
}
.status-badge.online { color: #4CAF50; }
.status-badge.offline { color: #f44336; }
.title-row {
display: flex;
align-items: center;
gap: 10px;
}
.title-left {
display: flex;
align-items: center;
gap: 8px;
}
.title-spacer { flex: 1; }
/* Modal styles */
.modal-backdrop {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5);
display: none;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: #2d2d2d;
border-radius: 8px;
width: 80vw;
height: 80vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #3a3a3a;
}
.modal-close-btn {
background: #f44336;
color: #fff;
border: none;
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
}
.modal-body {
flex: 1 1 auto;
overflow: auto;
padding: 12px 16px;
}
.modal-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-top: 1px solid #3a3a3a;
}
.footer-left { color: #cccccc; font-style: italic; }
</style>
</head>
<body>
<h1>Mindcraft</h1>
<div class="title-row">
<div class="title-left">
<h1 style="margin: 0;">Mindcraft</h1>
<span id="msStatus" class="status-badge offline">mindserver offline</span>
</div>
<div class="title-spacer"></div>
</div>
<div id="agents"></div>
<div id="createAgentSection" style="margin-top:20px;background:#2d2d2d;padding:20px;border-radius:8px;">
<h2>Create Agent</h2>
<div id="settingsForm"></div>
<div id="profileStatus" style="margin-top:6px;font-style:italic;color:#cccccc;">Profile: Not uploaded</div>
<div style="margin-top:10px;">
<button id="uploadProfileBtn" class="start-btn">Upload Profile</button>
<input type="file" id="profileFileInput" accept=".json,application/json" style="display:none">
<button id="submitCreateAgentBtn" class="start-btn" disabled>Create Agent</button>
<div style="margin-top:10px;">
<button id="openCreateAgentBtn" class="start-btn">New Agent</button>
</div>
<!-- Modal -->
<div id="createAgentModal" class="modal-backdrop">
<div class="modal">
<div class="modal-header">
<h2 style="margin:0;">Create Agent</h2>
<button id="closeCreateAgentBtn" class="modal-close-btn">Close</button>
</div>
<div class="modal-body">
<div id="createAgentSection">
<div id="profileStatus" style="margin:6px 0;">Profile: Not uploaded</div>
<div id="settingsForm"></div>
<div id="createError" style="color:#f44336;margin-top:10px;"></div>
<input type="file" id="profileFileInput" accept=".json,application/json" style="display:none">
</div>
</div>
<div class="modal-footer">
<div class="footer-left" id="footerStatus">Configure settings, then upload a profile and create the agent.</div>
<div class="footer-actions">
<button id="uploadProfileBtn" class="start-btn">Upload Profile</button>
<button id="submitCreateAgentBtn" class="start-btn" disabled>Create Agent</button>
</div>
</div>
</div>
<div id="createError" style="color:#f44336;margin-top:10px;"></div>
</div>
<script>
@ -144,44 +237,82 @@
const agentSettings = {};
const agentLastMessage = {};
const statusEl = document.getElementById('msStatus');
function updateStatus(connected) {
if (!statusEl) return;
if (connected) {
statusEl.textContent = 'MindServer online';
statusEl.classList.remove('offline');
statusEl.classList.add('online');
} else {
statusEl.textContent = 'MindServer offline';
statusEl.classList.remove('online');
statusEl.classList.add('offline');
}
}
function subscribeToState() {
socket.emit('listen-to-agents');
}
// Initial status
updateStatus(false);
socket.on('connect', () => {
updateStatus(true);
subscribeToState();
});
socket.on('disconnect', () => {
updateStatus(false);
});
socket.on('connect_error', () => {
updateStatus(false);
});
fetch('/settings_spec.json')
.then(r => r.json())
.then(spec => {
settingsSpec = spec;
const form = document.getElementById('settingsForm');
Object.keys(spec).forEach(key => {
if (key === 'profile') return; // profile handled via upload
const cfg = spec[key];
const wrapper = document.createElement('div');
wrapper.className = 'setting-wrapper';
const label = document.createElement('label');
label.textContent = key;
label.title = cfg.description || '';
let input;
switch (cfg.type) {
case 'boolean':
input = document.createElement('input');
input.type = 'checkbox';
input.checked = cfg.default === true;
break;
case 'number':
input = document.createElement('input');
input.type = 'number';
input.value = cfg.default;
break;
default:
input = document.createElement('input');
input.type = 'text';
input.value = typeof cfg.default === 'object' ? JSON.stringify(cfg.default) : cfg.default;
}
input.title = cfg.description || '';
input.id = `setting-${key}`;
wrapper.appendChild(label);
wrapper.appendChild(input);
form.appendChild(wrapper);
});
buildSettingsForm();
});
function buildSettingsForm() {
const form = document.getElementById('settingsForm');
form.innerHTML = '';
// ensure grid for multi-column layout
form.style.display = 'grid';
form.style.gridTemplateColumns = 'repeat(auto-fit, minmax(320px, 1fr))';
form.style.gap = '8px';
Object.keys(settingsSpec).forEach(key => {
if (key === 'profile') return; // profile handled via upload
const cfg = settingsSpec[key];
const wrapper = document.createElement('div');
wrapper.className = 'setting-wrapper';
const label = document.createElement('label');
label.textContent = key;
label.title = cfg.description || '';
let input;
switch (cfg.type) {
case 'boolean':
input = document.createElement('input');
input.type = 'checkbox';
input.checked = cfg.default === true;
break;
case 'number':
input = document.createElement('input');
input.type = 'number';
input.value = cfg.default;
break;
default:
input = document.createElement('input');
input.type = 'text';
input.value = typeof cfg.default === 'object' ? JSON.stringify(cfg.default) : cfg.default;
}
input.title = cfg.description || '';
input.id = `setting-${key}`;
wrapper.appendChild(label);
wrapper.appendChild(input);
form.appendChild(wrapper);
});
}
document.getElementById('uploadProfileBtn').addEventListener('click', () => {
document.getElementById('profileFileInput').click();
});
@ -233,10 +364,22 @@
document.getElementById('submitCreateAgentBtn').disabled = true;
document.getElementById('profileStatus').textContent = 'Profile: Not uploaded';
document.getElementById('createError').textContent = '';
hideCreateAgentModal();
}
});
});
// Modal open/close logic
const modalBackdrop = document.getElementById('createAgentModal');
document.getElementById('openCreateAgentBtn').addEventListener('click', () => {
buildSettingsForm();
modalBackdrop.style.display = 'flex';
});
function hideCreateAgentModal() {
modalBackdrop.style.display = 'none';
}
document.getElementById('closeCreateAgentBtn').addEventListener('click', hideCreateAgentModal);
socket.on('bot-output', (agentName, message) => {
agentLastMessage[agentName] = message;
const messageDiv = document.getElementById(`lastMessage-${agentName}`);
@ -245,6 +388,18 @@
}
});
// Subscribe to aggregated state updates (re-sent on each connect)
socket.on('state-update', (states) => {
Object.keys(states || {}).forEach(name => {
const st = states[name];
const el = document.getElementById(`health-${name}`);
if (el && st && !st.error) {
const health = st?.gameplay?.health;
if (typeof health === 'number') el.textContent = `Health: ${health}`;
}
});
});
function fetchAgentSettings(name) {
return new Promise((resolve) => {
if (agentSettings[name]) { resolve(agentSettings[name]); return; }
@ -282,6 +437,7 @@
`}
</div>
</div>
<div id="health-${agent.name}" class="last-message">Health: -</div>
<div id="lastMessage-${agent.name}" class="last-message">${lastMessage}</div>
${viewerHTML}
</div>`;

View file

@ -6,8 +6,8 @@
},
"minecraft_version": {
"type": "string",
"description": "The version of Minecraft to use",
"default": "1.21.1"
"description": "The version of Minecraft to use. Set to 'auto' to automatically detect the version.",
"default": "auto"
},
"host": {
"type": "string",
@ -16,18 +16,20 @@
},
"port": {
"type": "number",
"description": "The minecraft server port to connect to",
"description": "The minecraft server port to connect to. -1 for auto-detect.",
"default": 55916
},
"auth": {
"type": "string",
"description": "The authentication method to use",
"default": "offline"
"default": "offline",
"options": ["offline", "microsoft"]
},
"base_profile": {
"type": "string",
"description": "Allowed values: survival, assistant, creative, god_mode. Each has fine tuned settings for different game modes.",
"default": "survival"
"default": "survival",
"options": ["survival", "assistant", "creative", "god_mode"]
},
"load_memory": {
"type": "boolean",
@ -97,7 +99,8 @@
"show_command_syntax": {
"type": "string",
"description": "Whether to show \"full\" command syntax, \"shortened\" command syntax, or \"none\"",
"default": "full"
"default": "full",
"options": ["full", "shortened", "none"]
},
"chat_bot_messages": {
"type": "boolean",