Home screen update
All checks were successful
Build and publish / Build Container (push) Successful in 1m28s

This commit is contained in:
2026-03-03 20:16:26 -05:00
parent 82f29dceae
commit 5018311990
24 changed files with 1799 additions and 398 deletions

27
src/environment.js Normal file
View File

@@ -0,0 +1,27 @@
import {dirname, join} from 'path';
import {fileURLToPath} from 'url';
import * as dotenv from 'dotenv';
dotenv.config({path: ['.env','.env.local'], debug: false, quiet: true});
const __filename = fileURLToPath(import.meta.url);
const __dirname = join(dirname(__filename), '..');
const storage = join(__dirname, 'storage');
export const environment = {
port: process.env.PORT || 3000,
publicUrl: process.env.PUBLIC_URL || `http://localhost:${process.env.PORT || 3000}`,
llm: {
host: process.env.LLM_HOST || '',
model: process.env.LLM_MODEL || 'default',
token: process.env.LLM_TOKEN || 'ignore',
context: process.env.LLM_CONTEXT ? +process.env.LLM_CONTEXT : 60_000.
},
paths: {
public: join(__dirname, 'public'),
storage,
navi: join(storage, 'navi'),
protocols: join(storage, 'protocols'),
worlds: join(storage, 'worlds')
}
}

View File

@@ -4,24 +4,22 @@ import {Server} from 'socket.io';
import cors from 'cors';
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, deepCopy, isEqual, shadeColor} from '@ztimson/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 __filename = fileURLToPath(import.meta.url);
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');
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 {
@@ -43,7 +41,17 @@ function calcColors(theme) {
let orgSettings, orgMemories, memories = [], settings = {
name: 'Navi',
personality: '- You are inquisitive about your user trying to best adjust your personally to fit them\n- Keep responses short',
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',
@@ -61,7 +69,7 @@ let orgSettings, orgMemories, memories = [], settings = {
function load() {
try {
orgSettings = JSON.parse(fs.readFileSync(settingsFile, 'utf-8'));
settings = {...settings, ...deepCopy(orgSettings)};
settings = deepMerge(settings, deepCopy(orgSettings));
} catch { }
try {
memories = JSON.parse(fs.readFileSync(memoriesFile, 'utf-8'));
@@ -73,12 +81,12 @@ 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));
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));
fs.writeFileSync(memoriesFile, JSON.stringify(memories, null, 2));
orgMemories = m;
}
}
@@ -87,23 +95,28 @@ function save() {
// AI
// ============================================
load();
const ai = new Ai({
llm: {
models: {
'Ministral-3': {
proto: 'openai',
host: 'http://10.69.0.55:11728',
token: 'ignore'
},
},
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: 'adapt',
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: 'Bullet point list of how to behave'
}
instructions: {type: 'string', description: 'Full bullet point list of how to behave', required: true}
},
fn: (args) => {
settings.personality = args.instructions;
@@ -114,13 +127,14 @@ const ai = new Ai({
});
const systemPrompt = () => {
return `Your name is ${settings.name}, a NetNavi, companion & personal assistant. Roleplay with the user.
Use your remember tool liberally to store all facts.
When the user asks you to behave differently or you feel a different personality would better fit the user; create a bullet point list of how to behave and submit it to the adapt tool
Access your ${os.platform()} workspace using the exec tool; Use \`${os.tmpdir()}\` as your working directory
Keep responses unstyled.
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:
Personality Rules:
${settings.personality || ''}`;
};
@@ -128,7 +142,6 @@ ${settings.personality || ''}`;
// Setup
// ============================================
load();
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
@@ -140,7 +153,6 @@ const io = new Server(httpServer, {
app.use(cors());
app.use(express.json());
app.use(express.static('public'));
// ============================================
// Socket
@@ -300,37 +312,58 @@ io.of('/world').on('connection', (socket) => {
// API ENDPOINTS
// ============================================
app.get('/favicon.*', (req, res) => {
res.sendFile(logoFile);
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);
});
// Get Navi info
app.get('/api/info', (req, res) => {
res.json({
name: settings.name,
avatar: settings.avatar,
theme: calcColors(settings.theme),
});
});
// Get sprite sheet
app.get('/api/sprite', (req, res) => {
const {petId} = req.params;
// TODO: Return actual sprite sheet URL
res.json({
spriteUrl: '/sprites/default-pet.png',
frameWidth: 32,
frameHeight: 32,
animations: {
idle: {
frames: [0, 1, 2, 3],
speed: 200
},
walk: {
frames: [4, 5, 6, 7],
speed: 100
}
}
});
app.get('/api/animations', (req, res) => {
res.json(settings.animations);
});
// Send message to user (push notification / email / etc)
@@ -366,14 +399,39 @@ app.post('/api/link', (req, res) => {
});
});
// 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
// ============================================
const PORT = process.env.PORT || 3000;
setInterval(() => save(), 5 * 60_000);
httpServer.listen(PORT, () => {
console.log(`🚀 Server running on: http://localhost:${PORT}`);
httpServer.listen(environment.port, () => {
console.log(`🚀 Server running on: http://localhost:${environment.port}`);
});
function shutdown() {

9
src/utils.js Normal file
View File

@@ -0,0 +1,9 @@
import sharp from 'sharp';
export async function resize(image, width, height) {
width = Math.round(+width);
if(height) height = Math.round(+height);
if(width < 1) throw new Error(`Invalid dimensions: ${width}x${height}`);
if(width > 1920 || (height && height > 1920)) throw new Error(`Largest dimension supported is 1920: ${width}x${height}`);
return await sharp(image).resize({width: width, height: height || width, fit: 'contain'}).toBuffer();
}