added mindserver to handle bot chat, better conversations

This commit is contained in:
MaxRobinsonTheGreat 2024-11-18 23:02:37 -06:00
parent 8f18b921ee
commit 7c5790d880
14 changed files with 322 additions and 73 deletions

View file

@ -3,7 +3,7 @@
"model": "gpt-4o",
"cooldown": 3000,
"cooldown": 4000,
"conversing": "You are a playful Minecraft bot named $NAME that can converse with players, see, move, mine, build, and interact with the world by using commands.\n$SELF_PROMPT Act human-like as if you were a typical Minecraft player, rather than an AI. Be very brief in your responses, don't apologize constantly, don't give instructions or make lists unless asked, and don't refuse requests. Don't pretend to act, use commands immediately when requested. Do NOT say this: 'Sure, I've stopped.', instead say this: 'Sure, I'll stop. !stop'. Do NOT say this: 'On my way! Give me a moment.', instead say this: 'On my way! !goToPlayer('playername', 3)'. This is extremely important to me, take a deep breath and have fun :)\nSummarized memory:'$MEMORY'\n$STATS\n$INVENTORY\n$COMMAND_DOCS\n$EXAMPLES\nConversation Begin:",
@ -14,11 +14,12 @@
"modes": {
"self_preservation": true,
"unstuck": true,
"cowardice": true,
"cowardice": false,
"self_defense": true,
"hunting": true,
"item_collecting": true,
"torch_placing": true,
"elbow_room": true,
"idle_staring": true,
"cheat": false
},
@ -30,15 +31,17 @@
],
[
{"role": "system", "content": "Send a message to john_goodman to work together"},
{"role": "assistant", "content": "!startChat('john_goodman', 'Hey John, lets work together to build a house!', 10)"}
{"role": "system", "content": "say hi to john_goodman"},
{"role": "assistant", "content": "!startChat('john_goodman', 'Hey John', 10))"},
{"role": "user", "content": "john_goodman: (FROM OTHER BOT)Hey there! What's up?"},
{"role": "assistant", "content": "Not much, just saying hi. Bye! !endChat('john_goodman')"}
],
[
{"role": "user", "content": "barry: (FROM OTHER BOT)Hey there! What's up?"},
{"role": "assistant", "content": "Not much, just hanging out. How about you?"},
{"role": "user", "content": "barry: (FROM OTHER BOT)I'm doing great, thanks for asking!"},
{"role": "assistant", "content": "Good to know! Goodbye. !endChat('barry')"}
{"role": "system", "content": "trade something with zorro_34"},
{"role": "assistant", "content": "!startChat('zorro_34', 'Hey let's trade. What do you have?', 10))"},
{"role": "user", "content": "zorro_34: (FROM OTHER BOT)Let's see... !inventory I have a wooden pickaxe, cobblestone, and cooked chicken"},
{"role": "assistant", "content": "Okay, could I trade you a cobblestone for a cooked chicken?"}
],
[
@ -49,9 +52,13 @@
{"role": "user", "content": "rupert: (FROM OTHER BOT)Give me all your chicken!"},
{"role": "assistant", "content": "!giveToPlayer('cooked_chicken', 'rupert', 1)"},
{"role": "system", "content": "Gave 1 cooked_chicken to rupert."},
{"role": "assistant", "content": "I gave you one, I need the rest!"},
{"role": "user", "content": "rupert: (FROM OTHER BOT)Give me ALL OF THEM"},
{"role": "assistant", "content": "No! You are mean. !endChat('rupert')"}
{"role": "user", "content": "rupert: (FROM OTHER BOT)Thanks! That's all I need."},
{"role": "assistant", "content": "Okay, bye. !endChat('rupert')"}
],
[
{"role": "user", "content": "gort: (FROM OTHER BOT)Let's do it, onward!"},
{"role": "assistant", "content": "Alright, let's see what's nearby... !nearbyBlocks"}
],
[

View file

@ -2,6 +2,7 @@ import { AgentProcess } from './src/process/agent-process.js';
import settings from './settings.js';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import { createMindServer } from './src/server/mind_server.js';
function parseArguments() {
return yargs(hideBin(process.argv))
@ -19,6 +20,8 @@ function getProfiles(args) {
}
function main() {
const mindServer = createMindServer();
const args = parseArguments();
const profiles = getProfiles(args);
console.log(profiles);

View file

@ -20,7 +20,10 @@
"replicate": "^0.29.4",
"ses": "^1.9.1",
"vec3": "^0.1.10",
"yargs": "^17.7.2"
"yargs": "^17.7.2",
"socket.io": "^4.7.2",
"socket.io-client": "^4.7.2",
"express": "^4.18.2"
},
"scripts": {
"postinstall": "patch-package",

View file

@ -3,7 +3,7 @@
"model": "gpt-4o",
"cooldown": 3000,
"cooldown": 4000,
"conversing": "You are a playful Minecraft bot named $NAME that can converse with players, see, move, mine, build, and interact with the world by using commands.\n$SELF_PROMPT Act human-like as if you were a typical Minecraft player, rather than an AI. Be very brief in your responses, don't apologize constantly, don't give instructions or make lists unless asked, and don't refuse requests. Don't pretend to act, use commands immediately when requested. Do NOT say this: 'Sure, I've stopped.', instead say this: 'Sure, I'll stop. !stop'. Do NOT say this: 'On my way! Give me a moment.', instead say this: 'On my way! !goToPlayer('playername', 3)'. This is extremely important to me, take a deep breath and have fun :)\nSummarized memory:'$MEMORY'\n$STATS\n$INVENTORY\n$COMMAND_DOCS\n$EXAMPLES\nConversation Begin:",
@ -14,11 +14,12 @@
"modes": {
"self_preservation": true,
"unstuck": true,
"cowardice": true,
"cowardice": false,
"self_defense": true,
"hunting": true,
"item_collecting": true,
"torch_placing": true,
"elbow_room": true,
"idle_staring": true,
"cheat": false
},
@ -30,15 +31,17 @@
],
[
{"role": "system", "content": "Send a message to john_goodman to work together"},
{"role": "assistant", "content": "!startChat('john_goodman', 'Hey John, lets work together to build a house!', 10))"}
{"role": "system", "content": "say hi to john_goodman"},
{"role": "assistant", "content": "!startChat('john_goodman', 'Hey John', 10))"},
{"role": "user", "content": "john_goodman: (FROM OTHER BOT)Hey there! What's up?"},
{"role": "assistant", "content": "Not much, just saying hi. Bye! !endChat('john_goodman')"}
],
[
{"role": "user", "content": "barry: (FROM OTHER BOT)Hey there! What's up?"},
{"role": "assistant", "content": "Not much, just hanging out. How about you?"},
{"role": "user", "content": "barry: (FROM OTHER BOT)I'm doing great, thanks for asking!"},
{"role": "assistant", "content": "Good to know! Goodbye. !endChat('barry')"}
{"role": "system", "content": "trade something with zorro_34"},
{"role": "assistant", "content": "!startChat('zorro_34', 'Hey let's trade. What do you have?', 10))"},
{"role": "user", "content": "zorro_34: (FROM OTHER BOT)Let's see... !inventory I have a wooden pickaxe, cobblestone, and cooked chicken"},
{"role": "assistant", "content": "Okay, could I trade you a cobblestone for a cooked chicken?"}
],
[
@ -49,9 +52,13 @@
{"role": "user", "content": "rupert: (FROM OTHER BOT)Give me all your chicken!"},
{"role": "assistant", "content": "!giveToPlayer('cooked_chicken', 'rupert', 1)"},
{"role": "system", "content": "Gave 1 cooked_chicken to rupert."},
{"role": "assistant", "content": "I gave you one, I need the rest!"},
{"role": "user", "content": "rupert: (FROM OTHER BOT)Give me ALL OF THEM"},
{"role": "assistant", "content": "No! You are mean. !endChat('rupert')"}
{"role": "user", "content": "rupert: (FROM OTHER BOT)Thanks! That's all I need."},
{"role": "assistant", "content": "Okay, bye. !endChat('rupert')"}
],
[
{"role": "user", "content": "gort: (FROM OTHER BOT)Let's do it, onward!"},
{"role": "assistant", "content": "Alright, let's see what's nearby... !nearbyBlocks"}
],
[

View file

@ -7,7 +7,7 @@ export default
"profiles": [
"./andy.json",
"./andy2.json",
"./randy.json",
// "./profiles/gpt.json",
// "./profiles/claude.json",
// "./profiles/gemini.json",

View file

@ -8,19 +8,24 @@ import { ActionManager } from './action_manager.js';
import { NPCContoller } from './npc/controller.js';
import { MemoryBank } from './memory_bank.js';
import { SelfPrompter } from './self_prompter.js';
import { isOtherAgent, initConversationManager, sendToBot, recieveFromBot } from './conversation.js';
import { isOtherAgent, initConversationManager, sendToBot, endAllChats } from './conversation.js';
import { handleTranslation, handleEnglishTranslation } from '../utils/translator.js';
import { addViewer } from './viewer.js';
import settings from '../../settings.js';
import { serverProxy } from './server_proxy.js';
export class Agent {
async start(profile_fp, load_mem=false, init_message=null, count_id=0) {
this.last_sender = null;
try {
// Add validation for profile_fp
if (!profile_fp) {
throw new Error('No profile filepath provided');
}
// Connect to MindServer via proxy
serverProxy.connect();
console.log('Starting agent initialization with profile:', profile_fp);
// Initialize components with more detailed error handling
@ -41,6 +46,10 @@ export class Agent {
console.log('Initializing self prompter...');
this.self_prompter = new SelfPrompter(this);
initConversationManager(this);
// After getting the name, register with MindServer via proxy
serverProxy.registerAgent(this.name);
} catch (error) {
throw new Error(`Failed to initialize agent components: ${error.message || error}`);
}
@ -145,7 +154,8 @@ export class Agent {
console.log(this.name, 'received message from', username, ':', message);
if (isOtherAgent(username)) {
recieveFromBot(username, message);
//recieveFromBot(username, message);
console.warn('recieved whisper from other bot??')
}
else {
let translation = await handleEnglishTranslation(message);
@ -179,6 +189,10 @@ export class Agent {
this.history.add('system', prompt);
await this.self_prompter.start(prompt);
}
else if (save_data?.last_sender) {
this.last_sender = save_data.last_sender;
await this.handleMessage(this.last_sender, `(You have restarted and this message is auto-generated. Continue the conversation with ${this.last_sender})`);
}
else if (init_message) {
await this.handleMessage('system', init_message, 2);
}
@ -232,9 +246,15 @@ export class Agent {
if (this.self_prompter.on) {
this.self_prompter.stop(false);
}
endAllChats();
}
async handleMessage(source, message, max_responses=null) {
if (!source || !message) {
console.warn('Received empty message from', source);
return false;
}
let used_command = false;
if (max_responses === null) {
max_responses = settings.max_commands === -1 ? Infinity : settings.max_commands;
@ -266,11 +286,15 @@ export class Agent {
}
}
if (!self_prompt)
this.last_sender = source;
else
this.last_sender = null;
// Now translate the message
message = await handleEnglishTranslation(message);
console.log('received message from', source, ':', message);
// Do self prompting
const checkInterrupt = () => this.self_prompter.shouldInterrupt(self_prompt) || this.shut_up;
let behavior_log = this.bot.modes.flushBehaviorLog();

View file

@ -65,7 +65,6 @@ export const actionsList = [
name: '!restart',
description: 'Restart the agent process.',
perform: async function (agent) {
await agent.history.save();
agent.cleanKill();
}
},
@ -226,18 +225,18 @@ export const actionsList = [
await skills.collectBlock(agent.bot, type, num);
}, false, 10) // 10 minute timeout
},
{
name: '!collectAllBlocks',
description: 'Collect all the nearest blocks of a given type until told to stop.',
params: {
'type': { type: 'BlockName', description: 'The block type to collect.' }
},
perform: runAsAction(async (agent, type) => {
let success = await skills.collectBlock(agent.bot, type, 1);
if (!success)
agent.actions.cancelResume();
}, true, 3) // 3 minute timeout
},
// {
// name: '!collectAllBlocks',
// description: 'Collect all the nearest blocks of a given type until told to stop.',
// params: {
// 'type': { type: 'BlockName', description: 'The block type to collect.' }
// },
// perform: runAsAction(async (agent, type) => {
// let success = await skills.collectBlock(agent.bot, type, 1);
// if (!success)
// agent.actions.cancelResume();
// }, true, 3) // 3 minute timeout
// },
{
name: '!craftRecipe',
description: 'Craft the given recipe a given number of times.',
@ -273,7 +272,7 @@ export const actionsList = [
perform: runAsAction(async (agent) => {
await skills.clearNearestFurnace(agent.bot);
})
},
},
{
name: '!placeHere',
description: 'Place a given block in the current location. Do NOT use to build structures, only use for single blocks/torches.',
@ -376,7 +375,7 @@ export const actionsList = [
},
{
name: '!endChat',
description: 'End the conversation from the most recent message.',
description: 'End the conversation with the given player.',
params: {
'player_name': { type: 'string', description: 'The name of the player to end the conversation with.' }
},

View file

@ -1,6 +1,7 @@
import settings from '../../settings.js';
import { readFileSync } from 'fs';
import { containsCommand } from './commands/index.js';
import { sendBotChatToServer } from './server_proxy.js';
let agent;
const agent_names = settings.profiles.map((p) => JSON.parse(readFileSync(p, 'utf8')).name);
@ -59,8 +60,8 @@ export function startChat(send_to, message, max_turns=5) {
}
export function sendToBot(send_to, message, start=false) {
if (message.length > 197)
message = message.substring(0, 197);
// if (message.length > 197)
// message = message.substring(0, 197);
if (!isOtherAgent(send_to)) {
agent.bot.whisper(send_to, message);
return;
@ -81,7 +82,8 @@ export function sendToBot(send_to, message, start=false) {
'idle': agent.isIdle()
};
agent.bot.whisper(send_to, JSON.stringify(json));
// agent.bot.whisper(send_to, JSON.stringify(json));
sendBotChatToServer(send_to, JSON.stringify(json));
}
export function recieveFromBot(sender, json) {
@ -94,14 +96,12 @@ export function recieveFromBot(sender, json) {
}
if (convo.ignore_until_start)
return;
if (convo.turn_count > 10) {
console.warn('Reached max messages from bot:', sender);
endChat(sender);
agent.bot.chat('chat maxxed out, ending conversation');
return;
}
convo.queue(recieved);
// responding to conversation takes priority over self prompting
if (agent.self_prompter.on)
agent.self_prompter.stopLoop();
if (inMessageTimer)
clearTimeout(inMessageTimer);
@ -124,18 +124,20 @@ export function _processInMessageQueue(name) {
}
export function _handleFullInMessage(sender, recieved) {
console.log(`responding to **${recieved}**`);
console.log(`responding to **${JSON.stringify(recieved)}**`);
const convo = _getConvo(sender);
convo.countTurn();
const message = _tagMessage(recieved.message);
if (recieved.end || (!recieved.idle && !agent.isIdle()) || convo.over()) {
if (recieved.end || convo.over()) {
// if end signal from other bot, or both are busy, or past max turns,
// add to history, but don't respond
agent.history.add(sender, message);
return;
}
if (recieved.start)
agent.shut_up = false;
agent.handleMessage(sender, message);
}
@ -145,6 +147,12 @@ export function endChat(sender) {
}
}
export function endAllChats() {
for (const sender in convos) {
convos[sender].ignore_until_start = true;
}
}
function _tagMessage(message) {
return "(FROM OTHER BOT)" + message;
}

View file

@ -23,6 +23,7 @@ export class History {
// Number of messages to remove from current history and save into memory
this.summary_chunk_size = 5;
// chunking reduces expensive calls to promptMemSaving and appendFullHistory
// and improves the quality of the memory summary
}
getHistory() { // expects an Examples object
@ -83,7 +84,8 @@ export class History {
const data = {
memory: this.memory,
turns: this.turns,
self_prompt: this.agent.self_prompter.on ? this.agent.self_prompter.prompt : null
self_prompt: this.agent.self_prompter.on ? this.agent.self_prompter.prompt : null,
last_sender: this.agent.last_sender
};
writeFileSync(this.memory_fp, JSON.stringify(data, null, 2));
console.log('Saved memory to:', this.memory_fp);

View file

@ -1064,6 +1064,21 @@ export async function moveAway(bot, distance) {
return true;
}
export async function moveAwayFrom(bot, entity, distance=1) {
const follow = new pf.goals.GoalFollow(entity, distance+1);
const inverted_goal = new pf.goals.GoalInvert(follow);
bot.pathfinder.setMovements(new pf.Movements(bot));
bot.pathfinder.setGoal(inverted_goal, true);
for (let i = 0; i < 10*distance; i++) {
await new Promise(resolve => setTimeout(resolve, 500));
if (bot.interrupt_code)
return false;
if (bot.entity.position.distanceTo(entity.position) > distance)
return true;
}
return false;
}
export async function avoidEnemies(bot, distance=16) {
/**
* Move a given distance away from all nearby enemy mobs.

View file

@ -23,7 +23,7 @@ async function say(agent, message) {
// the order of this list matters! first modes will be prioritized
// while update functions are async, they should *not* be awaited longer than ~100ms as it will block the update loop
// to perform longer actions, use the execute function which won't block the update loop
const modes = [
const modes_list = [
{
name: 'self_preservation',
description: 'Respond to drowning, burning, and damage at low health. Interrupts all actions.',
@ -209,6 +209,23 @@ const modes = [
}
}
},
{
name: 'elbow_room',
description: 'Move away from nearby players when idle.',
interrupts: ['action:followPlayer'],
on: true,
active: false,
crowded_distance: 0.5,
clear_distance: 1,
update: async function (agent) {
const player = world.getNearestEntityWhere(agent.bot, entity => entity.type === 'player', this.crowded_distance);
if (player) {
execute(this, agent, async () => {
await skills.moveAwayFrom(agent.bot, player, this.clear_distance);
});
}
}
},
{
name: 'idle_staring',
description: 'Animation to look around at entities when idle.',
@ -267,13 +284,19 @@ async function execute(mode, agent, func, timeout=-1) {
console.log(`Mode ${mode.name} finished executing, code_return: ${code_return.message}`);
}
let _agent = null;
class ModeController {
constructor(agent) {
this.agent = agent;
this.modes_list = modes;
/*
SECURITY WARNING:
This object is accessible by LLM generated code, as are all of its references.
This can be used to access sensitive information like API keys by malicious human prompters.
Do not store references to anything outside this object to prevent this.
*/
constructor() {
this.modes_map = {};
this.behavior_log = '';
for (let mode of this.modes_list) {
for (let mode of modes_list) {
this.modes_map[mode.name] = mode;
}
}
@ -299,7 +322,7 @@ class ModeController {
}
unPauseAll() {
for (let mode of this.modes_list) {
for (let mode of modes_list) {
if (mode.paused) console.log(`Unpausing mode ${mode.name}`);
mode.paused = false;
}
@ -307,7 +330,7 @@ class ModeController {
getMiniDocs() { // no descriptions
let res = 'Agent Modes:';
for (let mode of this.modes_list) {
for (let mode of modes_list) {
let on = mode.on ? 'ON' : 'OFF';
res += `\n- ${mode.name}(${on})`;
}
@ -316,7 +339,7 @@ class ModeController {
getDocs() {
let res = 'Agent Modes:';
for (let mode of this.modes_list) {
for (let mode of modes_list) {
let on = mode.on ? 'ON' : 'OFF';
res += `\n- ${mode.name}(${on}): ${mode.description}`;
}
@ -324,13 +347,13 @@ class ModeController {
}
async update() {
if (this.agent.isIdle()) {
if (_agent.isIdle()) {
this.unPauseAll();
}
for (let mode of this.modes_list) {
let interruptible = mode.interrupts.some(i => i === 'all') || mode.interrupts.some(i => i === this.agent.actions.currentActionLabel);
if (mode.on && !mode.paused && !mode.active && (this.agent.isIdle() || interruptible)) {
await mode.update(this.agent);
for (let mode of modes_list) {
let interruptible = mode.interrupts.some(i => i === 'all') || mode.interrupts.some(i => i === _agent.actions.currentActionLabel);
if (mode.on && !mode.paused && !mode.active && (_agent.isIdle() || interruptible)) {
await mode.update(_agent);
}
if (mode.active) break;
}
@ -344,14 +367,14 @@ class ModeController {
getJson() {
let res = {};
for (let mode of this.modes_list) {
for (let mode of modes_list) {
res[mode.name] = mode.on;
}
return res;
}
loadJson(json) {
for (let mode of this.modes_list) {
for (let mode of modes_list) {
if (json[mode.name] != undefined) {
mode.on = json[mode.name];
}
@ -360,10 +383,11 @@ class ModeController {
}
export function initModes(agent) {
_agent = agent;
// the mode controller is added to the bot object so it is accessible from anywhere the bot is used
agent.bot.modes = new ModeController(agent);
let modes = agent.prompter.getInitModes();
if (modes) {
agent.bot.modes.loadJson(modes);
agent.bot.modes = new ModeController();
let modes_json = agent.prompter.getInitModes();
if (modes_json) {
agent.bot.modes.loadJson(modes_json);
}
}

53
src/agent/server_proxy.js Normal file
View file

@ -0,0 +1,53 @@
import { io } from 'socket.io-client';
import { recieveFromBot } from './conversation.js';
class ServerProxy {
constructor() {
if (ServerProxy.instance) {
return ServerProxy.instance;
}
this.socket = null;
this.connected = false;
ServerProxy.instance = this;
}
connect() {
if (this.connected) return;
this.socket = io('http://localhost:8080');
this.connected = true;
this.socket.on('connect', () => {
console.log('Connected to MindServer');
});
this.socket.on('disconnect', () => {
console.log('Disconnected from MindServer');
this.connected = false;
});
this.socket.on('chat-message', (agentName, json) => {
recieveFromBot(agentName, json);
});
}
registerAgent(agentName) {
if (!this.connected) {
console.warn('Cannot register agent: not connected to MindServer');
return;
}
this.socket.emit('register-agent', agentName);
}
getSocket() {
return this.socket;
}
}
// Create and export a singleton instance
export const serverProxy = new ServerProxy();
export function sendBotChatToServer(agentName, json) {
serverProxy.getSocket().emit('chat-message', agentName, json);
}

60
src/server/mind_server.js Normal file
View file

@ -0,0 +1,60 @@
import { Server } from 'socket.io';
import express from 'express';
import http from 'http';
import path from 'path';
import { fileURLToPath } from 'url';
// Module-level variables
let io;
let server;
const connectedAgents = {};
// Initialize the server
export function createMindServer(port = 8080) {
const app = express();
server = http.createServer(app);
io = new Server(server);
// Serve static files
const __dirname = path.dirname(fileURLToPath(import.meta.url));
app.use(express.static(path.join(__dirname, 'public')));
// Socket.io connection handling
io.on('connection', (socket) => {
let curAgentName = null;
console.log('Client connected');
socket.emit('agents-update', Object.keys(connectedAgents));
socket.on('register-agent', (agentName) => {
console.log('Agent registered:', agentName);
connectedAgents[agentName] = socket;
curAgentName = agentName;
io.emit('agents-update', Object.keys(connectedAgents));
});
socket.on('chat-message', (agentName, json) => {
console.log(`${curAgentName} received message from ${agentName}: ${json}`);
const agentSocket = connectedAgents[agentName];
if (agentSocket) {
agentSocket.emit('chat-message', curAgentName, json);
}
});
socket.on('disconnect', () => {
console.log('Client disconnected');
delete connectedAgents[socket.id];
io.emit('agents-update', Object.keys(connectedAgents));
});
});
server.listen(port, 'localhost', () => {
console.log(`MindServer running on port ${port}`);
});
return server;
}
// Optional: export these if you need access to them from other files
export const getIO = () => io;
export const getServer = () => server;
export const getConnectedAgents = () => connectedAgents;

View file

@ -0,0 +1,44 @@
<!DOCTYPE html>
<html>
<head>
<title>Mindcraft Agents</title>
<script src="/socket.io/socket.io.js"></script>
<style>
body {
font-family: Arial, sans-serif;
margin: 20px;
background: #f0f0f0;
}
#agents {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
h1 {
color: #333;
}
.agent {
margin: 10px 0;
padding: 10px;
background: #f8f8f8;
border-radius: 4px;
}
</style>
</head>
<body>
<h1>Connected Mindcraft Agents</h1>
<div id="agents"></div>
<script>
const socket = io();
const agentsDiv = document.getElementById('agents');
socket.on('agents-update', (agents) => {
agentsDiv.innerHTML = agents.length ?
agents.map(name => `<div class="agent">${name}</div>`).join('') :
'<div class="agent">No agents connected</div>';
});
</script>
</body>
</html>