generated from ztimson/template
All checks were successful
Build and publish / Build Container (push) Successful in 1m28s
444 lines
12 KiB
JavaScript
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) => {
|
|
if(!['none', ...Object.keys(settings.animations.emote)].includes(args.emote))
|
|
throw new Error(`Invalid emote, must be one of: ${['none', ...Object.keys(settings.animations.emote)].join(', ')}`)
|
|
stream({emote: args.emote});
|
|
return 'done!';
|
|
}
|
|
}, {
|
|
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);
|