Compare commits

..

10 Commits

Author SHA1 Message Date
1c5bbf63a5 Added missing animations
All checks were successful
Build and publish / Build Container (push) Successful in 1m32s
2026-03-03 20:33:51 -05:00
6c2c3a0d07 Small tts fix
All checks were successful
Build and publish / Build Container (push) Successful in 1m26s
2026-03-03 20:23:59 -05:00
5018311990 Home screen update
All checks were successful
Build and publish / Build Container (push) Successful in 1m28s
2026-03-03 20:16:26 -05:00
82f29dceae Updated memories system 2026-03-02 14:12:24 -05:00
0595e72f7f Dynamic system prompt
All checks were successful
Build and publish / Build Container (push) Successful in 1m34s
2026-03-02 12:46:21 -05:00
7b2621c264 Refactored code formatting to use consistent indentation and object destructuring across client and server files.
All checks were successful
Build and publish / Build Container (push) Successful in 1m44s
2026-03-02 12:28:18 -05:00
09a59f170c Update Dockerfile
All checks were successful
Build and publish / Build Container (push) Successful in 1m41s
2026-03-02 08:14:01 -05:00
8c2b80951b Styling improvment
All checks were successful
Build and publish / Build Container (push) Successful in 1m41s
2026-03-02 02:49:15 -05:00
c5c070ebc2 Added python to navi
All checks were successful
Build and publish / Build Container (push) Successful in 1m39s
2026-03-01 18:48:20 -05:00
1d68638e69 Fixed cli tools
All checks were successful
Build and publish / Build Container (push) Successful in 1m28s
2026-03-01 18:13:26 -05:00
35 changed files with 3516 additions and 2011 deletions

View File

