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;