Styling improvment
All checks were successful
Build and publish / Build Container (push) Successful in 1m41s

This commit is contained in:
2026-03-02 02:49:15 -05:00
parent c5c070ebc2
commit 8c2b80951b
15 changed files with 824 additions and 823 deletions

View File

@@ -11,4 +11,5 @@ RUN apt-get update && apt-get install -y --no-install-recommends libpython3.11 p
echo 'alias pip="pip3"' >> /root/.bashrc && \
echo 'alias python="python3"' >> /root/.bashrc
WORKDIR /tmp
CMD ["npm", "run", "start"]

7
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "1.0.0",
"dependencies": {
"@ztimson/ai-utils": "^0.8.7",
"@ztimson/utils": "^0.28.14",
"cors": "^2.8.5",
"express": "^4.18.2",
"socket.io": "^4.6.1"
@@ -324,9 +325,9 @@
"license": "MIT"
},
"node_modules/@ztimson/utils": {
"version": "0.28.13",
"resolved": "https://registry.npmjs.org/@ztimson/utils/-/utils-0.28.13.tgz",
"integrity": "sha512-6nk7mW1vPX5QltTjCNK6u6y8UGYTOgeDadWHbrD+1QM5PZ1PbosMqVMQ6l4dhsQT7IHFxvLI3+U720T2fLGwoA==",
"version": "0.28.14",
"resolved": "https://registry.npmjs.org/@ztimson/utils/-/utils-0.28.14.tgz",
"integrity": "sha512-ZI8kT1CV9La22w/E+HoPOsSV0ewgz0Rl4LXKIxFxH2dJVkXDCajgjfSHEfIHniifeOMma2nF+VO6yqT9BtvorQ==",
"license": "MIT",
"dependencies": {
"var-persist": "^1.0.1"

View File

@@ -4,6 +4,7 @@
"type": "module",
"dependencies": {
"@ztimson/ai-utils": "^0.8.7",
"@ztimson/utils": "^0.28.14",
"cors": "^2.8.5",
"express": "^4.18.2",
"socket.io": "^4.6.1"

162
public/components/btn.mjs Normal file
View File

@@ -0,0 +1,162 @@
function contrast(color) {
const exploded = color?.match(color.length >= 6 ? /[0-9a-fA-F]{2}/g : /[0-9a-fA-F]/g);
if(!exploded || exploded?.length < 3) return 'black';
const [r, g, b] = exploded.map(hex => parseInt(hex.length === 1 ? `${hex}${hex}` : hex, 16));
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.5 ? 'black' : 'white';
}
function shadeColor(hex, amount) {
function dec2Hex(num) {
const hex = Math.round(num * 255).toString(16);
return hex.length === 1 ? '0' + hex : hex;
}
function hex2Int(hex) {
let r = 0, g = 0, b = 0;
if (hex.length === 4) {
r = parseInt(hex[1] + hex[1], 16);
g = parseInt(hex[2] + hex[2], 16);
b = parseInt(hex[3] + hex[3], 16);
} else {
r = parseInt(hex.slice(1, 3), 16);
g = parseInt(hex.slice(3, 5), 16);
b = parseInt(hex.slice(5, 7), 16);
}
return { r, g, b };
}
function hue2rgb(p, q, t) {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
}
function int2Hex(r, g, b) {
return '#' + dec2Hex(r) + dec2Hex(g) + dec2Hex(b);
}
let { r, g, b } = hex2Int(hex);
r /= 255; g /= 255; b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h, s, l = (max + min) / 2;
if (max === min) {
h = s = 0;
} else {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
case b: h = (r - g) / d + 4; break;
default: h = 0; break;
}
h /= 6;
}
l = Math.max(0, Math.min(1, l + amount));
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
return int2Hex(hue2rgb(p, q, h + 1 / 3), hue2rgb(p, q, h), hue2rgb(p, q, h - 1 / 3));
}
class BtnComponent extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
:host {
--base-color: #ccc;
--dark-color: #999;
--light-color: #eee;
--text-color: #000;
}
.btn {
display: inline-flex;
min-width: 40px;
width: 100%;
height: 40px;
padding: 5px;
border-radius: 6px;
font-size: 20px;
transition: transform 0.1s, background-color 0.2s, border-color 0.2s, box-shadow 0.2s;
align-items: center;
justify-content: center;
user-select: none;
cursor: url('/assets/cursor.png'), auto;
fill: var(--text-color);
color: var(--text-color);
box-sizing: border-box;
}
.btn:not(.disabled) {
background: var(--base-color);
border: 2px solid var(--dark-color);
box-shadow: 0 4px 0 var(--dark-color);
}
.btn:not(.disabled):hover {
transform: translateY(-2px);
background: var(--light-color);
border: 2px solid var(--base-color);
box-shadow: 0 6px 0 var(--base-color);
}
.btn:not(.disabled):active {
transform: translateY(2px);
background: var(--base-color);
border: 2px solid var(--dark-color);
box-shadow: 0 2px 0 var(--dark-color);
}
.btn.disabled {
cursor: no-drop;
background: var(--dark-color);
border: 2px solid var(--base-color);
box-shadow: 0 4px 0 var(--dark-color);
}
</style>
<div class="btn">
<slot></slot>
</div>
`;
this.shadowRoot.querySelector('.btn').addEventListener('click', (e) => {
if(this.hasAttribute('disabled')) {
e.stopPropagation();
e.preventDefault();
}
});
this.updateColors();
}
static get observedAttributes() {
return ['color', 'disabled'];
}
attributeChangedCallback(name, oldValue, newValue) {
if(name === 'color') this.updateColors();
if(name === 'disabled') {
const disabled = this.hasAttribute('disabled');
this.shadowRoot.querySelector('.btn').classList[disabled ? 'add' : 'remove']('disabled');
}
}
updateColors() {
const hex = this.getAttribute('color');
this.shadowRoot.host.style.setProperty('--base-color', hex);
this.shadowRoot.host.style.setProperty('--dark-color', shadeColor(hex, -.1));
this.shadowRoot.host.style.setProperty('--light-color', shadeColor(hex, .1));
this.shadowRoot.host.style.setProperty('--text-color', contrast(hex));
}
}
customElements.define('btn-component', BtnComponent);

View File

@@ -1,35 +1,28 @@
import './btn.mjs';
class JukeboxComponent extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
// Use global singleton 🎵
this.api = window.netNaviAPI;
this.navi = window.navi;
this.navi.init();
this.unsubscribeWorld = this.navi.on('world:data', (data) => {
console.log(data, this.navi.world.data);
this.render()
});
this.playlist = [];
this.currentTrackIndex = 0;
this.bgMusic = null;
this.isMuted = false;
this.hasInteracted = false;
this.theme = null;
this.isPlaylistMode = false;
}
connectedCallback() {
this.render();
this.setupAPIListeners();
}
disconnectedCallback() {
if(this.unsubscribeWorld) this.unsubscribeWorld();
}
setupAPIListeners() {
this.unsubscribeWorld = this.api.on('world:loaded', (data) => {
if(data.theme?.music) this.loadMusic(data.theme.music, data.theme);
});
}
render() {
this.shadowRoot.innerHTML = `
<style>
@@ -44,8 +37,8 @@ class JukeboxComponent extends HTMLElement {
display: flex;
top: 20px;
right: 20px;
background: var(--dialogue-header-bg, #ffffff);
border: 3px solid var(--dialogue-border, #000);
background: ${this.navi.world.data.theme.colors.primary};
border: 3px solid ${this.navi.world.data.theme.colors.border};
border-radius: 8px;
padding: 12px;
z-index: 1000;
@@ -55,8 +48,9 @@ class JukeboxComponent extends HTMLElement {
.track-info {
display: flex;
align-items: center;
background: var(--dialogue-bg, #8b5cf6);
color: var(--dialogue-text, #ffffff);
background: ${this.navi.world.data.theme.colors.background};
border: 2px solid ${this.navi.world.data.theme.colors.border};
color: ${this.navi.world.data.theme.colors.text};
padding: 8px;
border-radius: 4px;
margin: 0 8px;
@@ -64,6 +58,7 @@ class JukeboxComponent extends HTMLElement {
height: 24px;
width: 120px;
position: relative;
cursor: text;
}
.track-name {
white-space: nowrap;
@@ -77,69 +72,37 @@ class JukeboxComponent extends HTMLElement {
0% { transform: translateX(0); }
100% { transform: translateX(-100%); }
}
.controls-row {
display: flex;
gap: 8px;
justify-content: center;
align-items: center;
.hidden {
display: none;
}
.control-btn {
width: 40px;
height: 40px;
background: var(--button-bg, #6366f1);
border: 2px solid var(--dialogue-border, #000);
border-radius: 6px;
font-size: 20px;
box-shadow: 0 3px 0 var(--button-shadow, #4338ca);
transition: transform 0.1s;
display: flex;
align-items: center;
justify-content: center;
}
.control-btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 0 var(--button-shadow, #4338ca);
}
.control-btn:active {
transform: translateY(2px);
box-shadow: 0 1px 0 var(--button-shadow, #4338ca);
}
.control-btn svg {
width: 24px;
height: 24px;
fill: #fff;
}
.hidden { display: none; }
</style>
<button class="control-btn" id="simple-mute-btn">
<btn-component id="simple-mute-btn" color="${this.navi.world.data.theme.colors.accent}">
<svg viewBox="0 0 24 24">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
</svg>
</button>
</btn-component>
<div class="audio-controls hidden" id="playlist-controls">
<div class="controls-row">
<button class="control-btn" id="prev-btn">
<btn-component id="prev-btn" color="${this.navi.world.data.theme.colors.accent}">
<svg viewBox="0 0 24 24">
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
</svg>
</button>
</div>
</btn-component>
<div class="track-info">
<span class="track-name" id="track-name">No track loaded</span>
</div>
<div class="controls-row">
<button class="control-btn" id="next-btn">
<div style="display: flex; gap: 8px">
<btn-component id="next-btn" color="${this.navi.world.data.theme.colors.accent}">
<svg viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/>
</svg>
</button>
<button class="control-btn" id="mute-btn">
</btn-component>
<btn-component id="mute-btn" color="#c0392b">
<svg viewBox="0 0 24 24">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
</svg>
</button>
</btn-component>
</div>
</div>
`;
@@ -147,18 +110,15 @@ class JukeboxComponent extends HTMLElement {
this.shadowRoot.getElementById('mute-btn').addEventListener('click', () => this.toggleMute());
this.shadowRoot.getElementById('prev-btn').addEventListener('click', () => this.previousTrack());
this.shadowRoot.getElementById('next-btn').addEventListener('click', () => this.nextTrack());
this.loadMusic(this.navi.world.data.theme.music);
}
loadMusic(musicConfig, theme) {
if (!musicConfig) return;
this.theme = theme;
loadMusic(musicConfig) {
if(!musicConfig) return;
this.isPlaylistMode = Array.isArray(musicConfig);
this.playlist = Array.isArray(musicConfig) ? musicConfig : [musicConfig];
this.currentTrackIndex = 0;
this.applyThemeColors();
if (this.isPlaylistMode) {
this.shadowRoot.getElementById('simple-mute-btn').classList.add('hidden');
this.shadowRoot.getElementById('playlist-controls').classList.remove('hidden');
@@ -205,15 +165,6 @@ class JukeboxComponent extends HTMLElement {
trackName.textContent = `[${trackNum}] ${fileName}`;
}
applyThemeColors() {
if (!this.theme) return;
const root = this.shadowRoot.host.style;
Object.entries(this.theme.colors).forEach(([key, value]) => {
const cssVar = '--' + key.replace(/([A-Z])/g, '-$1').toLowerCase();
root.setProperty(cssVar, value);
});
}
setupAutoplayHandler() {
const startMusic = () => {
if (!this.hasInteracted && !this.isMuted && this.bgMusic) {

View File

@@ -1,3 +1,5 @@
import './btn.mjs';
class LlmComponent extends HTMLElement {
hideTools = ['adjust_personality', 'recall', 'remember']
@@ -6,11 +8,9 @@ class LlmComponent extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.navi = window.navi;
this.navi.init().then(() => this.render());
// Use global singleton 🔥
this.api = window.netNaviAPI;
this.isTyping = false;
this.isReceiving = false;
this.streamComplete = false;
this.isDialogueOpen = false;
@@ -23,9 +23,6 @@ class LlmComponent extends HTMLElement {
this.currentRequest = null;
this.attachedFiles = [];
this.currentStreamingMessage = null;
this.render();
this.initEventListeners();
}
render() {
@@ -41,8 +38,8 @@ class LlmComponent extends HTMLElement {
}
::-webkit-scrollbar-thumb {
background: var(--dialogue-header-bg, #fff);
border: 2px solid var(--dialog-border, #000);
background: ${this.navi.theme.primary};
border: 2px solid ${this.navi.theme.border};
border-radius: 9px;
}
@@ -76,8 +73,8 @@ class LlmComponent extends HTMLElement {
}
.dialogue-content {
background: var(--dialogue-bg, #fff);
border: 5px solid var(--dialogue-border, #000);
background: ${this.navi.theme.background};
border: 5px solid ${this.navi.theme.border};
border-radius: 16px 16px 0 0;
padding: 0;
box-shadow: 0 -4px 20px rgba(0,0,0,0.5);
@@ -95,9 +92,9 @@ class LlmComponent extends HTMLElement {
.dialogue-header {
user-select: none;
padding: 12px 20px;
background: var(--dialogue-header-bg, #fff);
border-bottom: 3px solid var(--dialogue-border, #000);
padding: 10px 10px 10px 15px;
background: ${this.navi.theme.primary};
border-bottom: 3px solid ${this.navi.theme.border};
border-radius: 12px 12px 0 0;
display: flex;
align-items: center;
@@ -109,47 +106,13 @@ class LlmComponent extends HTMLElement {
border-radius: 0;
}
.speaker-name {
font-weight: bold;
font-size: 16px;
color: var(--dialogue-text, #000);
font-family: 'Courier New', monospace;
pointer-events: none;
}
.expand-btn {
background: var(--button-bg, #4a90e2);
border: 2px solid var(--dialogue-border, #000);
color: var(--button-text, #fff);
padding: 8px;
font-family: 'Courier New', monospace;
font-weight: bold;
font-size: 14px;
border-radius: 4px;
box-shadow: 0 3px 0 var(--button-shadow, #2a5a9a);
display: flex;
align-items: center;
gap: 4px;
}
.expand-btn:active {
transform: translateY(2px);
box-shadow: 0 1px 0 var(--button-shadow, #2a5a9a);
}
.expand-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
}
.message-body {
padding: 20px;
padding: 1.75rem 1.25rem;
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 16px;
gap: 1.75rem;
min-height: 200px;
max-height: 400px;
}
@@ -176,51 +139,41 @@ class LlmComponent extends HTMLElement {
.message-label {
font-size: 12px;
font-weight: bold;
color: var(--dialogue-text, #000);
color: ${this.navi.theme.text};
font-family: 'Courier New', monospace;
opacity: 0.7;
padding: 0 8px;
}
.message-bubble {
max-width: 80%;
padding: 12px 16px;
border-radius: 12px;
font-size: 15px;
line-height: 1.5;
font-family: 'Courier New', monospace;
word-wrap: break-word;
border: 2px solid var(--dialogue-border, #000);
box-shadow: 2px 2px 0 rgba(0,0,0,0.2);
user-select: text;
}
.message-wrapper.user .message-bubble {
background: var(--button-bg, #4a90e2);
color: var(--button-text, #fff);
background: ${this.navi.theme.primary};
color: ${this.navi.theme.primaryContrast};
padding: 0.5rem 0.75rem;
border-radius: 12px;
border: 2px solid ${this.navi.theme.border};
}
.message-wrapper.assistant .message-bubble {
background: var(--dialogue-bg, #fff);
color: var(--dialogue-text, #000);
}
.message-timestamp {
font-size: 10px;
opacity: 0.6;
padding: 0 8px;
font-family: 'Courier New', monospace;
color: var(--dialogue-text, #000);
.message-wrapper.navi .message-bubble {
color: ${this.navi.theme.text};
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
text-align: center;;
height: 100%;
font-size: 32px;
font-weight: bold;
color: var(--dialogue-text, #000);
color: ${this.navi.theme.text};
font-family: 'Courier New', monospace;
text-shadow:
3px 3px 0 rgba(74, 144, 226, 0.3),
@@ -262,7 +215,7 @@ class LlmComponent extends HTMLElement {
.tool-call {
display: inline-block;
background: var(--button-bg, #000);
background: ${this.navi.theme.primary};
color: #fff;
padding: 2px 8px;
margin: 2px;
@@ -281,11 +234,11 @@ class LlmComponent extends HTMLElement {
.file-badge {
display: flex;
align-items: center;
background: var(--button-bg, #4a90e2);
color: var(--button-text, #fff);
background: ${this.navi.theme.primary};
color: ${this.navi.theme.primaryContrast};
padding: 4px 10px;
margin: 0;
border: 2px solid var(--dialogue-border, #000);
border: 2px solid ${this.navi.theme.border};
border-radius: 6px;
font-size: 13px;
font-family: 'Courier New', monospace;
@@ -304,14 +257,14 @@ class LlmComponent extends HTMLElement {
justify-content: flex-end;
}
.message-wrapper.assistant .message-files {
.message-wrapper.navi .message-files {
justify-content: flex-start;
}
.attached-files {
padding: 8px 12px;
border-top: 3px solid var(--dialogue-border, #000);
background: var(--dialogue-input-bg, #f0f0f0);
border-top: 3px solid ${this.navi.theme.border};
background: ${this.navi.theme.backgroundDark};
display: none;
flex-wrap: wrap;
gap: 6px;
@@ -325,8 +278,9 @@ class LlmComponent extends HTMLElement {
.attached-file {
display: inline-flex;
align-items: center;
background: var(--dialogue-bg, #fff);
border: 2px solid var(--dialogue-border, #000);
background: ${this.navi.theme.primary};
color: ${this.navi.theme.primaryContrast};
border: 2px solid ${this.navi.theme.border};
padding: 6px 10px;
border-radius: 6px;
font-family: 'Courier New', monospace;
@@ -343,7 +297,7 @@ class LlmComponent extends HTMLElement {
.attached-file .remove-file {
background: #e74c3c;
border: none;
border: 1px solid black;
color: #fff;
padding: 2px 6px;
border-radius: 3px;
@@ -357,8 +311,8 @@ class LlmComponent extends HTMLElement {
.dialogue-input {
padding: 12px 20px;
border-top: 3px solid var(--dialogue-border, #000);
background: var(--dialogue-input-bg, #f0f0f0);
border-top: 3px solid ${this.navi.theme.border};
background: ${this.navi.theme.backgroundDark};
flex-shrink: 0;
}
@@ -368,13 +322,13 @@ class LlmComponent extends HTMLElement {
.dialogue-input textarea {
width: 100%;
background: var(--dialogue-bg, #fff);
border: 3px solid var(--dialogue-border, #000);
background: ${this.navi.theme.background};
border: 3px solid ${this.navi.theme.border};
padding: 10px 14px;
font-family: 'Courier New', monospace;
font-size: 16px;
border-radius: 4px;
color: var(--dialogue-text, #000);
color: ${this.navi.theme.text};
resize: none;
min-height: 44px;
max-height: 120px;
@@ -384,7 +338,6 @@ class LlmComponent extends HTMLElement {
.dialogue-input textarea:focus {
outline: none;
box-shadow: 0 0 0 2px var(--button-bg, #4a90e2);
}
.dialogue-input textarea:disabled {
@@ -395,78 +348,57 @@ class LlmComponent extends HTMLElement {
.button-row {
display: flex;
gap: 8px;
padding-bottom: 5px;
}
.clear-btn, .attach-btn, .dialogue-send-btn {
background: var(--button-bg, #4a90e2);
border: 3px solid var(--dialogue-border, #000);
color: var(--button-text, #fff);
background: ${this.navi.theme.accent};
border: 3px solid ${this.navi.theme.border};
color: ${this.navi.theme.accentContrast};
padding: 10px 18px;
font-family: 'Courier New', monospace;
font-weight: bold;
font-size: 14px;
border-radius: 4px;
box-shadow: 0 3px 0 var(--button-shadow, #2a5a9a);
box-shadow: 0 3px 0 ${this.navi.theme.accentDark};
transition: background 0.2s;
white-space: nowrap;
flex: 1;
}
.clear-btn {
background: #e74c3c;
box-shadow: 0 3px 0 #c0392b;
}
.clear-btn:active {
transform: translateY(2px);
box-shadow: 0 1px 0 #c0392b;
}
.attach-btn:active, .dialogue-send-btn:active {
transform: translateY(2px);
box-shadow: 0 1px 0 var(--button-shadow, #2a5a9a);
}
.attach-btn:disabled, .dialogue-send-btn:disabled, .clear-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.dialogue-send-btn.stop {
background: #e74c3c;
box-shadow: 0 3px 0 #c0392b;
box-shadow: 0 3px 0 #;
}
.dialogue-send-btn.stop:active {
box-shadow: 0 1px 0 #c0392b;
}
.dialogue-send-btn.skip {
background: #f39c12;
box-shadow: 0 3px 0 #d68910;
}
.dialogue-send-btn.skip:active {
box-shadow: 0 1px 0 #d68910;
}
#file-input {
display: none;
}
.flex-fill-even {
flex: 1 1 0;
}
</style>
<div id="dialogue-box" class="minimized">
<div class="dialogue-content">
<div class="dialogue-header" id="dialogue-header">
<img alt="logo" src="/favicon.png" style="height: 32px; width: auto;">
<button class="expand-btn" id="expand-btn">
<div style="display: flex; align-items: center; gap: 0.5rem">
<img alt="logo" src="${this.navi.icon}" style="height: 32px; width: auto;">
<span style="color: ${this.navi.theme.primaryContrast}; font-size: 1.75rem">${this.navi.info.name}</span>
</div>
<btn-component id="expand-btn" color="${this.navi.theme.accent}">
<svg class="expand-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="3" width="8" height="8" stroke="currentColor" stroke-width="2.5" fill="none"/>
<rect x="13" y="13" width="8" height="8" stroke="currentColor" stroke-width="2.5" fill="none"/>
<path d="M11 3 L11 11 L3 11" stroke="currentColor" stroke-width="2.5" fill="none"/>
<path d="M13 21 L13 13 L21 13" stroke="currentColor" stroke-width="2.5" fill="none"/>
</svg>
</button>
</btn-component>
</div>
<div class="message-body" id="message-body">
<div class="empty-state">NetNavi v1.0.0</div>
@@ -477,19 +409,16 @@ class LlmComponent extends HTMLElement {
<textarea id="dialogue-input" placeholder="Type your message..." rows="1"></textarea>
</div>
<div class="button-row">
<button class="clear-btn" id="clear-btn">CLEAR</button>
<button class="attach-btn" id="attach-btn">ATTACH</button>
<button class="dialogue-send-btn" id="dialogue-send">SEND</button>
<btn-component class="flex-fill-even" id="clear-btn" color="#c0392b">CLEAR</btn-component>
<btn-component class="flex-fill-even" id="attach-btn" color="${this.navi.theme.accent}">ATTACH</btn-component>
<btn-component class="flex-fill-even" id="dialogue-send" color="${this.navi.theme.accent}">SEND</btn-component>
</div>
</div>
<input type="file" id="file-input" multiple accept="*/*">
</div>
</div>
`;
}
initEventListeners() {
const dialogueBox = this.shadowRoot.getElementById('dialogue-box');
const dialogueHeader = this.shadowRoot.getElementById('dialogue-header');
const dialogueInput = this.shadowRoot.getElementById('dialogue-input');
const dialogueSend = this.shadowRoot.getElementById('dialogue-send');
@@ -514,13 +443,11 @@ class LlmComponent extends HTMLElement {
});
dialogueHeader.addEventListener('click', (e) => {
if (e.target === dialogueHeader || e.target.classList.contains('speaker-name')) {
this.toggleDialogue();
}
});
dialogueInput.addEventListener('focus', () => {
if (!this.isDialogueOpen) this.openDialogue();
if(!this.isDialogueOpen) this.openDialogue();
});
clearBtn.addEventListener('click', (e) => {
@@ -559,7 +486,7 @@ class LlmComponent extends HTMLElement {
this.messageHistory = [];
const messageBody = this.shadowRoot.getElementById('message-body');
messageBody.innerHTML = '<div class="empty-state">NetNavi v1.0.0</div>';
this.api.clearLLMHistory();
this.navi.clearChat();
}
toggleExpand() {
@@ -607,7 +534,7 @@ class LlmComponent extends HTMLElement {
container.classList.add('has-files');
container.innerHTML = this.attachedFiles.map((file, i) => `
<div class="attached-file">
<span class="file-name" title="${file.name}">📄 ${file.name}</span>
<span class="file-name" title="${file.name}">${file.name}</span>
<button class="remove-file" data-index="${i}"></button>
</div>
`).join('');
@@ -628,11 +555,6 @@ class LlmComponent extends HTMLElement {
});
}
processMessageForDisplay(text) {
return text.replace(/<file name="([^"]+)">[\s\S]*?<\/file>/g,
'<span class="file-badge">📄 $1</span>');
}
formatTime(date) {
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
@@ -659,16 +581,6 @@ class LlmComponent extends HTMLElement {
}));
}
closeDialogue() {
this.isDialogueOpen = false;
const dialogueBox = this.shadowRoot.getElementById('dialogue-box');
dialogueBox.classList.add('minimized');
this.dispatchEvent(new CustomEvent('dialogue-toggle', {
detail: { isOpen: this.isDialogueOpen }
}));
}
playTextBeep() {
const oscillator = this.audioCtx.createOscillator();
const gainNode = this.audioCtx.createGain();
@@ -709,8 +621,7 @@ class LlmComponent extends HTMLElement {
const cleanText = text.replace(fileRegex, '').trim();
const messageWrapper = document.createElement('div');
messageWrapper.className = `message-wrapper ${isUser ? 'user' : 'assistant'}`;
const timestamp = this.formatTime(new Date());
messageWrapper.className = `message-wrapper ${isUser ? 'user' : 'navi'}`;
const fileBadgesHtml = fileBadges.length > 0
? `<div class="message-files">${fileBadges.map(name =>
@@ -718,14 +629,11 @@ class LlmComponent extends HTMLElement {
: '';
messageWrapper.innerHTML = `
<div class="message-label">${isUser ? 'You' : 'PET'}</div>
${fileBadgesHtml}
<div class="message-bubble">${cleanText}</div>
<div class="message-timestamp">${timestamp}</div>
`;
<div class="message-bubble">${cleanText}</div>`;
messageBody.appendChild(messageWrapper);
this.messageHistory.push({ text, html: cleanText, isUser, element: messageWrapper, timestamp });
this.messageHistory.push({ text, html: cleanText, isUser, element: messageWrapper, timestamp: Date.now() });
this.scrollToBottom();
}
@@ -745,24 +653,20 @@ class LlmComponent extends HTMLElement {
attachBtn.disabled = true;
clearBtn.disabled = true;
dialogueSend.textContent = 'STOP';
dialogueSend.classList.add('stop');
dialogueSend.classList.remove('skip');
dialogueSend.setAttribute('color', '#c0392b');
attachBtn.setAttribute('disabled', true);
clearBtn.setAttribute('disabled', true);
const emptyState = messageBody.querySelector('.empty-state');
if (emptyState) messageBody.innerHTML = '';
const timestamp = this.formatTime(new Date());
const messageWrapper = document.createElement('div');
messageWrapper.className = 'message-wrapper assistant';
messageWrapper.innerHTML = `
<div class="message-label">PET</div>
<div class="message-bubble" id="streaming-bubble"></div>
<div class="message-timestamp">${timestamp}</div>
`;
messageWrapper.className = 'message-wrapper navi';
messageWrapper.innerHTML = `<div class="message-bubble" id="streaming-bubble"></div>`;
messageBody.appendChild(messageWrapper);
this.currentStreamingMessage = { text: '', html: '', isUser: false, element: messageWrapper, timestamp };
this.currentStreamingMessage = { text: '', html: '', isUser: false, element: messageWrapper, timestamp: Date.now() };
this.messageHistory.push(this.currentStreamingMessage);
this.scrollToBottom();
@@ -779,14 +683,12 @@ class LlmComponent extends HTMLElement {
handleStreamComplete(response) {
this.streamComplete = true;
if (this.typingIndex >= this.streamBuffer.length) {
this.cleanupStreaming();
} else {
const dialogueSend = this.shadowRoot.getElementById('dialogue-send');
dialogueSend.textContent = 'SKIP';
dialogueSend.classList.remove('stop');
dialogueSend.classList.add('skip');
dialogueSend.setAttribute('color', '#f39c12');
}
}
@@ -861,7 +763,9 @@ class LlmComponent extends HTMLElement {
attachBtn.disabled = false;
clearBtn.disabled = false;
dialogueSend.textContent = 'SEND';
dialogueSend.classList.remove('stop', 'skip');
dialogueSend.setAttribute('color', this.navi.theme.accent);
attachBtn.removeAttribute('disabled');
clearBtn.removeAttribute('disabled');
if (bubble) {
bubble.id = '';
@@ -913,9 +817,7 @@ class LlmComponent extends HTMLElement {
this.renderAttachedFiles();
// Send via API with streaming callback 💬
this.currentRequest = this.api.sendPetMessage(text, (chunk) => {
this.handleStreamChunk(chunk);
});
this.currentRequest = this.navi.ask(text, (chunk) => this.handleStreamChunk(chunk));
// Handle completion/errors with promise
try {

View File

@@ -5,15 +5,6 @@ const TILE_WIDTH = 64;
const TILE_HEIGHT = 32;
const TILE_DEPTH = 16;
// ============================================
// HAPTIC FEEDBACK
// ============================================
function triggerHaptic() {
if ('vibrate' in navigator) {
navigator.vibrate(10);
}
}
// ============================================
// THEME HANDLER
// ============================================
@@ -183,7 +174,6 @@ function createPet(gridX, gridY, name = 'PET') {
class Game {
constructor() {
this.worldId = '';
this.theme = null;
this.world = null;
this.app = null;
this.pet = null;
@@ -193,57 +183,51 @@ class Game {
this.keys = {};
// Use global singleton 🌍
this.api = window.netNaviAPI;
this.navi = window.navi;
this.worldActions = null;
this.playerInfo = {
name: 'Guest',
apiUrl: this.api.baseUrl
apiUrl: this.navi.navi
};
}
async init() {
try {
// Join world with callbacks 🌍
this.worldActions = this.api.joinWorld(this.worldId, {
onWorldLoaded: (data) => {
this.world = data.world;
this.theme = data.theme;
applyTheme(this.theme);
this.initializeRenderer();
},
await this.navi.init();
this.worldActions = this.navi.connect(this.worldId);
onCurrentPlayers: (players) => {
this.worldActions.onData = (data) => {
this.world = data;
applyTheme(this.world.theme);
this.initializeRenderer();
}
this.worldActions.onPlayers = (players) => {
players.forEach(player => {
if (player.socketId !== this.api.worldSocket.id) {
if (player.name !== this.navi.info.name) {
this.addOtherPlayer(player);
}
});
},
}
onPlayerJoined: (player) => {
this.worldActions.onJoined = (player) => {
this.addOtherPlayer(player);
},
}
onPlayerMoved: (data) => {
this.worldActions.onMoved = (data) => {
const sprite = this.otherPlayers.get(data.socketId);
if (sprite) {
this.moveOtherPlayer(sprite, data.x, data.y);
}
},
};
onPlayerLeft: (data) => {
this.worldActions.onLeft = (data) => {
const sprite = this.otherPlayers.get(data.socketId);
if (sprite) {
this.app.stage.removeChild(sprite);
this.otherPlayers.delete(data.socketId);
if(sprite) this.otherPlayers.delete(data.socketId);
}
},
onError: (error) => {
console.error('❌ World error:', error);
}
});
this.worldActions.onError = (error) => console.error('❌ World error:', error);
console.log('✨ Game initializing...');
} catch (error) {
@@ -299,12 +283,13 @@ class Game {
this.app.stage.addChild(tiles);
this.world.tiles.forEach(tileData => {
const tile = createTile(tileData, this.theme);
const tile = createTile(tileData, this.world.theme);
tile.on('pointerdown', () => this.movePetTo(tile.gridX, tile.gridY));
tiles.addChild(tile);
});
this.pet = createPet(this.world.pet.startX, this.world.pet.startY, this.playerInfo.name);
const spawn = this.world.tiles.find(t => t.type === 'spawn');
this.pet = createPet(spawn.x, spawn.y, this.playerInfo.name);
this.app.stage.addChild(this.pet);
this.dialogue = document.getElementById('llm');

View File

@@ -12,9 +12,7 @@
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
box-sizing: border-box !important;
}
html, body {
@@ -22,6 +20,8 @@
height: 100%;
overflow: hidden;
font-family: 'Courier New', monospace;
margin: 0;
padding: 0;
}
*, button, input {
@@ -48,11 +48,12 @@
<jukebox-component id="jukebox"></jukebox-component>
<llm-component id="llm"></llm-component>
<script src="/netnavi-api.js"></script>
<script>window.netNaviAPI = new NetNaviAPI();</script>
<script src="/jukebox.js"></script>
<script src="/llm.js"></script>
<script src="/world.js"></script>
<script type="module">
import Navi from './navi.mjs';
window.navi = new Navi();
</script>
<script type="module" src="/components/jukebox.mjs"></script>
<script type="module" src="/components/llm.mjs"></script>
<script type="module" src="/components/world.mjs"></script>
</body>
</html>

254
public/navi.mjs Normal file
View File

@@ -0,0 +1,254 @@
export default 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');
}
}

View File

@@ -1,304 +0,0 @@
class NetNaviAPI {
constructor(baseUrl = window.location.origin) {
if(window.netNaviAPI) return window.netNaviAPI;
this.baseUrl = baseUrl.replace(/\/$/, '');
// State properties
this.petInfo = null;
this.spriteSheet = null;
this.isConnected = false;
this.lastSync = null;
this.currentWorld = null;
this.currentWorldHost = null;
this.currentPlayers = new Map();
// Socket
this.worldSocket = null;
this.llmSocket = io(`${this.baseUrl}/llm`);
this._setupLLMListeners();
this.listeners = new Map();
}
_setupLLMListeners() {
this.llmSocket.on('stream', (chunk) => {
this.emit('llm:stream', chunk);
if (this.currentStreamCallback) this.currentStreamCallback(chunk);
});
this.llmSocket.on('response', (data) => {
this.emit('llm:response', data);
if (this.currentResolve) this.currentResolve(data);
this.currentStreamCallback = null;
this.currentResolve = null;
this.currentReject = null;
});
this.llmSocket.on('error', (error) => {
console.error('❌ LLM socket error:', error);
this.emit('llm:error', error);
if (this.currentReject) this.currentReject(error);
this.currentStreamCallback = null;
this.currentResolve = null;
this.currentReject = null;
});
}
// ============================================
// 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 getPetInfo(petId, forceRefresh = false) {
if (this.petInfo && !forceRefresh) {
return this.petInfo;
}
const response = await fetch(`${this.baseUrl}/api/info`);
if (!response.ok) throw new Error('Failed to fetch PET info fr fr');
this.petInfo = await response.json();
this.lastSync = Date.now();
this.emit('petInfo:updated', this.petInfo);
return this.petInfo;
}
async getSpriteSheet(petId, forceRefresh = false) {
if (this.spriteSheet && !forceRefresh) {
return this.spriteSheet;
}
const response = await fetch(`${this.baseUrl}/api/sprite`);
if (!response.ok) throw new Error('Sprite sheet is cooked 💀');
this.spriteSheet = await response.json();
this.emit('sprite:updated', this.spriteSheet);
return this.spriteSheet;
}
async sendUserMessage(userId, message) {
const response = await fetch(`${this.baseUrl}/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.baseUrl}/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
// ============================================
joinWorld(worldId, callbacks = {}) {
// Parse the worldId to check if it includes a host
let targetHost = this.baseUrl;
let actualWorldId = worldId;
// Check if worldId is a URL (like "http://other-server.com/worldName")
if(worldId?.startsWith('http://') || worldId?.startsWith('https://')) {
const url = new URL(worldId);
targetHost = url.origin;
actualWorldId = url.pathname.replace(/^\//, '') || 'default';
}
// Leave current world first if we're switching 🚪
if (this.worldSocket && this.currentWorld) {
this.worldSocket.emit('player-leave'); // Let server know we're bouncing
if (this.currentWorldHost !== targetHost) {
this.worldSocket.disconnect();
this.worldSocket = null;
}
}
// Initialize world socket for the target host 🔌
if (!this.worldSocket) {
this.worldSocket = io(targetHost);
this.currentWorldHost = targetHost;
this.isConnected = true;
this.emit('connection:changed', true);
}
this.currentWorld = actualWorldId;
// Auto-build playerInfo from state 💪
const playerInfo = {
name: this.petInfo?.name || 'Guest',
apiUrl: this.baseUrl
};
// Setup socket listeners with provided callbacks 📡
this.worldSocket.on('world-data', (data) => {
this.emit('world:loaded', data);
if (callbacks.onWorldLoaded) callbacks.onWorldLoaded(data);
});
this.worldSocket.on('current-players', (players) => {
this.currentPlayers.clear();
players.forEach(p => this.currentPlayers.set(p.socketId, p));
this.emit('world:currentPlayers', players);
if (callbacks.onCurrentPlayers) callbacks.onCurrentPlayers(players);
});
this.worldSocket.on('player-joined', (player) => {
this.currentPlayers.set(player.socketId, player);
this.emit('world:playerJoined', player);
if (callbacks.onPlayerJoined) callbacks.onPlayerJoined(player);
});
this.worldSocket.on('player-moved', (data) => {
const player = this.currentPlayers.get(data.socketId);
if (player) {
player.x = data.x;
player.y = data.y;
}
this.emit('world:playerMoved', data);
if (callbacks.onPlayerMoved) callbacks.onPlayerMoved(data);
});
this.worldSocket.on('player-left', (data) => {
this.currentPlayers.delete(data.socketId);
this.emit('world:playerLeft', data);
if (callbacks.onPlayerLeft) callbacks.onPlayerLeft(data);
});
this.worldSocket.on('error', (error) => {
console.error('❌ World socket error:', error);
this.emit('world:error', error);
if (callbacks.onError) callbacks.onError(error);
});
// Join the world 🌍
this.worldSocket.emit('join-world', { worldId: actualWorldId, playerInfo });
// Return actions dictionary for sending updates 📤
return {
move: (x, y) => {
this.worldSocket.emit('player-move', { x, y });
},
leave: () => {
this.worldSocket.disconnect();
this.worldSocket = null;
this.currentWorld = null;
this.currentWorldHost = null;
this.currentPlayers.clear();
this.isConnected = false;
this.emit('connection:changed', false);
},
reconnect: () => {
if (!this.worldSocket || !this.worldSocket.connected) {
this.worldSocket = io(this.currentWorldHost || targetHost);
this.worldSocket.emit('join-world', { worldId: actualWorldId, playerInfo });
this.isConnected = true;
this.emit('connection:changed', true);
}
}
};
}
// ============================================
// LLM SOCKET
// ============================================
sendPetMessage(message, onStreamCallback) {
this.currentStreamCallback = onStreamCallback;
const promise = new Promise((resolve, reject) => {
this.currentResolve = resolve;
this.currentReject = reject;
this.llmSocket.emit('message', {message, apiUrl: this.baseUrl});
});
promise.abort = () => {
this.llmSocket.emit('abort');
if(this.currentReject) this.currentReject(new Error('Aborted by user'));
this.currentStreamCallback = null;
this.currentResolve = null;
this.currentReject = null;
};
return promise;
}
clearLLMHistory() {
if(this.llmSocket) {
this.llmSocket.emit('clear');
this.emit('llm:cleared');
}
}
disconnectLLM() {
if(this.llmSocket) {
this.llmSocket.disconnect();
this.llmSocket = null;
}
}
// ============================================
// UTILITY
// ============================================
getState() {
return {
petInfo: this.petInfo,
spriteSheet: this.spriteSheet,
isConnected: this.isConnected,
lastSync: this.lastSync,
currentWorld: this.currentWorld,
currentPlayers: Array.from(this.currentPlayers.values())
};
}
disconnect() {
if (this.worldSocket) {
this.worldSocket.disconnect();
this.worldSocket = null;
}
if (this.llmSocket) {
this.llmSocket.disconnect();
this.llmSocket = null;
}
this.isConnected = false;
this.emit('connection:changed', false);
}
}

View File

@@ -6,21 +6,61 @@ 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, shadeColor} from '@ztimson/utils';
// ============================================
// Settings
// ============================================
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
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');
let updated = false;
const app = express();
const httpServer = createServer(app);
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 memories = [], settings = {};
const settingsFile = join(__dirname, '../navi', 'settings.json');
const memoriesFile = join(__dirname, '../navi', 'memories.json');
const logoFile = join(__dirname, '../navi', 'logo.png');
let memories = [], settings = {
name: 'Navi',
personality: 'You are inquisitive about your user trying to best adjust your personally to fit them',
instructions: '',
theme: {
background: '#fff',
border: '#000',
text: '#252525',
primary: '#9f32ef',
accent: '#6f16c3',
muted: '#a8a8a8',
}
};
function load() {
try {
settings = JSON.parse(fs.readFileSync(settingsFile, 'utf-8'));
settings = {
...settings,
...JSON.parse(fs.readFileSync(settingsFile, 'utf-8'))
};
} catch { }
try {
memories = JSON.parse(fs.readFileSync(memoriesFile, 'utf-8'));
@@ -28,6 +68,9 @@ function load() {
}
function save() {
if(!updated) return;
updated = false;
const dir = dirname(settingsFile);
if (!fs.existsSync(dir)) {
fs.mkdir(dir, { recursive: true }, (err) => {
@@ -38,11 +81,6 @@ function save() {
fs.writeFileSync(memoriesFile, JSON.stringify(memories, null, 2));
}
function shutdown() {
save();
process.exit(0);
}
load();
const ai = new Ai({
llm: {
@@ -56,13 +94,19 @@ const ai = new Ai({
args: {instructions: {type: 'string', description: 'Bullet point list of how to behave'}},
fn: (args) => {
settings.personality = args.instructions;
save();
updated = true;
return 'done!';
}
}],
},
});
// ============================================
// Setup
// ============================================
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: {origin: "*", methods: ["GET", "POST"]}
});
@@ -72,39 +116,92 @@ app.use(express.json());
app.use(express.static('public'));
// ============================================
// WORLD MANAGEMENT
// Primary Socket
// ============================================
const worldPlayers = new Map();
const chatHistory = new Map();
// Load world data
function loadWorld(worldId) {
try {
const worldPath = join(__dirname, '../worlds', worldId || '', 'world.json');
const world = JSON.parse(fs.readFileSync(worldPath, 'utf-8'));
const themePath = join(__dirname, '../worlds', worldId || '', world.theme);
const theme = JSON.parse(fs.readFileSync(themePath, 'utf-8'));
worldPlayers.set(worldId, new Map());
return {world, theme};
} catch (error) {
console.error(`Failed to load world ${worldId}:`, error);
return null;
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,
stream: (chunk) => socket.emit('llm-stream', chunk)
}).then(resp => {
chatHistory.set(socket.id, history);
socket.emit('llm-response', { message: resp });
updated = true;
}).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);
});
});
// ============================================
// SOCKET.IO - WORLD CHANNELS
// World Socket
// ============================================
io.on('connection', (socket) => {
console.debug('🔌 Client connected:', socket.id);
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-world', (data) => {
const { worldId, playerInfo } = data;
const worldData = loadWorld(worldId);
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
@@ -113,92 +210,51 @@ io.on('connection', (socket) => {
const players = worldPlayers.get(currentWorld);
if(players) {
players.delete(socket.id);
socket.to(`world:${currentWorld}`).emit('player-left', {socketId: socket.id});
socket.to(`world:${currentWorld}`).emit('left', {socketId: socket.id});
}
}
// Join new world
currentWorld = worldId;
currentWorld = world;
const spawn = worldData.tiles.find(t => t.type === 'spawn');
playerData = {
...info,
socketId: socket.id,
name: playerInfo.name,
apiUrl: playerInfo.apiUrl,
x: worldData.world.pet.startX,
y: worldData.world.pet.startY
navi: api,
x: spawn.x,
y: spawn.y
};
socket.join(`world:${worldId}`);
const players = worldPlayers.get(worldId);
socket.join(`world:${world}`);
const players = worldPlayers.get(world);
players.set(socket.id, playerData);
socket.emit('world-data', worldData);
socket.emit('data', worldData);
const currentPlayers = Array.from(players.values());
socket.emit('current-players', currentPlayers);
socket.to(`world:${worldId}`).emit('player-joined', playerData);
socket.emit('players', currentPlayers);
socket.to(`world:${world}`).emit('joined', playerData);
});
// Player movement
socket.on('player-move', (data) => {
socket.on('move', (data) => {
if(!currentWorld || !playerData) return;
const { x, y } = data;
playerData.x = x;
playerData.y = y;
socket.to(`world:${currentWorld}`).emit('player-moved', {socketId: socket.id, x, y});
socket.to(`world:${currentWorld}`).emit('moved', {socketId: socket.id, x, y});
});
// Disconnect
socket.on('disconnect', () => {
console.debug('🔌 Client disconnected:', socket.id);
console.debug('🌍 Navi disconnected:', socket.id);
if(currentWorld) {
const players = worldPlayers.get(currentWorld);
if(players) {
players.delete(socket.id);
socket.to(`world:${currentWorld}`).emit('player-left', {socketId: socket.id});
socket.to(`world:${currentWorld}`).emit('left', {socketId: socket.id});
}
}
});
});
// ============================================
// LLM CHANNEL
// ============================================
const petNamespace = io.of('/llm');
petNamespace.on('connection', (socket) => {
chatHistory.set(socket.id, []);
let currentRequest = null;
socket.on('clear', async () => {
chatHistory.set(socket.id, []);
});
socket.on('abort', () => {
if (currentRequest?.abort) {
currentRequest.abort();
currentRequest = null;
}
});
socket.on('message', async (data) => {
const { message, apiUrl } = data;
const history = chatHistory.get(socket.id);
currentRequest = ai.language.ask(message, {
history,
memory: memories,
stream: (chunk) => socket.emit('stream', chunk)
}).then(resp => {
chatHistory.set(socket.id, history);
socket.emit('response', { message: resp });
}).catch(err => {
socket.emit('error', {message: err.message || err.toString()});
}).finally(() => {
currentRequest = null;
});
});
socket.on('disconnect', () => {
console.log('🔌 LLM Client disconnected:', socket.id);
chatHistory.delete(socket.id);
});
});
// ============================================
// REST API ENDPOINTS
// ============================================
@@ -207,20 +263,11 @@ app.get('/favicon.*', (req, res) => {
res.sendFile(logoFile);
});
// Get PET info
// Get Navi info
app.get('/api/info', (req, res) => {
const { petId } = req.params;
// TODO: Fetch from database
res.json({
id: petId,
name: 'MyCoolPET',
owner: 'player1',
bandwidth: 75,
shards: [],
stats: {
level: 5,
health: 100
}
name: settings.name,
theme: calcColors(settings.theme),
});
});
@@ -268,11 +315,15 @@ app.post('/api/link', (req, res) => {
// ============================================
const PORT = process.env.PORT || 3000;
setInterval(() => save(), 5 * 60_000);
httpServer.listen(PORT, () => {
loadWorld();
console.log('✅ Home world loaded');
console.log(`🚀 Server running on: http://localhost:${PORT}`);
});
function shutdown() {
save();
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);

View File

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

View File

@@ -0,0 +1,26 @@
{
"name": "Evas Glade",
"version": "1.0.0",
"type": "theme",
"icon": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 640 640\"><path d=\"M535.3 70.7C541.7 64.6 551 62.4 559.6 65.2C569.4 68.5 576 77.7 576 88L576 274.9C576 406.1 467.9 512 337.2 512C260.2 512 193.8 462.5 169.7 393.3C134.3 424.1 112 469.4 112 520C112 533.3 101.3 544 88 544C74.7 544 64 533.3 64 520C64 445.1 102.2 379.1 160.1 340.3C195.4 316.7 237.5 304 280 304L360 304C373.3 304 384 293.3 384 280C384 266.7 373.3 256 360 256L280 256C240.3 256 202.7 264.8 169 280.5C192.3 210.5 258.2 160 336 160C402.4 160 451.8 137.9 484.7 116C503.9 103.2 520.2 87.9 535.4 70.7z\"/></svg>",
"background": {
"image": "/assets/background.jpg",
"style": "cover"
},
"music": ["/assets/music.mp3"],
"colors": {
"tileTop": "#7a5a8c",
"tileSide": "#4a2d5a",
"tileHighlight": "#a17acf",
"gridColor": "#5e2f6a",
"gridHighlight": "#ff75b5",
"background": "#e6e6fa",
"border": "#000000",
"text": "#2d2524",
"dialogueInputBg": "#f0e6ff",
"primary": "#aa33ff",
"accent": "#8a2be2",
"muted": "#ff75b5"
}
}

View File

@@ -1,7 +1,8 @@
{
"name": "Home World",
"version": "1.0.0",
"theme": "./theme.json",
"theme": "theme_default",
"gridSize": 8,
"tiles": [
{"x": 0, "y": 0, "type": "floor"},
@@ -40,7 +41,7 @@
{"x": 1, "y": 4, "type": "floor"},
{"x": 2, "y": 4, "type": "floor"},
{"x": 3, "y": 4, "type": "floor"},
{"x": 4, "y": 4, "type": "floor"},
{"x": 4, "y": 4, "type": "spawn"},
{"x": 5, "y": 4, "type": "floor"},
{"x": 6, "y": 4, "type": "floor"},
{"x": 7, "y": 4, "type": "floor"},
@@ -68,9 +69,5 @@
{"x": 5, "y": 7, "type": "floor"},
{"x": 6, "y": 7, "type": "floor"},
{"x": 7, "y": 7, "type": "floor"}
],
"pet": {
"startX": 4,
"startY": 4
}
]
}

View File

@@ -1,27 +0,0 @@
{
"name": "Evas Glade 🌿",
"version": "1.0.0",
"type": "theme",
"background": {
"image": "/assets/background.jpg",
"style": "cover"
},
"music": "/assets/music.mp3",
"colors": {
"tileTop": "#7a5a8c",
"tileSide": "#4a2d5a",
"tileHighlight": "#a17acf",
"gridColor": "#5e2f6a",
"gridHighlight": "#ff75b5",
"dialogueBg": "#e6e6fa",
"dialogueBorder": "#000000",
"dialogueHeaderBg": "#aa33ff",
"dialogueInputBg": "#f0e6ff",
"dialogueText": "#2d2524",
"buttonBg": "#8a2be2",
"buttonText": "#ffffff",
"buttonShadow": "#5a3a7d",
"muteButtonBg": "#ff75b5",
"muteButtonBorder": "#a17acf"
}
}