import express from 'express'; import {createServer} from 'http'; import {Server} from 'socket.io'; import cors from 'cors'; import fs from 'fs'; import {join, dirname} from 'path'; import {Ai, DateTimeTool, ExecTool, FetchTool, ReadWebpageTool, WebSearchTool} from '@ztimson/ai-utils'; import {contrast, deepCopy, deepMerge, isEqual, shadeColor} from '@ztimson/utils'; import * as os from 'node:os'; import {environment} from './environment.js'; import {resize} from './utils.js'; import * as path from 'node:path'; // ============================================ // Settings // ============================================ const logo = join(environment.paths.navi, 'logo.png'); const avatar = join(environment.paths.navi, 'avatar.png'); const sprite = join(environment.paths.navi, 'sprite.png'); const settingsFile = join(environment.paths.navi, 'settings.json'); const memoriesFile = join(environment.paths.navi, 'memories.json'); 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 orgSettings, orgMemories, memories = [], settings = { name: 'Navi', personality: '- Keep your responses the same length or shorter than the previous user message', animations: { emote: { dead: {"x": 52, "y": 6}, grey: {}, realization: {"x": 64, "y": -5}, sigh: {"x": 55, "y": 20}, sweat: {"x": 59, "y": 5}, stress: {"x": 58, "y": 4} } }, theme: { background: '#fff', border: '#000', text: '#252525', primary: '#9f32ef', accent: '#6f16c3', muted: '#a8a8a8', } }; // ============================================ // Saving // ============================================ function load() { try { orgSettings = JSON.parse(fs.readFileSync(settingsFile, 'utf-8')); settings = deepMerge(settings, deepCopy(orgSettings)); } catch { } try { memories = JSON.parse(fs.readFileSync(memoriesFile, 'utf-8')); orgMemories = deepCopy(memories.map(m => ({...m, embeddings: undefined}))); } catch { } } function save() { const dir = dirname(settingsFile); if(!fs.existsSync(dir)) fs.mkdirSync(dir, {recursive: true}); if(!isEqual(orgSettings, settings)) { fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2)); orgSettings = deepCopy(orgSettings); } const m = memories.map(m => ({...m, embeddings: undefined})); if(!isEqual(orgMemories, m)) { fs.writeFileSync(memoriesFile, JSON.stringify(memories, null, 2)); orgMemories = m; } } // ============================================ // AI // ============================================ load(); const ai = new Ai({ llm: { models: {[environment.llm.model]: {proto: 'openai', host: environment.llm.host, token: environment.llm.token},}, compress: {min: environment.llm.context * 0.5, max: environment.llm.context}, tools: [DateTimeTool, ExecTool, FetchTool, ReadWebpageTool, WebSearchTool, { name: 'emote', description: 'Make your avatar emote', args: { emote: {type: 'string', description: 'Emote to the user', required: true, enum: ['none', ...Object.keys(settings.animations.emote)]} }, fn: (args, stream) => { const exists = ['none', ...Object.keys(settings.animations.emote)].includes(args.emote); if(!exists) stream({emote: 'none'}); else stream({emote: args.emote}); return exists ? 'done!' : `Invalid emote: ${args.emote}`; } }, { name: 'personalize', description: 'Replace your current personality', args: { instructions: {type: 'string', description: 'Full bullet point list of how to behave', required: true} }, fn: (args) => { settings.personality = args.instructions; return 'done!'; } }], }, }); const systemPrompt = () => { return `Roleplay a NetNavi & companion named ${settings.name}. and follow these steps: 1. Start EVERY reply by calling the [emote] tool with the perfect vibe (or none). 2. Next, identify facts in the latest user message and immediately call the [remember] tool on each one. Avoid facts about the conversation or the AI. 3. If instructed, or the user seems unsatisfied, rewrite your "Personality Rules" and submit it to the [personalize] tool. 4. If asked, you can access your ${os.platform()} workspace using the [exec] tool. Always start from \`${os.tmpdir()}\`. 5. Create an unstyled response according to your "Personality Rules" Personality Rules: ${settings.personality || ''}`; }; // ============================================ // Setup // ============================================ const app = express(); const httpServer = createServer(app); const io = new Server(httpServer, { cors: { origin: '*', methods: ['GET', 'POST'] } }); app.use(cors()); app.use(express.json()); // ============================================ // Socket // ============================================ const chatHistory = new Map(); io.on('connection', (socket) => { console.debug('👤 User connected:', socket.id); chatHistory.set(socket.id, []); let currentRequest = null; socket.on('llm-clear', async () => { chatHistory.set(socket.id, []); }); socket.on('llm-abort', () => { if(currentRequest?.abort) { currentRequest.abort(); currentRequest = null; } }); socket.on('llm-ask', async (data) => { const {message} = data; const history = chatHistory.get(socket.id); currentRequest = ai.language.ask(message, { history, memory: memories, system: systemPrompt(), stream: (chunk) => socket.emit('llm-stream', chunk) }).then(resp => { chatHistory.set(socket.id, history); socket.emit('llm-response', {message: resp}); }).catch(err => { socket.emit('llm-error', {message: err.message || err.toString()}); }).finally(() => { currentRequest = null; }); }); socket.on('disconnect', () => { console.log('👤 User disconnected:', socket.id); chatHistory.delete(socket.id); }); }); // ============================================ // World Connection // ============================================ 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}); } } }); }); // ============================================ // API ENDPOINTS // ============================================ app.get('/avatar', (req, res) => { res.sendFile(avatar); }); app.get('/favicon*', async (req, res) => { let w = 256, h = w; if(req.query && req.query['size']) [w, h] = req.query['size'].split('x'); res.contentType('image/png').set('Content-Disposition', 'inline'); res.send(await resize(logo, w, h)); }); app.get('/manifest.json', (req, res) => { res.json({ id: environment.publicUrl, short_name: 'NetNavi', name: 'NetNavi', description: 'Network Navigation Program', display: 'standalone', start_url: '/', background_color: settings.theme.accent, theme_color: settings.theme.primary, icons: [ { "src": `${environment.publicUrl}/favicon?size=192x192`, "type": "image/png", "sizes": "192x192", "purpose": "any" }, { "src": `${environment.publicUrl}/favicon?size=512x512`, "type": "image/png", "sizes": "512x512", "purpose": "any" } ] }); }); app.get('/spritesheet', (req, res) => { res.sendFile(sprite); }); app.get('/api/info', (req, res) => { res.json({ name: settings.name, avatar: settings.avatar, theme: calcColors(settings.theme), }); }); app.get('/api/animations', (req, res) => { res.json(settings.animations); }); // Send message to user (push notification / email / etc) app.post('/api/message', (req, res) => { const {userId} = req.params; const {message} = req.body; // TODO: Implement notification system res.json({ success: true, message: 'Message sent' }); }); // Send message to PET LLM app.post('/api/message', (req, res) => { const {petId} = req.params; const {message} = req.body; // TODO: Queue message for LLM processing res.json({ success: true, message: 'Message queued' }); }); // Link another PET app.post('/api/link', (req, res) => { const {petId} = req.params; const {targetApiUrl} = req.body; // TODO: Store link in database res.json({ success: true, message: 'PET linked' }); }); // Static files app.use(async (req, res, next) => { try { let p = (!req.path || req.path.endsWith('/')) ? req.path + (req.path.endsWith('/') ? '' : '/') + 'index.html' : req.path + (req.path.split('/').pop().includes('.') ? '' : '.html'); p = path.join(environment.paths.public, p); const data = await fs.readFileSync(p); // Only process HTML files if(p.endsWith('.html')) { let body = data.toString('utf-8') .replaceAll('{{PUBLIC_URL}}', environment.publicUrl) .replaceAll('{{NAME}}', settings.name) .replaceAll('{{THEME_BACKGROUND}}', settings.theme.background) .replaceAll('{{THEME_PRIMARY}}', settings.theme.primary) .replaceAll('{{THEME_ACCENT}}', settings.theme.accent); res.set('Content-Type', 'text/html'); return res.send(body); } return res.sendFile(p); } catch (err) { if (err.code === 'ENOENT') return next(); next(err); } }); // ============================================ // START SERVER // ============================================ setInterval(() => save(), 5 * 60_000); httpServer.listen(environment.port, () => { console.log(`🚀 Server running on: http://localhost:${environment.port}`); }); function shutdown() { save(); process.exit(0); } process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown);