Files
navi/src/server.js
ztimson 6c2c3a0d07
All checks were successful
Build and publish / Build Container (push) Successful in 1m26s
Small tts fix
2026-03-03 20:23:59 -05:00

444 lines
12 KiB
JavaScript

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);