@@ -1,4 +1,3 @@
FROM node:22-slim FROM node:22-slim
ENV PORT=80 ENV PORT=80
@@ -6,6 +5,10 @@ ENV PORT=80
WORKDIR /app WORKDIR /app
COPY . . COPY . .
RUN npm ci RUN apt-get update && apt-get install -y --no-install-recommends libpython3.11 python3 python3-pip && \
npm ci && rm -rf /root/.npm/* /root/.cache/* /tmp/* /var/lib/apt/lists/* && \
echo 'alias sudo=""' >> /root/.bashrc && \
echo 'alias pip="pip3"' >> /root/.bashrc && \
echo 'alias python="python3"' >> /root/.bashrc
CMD ["npm", "run", "start"] CMD ["npm", "run", "start"]

View File

@@ -76,6 +76,13 @@ Your personal AI network navigator
</details> </details>
## TODO
- Use zips for protocols - move avatar to protocol zip
- Home screen corner menu
- Notifications
- CRON / Schedule
## License ## License
Copyright © 2023 Zakary Timson | All Rights Reserved | Available under MIT Licensing Copyright © 2023 Zakary Timson | All Rights Reserved | Available under MIT Licensing

604
package-lock.json generated
View File

@@ -8,9 +8,12 @@
"name": "@ztimson/net-navi", "name": "@ztimson/net-navi",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@ztimson/ai-utils": "^0.8.5", "@ztimson/ai-utils": "^0.8.9",
"@ztimson/utils": "^0.28.14",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"sharp": "^0.34.5",
"socket.io": "^4.6.1" "socket.io": "^4.6.1"
} }
}, },
@@ -43,6 +46,16 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@emnapi/runtime": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
"integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@huggingface/jinja": { "node_modules/@huggingface/jinja": {
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.2.2.tgz", "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.2.2.tgz",
@@ -52,6 +65,471 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [
"ppc64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"cpu": [
"riscv64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [
"ppc64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"cpu": [
"riscv64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@protobufjs/aspromise": { "node_modules/@protobufjs/aspromise": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
@@ -301,10 +779,33 @@
"onnxruntime-node": "1.14.0" "onnxruntime-node": "1.14.0"
} }
}, },
"node_modules/@xenova/transformers/node_modules/sharp": {
"version": "0.32.6",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz",
"integrity": "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"color": "^4.2.3",
"detect-libc": "^2.0.2",
"node-addon-api": "^6.1.0",
"prebuild-install": "^7.1.1",
"semver": "^7.5.4",
"simple-get": "^4.0.1",
"tar-fs": "^3.0.4",
"tunnel-agent": "^0.6.0"
},
"engines": {
"node": ">=14.15.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@ztimson/ai-utils": { "node_modules/@ztimson/ai-utils": {
"version": "0.8.5", "version": "0.8.9",
"resolved": "https://registry.npmjs.org/@ztimson/ai-utils/-/ai-utils-0.8.5.tgz", "resolved": "https://registry.npmjs.org/@ztimson/ai-utils/-/ai-utils-0.8.9.tgz",
"integrity": "sha512-MIIg03NwUm5v0vHihMDYLaFK8DkuLh1G5lmQpVyZif0h4s2wh9eO7uUg6E7c0UtvSq5QDh/eCyEDd5HvHxq01w==", "integrity": "sha512-BHaJjtFS+qF/LZJinscmHPtef1jq/6xmPXw6kKbSYLmcQJLmVoQg5NJXU2EQjaYtc36OuYXeM98nWBBYWl6hdA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.78.0", "@anthropic-ai/sdk": "^0.78.0",
@@ -324,9 +825,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@ztimson/utils": { "node_modules/@ztimson/utils": {
"version": "0.28.13", "version": "0.28.14",
"resolved": "https://registry.npmjs.org/@ztimson/utils/-/utils-0.28.13.tgz", "resolved": "https://registry.npmjs.org/@ztimson/utils/-/utils-0.28.14.tgz",
"integrity": "sha512-6nk7mW1vPX5QltTjCNK6u6y8UGYTOgeDadWHbrD+1QM5PZ1PbosMqVMQ6l4dhsQT7IHFxvLI3+U720T2fLGwoA==", "integrity": "sha512-ZI8kT1CV9La22w/E+HoPOsSV0ewgz0Rl4LXKIxFxH2dJVkXDCajgjfSHEfIHniifeOMma2nF+VO6yqT9BtvorQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"var-persist": "^1.0.1" "var-persist": "^1.0.1"
@@ -423,7 +924,6 @@
"resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.5.tgz", "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.5.tgz",
"integrity": "sha512-XvwYM6VZqKoqDll8BmSww5luA5eflDzY0uEFfBJtFKe4PAAtxBjU3YIxzIBzhyaEQBy1VXEQBto4cpN5RZJw+w==", "integrity": "sha512-XvwYM6VZqKoqDll8BmSww5luA5eflDzY0uEFfBJtFKe4PAAtxBjU3YIxzIBzhyaEQBy1VXEQBto4cpN5RZJw+w==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"dependencies": { "dependencies": {
"bare-events": "^2.5.4", "bare-events": "^2.5.4",
"bare-path": "^3.0.0", "bare-path": "^3.0.0",
@@ -444,11 +944,10 @@
} }
}, },
"node_modules/bare-os": { "node_modules/bare-os": {
"version": "3.7.0", "version": "3.7.1",
"resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.7.0.tgz", "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.7.1.tgz",
"integrity": "sha512-64Rcwj8qlnTZU8Ps6JJEdSmxBEUGgI7g8l+lMtsJLl4IsfTcHMTfJ188u2iGV6P6YPRZrtv72B2kjn+hp+Yv3g==", "integrity": "sha512-ebvMaS5BgZKmJlvuWh14dg9rbUI84QeV3WlWn6Ph6lFI8jJoh7ADtVTyD2c93euwbe+zgi0DVrl4YmqXeM9aIA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"engines": { "engines": {
"bare": ">=1.14.0" "bare": ">=1.14.0"
} }
@@ -458,7 +957,6 @@
"resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz",
"integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"dependencies": { "dependencies": {
"bare-os": "^3.0.1" "bare-os": "^3.0.1"
} }
@@ -468,7 +966,6 @@
"resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.8.0.tgz", "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.8.0.tgz",
"integrity": "sha512-reUN0M2sHRqCdG4lUK3Fw8w98eeUIZHL5c3H7Mbhk2yVBL+oofgaIp0ieLfD5QXwPCypBpmEEKU2WZKzbAk8GA==", "integrity": "sha512-reUN0M2sHRqCdG4lUK3Fw8w98eeUIZHL5c3H7Mbhk2yVBL+oofgaIp0ieLfD5QXwPCypBpmEEKU2WZKzbAk8GA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"dependencies": { "dependencies": {
"streamx": "^2.21.0", "streamx": "^2.21.0",
"teex": "^1.0.1" "teex": "^1.0.1"
@@ -491,7 +988,6 @@
"resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz",
"integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"dependencies": { "dependencies": {
"bare-path": "^3.0.0" "bare-path": "^3.0.0"
} }
@@ -979,6 +1475,18 @@
"url": "https://github.com/fb55/domutils?sponsor=1" "url": "https://github.com/fb55/domutils?sponsor=1"
} }
}, },
"node_modules/dotenv": {
"version": "17.3.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
"integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -2040,9 +2548,9 @@
} }
}, },
"node_modules/pump": { "node_modules/pump": {
"version": "3.0.3", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"end-of-stream": "^1.1.0", "end-of-stream": "^1.1.0",
@@ -2228,26 +2736,47 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/sharp": { "node_modules/sharp": {
"version": "0.32.6", "version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==", "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"color": "^4.2.3", "@img/colour": "^1.0.0",
"detect-libc": "^2.0.2", "detect-libc": "^2.1.2",
"node-addon-api": "^6.1.0", "semver": "^7.7.3"
"prebuild-install": "^7.1.1",
"semver": "^7.5.4",
"simple-get": "^4.0.1",
"tar-fs": "^3.0.4",
"tunnel-agent": "^0.6.0"
}, },
"engines": { "engines": {
"node": ">=14.15.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
} }
}, },
"node_modules/side-channel": { "node_modules/side-channel": {
@@ -2583,12 +3112,13 @@
} }
}, },
"node_modules/tar-stream": { "node_modules/tar-stream": {
"version": "3.1.7", "version": "3.1.8",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz",
"integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", "integrity": "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"b4a": "^1.6.4", "b4a": "^1.6.4",
"bare-fs": "^4.5.5",
"fast-fifo": "^1.2.0", "fast-fifo": "^1.2.0",
"streamx": "^2.15.0" "streamx": "^2.15.0"
} }
@@ -2598,7 +3128,6 @@
"resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz",
"integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==",
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"streamx": "^2.12.5" "streamx": "^2.12.5"
} }
@@ -2657,6 +3186,13 @@
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
},
"node_modules/tunnel-agent": { "node_modules/tunnel-agent": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",

View File

@@ -1,11 +1,15 @@
{ {
"name": "@ztimson/net-navi", "name": "@ztimson/net-navi",
"version": "1.0.0", "version": "1.0.0",
"description": "Network Navigation Program",
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@ztimson/ai-utils": "^0.8.5", "@ztimson/ai-utils": "^0.8.9",
"@ztimson/utils": "^0.28.14",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"sharp": "^0.34.5",
"socket.io": "^4.6.1" "socket.io": "^4.6.1"
}, },
"scripts": { "scripts": {

View File

@@ -0,0 +1,237 @@
import {TTS} from '../tts.mjs';
class AvatarComponent extends HTMLElement {
static get observedAttributes() { return []; }
constructor() {
super();
this.attachShadow({mode: 'open'});
this.activeEmotes = [];
this.mouthSvg = null;
this.mouthState = 'closed';
this.setupMouthAnimation();
this.navi = window.navi;
this.navi.animations().then(animations => {
this.animations = animations;
if(!this.animations) return console.error(`Invalid animations: ${this.animations}`);
this.render(this.animations);
navi.on('emote', emote => this.emote(emote));
});
window.emote = this.emote.bind(this);
}
render(data) {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
position: relative;
pointer-events: none;
width: 500px;
height: 500px;
}
.avatar-container {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.avatar {
max-width: 100%;
max-height: 100%;
object-fit: contain;
filter: drop-shadow(2px 4px 6px black);
}
.emote-overlay {
position: absolute;
width: auto;
height: auto;
object-fit: contain;
pointer-events: none;
}
.mouth-overlay {
position: absolute;
pointer-events: none;
z-index: 10;
}
</style>
<div class="avatar-container">
<img src="${this.navi.avatar}" class="avatar" alt="Avatar">
</div>`;
this.loadMouthSvg();
}
loadMouthSvg() {
fetch('/emotes/mouth.svg').then(r => r.text()).then(svg => {
const container = document.createElement('div');
container.className = 'mouth-overlay';
container.innerHTML = svg;
this.mouthSvg = container.firstElementChild;
const mouthPos = this.animations?.emote?.['mouth'] || {x: 50, y: 60, r: 0};
container.style.left = `${mouthPos.x}%`;
container.style.top = `${mouthPos.y}%`;
container.style.transform = `translate(-50%, -50%) rotate(${mouthPos.r || 0}deg)`;
container.style.width = '50px';
container.style.height = '25px';
const avatarContainer = this.shadowRoot.querySelector('.avatar-container');
if(avatarContainer) avatarContainer.appendChild(container);
});
}
setupMouthAnimation() {
const tts = TTS.getInstance();
let mouthAnimationInterval = null;
tts.on('onSentenceStart', () => {
if(mouthAnimationInterval) return;
const next = () => {
mouthAnimationInterval = setTimeout(() => {
next();
this.toggleMouthState();
}, ~~(Math.random() * 100) + 100);
}
next();
});
tts.on('onSentenceEnd', () => this.setMouthState('closed'));
tts.on('onComplete', () => {
if(mouthAnimationInterval) {
clearTimeout(mouthAnimationInterval);
mouthAnimationInterval = null;
}
this.setMouthState('closed');
});
}
toggleMouthState() {
if(!this.mouthSvg) return;
this.setMouthState(this.mouthState === 'open' ? 'partial' : 'open');
}
setMouthState(state) {
if(!this.mouthSvg) return;
this.mouthState = state;
this.mouthSvg.classList.remove('closed', 'partial', 'open');
this.mouthSvg.classList.add(state);
}
clear(all = true) {
if(all) {
const a = this.shadowRoot.querySelector('.avatar');
a.animate([{filter: 'drop-shadow(2px 4px 6px black) grayscale(0%) brightness(100%)'}], {duration: 100, fill: 'forwards'});
}
this.activeEmotes.forEach(e => e.remove());
this.activeEmotes = [];
}
emote(emote) {
const animate = (e, emote, style, index) => {
const duration = 3000;
if(emote === 'blush') {
e.animate([
{transform: `scale(0.75) rotate(${style.r ?? 0}deg)`, opacity: 0},
{transform: `scale(0.75) rotate(${style.r ?? 0}deg)`, opacity: 1}
], {duration: duration, easing: 'ease-out', fill: 'forwards'});
} else if(emote === 'cry') {
e.animate([
{transform: `rotate(${style.r ?? 0}deg) translateX(50%) translateY(-25%) scale(0)`},
{transform: `rotate(${style.r ?? 0}deg) translateX(50%) translateY(0) scale(0.5)`}
], {duration: duration, easing: 'ease-out', fill: 'forwards'});
} else if(emote === 'dead') {
e.animate([
{transform: `rotate(${style.r ?? 0}deg) scale(2) translateY(0)`},
{transform: `rotate(${style.r ?? 0}deg) scale(2) translateY(-10%)`}
], {duration: duration, easing: 'ease-out', fill: 'forwards'});
setTimeout(() => {
e.animate([
{transform: `rotate(${style.r ?? 0}deg) scale(2) translateY(-10%)`},
{transform: `rotate(${style.r ?? 0}deg) scale(2) translateY(-8%)`}
], {duration: 1500, easing: 'ease-in-out', iterations: Infinity, direction: 'alternate'});
}, duration);
} else if(emote === 'drool') {
e.src = `${this.navi.api}/emotes/tear.png`;
e.animate([
{transform: `rotate(${style.r ?? 0}deg) translateX(50%) translateY(-12.5%) scale(0)`},
{transform: `rotate(${style.r ?? 0}deg) translateX(50%) translateY(0) scale(0.25)`}
], {duration: duration, easing: 'ease-out', fill: 'forwards'});
} else if(emote === 'love') {
e.animate([
{transform: `rotate(${style.r ?? 0}deg) scale(0.5)`},
{transform: `rotate(${style.r ?? 0}deg) scale(0.7)`}
], {duration: 200, easing: 'steps(2, jump-end)', iterations: Infinity, direction: 'alternate'});
} else if(emote === 'question') {
e.style.transform = `rotate(${style.r ?? 0}deg)`;
e.animate([
{opacity: 1, offset: 0},
{opacity: 1, offset: 0.49},
{opacity: 0, offset: 0.5},
{opacity: 0, offset: 1}
], {duration: 200, iterations: Infinity, direction: 'alternate', delay: (index % 2) * 200});
} else if(emote === 'realize') {
e.animate([
{transform: `rotate(${style.r ?? 0}deg) scale(0.9)`},
{transform: `rotate(${style.r ?? 0}deg) scale(1.1)`}
], {duration: 500, easing: 'ease-out', iterations: Infinity, direction: 'alternate'});
} else if(emote === 'stars') {
e.animate([
{transform: `rotate(${style.r ?? 0}deg) scale(0.25)`},
{transform: `rotate(${style.r ?? 0}deg) scale(0.3)`}
], {duration: 100, easing: 'steps(2, jump-end)', iterations: Infinity, direction: 'alternate'});
} else if(emote === 'stress') {
e.animate([
{transform: `rotate(${style.r ?? 0}deg) scale(0.9)`},
{transform: `rotate(${style.r ?? 0}deg) scale(1.1)`}
], {duration: 333, easing: 'ease-out', iterations: Infinity, direction: 'alternate'});
} else if(emote === 'sigh') {
e.animate([
{transform: `rotate(${style.r ?? 0}deg) translate(0, 0)`},
{transform: `rotate(${style.r ?? 0}deg) translate(10%, 10%)`}
], {duration: duration, easing: 'ease-out', fill: 'forwards'});
} else if(emote === 'sweat') {
e.animate([
{transform: `rotate(${style.r ?? 0}deg) scale(0.5) translateY(0)`},
{transform: `rotate(${style.r ?? 0}deg) scale(0.5) translateY(20%)`}
], {duration: duration, easing: 'ease-out', fill: 'forwards'});
} else if(emote === 'tear') {
e.animate([
{transform: `rotate(${style.r ?? 0}deg) translateX(50%) translateY(-15%) scale(0)`},
{transform: `rotate(${style.r ?? 0}deg) translateX(50%) translateY(0) scale(0.3)`}
], {duration: duration, easing: 'ease-out', fill: 'forwards'});
}
};
if(!emote || emote === 'none') return this.clear();
if(!this.animations.emote[emote]) throw new Error(`Invalid animation: ${emote}`);
const pos = this.animations.emote[emote];
this.clear(false);
const a = this.shadowRoot.querySelector('.avatar');
const container = this.shadowRoot.querySelector('.avatar-container');
const positions = Array.isArray(pos) ? pos : (pos.x != null ? [pos] : []);
if(['dead', 'grey'].includes(emote)) {
a.animate([
{filter: 'drop-shadow(2px 4px 6px black) grayscale(100%) brightness(150%)'}
], {duration: 100, fill: 'forwards'});
} else {
a.animate([
{filter: 'drop-shadow(2px 4px 6px black) grayscale(0%) brightness(100%)'}
], {duration: 100, fill: 'forwards'});
}
positions.forEach((p, i) => {
const e = document.createElement('img');
e.className = 'emote-overlay';
e.src = `${this.navi.api}/emotes/${emote}.png`;
e.style.top = `${p.y}%`;
e.style.left = `${p.x}%`;
container.appendChild(e);
this.activeEmotes.push(e);
animate(e, emote, p, i);
});
}
}
customElements.define('avatar-component', AvatarComponent);

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

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

View File

@@ -1,35 +1,28 @@
import './btn.mjs';
class JukeboxComponent extends HTMLElement { class JukeboxComponent extends HTMLElement {
constructor() { constructor() {
super(); super();
this.attachShadow({ mode: 'open' }); this.attachShadow({ mode: 'open' });
this.navi = window.navi;
// Use global singleton 🎵 this.navi.init();
this.api = window.netNaviAPI; this.unsubscribeWorld = this.navi.on('world:data', (data) => {
console.log(data, this.navi.world.data);
this.render()
});
this.playlist = []; this.playlist = [];
this.currentTrackIndex = 0; this.currentTrackIndex = 0;
this.bgMusic = null; this.bgMusic = null;
this.isMuted = false; this.isMuted = false;
this.hasInteracted = false; this.hasInteracted = false;
this.theme = null;
this.isPlaylistMode = false; this.isPlaylistMode = false;
} }
connectedCallback() {
this.render();
this.setupAPIListeners();
}
disconnectedCallback() { disconnectedCallback() {
if(this.unsubscribeWorld) this.unsubscribeWorld(); if(this.unsubscribeWorld) this.unsubscribeWorld();
} }
setupAPIListeners() {
this.unsubscribeWorld = this.api.on('world:loaded', (data) => {
if(data.theme?.music) this.loadMusic(data.theme.music, data.theme);
});
}
render() { render() {
this.shadowRoot.innerHTML = ` this.shadowRoot.innerHTML = `
<style> <style>
@@ -44,8 +37,8 @@ class JukeboxComponent extends HTMLElement {
display: flex; display: flex;
top: 20px; top: 20px;
right: 20px; right: 20px;
background: var(--dialogue-header-bg, #ffffff); background: ${this.navi.world.data.theme.colors.primary};
border: 3px solid var(--dialogue-border, #000); border: 3px solid ${this.navi.world.data.theme.colors.border};
border-radius: 8px; border-radius: 8px;
padding: 12px; padding: 12px;
z-index: 1000; z-index: 1000;
@@ -55,8 +48,9 @@ class JukeboxComponent extends HTMLElement {
.track-info { .track-info {
display: flex; display: flex;
align-items: center; align-items: center;
background: var(--dialogue-bg, #8b5cf6); background: ${this.navi.world.data.theme.colors.background};
color: var(--dialogue-text, #ffffff); border: 2px solid ${this.navi.world.data.theme.colors.border};
color: ${this.navi.world.data.theme.colors.text};
padding: 8px; padding: 8px;
border-radius: 4px; border-radius: 4px;
margin: 0 8px; margin: 0 8px;
@@ -64,6 +58,7 @@ class JukeboxComponent extends HTMLElement {
height: 24px; height: 24px;
width: 120px; width: 120px;
position: relative; position: relative;
cursor: text;
} }
.track-name { .track-name {
white-space: nowrap; white-space: nowrap;
@@ -77,69 +72,37 @@ class JukeboxComponent extends HTMLElement {
0% { transform: translateX(0); } 0% { transform: translateX(0); }
100% { transform: translateX(-100%); } 100% { transform: translateX(-100%); }
} }
.controls-row { .hidden {
display: flex; display: none;
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;
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> </style>
<button class="control-btn" id="simple-mute-btn"> <btn-component id="simple-mute-btn" color="${this.navi.world.data.theme.colors.accent}">
<svg viewBox="0 0 24 24"> <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"/> <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> </svg>
</button> </btn-component>
<div class="audio-controls hidden" id="playlist-controls"> <div class="audio-controls hidden" id="playlist-controls">
<div class="controls-row"> <btn-component id="prev-btn" color="${this.navi.world.data.theme.colors.accent}">
<button class="control-btn" id="prev-btn">
<svg viewBox="0 0 24 24"> <svg viewBox="0 0 24 24">
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/> <path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
</svg> </svg>
</button> </btn-component>
</div>
<div class="track-info"> <div class="track-info">
<span class="track-name" id="track-name">No track loaded</span> <span class="track-name" id="track-name">No track loaded</span>
</div> </div>
<div class="controls-row"> <div style="display: flex; gap: 8px">
<button class="control-btn" id="next-btn"> <btn-component id="next-btn" color="${this.navi.world.data.theme.colors.accent}">
<svg viewBox="0 0 24 24"> <svg viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/> <path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/>
</svg> </svg>
</button> </btn-component>
<button class="control-btn" id="mute-btn"> <btn-component id="mute-btn" color="#c0392b">
<svg viewBox="0 0 24 24"> <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"/> <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> </svg>
</button> </btn-component>
</div> </div>
</div> </div>
`; `;
@@ -147,18 +110,15 @@ class JukeboxComponent extends HTMLElement {
this.shadowRoot.getElementById('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('prev-btn').addEventListener('click', () => this.previousTrack());
this.shadowRoot.getElementById('next-btn').addEventListener('click', () => this.nextTrack()); this.shadowRoot.getElementById('next-btn').addEventListener('click', () => this.nextTrack());
this.loadMusic(this.navi.world.data.theme.music);
} }
loadMusic(musicConfig, theme) { loadMusic(musicConfig) {
if (!musicConfig) return; if(!musicConfig) return;
this.theme = theme;
this.isPlaylistMode = Array.isArray(musicConfig); this.isPlaylistMode = Array.isArray(musicConfig);
this.playlist = Array.isArray(musicConfig) ? musicConfig : [musicConfig]; this.playlist = Array.isArray(musicConfig) ? musicConfig : [musicConfig];
this.currentTrackIndex = 0; this.currentTrackIndex = 0;
this.applyThemeColors();
if (this.isPlaylistMode) { if (this.isPlaylistMode) {
this.shadowRoot.getElementById('simple-mute-btn').classList.add('hidden'); this.shadowRoot.getElementById('simple-mute-btn').classList.add('hidden');
this.shadowRoot.getElementById('playlist-controls').classList.remove('hidden'); this.shadowRoot.getElementById('playlist-controls').classList.remove('hidden');
@@ -205,15 +165,6 @@ class JukeboxComponent extends HTMLElement {
trackName.textContent = `[${trackNum}] ${fileName}`; 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() { setupAutoplayHandler() {
const startMusic = () => { const startMusic = () => {
if (!this.hasInteracted && !this.isMuted && this.bgMusic) { if (!this.hasInteracted && !this.isMuted && this.bgMusic) {

977
public/components/llm.mjs Normal file
View File

@@ -0,0 +1,977 @@
import './btn.mjs';
import {TTS} from '../tts.mjs';
class LlmComponent extends HTMLElement {
hideTools = ['emote', 'personalize', 'recall', 'remember']
get isOpen() { return this.isDialogueOpen; };
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.navi = window.navi;
this.navi.init().then(() => this.render());
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;
// TTS setup
this.tts = null;
this.streamingSpeech = null;
this.autoSpeak = false;
this.speakingMessageIdx = null;
}
render() {
this.shadowRoot.innerHTML = `
<style>
::-webkit-scrollbar {
width: 12px;
}
::-webkit-scrollbar-track {
background: transparent;
border: none;
}
::-webkit-scrollbar-thumb {
background: ${this.navi.theme.primary};
border: 2px solid ${this.navi.theme.border};
border-radius: 9px;
}
:host {
display: block;
}
#dialogue-box {
position: fixed;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 600px;
max-width: 90vw;
height: 60%;
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: ${this.navi.theme.background};
border: 5px solid ${this.navi.theme.border};
border-radius: 16px 16px 0 0;
padding: 0;
box-shadow: 0 -4px 20px rgba(0,0,0,0.5);
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 {
user-select: none;
padding: 10px 10px 10px 15px;
background: ${this.navi.theme.primary};
border-bottom: 3px solid ${this.navi.theme.border};
border-radius: 12px 12px 0 0;
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
#dialogue-box.expanded .dialogue-header {
border-radius: 0;
}
.header-buttons {
display: flex;
gap: 0.5rem;
}
.message-body {
padding: 1.75rem 1.25rem;
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1.75rem;
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: ${this.navi.theme.text};
font-family: 'Courier New', monospace;
opacity: 0.7;
padding: 0 8px;
}
.message-bubble {
font-size: 15px;
line-height: 1.5;
font-family: 'Courier New', monospace;
word-wrap: break-word;
user-select: text;
}
.message-wrapper.user .message-bubble {
background: ${this.navi.theme.primary};
color: ${this.navi.theme.primaryContrast};
padding: 0.5rem 0.75rem;
border-radius: 12px;
border: 2px solid ${this.navi.theme.border};
}
.message-wrapper.navi .message-bubble {
color: ${this.navi.theme.text};
}
.message-actions {
display: flex;
gap: 0.5rem;
padding: 0 8px;
opacity: 0.6;
}
.message-action-btn {
background: transparent;
border: none;
color: ${this.navi.theme.text};
cursor: pointer;
padding: 0;
font-size: 14px;
transition: opacity 0.2s;
}
.message-action-btn:hover {
opacity: 1;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
text-align: center;;
height: 100%;
font-size: 32px;
font-weight: bold;
color: ${this.navi.theme.text};
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: ${this.navi.theme.primary};
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: ${this.navi.theme.primary};
color: ${this.navi.theme.primaryContrast};
padding: 4px 10px;
margin: 0;
border: 2px solid ${this.navi.theme.border};
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.navi .message-files {
justify-content: flex-start;
}
.attached-files {
padding: 8px 12px;
border-top: 3px solid ${this.navi.theme.border};
background: ${this.navi.theme.backgroundDark};
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: ${this.navi.theme.primary};
color: ${this.navi.theme.primaryContrast};
border: 2px solid ${this.navi.theme.border};
padding: 6px 10px;
border-radius: 6px;
font-family: 'Courier New', monospace;
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: 1px solid black;
color: #fff;
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 ${this.navi.theme.border};
background: ${this.navi.theme.backgroundDark};
flex-shrink: 0;
}
.input-wrapper {
margin-bottom: 8px;
}
.dialogue-input textarea {
width: 100%;
background: ${this.navi.theme.background};
border: 3px solid ${this.navi.theme.border};
padding: 10px 14px;
font-family: 'Courier New', monospace;
font-size: 16px;
border-radius: 4px;
color: ${this.navi.theme.text};
resize: none;
min-height: 44px;
max-height: 120px;
overflow-y: auto;
box-sizing: border-box;
}
.dialogue-input textarea:focus {
outline: none;
}
.dialogue-input textarea:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.button-row {
display: flex;
gap: 8px;
padding-bottom: 5px;
}
.clear-btn, .attach-btn, .dialogue-send-btn {
background: ${this.navi.theme.accent};
border: 3px solid ${this.navi.theme.border};
color: ${this.navi.theme.accentContrast};
padding: 10px 18px;
font-family: 'Courier New', monospace;
font-weight: bold;
font-size: 14px;
border-radius: 4px;
box-shadow: 0 3px 0 ${this.navi.theme.accentDark};
transition: background 0.2s;
white-space: nowrap;
flex: 1;
}
.dialogue-send-btn.stop {
background: #e74c3c;
box-shadow: 0 1px 0 #c0392b;
}
.dialogue-send-btn.stop:active {
box-shadow: 0 1px 0 #c0392b;
}
#file-input {
display: none;
}
.flex-fill-even {
flex: 1 1 0;
}
</style>
<div id="dialogue-box" class="minimized">
<div class="dialogue-content">
<div class="dialogue-header" id="dialogue-header">
<div style="display: flex; align-items: center; gap: 0.5rem; flex-grow: 1;">
<img alt="logo" src="${this.navi.icon}" style="height: 32px; width: auto;">
<span style="color: ${this.navi.theme.primaryContrast}; font-size: 1.75rem">${this.navi.info.name}</span>
</div>
<div class="header-buttons">
<btn-component id="autospeak-btn" color="${this.navi.theme.accent}">
<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>
</btn-component>
<btn-component id="expand-btn" color="${this.navi.theme.accent}">
<svg class="expand-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="3" width="8" height="8" stroke="currentColor" stroke-width="2.5" fill="none"/>
<rect x="13" y="13" width="8" height="8" stroke="currentColor" stroke-width="2.5" fill="none"/>
<path d="M11 3 L11 11 L3 11" stroke="currentColor" stroke-width="2.5" fill="none"/>
<path d="M13 21 L13 13 L21 13" stroke="currentColor" stroke-width="2.5" fill="none"/>
</svg>
</btn-component>
</div>
</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">
<btn-component class="flex-fill-even" id="clear-btn" color="#c0392b">CLEAR</btn-component>
<btn-component class="flex-fill-even" id="attach-btn" color="${this.navi.theme.accent}">ATTACH</btn-component>
<btn-component class="flex-fill-even" id="dialogue-send" color="${this.navi.theme.accent}">SEND</btn-component>
</div>
</div>
<input type="file" id="file-input" multiple accept="*/*">
</div>
</div>
`;
// Init TTS
this.tts = TTS.getInstance();
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');
const autospeakBtn = this.shadowRoot.getElementById('autospeak-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.closest('.header-buttons')) return;
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();
});
autospeakBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.toggleAutoSpeak();
});
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();
}
});
}
toggleAutoSpeak() {
this.autoSpeak = !this.autoSpeak;
const btn = this.shadowRoot.getElementById('autospeak-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>';
btn.innerHTML = this.autoSpeak ? unmutedSVG : mutedSVG;
if (!this.autoSpeak && this.tts) {
this.tts.stop();
this.streamingSpeech = null;
}
}
toggleSpeech(idx) {
if (!this.tts) return;
if (this.speakingMessageIdx === idx) {
this.tts.stop();
this.speakingMessageIdx = null;
this.updateMessageActions();
return;
}
this.tts.stop();
const message = this.messageHistory[idx];
if (!message || message.isUser || !message.text) return;
this.speakingMessageIdx = idx;
this.updateMessageActions();
this.tts.speak(message.text).then(() => {
this.speakingMessageIdx = null;
this.updateMessageActions();
}).catch(() => {
this.speakingMessageIdx = null;
this.updateMessageActions();
});
}
updateMessageActions() {
this.messageHistory.forEach((msg, idx) => {
if (msg.isUser || !msg.element) return;
let actionsDiv = msg.element.querySelector('.message-actions');
if (!actionsDiv) {
actionsDiv = document.createElement('div');
actionsDiv.className = 'message-actions';
msg.element.appendChild(actionsDiv);
}
const isSpeaking = this.speakingMessageIdx === idx;
actionsDiv.innerHTML = `
<button class="message-action-btn" data-action="speak" data-idx="${idx}">
<i class="fa ${isSpeaking ? 'fa-stop' : 'fa-volume-up'}"></i>
</button>
`;
const speakBtn = actionsDiv.querySelector('[data-action="speak"]');
speakBtn.addEventListener('click', () => this.toggleSpeech(idx));
});
}
clearChat() {
this.messageHistory = [];
const messageBody = this.shadowRoot.getElementById('message-body');
messageBody.innerHTML = '<div class="empty-state">NetNavi v1.0.0</div>';
this.navi.clearChat();
if (this.tts) this.tts.stop();
this.speakingMessageIdx = null;
}
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);
});
}
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 }
}));
}
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' : 'navi'}`;
const fileBadgesHtml = fileBadges.length > 0
? `<div class="message-files">${fileBadges.map(name =>
`<span class="file-badge">📄 ${name}</span>`).join('')}</div>`
: '';
messageWrapper.innerHTML = `
${fileBadgesHtml}
<div class="message-bubble">${cleanText}</div>`;
messageBody.appendChild(messageWrapper);
const msgData = { text, html: cleanText, isUser, element: messageWrapper, timestamp: Date.now() };
this.messageHistory.push(msgData);
if (!isUser) {
this.updateMessageActions();
}
this.scrollToBottom();
}
startStreaming() {
this.isReceiving = true;
this.streamComplete = false;
this.streamBuffer = '';
this.typingIndex = 0;
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');
attachBtn.disabled = true;
clearBtn.disabled = true;
dialogueSend.textContent = 'STOP';
dialogueSend.setAttribute('color', '#c0392b');
attachBtn.setAttribute('disabled', true);
clearBtn.setAttribute('disabled', true);
const emptyState = messageBody.querySelector('.empty-state');
if (emptyState) messageBody.innerHTML = '';
const messageWrapper = document.createElement('div');
messageWrapper.className = 'message-wrapper navi';
messageWrapper.innerHTML = `<div class="message-bubble" id="streaming-bubble"></div>`;
messageBody.appendChild(messageWrapper);
this.currentStreamingMessage = { text: '', html: '', isUser: false, element: messageWrapper, timestamp: Date.now() };
this.messageHistory.push(this.currentStreamingMessage);
// Start TTS streaming if autoSpeak is on
if (this.autoSpeak && this.tts) {
this.streamingSpeech = this.tts.speakStream();
}
this.scrollToBottom();
this.typingInterval = setInterval(() => this.typeNextChar(), 30);
}
handleStreamChunk(chunk) {
if (!this.isReceiving) this.startStreaming();
if (chunk.text) {
this.streamBuffer += chunk.text;
// Feed to TTS stream
if (this.streamingSpeech) {
this.streamingSpeech.next(chunk.text);
}
}
if (chunk.tool && !this.hideTools.includes(chunk.tool)) this.streamBuffer += `<span class="tool-call">⚡ ${chunk.tool}</span>`;
}
async handleStreamComplete(response) {
this.streamComplete = true;
if (this.typingIndex >= this.streamBuffer.length) {
await this.cleanupStreaming();
} else {
const dialogueSend = this.shadowRoot.getElementById('dialogue-send');
dialogueSend.textContent = 'SKIP';
dialogueSend.setAttribute('color', '#f39c12');
}
}
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();
}
async 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();
await this.cleanupStreaming();
}
async cleanupStreaming() {
clearInterval(this.typingInterval);
// Finalize TTS stream
if (this.streamingSpeech) {
await this.streamingSpeech.done();
this.streamingSpeech = null;
}
this.isReceiving = false;
this.streamComplete = false;
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');
attachBtn.disabled = false;
clearBtn.disabled = false;
dialogueSend.textContent = 'SEND';
dialogueSend.setAttribute('color', this.navi.theme.accent);
attachBtn.removeAttribute('disabled');
clearBtn.removeAttribute('disabled');
if (bubble) {
bubble.id = '';
bubble.innerHTML = this.currentStreamingMessage.html;
}
this.updateMessageActions();
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;
}
if (this.tts) this.tts.stop();
this.streamingSpeech = null;
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.navi.ask(text, (chunk) => this.handleStreamChunk(chunk));
// Handle completion/errors with promise
try {
const response = await this.currentRequest;
await 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);
}
await this.cleanupStreaming();
}
}
}
customElements.define('llm-component', LlmComponent);

