generated from ztimson/template
This commit is contained in:
44
.github/workflows/buid.yaml
vendored
Normal file
44
.github/workflows/buid.yaml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: Build and publish
|
||||
run-name: Build and publish
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
jobs:
|
||||
container:
|
||||
name: Build Container
|
||||
runs-on: ubuntu-latest
|
||||
container: docker
|
||||
steps:
|
||||
- name: Build Container
|
||||
run: |
|
||||
git clone -b "${{github.ref_name}}" "$(echo ${{github.server_url}}/${{github.repository}}.git | sed s%://%://${{github.token}}@% )" .
|
||||
DOCKER_HUB=$([ -n "${{secrets.DOCKER_HUB_USER}}" ] && [ -n "${{secrets.DOCKER_HUB_TOKEN}}" ] && [ -n "${{secrets.DOCKER_HUB_IMAGE}}" ] && echo "true" || echo "false")
|
||||
REGISTRY="$(echo "${{github.server_url}}" | sed -E 's|https?://||')"
|
||||
VERSION=$(cat package.json | grep version | grep -Eo '[0-9][[:alnum:]\.\/\-]+')
|
||||
|
||||
docker login -u "${{github.repository_owner}}" -p "${{secrets.DEPLOY_TOKEN}}" "$REGISTRY"
|
||||
if [ "$DOCKER_HUB" = "true" ]; then docker login -u "${{secrets.DOCKER_HUB_USER}}" -p "${{secrets.DOCKER_HUB_TOKEN}}" docker.io; fi
|
||||
|
||||
docker build -t "$REGISTRY/${{github.repository}}:${{github.ref_name}}" .
|
||||
docker push "$REGISTRY/${{github.repository}}:${{github.ref_name}}"
|
||||
if [ "$DOCKER_HUB" = "true" ]; then
|
||||
docker tag "$REGISTRY/${{github.repository}}:${{github.ref_name}}" "docker.io/${{secrets.DOCKER_HUB_IMAGE}}:${{github.ref_name}}"
|
||||
docker push "docker.io/${{secrets.DOCKER_HUB_IMAGE}}:${{github.ref_name}}"
|
||||
fi
|
||||
|
||||
if [ "${{github.ref_name}}" = "master" ]; then
|
||||
docker tag "$REGISTRY/${{github.repository}}:${{github.ref_name}}" "$REGISTRY/${{github.repository}}:$VERSION"
|
||||
docker push "$REGISTRY/${{github.repository}}:$VERSION"
|
||||
if [ "$DOCKER_HUB" = "true" ]; then
|
||||
docker tag "$REGISTRY/${{github.repository}}:${{github.ref_name}}" "docker.io/${{secrets.DOCKER_HUB_IMAGE}}:$VERSION"
|
||||
docker push "docker.io/${{secrets.DOCKER_HUB_IMAGE}}:$VERSION"
|
||||
fi
|
||||
|
||||
docker tag "$REGISTRY/${{github.repository}}:${{github.ref_name}}" "$REGISTRY/${{github.repository}}:latest"
|
||||
docker push "$REGISTRY/${{github.repository}}:latest"
|
||||
if [ "$DOCKER_HUB" = "true" ]; then
|
||||
docker tag "$REGISTRY/${{github.repository}}:${{github.ref_name}}" "docker.io/${{secrets.DOCKER_HUB_IMAGE}}:latest"
|
||||
docker push "docker.io/${{secrets.DOCKER_HUB_IMAGE}}:latest"
|
||||
fi
|
||||
fi
|
||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# IDEs
|
||||
.idea
|
||||
.proxyai
|
||||
.vscode
|
||||
|
||||
# Artifacts
|
||||
**/dist
|
||||
**/node_modules
|
||||
coverage
|
||||
navi/*.json
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
|
||||
# Databases
|
||||
*.db
|
||||
*.db3
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
dump
|
||||
|
||||
# Environment files
|
||||
*.local
|
||||
11
Dockerfile
Normal file
11
Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
|
||||
FROM node:22-alpine
|
||||
|
||||
ENV PORT=80
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
RUN npm ci
|
||||
|
||||
CMD ["npm", "run", "start"]
|
||||
76
README.md
76
README.md
@@ -3,36 +3,35 @@
|
||||
<br />
|
||||
|
||||
<!-- Logo -->
|
||||
<img src="https://git.zakscode.com/repo-avatars/2b4ee6ba1f2e2618bf7694e4a52fb56d1d0ea6abafa2dcbe496ab786b86d5a76" alt="Logo" width="200" height="200">
|
||||
<img src="navi/logo.png" alt="Logo" width="200" height="200">
|
||||
|
||||
<!-- Title -->
|
||||
### Template
|
||||
### Net Navi
|
||||
|
||||
<!-- Description -->
|
||||
Simple repository template
|
||||
Your personal AI assistant
|
||||
|
||||
<!-- Repo badges -->
|
||||
[](https://git.zakscode.com/ztimson/template/tags)
|
||||
[](https://git.zakscode.com/ztimson/template/pulls)
|
||||
[](https://git.zakscode.com/ztimson/template/issues)
|
||||
[](https://git.zakscode.com/ztimson/navi/tags)
|
||||
[](https://git.zakscode.com/ztimson/navi/pulls)
|
||||
[](https://git.zakscode.com/ztimson/navi/issues)
|
||||
|
||||
<!-- Links -->
|
||||
|
||||
---
|
||||
<div>
|
||||
<a href="https://git.zakscode.com/ztimson/template/wiki" target="_blank">Documentation</a>
|
||||
• <a href="https://git.zakscode.com/ztimson/template/releases" target="_blank">Release Notes</a>
|
||||
• <a href="https://git.zakscode.com/ztimson/template/issues/new?template=.github%2fissue_template%2fbug.md" target="_blank">Report a Bug</a>
|
||||
• <a href="https://git.zakscode.com/ztimson/template/issues/new?template=.github%2fissue_template%2fenhancement.md" target="_blank">Request a Feature</a>
|
||||
<a href="https://git.zakscode.com/ztimson/navi/wiki" target="_blank">Documentation</a>
|
||||
• <a href="https://git.zakscode.com/ztimson/navi/releases" target="_blank">Release Notes</a>
|
||||
• <a href="https://git.zakscode.com/ztimson/navi/issues/new?template=.github%2fissue_template%2fbug.md" target="_blank">Report a Bug</a>
|
||||
• <a href="https://git.zakscode.com/ztimson/navi/issues/new?template=.github%2fissue_template%2fenhancement.md" target="_blank">Request a Feature</a>
|
||||
</div>
|
||||
|
||||
---
|
||||
</div>
|
||||
|
||||
## Table of Contents
|
||||
- [Template](#top)
|
||||
- [Net Navi](#top)
|
||||
- [About](#about)
|
||||
- [Demo](#demo)
|
||||
- [Built With](#built-with)
|
||||
- [Setup](#setup)
|
||||
- [Production](#production)
|
||||
@@ -41,56 +40,7 @@
|
||||
|
||||
## About
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
|
||||
|
||||
### Demo
|
||||
|
||||
Website: https://git.zakscode.com
|
||||
|
||||
### Built With
|
||||
[](https://angular.io/)
|
||||
[](https://www.android.com/)
|
||||
[](https://www.arduino.cc/)
|
||||
[](https://getbootstrap.com)
|
||||
[](https://en.cppreference.com/w/c/language)
|
||||
[](https://cplusplus.com/)
|
||||
[](https://dotnet.microsoft.com/)
|
||||
[](https://www.w3.org/Style/CSS/Overview.en.html)
|
||||
[](https://www.djangoproject.com/)
|
||||
[](https://docker.com/)
|
||||
[](https://www.electronjs.org/)
|
||||
[](https://firebase.google.com/)
|
||||
[](https://go.dev/)
|
||||
[](https://graphql.org/)
|
||||
[](https://developer.mozilla.org/en-US/docs/Glossary/HTML)
|
||||
[](https://java.com/)
|
||||
[](https://javascript.com/)
|
||||
[](https://jquery.com )
|
||||
[](https://laravel.com)
|
||||
[](https://www.linux.org/)
|
||||
[](https://git.zakscode.com/ztimson/momentum)
|
||||
[](https://www.mongodb.com/)
|
||||
[](https://www.mysql.com/)
|
||||
[](https://nestjs.com/)
|
||||
[](https://dotnet.microsoft.com/)
|
||||
[](https://nextjs.org/)
|
||||
[](https://www.nginx.com/)
|
||||
[](https://nodejs.org/)
|
||||
[](https://p5js.org/)
|
||||
[](https://www.php.net/)
|
||||
[](https://www.postgresql.org/)
|
||||
[](https://www.python.org/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://redis.com/)
|
||||
[](https://sass-lang.com/)
|
||||
[](https://en.wikipedia.org/wiki/Shell_script)
|
||||
[](https://www.microsoft.com/en-ca/sql-server)
|
||||
[](https://www.sqlite.org/index.html)
|
||||
[](https://svelte.dev/)
|
||||
[](https://typescriptlang.org/)
|
||||
[](https://microsoft.com/windows)
|
||||
[](https://vitejs.dev/)
|
||||
[](https://vuejs.org/)
|
||||
Your personal AI network navigator
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -105,7 +55,7 @@ Website: https://git.zakscode.com
|
||||
- [Docker](https://docs.docker.com/install/)
|
||||
|
||||
#### Instructions
|
||||
1. Run the docker image: `docker run -p 80:80 git.zakscode.com/ztimson/template:latest`
|
||||
1. Run the docker image: `docker run -p 80:80 git.zakscode.com/ztimson/navi:latest`
|
||||
2. Open [http://localhost](http://localhost)
|
||||
</details>
|
||||
|
||||
|
||||
BIN
navi/logo.png
Normal file
BIN
navi/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
2885
package-lock.json
generated
Normal file
2885
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
package.json
Normal file
14
package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@ztimson/net-navi",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@ztimson/ai-utils": "^0.8.4",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"socket.io": "^4.6.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node src/server.js"
|
||||
}
|
||||
}
|
||||
BIN
public/assets/background.jpg
Normal file
BIN
public/assets/background.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
BIN
public/assets/cursor.png
Normal file
BIN
public/assets/cursor.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
BIN
public/assets/forest.png
Normal file
BIN
public/assets/forest.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
BIN
public/assets/music.mp3
Normal file
BIN
public/assets/music.mp3
Normal file
Binary file not shown.
55
public/index.html
Normal file
55
public/index.html
Normal file
@@ -0,0 +1,55 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>NetNavi v1.0.0</title>
|
||||
<link rel="icon" href="/favicon.png" />
|
||||
|
||||
<script src="https://cdn.socket.io/4.6.0/socket.io.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pixi.js/7.3.2/pixi.min.js"></script>
|
||||
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
cursor: url('/cursor.png'), auto !important;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
button, input {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
body {
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
267
public/jukebox.js
Normal file
267
public/jukebox.js
Normal file
@@ -0,0 +1,267 @@
|
||||
class JukeboxComponent extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
|
||||
// Use global singleton 🎵
|
||||
this.api = window.netNaviAPI;
|
||||
|
||||
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() {
|
||||
// Cleanup listeners when component is removed
|
||||
if (this.unsubscribeWorld) this.unsubscribeWorld();
|
||||
}
|
||||
|
||||
setupAPIListeners() {
|
||||
// Listen for world loaded events to auto-load music 🎧
|
||||
this.unsubscribeWorld = this.api.on('world:loaded', (data) => {
|
||||
console.log('🎵 Jukebox detected world loaded:', data.theme.name);
|
||||
if (data.theme?.music) {
|
||||
this.loadMusic(data.theme.music, data.theme);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host { display: block; }
|
||||
.audio-controls {
|
||||
position: fixed;
|
||||
display: flex;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: var(--dialogue-header-bg, #ffffff);
|
||||
border: 3px solid var(--dialogue-border, #000);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
|
||||
min-width: 200px;
|
||||
}
|
||||
.track-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--dialogue-bg, #8b5cf6);
|
||||
color: var(--dialogue-text, #ffffff);
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
margin: 0 8px;
|
||||
overflow: hidden;
|
||||
height: 24px;
|
||||
width: 120px;
|
||||
position: relative;
|
||||
}
|
||||
.track-name {
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
padding-left: 100%;
|
||||
animation: marquee 10s linear infinite;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
@keyframes marquee {
|
||||
0% { transform: translateX(0); }
|
||||
100% { transform: translateX(-100%); }
|
||||
}
|
||||
.controls-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.control-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: var(--button-bg, #6366f1);
|
||||
border: 2px solid var(--dialogue-border, #000);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
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">
|
||||
<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>
|
||||
|
||||
<div class="audio-controls hidden" id="playlist-controls">
|
||||
<div class="controls-row">
|
||||
<button class="control-btn" id="prev-btn">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<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">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="control-btn" id="mute-btn">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.shadowRoot.getElementById('simple-mute-btn').addEventListener('click', () => this.toggleMute());
|
||||
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());
|
||||
}
|
||||
|
||||
loadMusic(musicConfig, theme) {
|
||||
if (!musicConfig) return;
|
||||
|
||||
this.theme = theme;
|
||||
this.isPlaylistMode = Array.isArray(musicConfig) && musicConfig.length > 1;
|
||||
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');
|
||||
} else {
|
||||
this.shadowRoot.getElementById('simple-mute-btn').classList.remove('hidden');
|
||||
this.shadowRoot.getElementById('playlist-controls').classList.add('hidden');
|
||||
}
|
||||
|
||||
this.loadTrack(this.currentTrackIndex);
|
||||
}
|
||||
|
||||
loadTrack(index) {
|
||||
if (this.bgMusic) {
|
||||
this.bgMusic.pause();
|
||||
this.bgMusic = null;
|
||||
}
|
||||
|
||||
if (index >= 0 && index < this.playlist.length) {
|
||||
this.bgMusic = new Audio(this.playlist[index]);
|
||||
this.bgMusic.volume = 0.5;
|
||||
|
||||
if (this.isPlaylistMode) {
|
||||
this.bgMusic.addEventListener('ended', () => {
|
||||
this.currentTrackIndex = (this.currentTrackIndex + 1) % this.playlist.length;
|
||||
this.loadTrack(this.currentTrackIndex);
|
||||
});
|
||||
this.updateTrackDisplay();
|
||||
} else {
|
||||
this.bgMusic.loop = true;
|
||||
}
|
||||
|
||||
this.setupAutoplayHandler();
|
||||
|
||||
if (this.hasInteracted && !this.isMuted) {
|
||||
this.bgMusic.play();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateTrackDisplay() {
|
||||
const trackName = this.shadowRoot.getElementById('track-name');
|
||||
const fileName = this.playlist[this.currentTrackIndex].split('/').pop();
|
||||
const trackNum = String(this.currentTrackIndex + 1).padStart(2, '0');
|
||||
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) {
|
||||
this.hasInteracted = true;
|
||||
this.bgMusic.play();
|
||||
}
|
||||
};
|
||||
const interactionEvents = ['click', 'keydown', 'touchstart'];
|
||||
interactionEvents.forEach(event => {
|
||||
document.addEventListener(event, startMusic, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
previousTrack() {
|
||||
if (!this.isPlaylistMode) return;
|
||||
this.currentTrackIndex = (this.currentTrackIndex - 1 + this.playlist.length) % this.playlist.length;
|
||||
this.loadTrack(this.currentTrackIndex);
|
||||
}
|
||||
|
||||
nextTrack() {
|
||||
if (!this.isPlaylistMode) return;
|
||||
this.currentTrackIndex = (this.currentTrackIndex + 1) % this.playlist.length;
|
||||
this.loadTrack(this.currentTrackIndex);
|
||||
}
|
||||
|
||||
toggleMute() {
|
||||
this.isMuted = !this.isMuted;
|
||||
const simpleMuteBtn = this.shadowRoot.getElementById('simple-mute-btn');
|
||||
const muteBtn = this.shadowRoot.getElementById('mute-btn');
|
||||
|
||||
const mutedSVG = '<svg viewBox="0 0 24 24"><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/></svg>';
|
||||
const unmutedSVG = '<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>';
|
||||
|
||||
if (this.bgMusic) {
|
||||
if (this.isMuted) {
|
||||
this.bgMusic.pause();
|
||||
simpleMuteBtn.innerHTML = mutedSVG;
|
||||
muteBtn.innerHTML = mutedSVG;
|
||||
} else {
|
||||
if (!this.hasInteracted) this.hasInteracted = true;
|
||||
this.bgMusic.play();
|
||||
simpleMuteBtn.innerHTML = unmutedSVG;
|
||||
muteBtn.innerHTML = unmutedSVG;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('jukebox-component', JukeboxComponent);
|
||||
938
public/llm.js
Normal file
938
public/llm.js
Normal file
@@ -0,0 +1,938 @@
|
||||
class LlmComponent extends HTMLElement {
|
||||
hideTools = ['adjust_personality', 'recall', 'remember']
|
||||
|
||||
get isOpen() { return this.isDialogueOpen; };
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
|
||||
// Use global singleton 🔥
|
||||
this.api = window.netNaviAPI;
|
||||
|
||||
this.isTyping = false;
|
||||
this.isReceiving = false;
|
||||
this.streamComplete = false;
|
||||
this.isDialogueOpen = false;
|
||||
this.isExpanded = false;
|
||||
this.messageHistory = [];
|
||||
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
this.streamBuffer = '';
|
||||
this.typingIndex = 0;
|
||||
this.typingInterval = null;
|
||||
this.currentRequest = null;
|
||||
this.attachedFiles = [];
|
||||
this.currentStreamingMessage = null;
|
||||
|
||||
this.render();
|
||||
this.initEventListeners();
|
||||
}
|
||||
|
||||
render() {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--dialogue-header-bg, #fff);
|
||||
border: 2px solid var(--dialog-border, #000);
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#dialogue-box {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 600px;
|
||||
max-width: 90vw;
|
||||
height: 600px;
|
||||
transition: width 0.3s ease-out, height 0.3s ease-out, max-width 0.3s ease-out, left 0.3s ease-out, transform 0.3s ease-out;
|
||||
transform-origin: bottom center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
#dialogue-box.minimized {
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
#dialogue-box.expanded {
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
height: 100vh;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.dialogue-content {
|
||||
background: var(--dialogue-bg, #fff);
|
||||
border: 5px solid var(--dialogue-border, #000);
|
||||
border-radius: 16px 16px 0 0;
|
||||
padding: 0;
|
||||
box-shadow: 0 -4px 20px rgba(0,0,0,0.5);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 600px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#dialogue-box.expanded .dialogue-content {
|
||||
max-height: 100vh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.dialogue-header {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding: 12px 20px;
|
||||
background: var(--dialogue-header-bg, #fff);
|
||||
border-bottom: 3px solid var(--dialogue-border, #000);
|
||||
border-radius: 12px 12px 0 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#dialogue-box.expanded .dialogue-header {
|
||||
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;
|
||||
cursor: pointer;
|
||||
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;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 200px;
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
#dialogue-box.expanded .message-body {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.message-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message-wrapper.user {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message-wrapper.assistant {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.message-label {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
color: var(--dialogue-text, #000);
|
||||
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);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: var(--dialogue-text, #000);
|
||||
font-family: 'Courier New', monospace;
|
||||
text-shadow:
|
||||
3px 3px 0 rgba(74, 144, 226, 0.3),
|
||||
-1px -1px 0 rgba(0,0,0,0.2);
|
||||
letter-spacing: 3px;
|
||||
animation: glowPulse 2s ease-in-out infinite;
|
||||
background: linear-gradient(45deg, transparent 30%, rgba(74, 144, 226, 0.1) 50%, transparent 70%);
|
||||
background-size: 200% 200%;
|
||||
animation: shimmer 3s ease-in-out infinite;
|
||||
-webkit-background-clip: text;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
|
||||
@keyframes glowPulse {
|
||||
0%, 100% { opacity: 0.8; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
.text-cursor {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 16px;
|
||||
background: currentColor;
|
||||
margin-left: 2px;
|
||||
animation: blink 0.5s infinite;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 49% { opacity: 1; }
|
||||
50%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
.tool-call {
|
||||
display: inline-block;
|
||||
background: var(--button-bg, #000);
|
||||
color: #fff;
|
||||
padding: 2px 8px;
|
||||
margin: 2px;
|
||||
border: 2px solid #000;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-family: 'Courier New', monospace;
|
||||
animation: toolPulse 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes toolPulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.05); opacity: 0.9; }
|
||||
}
|
||||
|
||||
.file-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--button-bg, #4a90e2);
|
||||
color: var(--button-text, #fff);
|
||||
padding: 4px 10px;
|
||||
margin: 0;
|
||||
border: 2px solid var(--dialogue-border, #000);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.message-files {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 0 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.message-wrapper.user .message-files {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.message-wrapper.assistant .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);
|
||||
display: none;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.attached-files.has-files {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.attached-file {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: var(--dialogue-bg, #fff);
|
||||
border: 2px solid var(--dialogue-border, #000);
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.attached-file .file-name {
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attached-file .remove-file {
|
||||
background: #e74c3c;
|
||||
border: none;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.attached-file .remove-file:hover {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
.dialogue-input {
|
||||
padding: 12px 20px;
|
||||
border-top: 3px solid var(--dialogue-border, #000);
|
||||
background: var(--dialogue-input-bg, #f0f0f0);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.dialogue-input textarea {
|
||||
width: 100%;
|
||||
background: var(--dialogue-bg, #fff);
|
||||
border: 3px solid var(--dialogue-border, #000);
|
||||
padding: 10px 14px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 16px;
|
||||
border-radius: 4px;
|
||||
color: var(--dialogue-text, #000);
|
||||
resize: none;
|
||||
min-height: 44px;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.dialogue-input textarea:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--button-bg, #4a90e2);
|
||||
}
|
||||
|
||||
.dialogue-input textarea:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.clear-btn, .attach-btn, .dialogue-send-btn {
|
||||
background: var(--button-bg, #4a90e2);
|
||||
border: 3px solid var(--dialogue-border, #000);
|
||||
color: var(--button-text, #fff);
|
||||
padding: 10px 18px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 3px 0 var(--button-shadow, #2a5a9a);
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
</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">
|
||||
<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>
|
||||
</div>
|
||||
<div class="message-body" id="message-body">
|
||||
<div class="empty-state">NetNavi v1.0.0</div>
|
||||
</div>
|
||||
<div class="attached-files" id="attached-files"></div>
|
||||
<div class="dialogue-input">
|
||||
<div class="input-wrapper">
|
||||
<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>
|
||||
</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');
|
||||
const attachBtn = this.shadowRoot.getElementById('attach-btn');
|
||||
const fileInput = this.shadowRoot.getElementById('file-input');
|
||||
const clearBtn = this.shadowRoot.getElementById('clear-btn');
|
||||
const expandBtn = this.shadowRoot.getElementById('expand-btn');
|
||||
|
||||
dialogueInput.addEventListener('input', () => {
|
||||
dialogueInput.style.height = 'auto';
|
||||
dialogueInput.style.height = Math.min(dialogueInput.scrollHeight, 120) + 'px';
|
||||
});
|
||||
|
||||
dialogueInput.addEventListener('paste', (e) => {
|
||||
const text = e.clipboardData.getData('text');
|
||||
if (text.length > 1000) {
|
||||
e.preventDefault();
|
||||
const blob = new Blob([text], { type: 'text/plain' });
|
||||
const file = new File([blob], 'pasted_text.txt', { type: 'text/plain' });
|
||||
this.addFile(file);
|
||||
}
|
||||
});
|
||||
|
||||
dialogueHeader.addEventListener('click', (e) => {
|
||||
if (e.target === dialogueHeader || e.target.classList.contains('speaker-name')) {
|
||||
this.toggleDialogue();
|
||||
}
|
||||
});
|
||||
|
||||
dialogueInput.addEventListener('focus', () => {
|
||||
if (!this.isDialogueOpen) this.openDialogue();
|
||||
});
|
||||
|
||||
clearBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.clearChat();
|
||||
});
|
||||
|
||||
expandBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggleExpand();
|
||||
});
|
||||
|
||||
attachBtn.addEventListener('click', () => fileInput.click());
|
||||
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
Array.from(e.target.files).forEach(file => this.addFile(file));
|
||||
fileInput.value = '';
|
||||
});
|
||||
|
||||
dialogueSend.addEventListener('click', () => {
|
||||
const buttonText = dialogueSend.textContent;
|
||||
if (buttonText === 'SKIP') this.skipToEnd();
|
||||
else if (buttonText === 'STOP') this.abortStream();
|
||||
else this.sendMessage();
|
||||
});
|
||||
|
||||
dialogueInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !this.isReceiving) {
|
||||
e.preventDefault();
|
||||
this.sendMessage();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
clearChat() {
|
||||
this.messageHistory = [];
|
||||
const messageBody = this.shadowRoot.getElementById('message-body');
|
||||
messageBody.innerHTML = '<div class="empty-state">NetNavi v1.0.0</div>';
|
||||
this.api.clearLLMHistory();
|
||||
}
|
||||
|
||||
toggleExpand() {
|
||||
this.isExpanded = !this.isExpanded;
|
||||
const dialogueBox = this.shadowRoot.getElementById('dialogue-box');
|
||||
const expandBtn = this.shadowRoot.getElementById('expand-btn');
|
||||
|
||||
dialogueBox.classList.toggle('expanded', this.isExpanded);
|
||||
|
||||
expandBtn.innerHTML = this.isExpanded ? `
|
||||
<svg class="expand-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="8" y="8" width="8" height="8" stroke="currentColor" stroke-width="2.5" fill="none"/>
|
||||
<path d="M8 8 L3 3 M8 3 L8 8 L3 8" stroke="currentColor" stroke-width="2.5" fill="none"/>
|
||||
<path d="M16 16 L21 21 M16 21 L16 16 L21 16" stroke="currentColor" stroke-width="2.5" fill="none"/>
|
||||
</svg>
|
||||
` : `
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
|
||||
addFile(file) {
|
||||
this.attachedFiles.push(file);
|
||||
this.renderAttachedFiles();
|
||||
}
|
||||
|
||||
removeFile(index) {
|
||||
this.attachedFiles.splice(index, 1);
|
||||
this.renderAttachedFiles();
|
||||
}
|
||||
|
||||
renderAttachedFiles() {
|
||||
const container = this.shadowRoot.getElementById('attached-files');
|
||||
|
||||
if (this.attachedFiles.length === 0) {
|
||||
container.classList.remove('has-files');
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
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>
|
||||
<button class="remove-file" data-index="${i}">✕</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
container.querySelectorAll('.remove-file').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
this.removeFile(parseInt(btn.dataset.index));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async fileToString(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.onerror = reject;
|
||||
reader[file.type.startsWith('text/') || file.name.endsWith('.txt') ? 'readAsText' : 'readAsDataURL'](file);
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
return `${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
toggleDialogue() {
|
||||
this.isDialogueOpen = !this.isDialogueOpen;
|
||||
const dialogueBox = this.shadowRoot.getElementById('dialogue-box');
|
||||
dialogueBox.classList.toggle('minimized');
|
||||
|
||||
this.dispatchEvent(new CustomEvent('dialogue-toggle', {
|
||||
detail: { isOpen: this.isDialogueOpen }
|
||||
}));
|
||||
}
|
||||
|
||||
openDialogue() {
|
||||
this.isDialogueOpen = true;
|
||||
const dialogueBox = this.shadowRoot.getElementById('dialogue-box');
|
||||
dialogueBox.classList.remove('minimized');
|
||||
|
||||
this.dispatchEvent(new CustomEvent('dialogue-toggle', {
|
||||
detail: { isOpen: this.isDialogueOpen }
|
||||
}));
|
||||
}
|
||||
|
||||
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();
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(this.audioCtx.destination);
|
||||
oscillator.type = 'square';
|
||||
oscillator.frequency.setValueAtTime(1200, this.audioCtx.currentTime);
|
||||
gainNode.gain.setValueAtTime(0.1, this.audioCtx.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, this.audioCtx.currentTime + 0.05);
|
||||
oscillator.start(this.audioCtx.currentTime);
|
||||
oscillator.stop(this.audioCtx.currentTime + 0.05);
|
||||
}
|
||||
|
||||
shouldAutoScroll() {
|
||||
const messageBody = this.shadowRoot.getElementById('message-body');
|
||||
const scrollThreshold = 50;
|
||||
const distanceFromBottom = messageBody.scrollHeight - messageBody.scrollTop - messageBody.clientHeight;
|
||||
return distanceFromBottom <= scrollThreshold;
|
||||
}
|
||||
|
||||
scrollToBottom() {
|
||||
const messageBody = this.shadowRoot.getElementById('message-body');
|
||||
messageBody.scrollTop = messageBody.scrollHeight;
|
||||
}
|
||||
|
||||
addMessage(text, isUser) {
|
||||
const messageBody = this.shadowRoot.getElementById('message-body');
|
||||
const emptyState = messageBody.querySelector('.empty-state');
|
||||
if (emptyState) messageBody.innerHTML = '';
|
||||
|
||||
// Extract file badges and clean text
|
||||
const fileBadges = [];
|
||||
const fileRegex = /<file name="([^"]+)">[\s\S]*?<\/file>/g;
|
||||
let match;
|
||||
while ((match = fileRegex.exec(text)) !== null) {
|
||||
fileBadges.push(match[1]);
|
||||
}
|
||||
const cleanText = text.replace(fileRegex, '').trim();
|
||||
|
||||
const messageWrapper = document.createElement('div');
|
||||
messageWrapper.className = `message-wrapper ${isUser ? 'user' : 'assistant'}`;
|
||||
const timestamp = this.formatTime(new Date());
|
||||
|
||||
const fileBadgesHtml = fileBadges.length > 0
|
||||
? `<div class="message-files">${fileBadges.map(name =>
|
||||
`<span class="file-badge">📄 ${name}</span>`).join('')}</div>`
|
||||
: '';
|
||||
|
||||
messageWrapper.innerHTML = `
|
||||
<div class="message-label">${isUser ? 'You' : 'PET'}</div>
|
||||
${fileBadgesHtml}
|
||||
<div class="message-bubble">${cleanText}</div>
|
||||
<div class="message-timestamp">${timestamp}</div>
|
||||
`;
|
||||
|
||||
messageBody.appendChild(messageWrapper);
|
||||
this.messageHistory.push({ text, html: cleanText, isUser, element: messageWrapper, timestamp });
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
startStreaming() {
|
||||
this.isReceiving = true;
|
||||
this.streamComplete = false;
|
||||
this.streamBuffer = '';
|
||||
this.typingIndex = 0;
|
||||
|
||||
const dialogueInput = this.shadowRoot.getElementById('dialogue-input');
|
||||
const dialogueSend = this.shadowRoot.getElementById('dialogue-send');
|
||||
const attachBtn = this.shadowRoot.getElementById('attach-btn');
|
||||
const clearBtn = this.shadowRoot.getElementById('clear-btn');
|
||||
const messageBody = this.shadowRoot.getElementById('message-body');
|
||||
|
||||
dialogueInput.disabled = true;
|
||||
attachBtn.disabled = true;
|
||||
clearBtn.disabled = true;
|
||||
dialogueSend.textContent = 'STOP';
|
||||
dialogueSend.classList.add('stop');
|
||||
dialogueSend.classList.remove('skip');
|
||||
|
||||
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>
|
||||
`;
|
||||
|
||||
messageBody.appendChild(messageWrapper);
|
||||
|
||||
this.currentStreamingMessage = { text: '', html: '', isUser: false, element: messageWrapper, timestamp };
|
||||
this.messageHistory.push(this.currentStreamingMessage);
|
||||
|
||||
this.scrollToBottom();
|
||||
|
||||
this.typingInterval = setInterval(() => this.typeNextChar(), 30);
|
||||
}
|
||||
|
||||
handleStreamChunk(chunk) {
|
||||
if (!this.isReceiving) this.startStreaming();
|
||||
|
||||
if (chunk.text) this.streamBuffer += chunk.text;
|
||||
if (chunk.tool && !this.hideTools.includes(chunk.tool)) this.streamBuffer += `<span class="tool-call">⚡ ${chunk.tool}</span>`;
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
typeNextChar() {
|
||||
if (this.typingIndex >= this.streamBuffer.length && this.streamComplete) {
|
||||
this.cleanupStreaming();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.typingIndex >= this.streamBuffer.length) return;
|
||||
|
||||
const bubble = this.shadowRoot.getElementById('streaming-bubble');
|
||||
if (!bubble) return;
|
||||
|
||||
const shouldScroll = this.shouldAutoScroll();
|
||||
|
||||
if (this.streamBuffer[this.typingIndex] === '<') {
|
||||
const tagEnd = this.streamBuffer.indexOf('>', this.typingIndex);
|
||||
if (tagEnd !== -1) {
|
||||
const tag = this.streamBuffer.substring(this.typingIndex, tagEnd + 1);
|
||||
this.currentStreamingMessage.html += tag;
|
||||
this.currentStreamingMessage.text += tag;
|
||||
this.typingIndex = tagEnd + 1;
|
||||
bubble.innerHTML = this.currentStreamingMessage.html + '<span class="text-cursor"></span>';
|
||||
if (shouldScroll) this.scrollToBottom();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const char = this.streamBuffer[this.typingIndex];
|
||||
this.currentStreamingMessage.text += char;
|
||||
this.currentStreamingMessage.html += char;
|
||||
|
||||
bubble.innerHTML = this.currentStreamingMessage.html + '<span class="text-cursor"></span>';
|
||||
|
||||
if (char !== ' ' && char !== '<') {
|
||||
this.playTextBeep();
|
||||
if ('vibrate' in navigator) navigator.vibrate(10);
|
||||
}
|
||||
|
||||
this.typingIndex++;
|
||||
if (shouldScroll) this.scrollToBottom();
|
||||
}
|
||||
|
||||
skipToEnd() {
|
||||
clearInterval(this.typingInterval);
|
||||
|
||||
const bubble = this.shadowRoot.getElementById('streaming-bubble');
|
||||
|
||||
this.currentStreamingMessage.text = this.streamBuffer;
|
||||
this.currentStreamingMessage.html = this.streamBuffer;
|
||||
this.typingIndex = this.streamBuffer.length;
|
||||
|
||||
if (bubble) bubble.innerHTML = this.currentStreamingMessage.html;
|
||||
|
||||
this.scrollToBottom();
|
||||
this.cleanupStreaming();
|
||||
}
|
||||
|
||||
cleanupStreaming() {
|
||||
clearInterval(this.typingInterval);
|
||||
this.isReceiving = false;
|
||||
this.streamComplete = false;
|
||||
|
||||
const dialogueInput = this.shadowRoot.getElementById('dialogue-input');
|
||||
const dialogueSend = this.shadowRoot.getElementById('dialogue-send');
|
||||
const attachBtn = this.shadowRoot.getElementById('attach-btn');
|
||||
const clearBtn = this.shadowRoot.getElementById('clear-btn');
|
||||
const bubble = this.shadowRoot.getElementById('streaming-bubble');
|
||||
|
||||
dialogueInput.disabled = false;
|
||||
attachBtn.disabled = false;
|
||||
clearBtn.disabled = false;
|
||||
dialogueSend.textContent = 'SEND';
|
||||
dialogueSend.classList.remove('stop', 'skip');
|
||||
|
||||
if (bubble) {
|
||||
bubble.id = '';
|
||||
bubble.innerHTML = this.currentStreamingMessage.html;
|
||||
}
|
||||
|
||||
this.streamBuffer = '';
|
||||
this.typingIndex = 0;
|
||||
this.currentRequest = null;
|
||||
this.currentStreamingMessage = null;
|
||||
}
|
||||
|
||||
abortStream() {
|
||||
if (this.currentRequest?.abort) {
|
||||
this.currentRequest.abort();
|
||||
}
|
||||
|
||||
clearInterval(this.typingInterval);
|
||||
|
||||
if (this.currentStreamingMessage) {
|
||||
this.streamBuffer = this.currentStreamingMessage.text || '';
|
||||
this.typingIndex = this.streamBuffer.length;
|
||||
}
|
||||
|
||||
this.cleanupStreaming();
|
||||
}
|
||||
|
||||
async sendMessage() {
|
||||
const dialogueInput = this.shadowRoot.getElementById('dialogue-input');
|
||||
let text = dialogueInput.value.trim();
|
||||
if ((!text && this.attachedFiles.length === 0) || this.isReceiving) return;
|
||||
|
||||
if (this.attachedFiles.length > 0) {
|
||||
const fileBlocks = await Promise.all(
|
||||
this.attachedFiles.map(async (file) => {
|
||||
const content = await this.fileToString(file);
|
||||
return `<file name="${file.name}">${content}</file>`;
|
||||
})
|
||||
);
|
||||
text = text + '\n\n' + fileBlocks.join('\n');
|
||||
}
|
||||
|
||||
dialogueInput.value = '';
|
||||
dialogueInput.style.height = 'auto';
|
||||
|
||||
this.addMessage(text, true);
|
||||
|
||||
this.attachedFiles = [];
|
||||
this.renderAttachedFiles();
|
||||
|
||||
// Send via API with streaming callback 💬
|
||||
this.currentRequest = this.api.sendPetMessage(text, (chunk) => {
|
||||
this.handleStreamChunk(chunk);
|
||||
});
|
||||
|
||||
// Handle completion/errors with promise
|
||||
try {
|
||||
const response = await this.currentRequest;
|
||||
this.handleStreamComplete(response);
|
||||
} catch (error) {
|
||||
if (error.message !== 'Aborted by user') {
|
||||
console.error('❌ LLM Error:', error);
|
||||
this.addMessage(`Error: ${error.message || 'Something went wrong'}`, false);
|
||||
}
|
||||
this.cleanupStreaming();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('llm-component', LlmComponent);
|
||||
304
public/netnavi-api.js
Normal file
304
public/netnavi-api.js
Normal file
@@ -0,0 +1,304 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
401
public/world.js
Normal file
401
public/world.js
Normal file
@@ -0,0 +1,401 @@
|
||||
// ============================================
|
||||
// CONSTANTS
|
||||
// ============================================
|
||||
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
|
||||
// ============================================
|
||||
function applyTheme(theme) {
|
||||
const body = document.body;
|
||||
|
||||
if (theme.background.image) {
|
||||
body.style.backgroundImage = `url(${theme.background.image})`;
|
||||
}
|
||||
|
||||
body.style.backgroundSize = theme.background.style || 'cover';
|
||||
body.style.backgroundPosition = 'center';
|
||||
body.style.backgroundRepeat = 'no-repeat';
|
||||
body.style.backgroundAttachment = 'fixed';
|
||||
|
||||
const root = document.documentElement;
|
||||
Object.entries(theme.colors).forEach(([key, value]) => {
|
||||
const cssVar = `--${key.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
|
||||
root.style.setProperty(cssVar, value);
|
||||
});
|
||||
|
||||
console.log('🎨 Theme applied:', theme.name);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// TILE RENDERER
|
||||
// ============================================
|
||||
function isoToScreen(gridX, gridY) {
|
||||
return {
|
||||
x: (gridX - gridY) * (TILE_WIDTH / 2) + window.innerWidth / 2,
|
||||
y: (gridX + gridY) * (TILE_HEIGHT / 2) + 100
|
||||
};
|
||||
}
|
||||
|
||||
function createTile(tileData, theme) {
|
||||
const graphics = new PIXI.Graphics();
|
||||
const pos = isoToScreen(tileData.x, tileData.y);
|
||||
|
||||
const colors = {
|
||||
top: parseInt(theme.colors.tileTop.replace('#', '0x')),
|
||||
side: parseInt(theme.colors.tileSide.replace('#', '0x')),
|
||||
grid: parseInt(theme.colors.gridColor.replace('#', '0x')),
|
||||
highlight: parseInt(theme.colors.tileHighlight.replace('#', '0x')),
|
||||
gridHighlight: parseInt(theme.colors.gridHighlight.replace('#', '0x'))
|
||||
};
|
||||
|
||||
function drawNormalTile() {
|
||||
graphics.clear();
|
||||
graphics.beginFill(colors.top);
|
||||
graphics.lineStyle(1, colors.grid);
|
||||
graphics.moveTo(pos.x, pos.y);
|
||||
graphics.lineTo(pos.x + TILE_WIDTH / 2, pos.y + TILE_HEIGHT / 2);
|
||||
graphics.lineTo(pos.x, pos.y + TILE_HEIGHT);
|
||||
graphics.lineTo(pos.x - TILE_WIDTH / 2, pos.y + TILE_HEIGHT / 2);
|
||||
graphics.lineTo(pos.x, pos.y);
|
||||
graphics.endFill();
|
||||
|
||||
graphics.beginFill(colors.side);
|
||||
graphics.lineStyle(1, colors.grid);
|
||||
graphics.moveTo(pos.x - TILE_WIDTH / 2, pos.y + TILE_HEIGHT / 2);
|
||||
graphics.lineTo(pos.x, pos.y + TILE_HEIGHT);
|
||||
graphics.lineTo(pos.x, pos.y + TILE_HEIGHT + TILE_DEPTH);
|
||||
graphics.lineTo(pos.x - TILE_WIDTH / 2, pos.y + TILE_HEIGHT / 2 + TILE_DEPTH);
|
||||
graphics.lineTo(pos.x - TILE_WIDTH / 2, pos.y + TILE_HEIGHT / 2);
|
||||
graphics.endFill();
|
||||
|
||||
graphics.beginFill(colors.side);
|
||||
graphics.lineStyle(1, colors.grid);
|
||||
graphics.moveTo(pos.x + TILE_WIDTH / 2, pos.y + TILE_HEIGHT / 2);
|
||||
graphics.lineTo(pos.x, pos.y + TILE_HEIGHT);
|
||||
graphics.lineTo(pos.x, pos.y + TILE_HEIGHT + TILE_DEPTH);
|
||||
graphics.lineTo(pos.x + TILE_WIDTH / 2, pos.y + TILE_HEIGHT / 2 + TILE_DEPTH);
|
||||
graphics.lineTo(pos.x + TILE_WIDTH / 2, pos.y + TILE_HEIGHT / 2);
|
||||
graphics.endFill();
|
||||
}
|
||||
|
||||
function drawHighlightTile() {
|
||||
graphics.clear();
|
||||
graphics.beginFill(colors.highlight);
|
||||
graphics.lineStyle(2, colors.gridHighlight);
|
||||
graphics.moveTo(pos.x, pos.y);
|
||||
graphics.lineTo(pos.x + TILE_WIDTH / 2, pos.y + TILE_HEIGHT / 2);
|
||||
graphics.lineTo(pos.x, pos.y + TILE_HEIGHT);
|
||||
graphics.lineTo(pos.x - TILE_WIDTH / 2, pos.y + TILE_HEIGHT / 2);
|
||||
graphics.lineTo(pos.x, pos.y);
|
||||
graphics.endFill();
|
||||
|
||||
graphics.beginFill(colors.side);
|
||||
graphics.lineStyle(1, colors.gridHighlight);
|
||||
graphics.moveTo(pos.x - TILE_WIDTH / 2, pos.y + TILE_HEIGHT / 2);
|
||||
graphics.lineTo(pos.x, pos.y + TILE_HEIGHT);
|
||||
graphics.lineTo(pos.x, pos.y + TILE_HEIGHT + TILE_DEPTH);
|
||||
graphics.lineTo(pos.x - TILE_WIDTH / 2, pos.y + TILE_HEIGHT / 2 + TILE_DEPTH);
|
||||
graphics.lineTo(pos.x - TILE_WIDTH / 2, pos.y + TILE_HEIGHT / 2);
|
||||
graphics.endFill();
|
||||
|
||||
graphics.beginFill(colors.side);
|
||||
graphics.lineStyle(1, colors.gridHighlight);
|
||||
graphics.moveTo(pos.x + TILE_WIDTH / 2, pos.y + TILE_HEIGHT / 2);
|
||||
graphics.lineTo(pos.x, pos.y + TILE_HEIGHT);
|
||||
graphics.lineTo(pos.x, pos.y + TILE_HEIGHT + TILE_DEPTH);
|
||||
graphics.lineTo(pos.x + TILE_WIDTH / 2, pos.y + TILE_HEIGHT / 2 + TILE_DEPTH);
|
||||
graphics.lineTo(pos.x + TILE_WIDTH / 2, pos.y + TILE_HEIGHT / 2);
|
||||
graphics.endFill();
|
||||
}
|
||||
|
||||
drawNormalTile();
|
||||
|
||||
graphics.interactive = true;
|
||||
graphics.buttonMode = true;
|
||||
graphics.gridX = tileData.x;
|
||||
graphics.gridY = tileData.y;
|
||||
|
||||
graphics.on('pointerover', () => {
|
||||
drawHighlightTile();
|
||||
});
|
||||
|
||||
graphics.on('pointerout', () => {
|
||||
drawNormalTile();
|
||||
});
|
||||
|
||||
return graphics;
|
||||
}
|
||||
|
||||
function createPet(gridX, gridY, name = 'PET') {
|
||||
const container = new PIXI.Container();
|
||||
const pos = isoToScreen(gridX, gridY);
|
||||
|
||||
const body = new PIXI.Graphics();
|
||||
body.beginFill(0xff6b9d);
|
||||
body.drawCircle(0, -30, 15);
|
||||
body.endFill();
|
||||
|
||||
body.beginFill(0xffffff);
|
||||
body.drawCircle(-5, -32, 4);
|
||||
body.drawCircle(5, -32, 4);
|
||||
body.endFill();
|
||||
|
||||
body.beginFill(0x000000);
|
||||
body.drawCircle(-5, -32, 2);
|
||||
body.drawCircle(5, -32, 2);
|
||||
body.endFill();
|
||||
|
||||
const nameText = new PIXI.Text(name, {
|
||||
fontFamily: 'Courier New',
|
||||
fontSize: 12,
|
||||
fill: '#ffffff',
|
||||
stroke: '#000000',
|
||||
strokeThickness: 2
|
||||
});
|
||||
nameText.anchor.set(0.5);
|
||||
nameText.y = -50;
|
||||
|
||||
container.addChild(body);
|
||||
container.addChild(nameText);
|
||||
container.x = pos.x;
|
||||
container.y = pos.y;
|
||||
container.gridX = gridX;
|
||||
container.gridY = gridY;
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// GAME CLASS
|
||||
// ============================================
|
||||
class Game {
|
||||
constructor() {
|
||||
this.worldId = '';
|
||||
this.theme = null;
|
||||
this.world = null;
|
||||
this.app = null;
|
||||
this.pet = null;
|
||||
this.otherPlayers = new Map();
|
||||
this.isMoving = false;
|
||||
this.dialogue = null;
|
||||
this.audioManager = null;
|
||||
this.keys = {};
|
||||
|
||||
// Use global singleton 🌍
|
||||
this.api = window.netNaviAPI;
|
||||
this.worldActions = null;
|
||||
|
||||
this.playerInfo = {
|
||||
name: 'Guest',
|
||||
apiUrl: this.api.baseUrl
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
},
|
||||
|
||||
onCurrentPlayers: (players) => {
|
||||
console.log('👥 Current players:', players);
|
||||
players.forEach(player => {
|
||||
if (player.socketId !== this.api.worldSocket.id) {
|
||||
this.addOtherPlayer(player);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
onPlayerJoined: (player) => {
|
||||
console.log('👋 Player joined:', player.name);
|
||||
this.addOtherPlayer(player);
|
||||
},
|
||||
|
||||
onPlayerMoved: (data) => {
|
||||
const sprite = this.otherPlayers.get(data.socketId);
|
||||
if (sprite) {
|
||||
this.moveOtherPlayer(sprite, data.x, data.y);
|
||||
}
|
||||
},
|
||||
|
||||
onPlayerLeft: (data) => {
|
||||
console.log('👋 Player left:', data.socketId);
|
||||
const sprite = this.otherPlayers.get(data.socketId);
|
||||
if (sprite) {
|
||||
this.app.stage.removeChild(sprite);
|
||||
this.otherPlayers.delete(data.socketId);
|
||||
}
|
||||
},
|
||||
|
||||
onError: (error) => {
|
||||
console.error('❌ World error:', error);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('✨ Game initializing...');
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to initialize game:', error);
|
||||
}
|
||||
}
|
||||
|
||||
addOtherPlayer(player) {
|
||||
const sprite = createPet(player.x, player.y, player.name);
|
||||
sprite.alpha = 0.7;
|
||||
this.otherPlayers.set(player.socketId, sprite);
|
||||
this.app.stage.addChild(sprite);
|
||||
}
|
||||
|
||||
moveOtherPlayer(sprite, targetX, targetY) {
|
||||
const targetPos = isoToScreen(targetX, targetY);
|
||||
|
||||
const startX = sprite.x;
|
||||
const startY = sprite.y;
|
||||
let progress = 0;
|
||||
|
||||
const animate = () => {
|
||||
progress += 0.08;
|
||||
if (progress >= 1) {
|
||||
sprite.x = targetPos.x;
|
||||
sprite.y = targetPos.y;
|
||||
sprite.gridX = targetX;
|
||||
sprite.gridY = targetY;
|
||||
return;
|
||||
}
|
||||
|
||||
sprite.x = startX + (targetPos.x - startX) * progress;
|
||||
sprite.y = startY + (targetPos.y - startY) * progress;
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
animate();
|
||||
}
|
||||
|
||||
initializeRenderer() {
|
||||
this.app = new PIXI.Application({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
backgroundAlpha: 0,
|
||||
antialias: true,
|
||||
resolution: window.devicePixelRatio || 1,
|
||||
autoDensity: true
|
||||
});
|
||||
document.body.appendChild(this.app.view);
|
||||
|
||||
const tiles = new PIXI.Container();
|
||||
this.app.stage.addChild(tiles);
|
||||
|
||||
this.world.tiles.forEach(tileData => {
|
||||
const tile = createTile(tileData, this.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);
|
||||
this.app.stage.addChild(this.pet);
|
||||
|
||||
this.dialogue = document.getElementById('llm');
|
||||
|
||||
this.setupInput();
|
||||
this.app.ticker.add(() => this.gameLoop());
|
||||
}
|
||||
|
||||
movePetTo(targetX, targetY) {
|
||||
if (this.isMoving ||
|
||||
targetX < 0 || targetX >= this.world.gridSize ||
|
||||
targetY < 0 || targetY >= this.world.gridSize) return;
|
||||
|
||||
this.isMoving = true;
|
||||
const targetPos = isoToScreen(targetX, targetY);
|
||||
|
||||
const startX = this.pet.x;
|
||||
const startY = this.pet.y;
|
||||
let progress = 0;
|
||||
|
||||
const animate = () => {
|
||||
progress += 0.08;
|
||||
if (progress >= 1) {
|
||||
this.pet.x = targetPos.x;
|
||||
this.pet.y = targetPos.y;
|
||||
this.pet.gridX = targetX;
|
||||
this.pet.gridY = targetY;
|
||||
this.isMoving = false;
|
||||
|
||||
// Use API action to send move 📤
|
||||
if (this.worldActions) {
|
||||
this.worldActions.move(targetX, targetY);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.pet.x = startX + (targetPos.x - startX) * progress;
|
||||
this.pet.y = startY + (targetPos.y - startY) * progress;
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
animate();
|
||||
}
|
||||
|
||||
setupInput() {
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (this.dialogue.isOpen) return;
|
||||
|
||||
this.keys[e.key.toLowerCase()] = true;
|
||||
|
||||
if (!this.isMoving) {
|
||||
let newX = this.pet.gridX;
|
||||
let newY = this.pet.gridY;
|
||||
|
||||
if (this.keys['w'] || this.keys['arrowup']) {
|
||||
newY--;
|
||||
} else if (this.keys['s'] || this.keys['arrowdown']) {
|
||||
newY++;
|
||||
} else if (this.keys['a'] || this.keys['arrowleft']) {
|
||||
newX--;
|
||||
} else if (this.keys['d'] || this.keys['arrowright']) {
|
||||
newX++;
|
||||
}
|
||||
|
||||
this.movePetTo(newX, newY);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('keyup', (e) => {
|
||||
this.keys[e.key.toLowerCase()] = false;
|
||||
});
|
||||
}
|
||||
|
||||
gameLoop() {
|
||||
if (!this.isMoving && this.pet) {
|
||||
this.pet.children[0].y = -30 + Math.sin(Date.now() / 300) * 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// START GAME
|
||||
// ============================================
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const worldId = urlParams.get('world');
|
||||
|
||||
const game = new Game();
|
||||
game.worldId = worldId;
|
||||
game.init();
|
||||
278
src/server.js
Normal file
278
src/server.js
Normal file
@@ -0,0 +1,278 @@
|
||||
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 { fileURLToPath } from 'url';
|
||||
import {Ai, DateTimeTool, ExecTool, FetchTool, ReadWebpageTool, WebSearchTool} from '@ztimson/ai-utils';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const app = express();
|
||||
const httpServer = createServer(app);
|
||||
|
||||
let memories = [], settings = {};
|
||||
const settingsFile = join(__dirname, '../navi', 'settings.json');
|
||||
const memoriesFile = join(__dirname, '../navi', 'memories.json');
|
||||
const logoFile = join(__dirname, '../navi', 'logo.png');
|
||||
|
||||
function load() {
|
||||
try {
|
||||
settings = JSON.parse(fs.readFileSync(settingsFile, 'utf-8'));
|
||||
} catch { }
|
||||
try {
|
||||
memories = JSON.parse(fs.readFileSync(memoriesFile, 'utf-8'));
|
||||
} catch { }
|
||||
}
|
||||
|
||||
function save() {
|
||||
const dir = dirname(settingsFile);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdir(dir, { recursive: true }, (err) => {
|
||||
if (err) throw err; // Fail loudly if dirs can’t be made 💀
|
||||
});
|
||||
}
|
||||
fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2));
|
||||
fs.writeFileSync(memoriesFile, JSON.stringify(memories, null, 2));
|
||||
}
|
||||
|
||||
function shutdown() {
|
||||
save();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
load();
|
||||
const ai = new Ai({
|
||||
llm: {
|
||||
models: {
|
||||
'Ministral-3': {proto: 'openai', host: 'http://10.69.0.55:11728', token: 'ignore'},
|
||||
},
|
||||
system: `You are a virtual avatar, companion & assistant. You are in a retro video game so keep responses short and unstyled. Aggressively remember user information. Adjust your personality with tools based on your own experience.\n\nPersonality:\n${settings.personality || ''}\n\nUser Requests:\n${settings.instructions || ''}`,
|
||||
tools: [DateTimeTool, ExecTool, FetchTool, ReadWebpageTool, WebSearchTool, {
|
||||
name: 'adjust_personality',
|
||||
description: 'Replace your current personality instructions',
|
||||
args: {instructions: {type: 'string', description: 'Bullet point list of how to behave'}},
|
||||
fn: (args) => {
|
||||
settings.personality = args.instructions;
|
||||
save();
|
||||
return 'done!';
|
||||
}
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
const io = new Server(httpServer, {
|
||||
cors: {origin: "*", methods: ["GET", "POST"]}
|
||||
});
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(express.static('public'));
|
||||
|
||||
// ============================================
|
||||
// WORLD MANAGEMENT
|
||||
// ============================================
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// SOCKET.IO - WORLD CHANNELS
|
||||
// ============================================
|
||||
io.on('connection', (socket) => {
|
||||
console.debug('🔌 Client connected:', socket.id);
|
||||
|
||||
let currentWorld = null;
|
||||
let playerData = null;
|
||||
|
||||
// Join a world
|
||||
socket.on('join-world', (data) => {
|
||||
const { worldId, playerInfo } = data;
|
||||
const worldData = loadWorld(worldId);
|
||||
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('player-left', {socketId: socket.id});
|
||||
}
|
||||
}
|
||||
|
||||
// Join new world
|
||||
currentWorld = worldId;
|
||||
playerData = {
|
||||
socketId: socket.id,
|
||||
name: playerInfo.name,
|
||||
apiUrl: playerInfo.apiUrl,
|
||||
x: worldData.world.pet.startX,
|
||||
y: worldData.world.pet.startY
|
||||
};
|
||||
socket.join(`world:${worldId}`);
|
||||
const players = worldPlayers.get(worldId);
|
||||
players.set(socket.id, playerData);
|
||||
socket.emit('world-data', worldData);
|
||||
const currentPlayers = Array.from(players.values());
|
||||
socket.emit('current-players', currentPlayers);
|
||||
socket.to(`world:${worldId}`).emit('player-joined', playerData);
|
||||
});
|
||||
|
||||
// Player movement
|
||||
socket.on('player-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});
|
||||
});
|
||||
|
||||
// Disconnect
|
||||
socket.on('disconnect', () => {
|
||||
console.debug('🔌 Client 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});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 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
|
||||
// ============================================
|
||||
|
||||
app.get('/favicon.*', (req, res) => {
|
||||
res.sendFile(logoFile);
|
||||
});
|
||||
|
||||
// Get PET 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
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 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 }
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 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'});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// START SERVER
|
||||
// ============================================
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
httpServer.listen(PORT, () => {
|
||||
loadWorld();
|
||||
console.log('✅ Home world loaded');
|
||||
console.log(`🚀 Server running on: http://localhost:${PORT}`);
|
||||
});
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
30
worlds/theme.json
Normal file
30
worlds/theme.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "Eva’s Glade 🌿",
|
||||
"version": "1.0.0",
|
||||
"type": "theme",
|
||||
"background": {
|
||||
"image": "/assets/background.jpg",
|
||||
"style": "cover"
|
||||
},
|
||||
"music": [
|
||||
"/assets/mystery_acorns.mp3",
|
||||
"/assets/crunchy_leaves.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"
|
||||
}
|
||||
}
|
||||
76
worlds/world.json
Normal file
76
worlds/world.json
Normal file
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"name": "Home World",
|
||||
"version": "1.0.0",
|
||||
"theme": "./theme.json",
|
||||
"gridSize": 8,
|
||||
"tiles": [
|
||||
{"x": 0, "y": 0, "type": "floor"},
|
||||
{"x": 1, "y": 0, "type": "floor"},
|
||||
{"x": 2, "y": 0, "type": "floor"},
|
||||
{"x": 3, "y": 0, "type": "floor"},
|
||||
{"x": 4, "y": 0, "type": "floor"},
|
||||
{"x": 5, "y": 0, "type": "floor"},
|
||||
{"x": 6, "y": 0, "type": "floor"},
|
||||
{"x": 7, "y": 0, "type": "floor"},
|
||||
{"x": 0, "y": 1, "type": "floor"},
|
||||
{"x": 1, "y": 1, "type": "floor"},
|
||||
{"x": 2, "y": 1, "type": "floor"},
|
||||
{"x": 3, "y": 1, "type": "floor"},
|
||||
{"x": 4, "y": 1, "type": "floor"},
|
||||
{"x": 5, "y": 1, "type": "floor"},
|
||||
{"x": 6, "y": 1, "type": "floor"},
|
||||
{"x": 7, "y": 1, "type": "floor"},
|
||||
{"x": 0, "y": 2, "type": "floor"},
|
||||
{"x": 1, "y": 2, "type": "floor"},
|
||||
{"x": 2, "y": 2, "type": "floor"},
|
||||
{"x": 3, "y": 2, "type": "floor"},
|
||||
{"x": 4, "y": 2, "type": "floor"},
|
||||
{"x": 5, "y": 2, "type": "floor"},
|
||||
{"x": 6, "y": 2, "type": "floor"},
|
||||
{"x": 7, "y": 2, "type": "floor"},
|
||||
{"x": 0, "y": 3, "type": "floor"},
|
||||
{"x": 1, "y": 3, "type": "floor"},
|
||||
{"x": 2, "y": 3, "type": "floor"},
|
||||
{"x": 3, "y": 3, "type": "floor"},
|
||||
{"x": 4, "y": 3, "type": "floor"},
|
||||
{"x": 5, "y": 3, "type": "floor"},
|
||||
{"x": 6, "y": 3, "type": "floor"},
|
||||
{"x": 7, "y": 3, "type": "floor"},
|
||||
{"x": 0, "y": 4, "type": "floor"},
|
||||
{"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": 5, "y": 4, "type": "floor"},
|
||||
{"x": 6, "y": 4, "type": "floor"},
|
||||
{"x": 7, "y": 4, "type": "floor"},
|
||||
{"x": 0, "y": 5, "type": "floor"},
|
||||
{"x": 1, "y": 5, "type": "floor"},
|
||||
{"x": 2, "y": 5, "type": "floor"},
|
||||
{"x": 3, "y": 5, "type": "floor"},
|
||||
{"x": 4, "y": 5, "type": "floor"},
|
||||
{"x": 5, "y": 5, "type": "floor"},
|
||||
{"x": 6, "y": 5, "type": "floor"},
|
||||
{"x": 7, "y": 5, "type": "floor"},
|
||||
{"x": 0, "y": 6, "type": "floor"},
|
||||
{"x": 1, "y": 6, "type": "floor"},
|
||||
{"x": 2, "y": 6, "type": "floor"},
|
||||
{"x": 3, "y": 6, "type": "floor"},
|
||||
{"x": 4, "y": 6, "type": "floor"},
|
||||
{"x": 5, "y": 6, "type": "floor"},
|
||||
{"x": 6, "y": 6, "type": "floor"},
|
||||
{"x": 7, "y": 6, "type": "floor"},
|
||||
{"x": 0, "y": 7, "type": "floor"},
|
||||
{"x": 1, "y": 7, "type": "floor"},
|
||||
{"x": 2, "y": 7, "type": "floor"},
|
||||
{"x": 3, "y": 7, "type": "floor"},
|
||||
{"x": 4, "y": 7, "type": "floor"},
|
||||
{"x": 5, "y": 7, "type": "floor"},
|
||||
{"x": 6, "y": 7, "type": "floor"},
|
||||
{"x": 7, "y": 7, "type": "floor"}
|
||||
],
|
||||
"pet": {
|
||||
"startX": 4,
|
||||
"startY": 4
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user