Files
navi/public/navi.mjs
ztimson 7b2621c264
All checks were successful
Build and publish / Build Container (push) Successful in 1m44s
Refactored code formatting to use consistent indentation and object destructuring across client and server files.
2026-03-02 12:28:18 -05:00

277 lines
6.3 KiB
JavaScript

class Navi {
api;
connected = false;
icon;
info;
theme;
world;
#init;
#listeners = new Map();
#socket;
#secret;
#world;
constructor(api = window.location.origin, secret = '') {
this.api = api.replace(/\/$/, '');
this.icon = `${this.api}/favicon.png`;
this.#secret = secret;
}
async init() {
if(this.#init) return this.#init;
this.#init = new Promise(async (res, rej) => {
try {
this.info = await fetch(`${this.api}/api/info`, {
headers: this.#secret ? {'Authorization': `Bearer ${this.#secret}`} : {}
}).then(resp => {
if(!resp.ok) throw new Error(`Invalid Navi API: ${this.api}`);
return resp.json();
});
this.theme = this.info.theme;
this.#socket = io(this.api, {auth: this.#secret ? {token: this.#secret} : null});
this.#socket.on('llm-stream', (chunk) => {
if(this.llmCallback) this.llmCallback(chunk);
this.emit('llm:stream', chunk);
});
this.#socket.on('llm-response', (data) => {
if(this.llmResolve) this.llmResolve(data);
this.emit('llm:response', data);
this.llmCallback = null;
this.llmResolve = null;
this.llmReject = null;
});
this.#socket.on('llm-error', (error) => {
if(this.llmReject) this.llmReject(error);
this.emit('llm:error', error);
this.llmCallback = null;
this.llmResolve = null;
this.llmReject = null;
});
this.connected = true;
this.emit('init');
res(this);
} catch(err) {
rej(err);
}
});
return this.#init;
}
// ============================================
// EVENT SYSTEM
// ============================================
on(event, callback) {
if(!this.#listeners.has(event)) this.#listeners.set(event, []);
this.#listeners.get(event).push(callback);
return () => this.off(event, callback);
}
off(event, callback) {
const callbacks = this.#listeners.get(event);
if(callbacks) {
const index = callbacks.indexOf(callback);
if(index > -1) callbacks.splice(index, 1);
}
}
emit(event, data) {
const callbacks = this.#listeners.get(event) || [];
callbacks.forEach(cb => cb(data));
}
// ============================================
// REST API
// ============================================
async sendUserMessage(userId, message) {
const response = await fetch(`${this.api}/api/message`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({message})
});
if(!response.ok) throw new Error('Message failed to send bestie');
const result = await response.json();
this.emit('message:sent', {
userId,
message,
result
});
return result;
}
async linkPet(petId, targetApiUrl) {
const response = await fetch(`${this.api}/api/link`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({targetApiUrl})
});
if(!response.ok) throw new Error('Link connection is bussin (negatively)');
const result = await response.json();
this.emit('pet:linked', {
petId,
targetApiUrl,
result
});
return result;
}
// ============================================
// WORLD SOCKET
// ============================================
connect(apiOrWorld, world) {
let api;
if(world) {
api = apiOrWorld;
} else {
api = this.api;
world = apiOrWorld;
}
if(!this.world) this.world = {};
if(this.#world && this.world.api !== api) {
this.#world.disconnect();
this.#world = null;
}
if(!this.#world) this.#world = io(`${api}/world`, {auth: this.#secret ? {token: this.#secret} : null});
this.world.api = api;
this.world.data = null;
this.world.name = world;
this.world.players = new Map();
const callbacks = {
move: (x, y) => {
this.#world.emit('move', {
x,
y
});
},
leave: () => {
this.#world.disconnect();
this.world = null;
},
onData: (data) => {
},
onPlayers: (players) => {
},
onJoined: (player) => {
},
onMoved: (player) => {
},
onLeft: (player) => {
},
onError: (error) => {
}
};
this.#world.on('data', (data) => {
this.world.data = data;
callbacks.onData(data);
this.emit('world:data', data);
});
this.#world.on('players', (players) => {
this.world.players.clear();
players.forEach(p => this.world.players.set(p.socketId, p));
callbacks.onPlayers(players);
this.emit('world:players', players);
});
this.#world.on('joined', (player) => {
this.world.players.set(player.socketId, player);
callbacks.onJoined(player);
this.emit('world:joined', player);
});
this.#world.on('moved', (data) => {
const player = this.world.players.get(data.socketId);
if(player) {
player.x = data.x;
player.y = data.y;
}
callbacks.onMoved(player);
this.emit('world:moved', player);
});
this.#world.on('left', (data) => {
const player = this.world.players.get(data.socketId);
this.world.players.delete(data.socketId);
callbacks.onLeft(player);
this.emit('world:left', player);
});
this.#world.on('error', (error) => {
console.error('World error:', error);
callbacks.onError(error);
this.emit('world:error', error);
});
this.#world.emit('join', {
world,
api
});
this.emit('world:join', world);
return callbacks;
}
// ============================================
// LLM
// ============================================
ask(message, stream) {
this.llmCallback = stream;
const promise = new Promise((resolve, reject) => {
this.llmResolve = resolve;
this.llmReject = reject;
this.#socket.emit('llm-ask', {message});
this.emit('llm:ask');
});
promise.abort = () => {
this.#socket.emit('llm-abort');
if(this.llmReject) this.llmReject(new Error('Aborted by user'));
this.llmCallback = null;
this.llmResolve = null;
this.llmReject = null;
this.emit('llm:abort');
};
return promise;
}
clearChat() {
if(this.#socket) this.#socket.emit('llm-clear');
this.emit('llm:clear');
}
// ============================================
// UTILITY
// ============================================
disconnect() {
this.connected = false;
this.icon = this.info = this.theme = this.world = this.#init = this.#secret = null;
if(this.#world) {
this.#world.disconnect();
this.#world = null;
}
if(this.#socket) {
this.#socket.disconnect();
this.#socket = null;
}
this.emit('disconnected');
}
}
export default Navi;