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 />
|
<br />
|
||||||
|
|
||||||
<!-- Logo -->
|
<!-- 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 -->
|
<!-- Title -->
|
||||||
### Template
|
### Net Navi
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
Simple repository template
|
Your personal AI assistant
|
||||||
|
|
||||||
<!-- Repo badges -->
|
<!-- Repo badges -->
|
||||||
[](https://git.zakscode.com/ztimson/template/tags)
|
[](https://git.zakscode.com/ztimson/navi/tags)
|
||||||
[](https://git.zakscode.com/ztimson/template/pulls)
|
[](https://git.zakscode.com/ztimson/navi/pulls)
|
||||||
[](https://git.zakscode.com/ztimson/template/issues)
|
[](https://git.zakscode.com/ztimson/navi/issues)
|
||||||
|
|
||||||
<!-- Links -->
|
<!-- Links -->
|
||||||
|
|
||||||
---
|
---
|
||||||
<div>
|
<div>
|
||||||
<a href="https://git.zakscode.com/ztimson/template/wiki" target="_blank">Documentation</a>
|
<a href="https://git.zakscode.com/ztimson/navi/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/navi/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/navi/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/issues/new?template=.github%2fissue_template%2fenhancement.md" target="_blank">Request a Feature</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
- [Template](#top)
|
- [Net Navi](#top)
|
||||||
- [About](#about)
|
- [About](#about)
|
||||||
- [Demo](#demo)
|
|
||||||
- [Built With](#built-with)
|
- [Built With](#built-with)
|
||||||
- [Setup](#setup)
|
- [Setup](#setup)
|
||||||
- [Production](#production)
|
- [Production](#production)
|
||||||
@@ -41,56 +40,7 @@
|
|||||||
|
|
||||||
## About
|
## 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.
|
Your personal AI network navigator
|
||||||
|
|
||||||
### 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/)
|
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
@@ -105,7 +55,7 @@ Website: https://git.zakscode.com
|
|||||||
- [Docker](https://docs.docker.com/install/)
|
- [Docker](https://docs.docker.com/install/)
|
||||||
|
|
||||||
#### Instructions
|
#### 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)
|
2. Open [http://localhost](http://localhost)
|
||||||
</details>
|
</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