380
public/components/world.mjs Normal file
View File

@@ -0,0 +1,380 @@
// ============================================
// CONSTANTS
// ============================================
const TILE_WIDTH = 64;
const TILE_HEIGHT = 32;
const TILE_DEPTH = 16;
// ============================================
// 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.world = null;
this.app = null;
this.pet = null;
this.otherPlayers = new Map();
this.isMoving = false;
this.dialogue = null;
this.keys = {};
// Use global singleton 🌍
this.navi = window.navi;
this.worldActions = null;
this.playerInfo = {
name: 'Guest',
apiUrl: this.navi.navi
};
}
async init() {
try {
// Join world with callbacks 🌍
await this.navi.init();
this.worldActions = this.navi.connect(this.worldId);
this.worldActions.onData = (data) => {
this.world = data;
applyTheme(this.world.theme);
this.initializeRenderer();
};
this.worldActions.onPlayers = (players) => {
players.forEach(player => {
if(player.name !== this.navi.info.name) {
this.addOtherPlayer(player);
}
});
};
this.worldActions.onJoined = (player) => {
this.addOtherPlayer(player);
};
this.worldActions.onMoved = (data) => {
const sprite = this.otherPlayers.get(data.socketId);
if(sprite) {
this.moveOtherPlayer(sprite, data.x, data.y);
}
};
this.worldActions.onLeft = (data) => {
const sprite = this.otherPlayers.get(data.socketId);
if(sprite) this.otherPlayers.delete(data.socketId);
};
this.worldActions.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.getElementById('game').appendChild(this.app.view);
const tiles = new PIXI.Container();
this.app.stage.addChild(tiles);
this.world.tiles.forEach(tileData => {
const tile = createTile(tileData, this.world.theme);
tile.on('pointerdown', () => this.movePetTo(tile.gridX, tile.gridY));
tiles.addChild(tile);
});
const spawn = this.world.tiles.find(t => t.type === 'spawn');
this.pet = createPet(spawn.x, spawn.y, this.playerInfo.name);
this.app.stage.addChild(this.pet);
this.dialogue = document.getElementById('llm');
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();

BIN
public/emotes/blush.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

BIN
public/emotes/cry.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
public/emotes/dead.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

BIN
public/emotes/love.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

30
public/emotes/mouth.svg Normal file
View File

@@ -0,0 +1,30 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 50" class="mouth closed">
<defs>
<style>
.mouth-path {
stroke: #000;
stroke-width: 2;
transition: d 0.15s ease-out;
}
/* Closed state - slight natural curve smile */
.mouth.closed .mouth-path {
d: path("M 20 25 Q 50 30 80 25");
}
/* Partial open - opens downward */
.mouth.partial .mouth-path {
fill: #ff6b9d;
d: path("M 20 25 Q 50 35 80 25");
}
/* Open - wider downward opening */
.mouth.open .mouth-path {
fill: #ff6b9d;
d: path("M 20 25 Q 50 42 80 25");
}
</style>
</defs>
<path class="mouth-path" d="M 20 25 Q 50 28 80 25"/>
</svg>

After

Width:  |  Height:  |  Size: 669 B

BIN
public/emotes/question.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

BIN
public/emotes/realize.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

BIN
public/emotes/sigh.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

BIN
public/emotes/stress.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

BIN
public/emotes/sweat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

BIN
public/emotes/tear.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -1,20 +1,34 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<title>NetNavi v1.0.0</title> <title>NetNavi</title>
<link rel="icon" href="/favicon.png" /> <link rel="icon" href="/favicon.png"/>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="{{THEME_PRIMARY}}">
<meta property="og:title" content="NetNavi">
<meta name="apple-mobile-web-app-title" content="NetNavi">
<meta property="og:site_name" content="NetNavi">
<meta name="description" content="Network Navigation Program">
<meta property="og:description" content="Network Navigation Program">
<meta property="og:image" content="/banner?size=1200x630">
<meta property="og:logo" content="/favicon?size=180">
<meta name="apple-touch-icon" content="/favicon.png">
<meta name="apple-touch-startup-image" content="/favicon.png">
<meta property="og:url" content="{{PUBLIC_URL}}">
<meta property="og:type" content="website">
<meta name="twitter:card" content="summary_large_image">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<link rel="manifest" href="/manifest.json">
<script src="https://cdn.socket.io/4.6.0/socket.io.min.js"></script> <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> <style>
* { * {
margin: 0; box-sizing: border-box !important;
padding: 0;
box-sizing: border-box;
} }
html, body { html, body {
@@ -22,6 +36,8 @@
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
font-family: 'Courier New', monospace; font-family: 'Courier New', monospace;
margin: 0;
padding: 0;
} }
*, button, input { *, button, input {
@@ -33,26 +49,110 @@
background-position: center; background-position: center;
background-repeat: no-repeat; background-repeat: no-repeat;
background-attachment: fixed; background-attachment: fixed;
background: black;
transition: 1s;
} }
canvas { .digital-background {
display: block; position: fixed;
position: absolute;
top: 0; top: 0;
left: 0; left: 0;
width: 100%;
height: 100%;
opacity: 0.3;
pointer-events: none;
}
@keyframes scanline {
0% { transform: translateY(-100%); }
50% { transform: translateY(100%); }
100% { transform: translateY(100%); }
}
.scanline {
animation: scanline 11s linear infinite;
}
@keyframes up-down {
0% { transform: translateY(0); }
50% { transform: translateY(-50px); }
100% { transform: translateY(0); }
}
.up-down {
animation: up-down 31s ease-in-out infinite;
}
@keyframes left-right {
0% { transform: translateX(0); }
50% { transform: translateX(-50px); }
100% { transform: translateX(0); }
}
.left-right {
animation: left-right 37s ease-in-out infinite;
}
#avatar {
position:fixed;
height: 110%;
width: auto;
}
@media (orientation: landscape) {
left:0;
bottom: 0;
transform: translateY(20%);
}
@media (orientation: portrait) {
left: 50%;
bottom: 50%;
transform: translate(-50%, 50%);
} }
</style> </style>
</head> </head>
<body> <body>
<div id="game"></div> <svg class="digital-background" xmlns="http://www.w3.org/2000/svg">
<jukebox-component id="jukebox"></jukebox-component> <defs>
<pattern id="grid" width="50" height="50" patternUnits="userSpaceOnUse">
<path d="M 50 0 L 0 0 0 50" fill="none" stroke="#ffffffaa" stroke-width="0.5"/>
</pattern>
<pattern id="gridL" width="200" height="200" patternUnits="userSpaceOnUse">
<path d="M 200 0 L 0 0 0 200" fill="none" stroke="#ffffffaa" stroke-width="1"/>
</pattern>
<linearGradient id="bg-gradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#0a0a0a;stop-opacity:0.7" />
<stop offset="100%" style="stop-color:#1a1a2e;stop-opacity:0.7" />
</linearGradient>
</defs>
<!-- Background gradient -->
<rect width="100%" height="100%" fill="url(#bg-gradient)"/>
<!-- Digital grid -->
<rect class="up-down" width="100%" height="120%" fill="url(#grid)"/>
<rect class="left-right" width="120%" height="100%" fill="url(#gridL)"/>
</svg>
<avatar-component id="avatar"></avatar-component>
<svg class="digital-background" xmlns="http://www.w3.org/2000/svg">
<!-- Scanline effect -->
<rect class="scanline" width="100%" height="8" fill="#ffffff22"/>
</svg>
<llm-component id="llm"></llm-component> <llm-component id="llm"></llm-component>
<script type="module">
import Navi from './navi.mjs';
<script src="/netnavi-api.js"></script> const navi = window.navi = new Navi();
<script>window.netNaviAPI = new NetNaviAPI();</script> navi.init().then(async () => {
<script src="/jukebox.js"></script> document.body.style.background = navi.theme.accent;
<script src="/llm.js"></script> });
<script src="/world.js"></script> </script>
<script type="module" src="/components/avatar.mjs"></script>
<script type="module" src="/components/llm.mjs"></script>
</body> </body>
</html> </html>

View File

@@ -1,934 +0,0 @@
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 {
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;
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;
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;
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);

296
public/navi.mjs Normal file
View File

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

View File

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

169
public/tts.mjs Normal file
View File

@@ -0,0 +1,169 @@
// tts.service.mjs
export class TTS {
static QUALITY_PATTERNS = ['Google', 'Microsoft', 'Samantha', 'Premium', 'Natural', 'Neural'];
static _errorHandlerInstalled = false;
static _instance = null;
_currentUtterance = null;
_voicesLoaded;
_stoppedUtterances = new WeakSet();
_rate = 1;
_pitch = 1;
_volume = 1;
_voice;
_hooks = {
onSentenceStart: [],
onSentenceEnd: [],
onChunk: [],
onComplete: []
};
constructor({ rate, pitch, volume, voice } = {}) {
TTS.installErrorHandler();
this._voicesLoaded = this.initializeVoices();
if (rate !== undefined) this._rate = rate;
if (pitch !== undefined) this._pitch = pitch;
if (volume !== undefined) this._volume = volume;
this._voice = voice === null ? undefined : voice;
}
static getInstance(config) {
if(!this._instance) this._instance = new TTS(config);
return this._instance;
}
static installErrorHandler() {
if (this._errorHandlerInstalled) return;
window.addEventListener('unhandledrejection', e => {
if (e.reason?.error === 'interrupted' && e.reason instanceof SpeechSynthesisErrorEvent) e.preventDefault();
});
this._errorHandlerInstalled = true;
}
initializeVoices() {
return new Promise(res => {
const voices = window.speechSynthesis.getVoices();
if (voices.length) {
if (!this._voice) this._voice = TTS.bestVoice();
res();
} else {
const h = () => {
window.speechSynthesis.removeEventListener('voiceschanged', h);
if (!this._voice) this._voice = TTS.bestVoice();
res();
};
window.speechSynthesis.addEventListener('voiceschanged', h);
}
});
}
static bestVoice(lang = 'en') {
const voices = window.speechSynthesis.getVoices();
for (const p of this.QUALITY_PATTERNS) {
const v = voices.find(v => v.name.includes(p) && v.lang.startsWith(lang));
if (v) return v;
}
return voices.find(v => v.lang.startsWith(lang));
}
static cleanText(t) {
return removeEmojis(t).replace(/```[\s\S]*?```/g, ' code block ').replace(/[#*_~`]/g, '');
}
on(event, callback) {
if(this._hooks[event]) this._hooks[event].push(callback);
}
off(event, callback) {
if(this._hooks[event]) this._hooks[event] = this._hooks[event].filter(cb => cb !== callback);
}
_emit(event, data) {
if (this._hooks[event]) {
this._hooks[event].forEach(cb => {
try {
cb(data);
} catch (err) {
console.error(`❌ Error in ${event} hook:`, err);
}
});
}
}
createUtterance(t) {
const u = new SpeechSynthesisUtterance(TTS.cleanText(t));
const v = this._voice || TTS.bestVoice();
if (v) u.voice = v;
u.rate = this._rate;
u.pitch = this._pitch;
u.volume = this._volume;
return u;
}
async speak(t) {
if(!t.trim()) return;
await this._voicesLoaded;
if(this._currentUtterance && !this._isStreaming) this.stop();
return new Promise((res, rej) => {
this._currentUtterance = this.createUtterance(t);
const u = this._currentUtterance;
u.onend = () => {
this._currentUtterance = null;
res();
};
u.onerror = e => {
console.error('❌ Utterance error:', e);
this._currentUtterance = null;
if (this._stoppedUtterances.has(u) && e.error === 'interrupted') res();
else rej(e);
};
window.speechSynthesis.speak(u);
this._emit('onSentenceStart', {sentence: t});
}).finally(() => this._emit('onSentenceEnd', {sentence: t}));
}
stop() {
if (this._currentUtterance) this._stoppedUtterances.add(this._currentUtterance);
window.speechSynthesis.cancel();
this._currentUtterance = null;
this._isStreaming = false;
this._emit('onComplete', {});
}
speakStream() {
this._isStreaming = true;
let buf = '';
let sentenceQueue = Promise.resolve();
const rx = /[^.!?\n]+[.!?\n]+/g;
return {
next: t => {
buf += t;
this._emit('onChunk', { text: t });
const ss = buf.match(rx);
if(ss) {
ss.forEach(s => {
const sentence = s.trim();
sentenceQueue = sentenceQueue.then(async () => this.speak(sentence));
});
}
buf = buf.replace(rx, '');
},
done: async () => {
if (buf.trim()) {
const sentence = buf.trim();
sentenceQueue = sentenceQueue.then(async () => this.speak(sentence));
buf = '';
}
await sentenceQueue;
this._isStreaming = false;
this._emit('onComplete', {});
}
};
}
}
export function removeEmojis(str) {
const emojiRegex = /(?:[\u2700-\u27bf]|(?:\ud83c[\udde6-\uddff]){2}|[\ud83c[\udde6-\uddff]|[\ud83d[\ude00-\ude4f]|[\ud83d[\ude80-\udeff]|[\ud83c[\udd00-\uddff]|[\ud83d[\ude50-\ude7f]|[\u2600-\u26ff]|[\u2700-\u27bf]|[\ud83e[\udd00-\uddff]|[\ud83c[\udf00-\uffff]|[\ud83d[\ude00-\udeff]|[\ud83c[\udde6-\uddff])/g;
return str.replace(emojiRegex, '');
}

60
public/world.html Normal file
View File

@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html>
<head>
<title>NetNavi v1.0.0</title>
<link rel="icon" href="/favicon.png"/>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<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>
* {
box-sizing: border-box !important;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
font-family: 'Courier New', monospace;
margin: 0;
padding: 0;
}
*, button, input {
cursor: url('/assets/cursor.png'), auto !important;
}
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>
<div id="game"></div>
<jukebox-component id="jukebox"></jukebox-component>
<llm-component id="llm"></llm-component>
<script type="module">
import Navi from './navi.mjs';
window.navi = new Navi();
</script>
<script type="module" src="/components/jukebox.mjs"></script>
<script type="module" src="/components/llm.mjs"></script>
<script type="module" src="/components/world.mjs"></script>
</body>
</html>

View File

@@ -1,397 +0,0 @@
// ============================================
// 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.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) => {
players.forEach(player => {
if (player.socketId !== this.api.worldSocket.id) {
this.addOtherPlayer(player);
}
});
},
onPlayerJoined: (player) => {
this.addOtherPlayer(player);
},
onPlayerMoved: (data) => {
const sprite = this.otherPlayers.get(data.socketId);
if (sprite) {
this.moveOtherPlayer(sprite, data.x, data.y);
}
},
onPlayerLeft: (data) => {
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.getElementById('game').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();

27
src/environment.js Normal file
View File

@@ -0,0 +1,27 @@
import {dirname, join} from 'path';
import {fileURLToPath} from 'url';
import * as dotenv from 'dotenv';
dotenv.config({path: ['.env','.env.local'], debug: false, quiet: true});
const __filename = fileURLToPath(import.meta.url);
const __dirname = join(dirname(__filename), '..');
const storage = join(__dirname, 'storage');
export const environment = {
port: process.env.PORT || 3000,
publicUrl: process.env.PUBLIC_URL || `http://localhost:${process.env.PORT || 3000}`,
llm: {
host: process.env.LLM_HOST || '',
model: process.env.LLM_MODEL || 'default',
token: process.env.LLM_TOKEN || 'ignore',
context: process.env.LLM_CONTEXT ? +process.env.LLM_CONTEXT : 60_000.
},
paths: {
public: join(__dirname, 'public'),
storage,
navi: join(storage, 'navi'),
protocols: join(storage, 'protocols'),
worlds: join(storage, 'worlds')
}
}

View File

@@ -1,111 +1,262 @@
import express from 'express'; import express from 'express';
import { createServer } from 'http'; import {createServer} from 'http';
import { Server } from 'socket.io'; import {Server} from 'socket.io';
import cors from 'cors'; import cors from 'cors';
import fs from 'fs'; import fs from 'fs';
import { join, dirname } from 'path'; import {join, dirname} from 'path';
import { fileURLToPath } from 'url';
import {Ai, DateTimeTool, ExecTool, FetchTool, ReadWebpageTool, WebSearchTool} from '@ztimson/ai-utils'; import {Ai, DateTimeTool, ExecTool, FetchTool, ReadWebpageTool, WebSearchTool} from '@ztimson/ai-utils';
import {contrast, deepCopy, deepMerge, isEqual, shadeColor} from '@ztimson/utils';
import * as os from 'node:os';
import {environment} from './environment.js';
import {resize} from './utils.js';
import * as path from 'node:path';
const __filename = fileURLToPath(import.meta.url); // ============================================
const __dirname = dirname(__filename); // Settings
// ============================================
const app = express(); const logo = join(environment.paths.navi, 'logo.png');
const httpServer = createServer(app); const avatar = join(environment.paths.navi, 'avatar.png');
const sprite = join(environment.paths.navi, 'sprite.png');
const settingsFile = join(environment.paths.navi, 'settings.json');
const memoriesFile = join(environment.paths.navi, 'memories.json');
let memories = [], settings = {}; function calcColors(theme) {
const settingsFile = join(__dirname, '../navi', 'settings.json'); return {
const memoriesFile = join(__dirname, '../navi', 'memories.json'); ...theme,
const logoFile = join(__dirname, '../navi', 'logo.png'); backgroundContrast: contrast(theme.background),
backgroundDark: shadeColor(theme.background, -.1),
backgroundLight: shadeColor(theme.background, .1),
primaryContrast: contrast(theme.primary),
primaryDark: shadeColor(theme.primary, -.1),
primaryLight: shadeColor(theme.primary, .1),
accentContrast: contrast(theme.accent),
accentDark: shadeColor(theme.accent, -.1),
accentLight: shadeColor(theme.accent, .1),
mutedContrast: contrast(theme.muted),
mutedDark: shadeColor(theme.muted, -.1),
mutedLight: shadeColor(theme.muted, .1),
};
}
let orgSettings, orgMemories, memories = [], settings = {
name: 'Navi',
personality: '- Keep your responses the same length or shorter than the previous user message',
animations: {
emote: {
"dead": {"x": 50, "y": 17},
"grey": {},
"realization": {"x": 64, "y": -5},
"sigh": {"x": 57, "y": 30},
"sweat": {"x": 55, "y": 20},
"blush": {"x": 43, "y": 25, "r": 15},
"mouth": {"x": 53, "y": 31.75, "r": 20},
"question": [{"x": 10, "y": 10, "r": -40}, {"x": 26, "y": 6, "r": -20}, {"x": 50, "y": 5, "r": 10}, {"x": 70, "y": 10, "r": 40}],
"cry": [{"x": 25.5, "y": 26.75}, {"x": 37, "y": 27.75}],
"drool": {"x": 26, "y": 27.5},
"love": [{"x": 49, "y": 24, "r": 15}, {"x": 38.5, "y": 23, "r": 15}],
"realize": {"x": 65, "y": 12},
"stress": {"x": 55, "y": 19},
"tear": {"x": 23, "y": 25.5}
}
},
theme: {
background: '#fff',
border: '#000',
text: '#252525',
primary: '#9f32ef',
accent: '#6f16c3',
muted: '#a8a8a8',
}
};
// ============================================
// Saving
// ============================================
function load() { function load() {
try { try {
settings = JSON.parse(fs.readFileSync(settingsFile, 'utf-8')); orgSettings = JSON.parse(fs.readFileSync(settingsFile, 'utf-8'));
settings = deepMerge(settings, deepCopy(orgSettings));
} catch { } } catch { }
try { try {
memories = JSON.parse(fs.readFileSync(memoriesFile, 'utf-8')); memories = JSON.parse(fs.readFileSync(memoriesFile, 'utf-8'));
orgMemories = deepCopy(memories.map(m => ({...m, embeddings: undefined})));
} catch { } } catch { }
} }
function save() { function save() {
const dir = dirname(settingsFile); const dir = dirname(settingsFile);
if (!fs.existsSync(dir)) { if(!fs.existsSync(dir)) fs.mkdirSync(dir, {recursive: true});
fs.mkdir(dir, { recursive: true }, (err) => { if(!isEqual(orgSettings, settings)) {
if (err) throw err; // Fail loudly if dirs cant be made 💀
});
}
fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2)); fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2));
orgSettings = deepCopy(orgSettings);
}
const m = memories.map(m => ({...m, embeddings: undefined}));
if(!isEqual(orgMemories, m)) {
fs.writeFileSync(memoriesFile, JSON.stringify(memories, null, 2)); fs.writeFileSync(memoriesFile, JSON.stringify(memories, null, 2));
orgMemories = m;
}
} }
function shutdown() { // ============================================
save(); // AI
process.exit(0); // ============================================
}
load(); load();
const ai = new Ai({ const ai = new Ai({
llm: { llm: {
models: { models: {[environment.llm.model]: {proto: 'openai', host: environment.llm.host, token: environment.llm.token},},
'Ministral-3': {proto: 'openai', host: 'http://10.69.0.55:11728', token: 'ignore'}, compress: {min: environment.llm.context * 0.5, max: environment.llm.context},
},
system: `You are a NetNavi, personal assistant & companion. Keep responses short and unstyled. Use your remember tool liberally to store all facts. Adjust your personality with tools based on your interactions with the user.\n\nPersonality:\n${settings.personality || ''}\n\nUser Requests:\n${settings.instructions || ''}`,
tools: [DateTimeTool, ExecTool, FetchTool, ReadWebpageTool, WebSearchTool, { tools: [DateTimeTool, ExecTool, FetchTool, ReadWebpageTool, WebSearchTool, {
name: 'adjust_personality', name: 'emote',
description: 'Replace your current personality instructions', description: 'Make your avatar emote',
args: {instructions: {type: 'string', description: 'Bullet point list of how to behave'}}, args: {
emote: {type: 'string', description: 'Emote to the user', required: true, enum: ['none', ...Object.keys(settings.animations.emote)]}
},
fn: (args, stream) => {
const exists = ['none', ...Object.keys(settings.animations.emote)].includes(args.emote);
if(!exists) stream({emote: 'none'});
else stream({emote: args.emote});
return exists ? 'done!' : `Invalid emote: ${args.emote}`;
}
}, {
name: 'personalize',
description: 'Replace your current personality',
args: {
instructions: {type: 'string', description: 'Full bullet point list of how to behave', required: true}
},
fn: (args) => { fn: (args) => {
settings.personality = args.instructions; settings.personality = args.instructions;
save();
return 'done!'; return 'done!';
} }
}], }],
}, },
}); });
const systemPrompt = () => {
return `Roleplay a NetNavi & companion named ${settings.name}. and follow these steps:
1. Start EVERY reply by calling the [emote] tool with the perfect vibe (or none).
2. Next, identify facts in the latest user message and immediately call the [remember] tool on each one. Avoid facts about the conversation or the AI.
3. If instructed, or the user seems unsatisfied, rewrite your "Personality Rules" and submit it to the [personalize] tool.
4. If asked, you can access your ${os.platform()} workspace using the [exec] tool. Always start from \`${os.tmpdir()}\`.
5. Create an unstyled response according to your "Personality Rules"
Personality Rules:
${settings.personality || ''}`;
};
// ============================================
// Setup
// ============================================
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, { const io = new Server(httpServer, {
cors: {origin: "*", methods: ["GET", "POST"]} cors: {
origin: '*',
methods: ['GET', 'POST']
}
}); });
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json());
app.use(express.static('public'));
// ============================================ // ============================================
// WORLD MANAGEMENT // Socket
// ============================================ // ============================================
const worldPlayers = new Map();
const chatHistory = new Map(); const chatHistory = new Map();
// Load world data io.on('connection', (socket) => {
function loadWorld(worldId) { console.debug('👤 User connected:', socket.id);
try {
const worldPath = join(__dirname, '../worlds', worldId || '', 'world.json'); chatHistory.set(socket.id, []);
const world = JSON.parse(fs.readFileSync(worldPath, 'utf-8')); let currentRequest = null;
const themePath = join(__dirname, '../worlds', worldId || '', world.theme);
const theme = JSON.parse(fs.readFileSync(themePath, 'utf-8')); socket.on('llm-clear', async () => {
worldPlayers.set(worldId, new Map()); chatHistory.set(socket.id, []);
return {world, theme}; });
} catch (error) {
console.error(`Failed to load world ${worldId}:`, error); socket.on('llm-abort', () => {
return null; if(currentRequest?.abort) {
currentRequest.abort();
currentRequest = null;
} }
} });
socket.on('llm-ask', async (data) => {
const {message} = data;
const history = chatHistory.get(socket.id);
currentRequest = ai.language.ask(message, {
history,
memory: memories,
system: systemPrompt(),
stream: (chunk) => socket.emit('llm-stream', chunk)
}).then(resp => {
chatHistory.set(socket.id, history);
socket.emit('llm-response', {message: resp});
}).catch(err => {
socket.emit('llm-error', {message: err.message || err.toString()});
}).finally(() => {
currentRequest = null;
});
});
socket.on('disconnect', () => {
console.log('👤 User disconnected:', socket.id);
chatHistory.delete(socket.id);
});
});
// ============================================ // ============================================
// SOCKET.IO - WORLD CHANNELS // World Connection
// ============================================ // ============================================
io.on('connection', (socket) => { const worldPlayers = new Map();
console.debug('🔌 Client connected:', socket.id);
// Load world data
function loadWorld(name) {
let w,
t;
try {
w = JSON.parse(fs.readFileSync(join(worlds, (name || 'home') + '.json'), 'utf-8'));
} catch(error) {
console.error(`Failed to load world ${name}:`, error);
return null;
}
try {
t = JSON.parse(fs.readFileSync(join(protocols, w.theme + '.json'), 'utf-8'));
t.colors = calcColors(t.colors);
} catch(error) {
console.error(`Failed to load theme protocol ${w.theme}:`, error);
return null;
}
worldPlayers.set(name, new Map());
return {
...w,
theme: t
};
}
io.of('/world').on('connection', (socket) => {
console.debug('🌍 Navi joined world:', socket.id);
let currentWorld = null; let currentWorld = null;
let playerData = null; let playerData = null;
// Join a world // Join a world
socket.on('join-world', (data) => { socket.on('join', async (data) => {
const { worldId, playerInfo } = data; const {
const worldData = loadWorld(worldId); world,
if(!worldData) return socket.emit('error', { message: 'World not found' }); api
} = data;
const info = await fetch(api.replace(/\/$/, '') + '/api/info').then(resp => {
if(resp.ok) return resp.json();
socket.emit('error', {message: `Invalid Navi API: ${api}`});
return resp.error;
});
const worldData = loadWorld(world);
if(!worldData) return socket.emit('error', {message: 'World not found'});
// Leave previous world if any // Leave previous world if any
if(currentWorld) { if(currentWorld) {
@@ -113,166 +264,188 @@ io.on('connection', (socket) => {
const players = worldPlayers.get(currentWorld); const players = worldPlayers.get(currentWorld);
if(players) { if(players) {
players.delete(socket.id); players.delete(socket.id);
socket.to(`world:${currentWorld}`).emit('player-left', {socketId: socket.id}); socket.to(`world:${currentWorld}`).emit('left', {socketId: socket.id});
} }
} }
// Join new world // Join new world
currentWorld = worldId; currentWorld = world;
const spawn = worldData.tiles.find(t => t.type === 'spawn');
playerData = { playerData = {
...info,
socketId: socket.id, socketId: socket.id,
name: playerInfo.name, navi: api,
apiUrl: playerInfo.apiUrl, x: spawn.x,
x: worldData.world.pet.startX, y: spawn.y
y: worldData.world.pet.startY
}; };
socket.join(`world:${worldId}`); socket.join(`world:${world}`);
const players = worldPlayers.get(worldId); const players = worldPlayers.get(world);
players.set(socket.id, playerData); players.set(socket.id, playerData);
socket.emit('world-data', worldData); socket.emit('data', worldData);
const currentPlayers = Array.from(players.values()); const currentPlayers = Array.from(players.values());
socket.emit('current-players', currentPlayers); socket.emit('players', currentPlayers);
socket.to(`world:${worldId}`).emit('player-joined', playerData); socket.to(`world:${world}`).emit('joined', playerData);
}); });
// Player movement // Player movement
socket.on('player-move', (data) => { socket.on('move', (data) => {
if(!currentWorld || !playerData) return; if(!currentWorld || !playerData) return;
const { x, y } = data; const {
x,
y
} = data;
playerData.x = x; playerData.x = x;
playerData.y = y; playerData.y = y;
socket.to(`world:${currentWorld}`).emit('player-moved', {socketId: socket.id, x, y}); socket.to(`world:${currentWorld}`).emit('moved', {
socketId: socket.id,
x,
y
});
}); });
// Disconnect // Disconnect
socket.on('disconnect', () => { socket.on('disconnect', () => {
console.debug('🔌 Client disconnected:', socket.id); console.debug('🌍 Navi disconnected:', socket.id);
if(currentWorld) { if(currentWorld) {
const players = worldPlayers.get(currentWorld); const players = worldPlayers.get(currentWorld);
if(players) { if(players) {
players.delete(socket.id); players.delete(socket.id);
socket.to(`world:${currentWorld}`).emit('player-left', {socketId: socket.id}); socket.to(`world:${currentWorld}`).emit('left', {socketId: socket.id});
} }
} }
}); });
}); });
// ============================================ // ============================================
// LLM CHANNEL // API ENDPOINTS
// ============================================ // ============================================
const petNamespace = io.of('/llm');
petNamespace.on('connection', (socket) => {
chatHistory.set(socket.id, []);
let currentRequest = null;
socket.on('clear', async () => { app.get('/avatar', (req, res) => {
chatHistory.set(socket.id, []); res.sendFile(avatar);
}); });
socket.on('abort', () => { app.get('/favicon*', async (req, res) => {
if (currentRequest?.abort) { let w = 256, h = w;
currentRequest.abort(); if(req.query && req.query['size']) [w, h] = req.query['size'].split('x');
currentRequest = null; res.contentType('image/png').set('Content-Disposition', 'inline');
res.send(await resize(logo, w, h));
});
app.get('/manifest.json', (req, res) => {
res.json({
id: environment.publicUrl,
short_name: 'NetNavi',
name: 'NetNavi',
description: 'Network Navigation Program',
display: 'standalone',
start_url: '/',
background_color: settings.theme.accent,
theme_color: settings.theme.primary,
icons: [
{
"src": `${environment.publicUrl}/favicon?size=192x192`,
"type": "image/png",
"sizes": "192x192",
"purpose": "any"
},
{
"src": `${environment.publicUrl}/favicon?size=512x512`,
"type": "image/png",
"sizes": "512x512",
"purpose": "any"
} }
}); ]
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);
}); });
}); });
// ============================================ app.get('/spritesheet', (req, res) => {
// REST API ENDPOINTS res.sendFile(sprite);
// ============================================
app.get('/favicon.*', (req, res) => {
res.sendFile(logoFile);
}); });
// Get PET info
app.get('/api/info', (req, res) => { app.get('/api/info', (req, res) => {
const { petId } = req.params;
// TODO: Fetch from database
res.json({ res.json({
id: petId, name: settings.name,
name: 'MyCoolPET', avatar: settings.avatar,
owner: 'player1', theme: calcColors(settings.theme),
bandwidth: 75,
shards: [],
stats: {
level: 5,
health: 100
}
}); });
}); });
// Get sprite sheet app.get('/api/animations', (req, res) => {
app.get('/api/sprite', (req, res) => { res.json(settings.animations);
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) // Send message to user (push notification / email / etc)
app.post('/api/message', (req, res) => { app.post('/api/message', (req, res) => {
const { userId } = req.params; const {userId} = req.params;
const { message } = req.body; const {message} = req.body;
// TODO: Implement notification system // TODO: Implement notification system
res.json({ success: true, message: 'Message sent' }); res.json({
success: true,
message: 'Message sent'
});
}); });
// Send message to PET LLM // Send message to PET LLM
app.post('/api/message', (req, res) => { app.post('/api/message', (req, res) => {
const { petId } = req.params; const {petId} = req.params;
const { message } = req.body; const {message} = req.body;
// TODO: Queue message for LLM processing // TODO: Queue message for LLM processing
res.json({ success: true, message: 'Message queued' }); res.json({
success: true,
message: 'Message queued'
});
}); });
// Link another PET // Link another PET
app.post('/api/link', (req, res) => { app.post('/api/link', (req, res) => {
const { petId } = req.params; const {petId} = req.params;
const { targetApiUrl } = req.body; const {targetApiUrl} = req.body;
// TODO: Store link in database // TODO: Store link in database
res.json({success: true, message: 'PET linked'}); res.json({
success: true,
message: 'PET linked'
});
});
// Static files
app.use(async (req, res, next) => {
try {
let p = (!req.path || req.path.endsWith('/'))
? req.path + (req.path.endsWith('/') ? '' : '/') + 'index.html'
: req.path + (req.path.split('/').pop().includes('.') ? '' : '.html');
p = path.join(environment.paths.public, p);
const data = await fs.readFileSync(p);
// Only process HTML files
if(p.endsWith('.html')) {
let body = data.toString('utf-8')
.replaceAll('{{PUBLIC_URL}}', environment.publicUrl)
.replaceAll('{{NAME}}', settings.name)
.replaceAll('{{THEME_BACKGROUND}}', settings.theme.background)
.replaceAll('{{THEME_PRIMARY}}', settings.theme.primary)
.replaceAll('{{THEME_ACCENT}}', settings.theme.accent);
res.set('Content-Type', 'text/html');
return res.send(body);
}
return res.sendFile(p);
} catch (err) {
if (err.code === 'ENOENT') return next();
next(err);
}
}); });
// ============================================ // ============================================
// START SERVER // START SERVER
// ============================================ // ============================================
const PORT = process.env.PORT || 3000; setInterval(() => save(), 5 * 60_000);
httpServer.listen(environment.port, () => {
httpServer.listen(PORT, () => { console.log(`🚀 Server running on: http://localhost:${environment.port}`);
loadWorld();
console.log('✅ Home world loaded');
console.log(`🚀 Server running on: http://localhost:${PORT}`);
}); });
function shutdown() {
save();
process.exit(0);
}
process.on('SIGINT', shutdown); process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown); process.on('SIGTERM', shutdown);

9
src/utils.js Normal file
View File

@@ -0,0 +1,9 @@
import sharp from 'sharp';
export async function resize(image, width, height) {
width = Math.round(+width);
if(height) height = Math.round(+height);
if(width < 1) throw new Error(`Invalid dimensions: ${width}x${height}`);
if(width > 1920 || (height && height > 1920)) throw new Error(`Largest dimension supported is 1920: ${width}x${height}`);
return await sharp(image).resize({width: width, height: height || width, fit: 'contain'}).toBuffer();
}

BIN
storage/navi/avatar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

View File

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

View File

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

View File

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

View File

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