generated from ztimson/template
Styling improvment
All checks were successful
Build and publish / Build Container (push) Successful in 1m41s
All checks were successful
Build and publish / Build Container (push) Successful in 1m41s
This commit is contained in:
295
src/server.js
295
src/server.js
@@ -6,21 +6,61 @@ import fs from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import {Ai, DateTimeTool, ExecTool, FetchTool, ReadWebpageTool, WebSearchTool} from '@ztimson/ai-utils';
|
||||
import {contrast, shadeColor} from '@ztimson/utils';
|
||||
|
||||
// ============================================
|
||||
// Settings
|
||||
// ============================================
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const __dirname = join(dirname(__filename), '..');
|
||||
const storage = join(__dirname, 'storage');
|
||||
const navi = join(storage, 'navi');
|
||||
const protocols = join(storage, 'protocols');
|
||||
const worlds = join(storage, 'worlds');
|
||||
const logoFile = join(navi, 'logo.png');
|
||||
const settingsFile = join(navi, 'settings.json');
|
||||
const memoriesFile = join(navi, 'memories.json');
|
||||
let updated = false;
|
||||
|
||||
const app = express();
|
||||
const httpServer = createServer(app);
|
||||
function calcColors(theme) {
|
||||
return {
|
||||
...theme,
|
||||
backgroundContrast: contrast(theme.background),
|
||||
backgroundDark: shadeColor(theme.background, -.1),
|
||||
backgroundLight: shadeColor(theme.background, .1),
|
||||
primaryContrast: contrast(theme.primary),
|
||||
primaryDark: shadeColor(theme.primary, -.1),
|
||||
primaryLight: shadeColor(theme.primary, .1),
|
||||
accentContrast: contrast(theme.accent),
|
||||
accentDark: shadeColor(theme.accent, -.1),
|
||||
accentLight: shadeColor(theme.accent, .1),
|
||||
mutedContrast: contrast(theme.muted),
|
||||
mutedDark: shadeColor(theme.muted, -.1),
|
||||
mutedLight: shadeColor(theme.muted, .1),
|
||||
}
|
||||
}
|
||||
|
||||
let memories = [], settings = {};
|
||||
const settingsFile = join(__dirname, '../navi', 'settings.json');
|
||||
const memoriesFile = join(__dirname, '../navi', 'memories.json');
|
||||
const logoFile = join(__dirname, '../navi', 'logo.png');
|
||||
let memories = [], settings = {
|
||||
name: 'Navi',
|
||||
personality: 'You are inquisitive about your user trying to best adjust your personally to fit them',
|
||||
instructions: '',
|
||||
theme: {
|
||||
background: '#fff',
|
||||
border: '#000',
|
||||
text: '#252525',
|
||||
primary: '#9f32ef',
|
||||
accent: '#6f16c3',
|
||||
muted: '#a8a8a8',
|
||||
}
|
||||
};
|
||||
|
||||
function load() {
|
||||
try {
|
||||
settings = JSON.parse(fs.readFileSync(settingsFile, 'utf-8'));
|
||||
settings = {
|
||||
...settings,
|
||||
...JSON.parse(fs.readFileSync(settingsFile, 'utf-8'))
|
||||
};
|
||||
} catch { }
|
||||
try {
|
||||
memories = JSON.parse(fs.readFileSync(memoriesFile, 'utf-8'));
|
||||
@@ -28,6 +68,9 @@ function load() {
|
||||
}
|
||||
|
||||
function save() {
|
||||
if(!updated) return;
|
||||
updated = false;
|
||||
|
||||
const dir = dirname(settingsFile);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdir(dir, { recursive: true }, (err) => {
|
||||
@@ -38,11 +81,6 @@ function save() {
|
||||
fs.writeFileSync(memoriesFile, JSON.stringify(memories, null, 2));
|
||||
}
|
||||
|
||||
function shutdown() {
|
||||
save();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
load();
|
||||
const ai = new Ai({
|
||||
llm: {
|
||||
@@ -56,13 +94,19 @@ const ai = new Ai({
|
||||
args: {instructions: {type: 'string', description: 'Bullet point list of how to behave'}},
|
||||
fn: (args) => {
|
||||
settings.personality = args.instructions;
|
||||
save();
|
||||
updated = true;
|
||||
return 'done!';
|
||||
}
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Setup
|
||||
// ============================================
|
||||
|
||||
const app = express();
|
||||
const httpServer = createServer(app);
|
||||
const io = new Server(httpServer, {
|
||||
cors: {origin: "*", methods: ["GET", "POST"]}
|
||||
});
|
||||
@@ -72,133 +116,145 @@ app.use(express.json());
|
||||
app.use(express.static('public'));
|
||||
|
||||
// ============================================
|
||||
// WORLD MANAGEMENT
|
||||
// Primary Socket
|
||||
// ============================================
|
||||
const worldPlayers = new Map();
|
||||
const chatHistory = new Map();
|
||||
|
||||
// Load world data
|
||||
function loadWorld(worldId) {
|
||||
try {
|
||||
const worldPath = join(__dirname, '../worlds', worldId || '', 'world.json');
|
||||
const world = JSON.parse(fs.readFileSync(worldPath, 'utf-8'));
|
||||
const themePath = join(__dirname, '../worlds', worldId || '', world.theme);
|
||||
const theme = JSON.parse(fs.readFileSync(themePath, 'utf-8'));
|
||||
worldPlayers.set(worldId, new Map());
|
||||
return {world, theme};
|
||||
} catch (error) {
|
||||
console.error(`Failed to load world ${worldId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// SOCKET.IO - WORLD CHANNELS
|
||||
// ============================================
|
||||
io.on('connection', (socket) => {
|
||||
console.debug('🔌 Client connected:', socket.id);
|
||||
console.debug('👤 User connected:', socket.id);
|
||||
|
||||
let currentWorld = null;
|
||||
let playerData = null;
|
||||
|
||||
// Join a world
|
||||
socket.on('join-world', (data) => {
|
||||
const { worldId, playerInfo } = data;
|
||||
const worldData = loadWorld(worldId);
|
||||
if(!worldData) return socket.emit('error', { message: 'World not found' });
|
||||
|
||||
// Leave previous world if any
|
||||
if(currentWorld) {
|
||||
socket.leave(`world:${currentWorld}`);
|
||||
const players = worldPlayers.get(currentWorld);
|
||||
if(players) {
|
||||
players.delete(socket.id);
|
||||
socket.to(`world:${currentWorld}`).emit('player-left', {socketId: socket.id});
|
||||
}
|
||||
}
|
||||
|
||||
// Join new world
|
||||
currentWorld = worldId;
|
||||
playerData = {
|
||||
socketId: socket.id,
|
||||
name: playerInfo.name,
|
||||
apiUrl: playerInfo.apiUrl,
|
||||
x: worldData.world.pet.startX,
|
||||
y: worldData.world.pet.startY
|
||||
};
|
||||
socket.join(`world:${worldId}`);
|
||||
const players = worldPlayers.get(worldId);
|
||||
players.set(socket.id, playerData);
|
||||
socket.emit('world-data', worldData);
|
||||
const currentPlayers = Array.from(players.values());
|
||||
socket.emit('current-players', currentPlayers);
|
||||
socket.to(`world:${worldId}`).emit('player-joined', playerData);
|
||||
});
|
||||
|
||||
// Player movement
|
||||
socket.on('player-move', (data) => {
|
||||
if(!currentWorld || !playerData) return;
|
||||
const { x, y } = data;
|
||||
playerData.x = x;
|
||||
playerData.y = y;
|
||||
socket.to(`world:${currentWorld}`).emit('player-moved', {socketId: socket.id, x, y});
|
||||
});
|
||||
|
||||
// Disconnect
|
||||
socket.on('disconnect', () => {
|
||||
console.debug('🔌 Client disconnected:', socket.id);
|
||||
if(currentWorld) {
|
||||
const players = worldPlayers.get(currentWorld);
|
||||
if(players) {
|
||||
players.delete(socket.id);
|
||||
socket.to(`world:${currentWorld}`).emit('player-left', {socketId: socket.id});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// LLM CHANNEL
|
||||
// ============================================
|
||||
const petNamespace = io.of('/llm');
|
||||
petNamespace.on('connection', (socket) => {
|
||||
chatHistory.set(socket.id, []);
|
||||
let currentRequest = null;
|
||||
|
||||
socket.on('clear', async () => {
|
||||
socket.on('llm-clear', async () => {
|
||||
chatHistory.set(socket.id, []);
|
||||
});
|
||||
|
||||
socket.on('abort', () => {
|
||||
socket.on('llm-abort', () => {
|
||||
if (currentRequest?.abort) {
|
||||
currentRequest.abort();
|
||||
currentRequest = null;
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('message', async (data) => {
|
||||
const { message, apiUrl } = data;
|
||||
socket.on('llm-ask', async (data) => {
|
||||
const { message } = data;
|
||||
const history = chatHistory.get(socket.id);
|
||||
currentRequest = ai.language.ask(message, {
|
||||
history,
|
||||
memory: memories,
|
||||
stream: (chunk) => socket.emit('stream', chunk)
|
||||
stream: (chunk) => socket.emit('llm-stream', chunk)
|
||||
}).then(resp => {
|
||||
chatHistory.set(socket.id, history);
|
||||
socket.emit('response', { message: resp });
|
||||
socket.emit('llm-response', { message: resp });
|
||||
updated = true;
|
||||
}).catch(err => {
|
||||
socket.emit('error', {message: err.message || err.toString()});
|
||||
socket.emit('llm-error', {message: err.message || err.toString()});
|
||||
}).finally(() => {
|
||||
currentRequest = null;
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('🔌 LLM Client disconnected:', socket.id);
|
||||
console.log('👤 User disconnected:', socket.id);
|
||||
chatHistory.delete(socket.id);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// World Socket
|
||||
// ============================================
|
||||
const worldPlayers = new Map();
|
||||
|
||||
// Load world data
|
||||
function loadWorld(name) {
|
||||
let w, t;
|
||||
try {
|
||||
w = JSON.parse(fs.readFileSync(join(worlds, (name || 'home') + '.json'), 'utf-8'));
|
||||
} catch (error) {
|
||||
console.error(`Failed to load world ${name}:`, error);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
t = JSON.parse(fs.readFileSync(join(protocols, w.theme + '.json'), 'utf-8'));
|
||||
t.colors = calcColors(t.colors);
|
||||
} catch (error) {
|
||||
console.error(`Failed to load theme protocol ${w.theme}:`, error);
|
||||
return null;
|
||||
}
|
||||
worldPlayers.set(name, new Map());
|
||||
return {...w, theme: t};
|
||||
}
|
||||
|
||||
io.of('/world').on('connection', (socket) => {
|
||||
console.debug('🌍 Navi joined world:', socket.id);
|
||||
|
||||
let currentWorld = null;
|
||||
let playerData = null;
|
||||
|
||||
// Join a world
|
||||
socket.on('join', async (data) => {
|
||||
const { world, api } = data;
|
||||
const info = await fetch(api.replace(/\/$/, '') + '/api/info').then(resp => {
|
||||
if(resp.ok) return resp.json();
|
||||
socket.emit('error', {message: `Invalid Navi API: ${api}`});
|
||||
return resp.error;
|
||||
});
|
||||
|
||||
const worldData = loadWorld(world);
|
||||
if(!worldData) return socket.emit('error', { message: 'World not found' });
|
||||
|
||||
// Leave previous world if any
|
||||
if(currentWorld) {
|
||||
socket.leave(`world:${currentWorld}`);
|
||||
const players = worldPlayers.get(currentWorld);
|
||||
if(players) {
|
||||
players.delete(socket.id);
|
||||
socket.to(`world:${currentWorld}`).emit('left', {socketId: socket.id});
|
||||
}
|
||||
}
|
||||
|
||||
// Join new world
|
||||
currentWorld = world;
|
||||
const spawn = worldData.tiles.find(t => t.type === 'spawn');
|
||||
playerData = {
|
||||
...info,
|
||||
socketId: socket.id,
|
||||
navi: api,
|
||||
x: spawn.x,
|
||||
y: spawn.y
|
||||
};
|
||||
socket.join(`world:${world}`);
|
||||
const players = worldPlayers.get(world);
|
||||
players.set(socket.id, playerData);
|
||||
socket.emit('data', worldData);
|
||||
const currentPlayers = Array.from(players.values());
|
||||
socket.emit('players', currentPlayers);
|
||||
socket.to(`world:${world}`).emit('joined', playerData);
|
||||
});
|
||||
|
||||
// Player movement
|
||||
socket.on('move', (data) => {
|
||||
if(!currentWorld || !playerData) return;
|
||||
const { x, y } = data;
|
||||
playerData.x = x;
|
||||
playerData.y = y;
|
||||
socket.to(`world:${currentWorld}`).emit('moved', {socketId: socket.id, x, y});
|
||||
});
|
||||
|
||||
// Disconnect
|
||||
socket.on('disconnect', () => {
|
||||
console.debug('🌍 Navi disconnected:', socket.id);
|
||||
if(currentWorld) {
|
||||
const players = worldPlayers.get(currentWorld);
|
||||
if(players) {
|
||||
players.delete(socket.id);
|
||||
socket.to(`world:${currentWorld}`).emit('left', {socketId: socket.id});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// REST API ENDPOINTS
|
||||
// ============================================
|
||||
@@ -207,21 +263,12 @@ app.get('/favicon.*', (req, res) => {
|
||||
res.sendFile(logoFile);
|
||||
});
|
||||
|
||||
// Get PET info
|
||||
// Get Navi info
|
||||
app.get('/api/info', (req, res) => {
|
||||
const { petId } = req.params;
|
||||
// TODO: Fetch from database
|
||||
res.json({
|
||||
id: petId,
|
||||
name: 'MyCoolPET',
|
||||
owner: 'player1',
|
||||
bandwidth: 75,
|
||||
shards: [],
|
||||
stats: {
|
||||
level: 5,
|
||||
health: 100
|
||||
}
|
||||
});
|
||||
name: settings.name,
|
||||
theme: calcColors(settings.theme),
|
||||
});
|
||||
});
|
||||
|
||||
// Get sprite sheet
|
||||
@@ -268,11 +315,15 @@ app.post('/api/link', (req, res) => {
|
||||
// ============================================
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
setInterval(() => save(), 5 * 60_000);
|
||||
httpServer.listen(PORT, () => {
|
||||
loadWorld();
|
||||
console.log('✅ Home world loaded');
|
||||
console.log(`🚀 Server running on: http://localhost:${PORT}`);
|
||||
});
|
||||
|
||||
function shutdown() {
|
||||
save();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
|
||||
Reference in New Issue
Block a user