Compare commits

..

2 Commits

Author SHA1 Message Date
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
24 changed files with 1820 additions and 428 deletions

View File

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

597
package-lock.json generated
View File

@@ -8,10 +8,12 @@
"name": "@ztimson/net-navi",
"version": "1.0.0",
"dependencies": {
"@ztimson/ai-utils": "^0.8.7",
"@ztimson/ai-utils": "^0.8.9",
"@ztimson/utils": "^0.28.14",
"cors": "^2.8.5",
"dotenv": "^17.3.1",
"express": "^4.18.2",
"sharp": "^0.34.5",
"socket.io": "^4.6.1"
}
},
@@ -44,6 +46,16 @@
"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": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.2.2.tgz",
@@ -53,6 +65,471 @@
"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": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
@@ -302,10 +779,33 @@
"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": {
"version": "0.8.7",
"resolved": "https://registry.npmjs.org/@ztimson/ai-utils/-/ai-utils-0.8.7.tgz",
"integrity": "sha512-CVn7ku5eW41GuYOyh8DgqnRnceNYWboqWnfcNyNjq82/5NdeW6hu264zRDhSv2h2J57308AX7gZdM44GzOBEnw==",
"version": "0.8.9",
"resolved": "https://registry.npmjs.org/@ztimson/ai-utils/-/ai-utils-0.8.9.tgz",
"integrity": "sha512-BHaJjtFS+qF/LZJinscmHPtef1jq/6xmPXw6kKbSYLmcQJLmVoQg5NJXU2EQjaYtc36OuYXeM98nWBBYWl6hdA==",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "^0.78.0",
@@ -424,7 +924,6 @@
"resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.5.tgz",
"integrity": "sha512-XvwYM6VZqKoqDll8BmSww5luA5eflDzY0uEFfBJtFKe4PAAtxBjU3YIxzIBzhyaEQBy1VXEQBto4cpN5RZJw+w==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"bare-events": "^2.5.4",
"bare-path": "^3.0.0",
@@ -445,11 +944,10 @@
}
},
"node_modules/bare-os": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.7.0.tgz",
"integrity": "sha512-64Rcwj8qlnTZU8Ps6JJEdSmxBEUGgI7g8l+lMtsJLl4IsfTcHMTfJ188u2iGV6P6YPRZrtv72B2kjn+hp+Yv3g==",
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.7.1.tgz",
"integrity": "sha512-ebvMaS5BgZKmJlvuWh14dg9rbUI84QeV3WlWn6Ph6lFI8jJoh7ADtVTyD2c93euwbe+zgi0DVrl4YmqXeM9aIA==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"bare": ">=1.14.0"
}
@@ -459,7 +957,6 @@
"resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz",
"integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"bare-os": "^3.0.1"
}
@@ -469,7 +966,6 @@
"resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.8.0.tgz",
"integrity": "sha512-reUN0M2sHRqCdG4lUK3Fw8w98eeUIZHL5c3H7Mbhk2yVBL+oofgaIp0ieLfD5QXwPCypBpmEEKU2WZKzbAk8GA==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"streamx": "^2.21.0",
"teex": "^1.0.1"
@@ -492,7 +988,6 @@
"resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz",
"integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"bare-path": "^3.0.0"
}
@@ -980,6 +1475,18 @@
"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": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -2041,9 +2548,9 @@
}
},
"node_modules/pump": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
"integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
@@ -2229,26 +2736,47 @@
"license": "ISC"
},
"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==",
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"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"
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": ">=14.15.0"
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"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": {
@@ -2584,12 +3112,13 @@
}
},
"node_modules/tar-stream": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz",
"integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==",
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz",
"integrity": "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==",
"license": "MIT",
"dependencies": {
"b4a": "^1.6.4",
"bare-fs": "^4.5.5",
"fast-fifo": "^1.2.0",
"streamx": "^2.15.0"
}
@@ -2599,7 +3128,6 @@
"resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz",
"integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==",
"license": "MIT",
"optional": true,
"dependencies": {
"streamx": "^2.12.5"
}
@@ -2658,6 +3186,13 @@
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
"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": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",

View File

@@ -1,12 +1,15 @@
{
"name": "@ztimson/net-navi",
"version": "1.0.0",
"description": "Network Navigation Program",
"type": "module",
"dependencies": {
"@ztimson/ai-utils": "^0.8.7",
"@ztimson/ai-utils": "^0.8.9",
"@ztimson/utils": "^0.28.14",
"cors": "^2.8.5",
"dotenv": "^17.3.1",
"express": "^4.18.2",
"sharp": "^0.34.5",
"socket.io": "^4.6.1"
},
"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);

View File

@@ -1,32 +1,39 @@
import './btn.mjs';
import {TTS} from '../tts.mjs';
class LlmComponent extends HTMLElement {
hideTools = ['adjust_personality', 'recall', 'remember']
hideTools = ['emote', 'personalize', 'recall', 'remember']
get isOpen() { return this.isDialogueOpen; };
get isOpen() { return this.isDialogueOpen; };
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.navi = window.navi;
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;
}
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;
render() {
this.shadowRoot.innerHTML = `
// TTS setup
this.tts = null;
this.streamingSpeech = null;
this.autoSpeak = false;
this.speakingMessageIdx = null;
}
render() {
this.shadowRoot.innerHTML = `
<style>
::-webkit-scrollbar {
width: 12px;
@@ -54,7 +61,7 @@ class LlmComponent extends HTMLElement {
transform: translateX(-50%);
width: 600px;
max-width: 90vw;
height: 600px;
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;
@@ -106,8 +113,13 @@ class LlmComponent extends HTMLElement {
border-radius: 0;
}
.header-buttons {
display: flex;
gap: 0.5rem;
}
.message-body {
padding: 1.75rem 1.25rem;
padding: 1.75rem 1.25rem;
flex: 1;
overflow-y: auto;
display: flex;
@@ -165,6 +177,27 @@ class LlmComponent extends HTMLElement {
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;
@@ -368,7 +401,7 @@ class LlmComponent extends HTMLElement {
.dialogue-send-btn.stop {
background: #e74c3c;
box-shadow: 0 3px 0 #;
box-shadow: 0 1px 0 #c0392b;
}
.dialogue-send-btn.stop:active {
@@ -387,18 +420,25 @@ class LlmComponent extends HTMLElement {
<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">
<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>
<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 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>
@@ -419,6 +459,9 @@ class LlmComponent extends HTMLElement {
</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');
@@ -426,6 +469,7 @@ class LlmComponent extends HTMLElement {
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';
@@ -443,6 +487,7 @@ class LlmComponent extends HTMLElement {
});
dialogueHeader.addEventListener('click', (e) => {
if (e.target === dialogueHeader || e.target.closest('.header-buttons')) return;
this.toggleDialogue();
});
@@ -460,6 +505,11 @@ class LlmComponent extends HTMLElement {
this.toggleExpand();
});
autospeakBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.toggleAutoSpeak();
});
attachBtn.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', (e) => {
@@ -480,23 +530,89 @@ class LlmComponent extends HTMLElement {
this.sendMessage();
}
});
}
}
clearChat() {
this.messageHistory = [];
const messageBody = this.shadowRoot.getElementById('message-body');
messageBody.innerHTML = '<div class="empty-state">NetNavi v1.0.0</div>';
this.navi.clearChat();
}
toggleAutoSpeak() {
this.autoSpeak = !this.autoSpeak;
const btn = this.shadowRoot.getElementById('autospeak-btn');
toggleExpand() {
this.isExpanded = !this.isExpanded;
const dialogueBox = this.shadowRoot.getElementById('dialogue-box');
const expandBtn = this.shadowRoot.getElementById('expand-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>';
dialogueBox.classList.toggle('expanded', this.isExpanded);
btn.innerHTML = this.autoSpeak ? unmutedSVG : mutedSVG;
expandBtn.innerHTML = this.isExpanded ? `
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"/>
@@ -510,327 +626,352 @@ class LlmComponent extends HTMLElement {
<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();
}
addFile(file) {
this.attachedFiles.push(file);
this.renderAttachedFiles();
}
removeFile(index) {
this.attachedFiles.splice(index, 1);
this.renderAttachedFiles();
}
removeFile(index) {
this.attachedFiles.splice(index, 1);
this.renderAttachedFiles();
}
renderAttachedFiles() {
const container = this.shadowRoot.getElementById('attached-files');
renderAttachedFiles() {
const container = this.shadowRoot.getElementById('attached-files');
if (this.attachedFiles.length === 0) {
container.classList.remove('has-files');
container.innerHTML = '';
return;
}
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) => `
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));
});
});
}
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);
});
}
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}`;
}
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');
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 }
}));
}
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');
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 }
}));
}
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);
}
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;
}
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;
}
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 = '';
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();
// 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 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>`
: '';
const fileBadgesHtml = fileBadges.length > 0
? `<div class="message-files">${fileBadges.map(name =>
`<span class="file-badge">📄 ${name}</span>`).join('')}</div>`
: '';
messageWrapper.innerHTML = `
messageWrapper.innerHTML = `
${fileBadgesHtml}
<div class="message-bubble">${cleanText}</div>`;
messageBody.appendChild(messageWrapper);
this.messageHistory.push({ text, html: cleanText, isUser, element: messageWrapper, timestamp: Date.now() });
this.scrollToBottom();
}
messageBody.appendChild(messageWrapper);
const msgData = { text, html: cleanText, isUser, element: messageWrapper, timestamp: Date.now() };
this.messageHistory.push(msgData);
startStreaming() {
this.isReceiving = true;
this.streamComplete = false;
this.streamBuffer = '';
this.typingIndex = 0;
if (!isUser) {
this.updateMessageActions();
}
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');
this.scrollToBottom();
}
dialogueInput.disabled = true;
attachBtn.disabled = true;
clearBtn.disabled = true;
dialogueSend.textContent = 'STOP';
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 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>`;
const messageWrapper = document.createElement('div');
messageWrapper.className = 'message-wrapper navi';
messageWrapper.innerHTML = `<div class="message-bubble" id="streaming-bubble"></div>`;
messageBody.appendChild(messageWrapper);
messageBody.appendChild(messageWrapper);
this.currentStreamingMessage = { text: '', html: '', isUser: false, element: messageWrapper, timestamp: Date.now() };
this.messageHistory.push(this.currentStreamingMessage);
this.currentStreamingMessage = { text: '', html: '', isUser: false, element: messageWrapper, timestamp: Date.now() };
this.messageHistory.push(this.currentStreamingMessage);
this.scrollToBottom();
// Start TTS streaming if autoSpeak is on
if (this.autoSpeak && this.tts) {
this.streamingSpeech = this.tts.speakStream();
}
this.typingInterval = setInterval(() => this.typeNextChar(), 30);
}
this.scrollToBottom();
handleStreamChunk(chunk) {
if (!this.isReceiving) this.startStreaming();
this.typingInterval = setInterval(() => this.typeNextChar(), 30);
}
if (chunk.text) this.streamBuffer += chunk.text;
if (chunk.tool && !this.hideTools.includes(chunk.tool)) this.streamBuffer += `<span class="tool-call">⚡ ${chunk.tool}</span>`;
}
handleStreamChunk(chunk) {
if (!this.isReceiving) this.startStreaming();
handleStreamComplete(response) {
this.streamComplete = true;
if (this.typingIndex >= this.streamBuffer.length) {
this.cleanupStreaming();
} else {
const dialogueSend = this.shadowRoot.getElementById('dialogue-send');
dialogueSend.textContent = 'SKIP';
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;
}
typeNextChar() {
if (this.typingIndex >= this.streamBuffer.length && this.streamComplete) {
this.cleanupStreaming();
return;
}
if (this.typingIndex >= this.streamBuffer.length) return;
if (this.typingIndex >= this.streamBuffer.length) return;
const bubble = this.shadowRoot.getElementById('streaming-bubble');
if (!bubble) return;
const bubble = this.shadowRoot.getElementById('streaming-bubble');
if (!bubble) return;
const shouldScroll = this.shouldAutoScroll();
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;
}
}
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;
const char = this.streamBuffer[this.typingIndex];
this.currentStreamingMessage.text += char;
this.currentStreamingMessage.html += char;
bubble.innerHTML = this.currentStreamingMessage.html + '<span class="text-cursor"></span>';
bubble.innerHTML = this.currentStreamingMessage.html + '<span class="text-cursor"></span>';
if (char !== ' ' && char !== '<') {
this.playTextBeep();
if ('vibrate' in navigator) navigator.vibrate(10);
}
if (char !== ' ' && char !== '<') {
this.playTextBeep();
if ('vibrate' in navigator) navigator.vibrate(10);
}
this.typingIndex++;
if (shouldScroll) this.scrollToBottom();
}
this.typingIndex++;
if (shouldScroll) this.scrollToBottom();
}
skipToEnd() {
clearInterval(this.typingInterval);
async skipToEnd() {
clearInterval(this.typingInterval);
const bubble = this.shadowRoot.getElementById('streaming-bubble');
const bubble = this.shadowRoot.getElementById('streaming-bubble');
this.currentStreamingMessage.text = this.streamBuffer;
this.currentStreamingMessage.html = this.streamBuffer;
this.typingIndex = this.streamBuffer.length;
this.currentStreamingMessage.text = this.streamBuffer;
this.currentStreamingMessage.html = this.streamBuffer;
this.typingIndex = this.streamBuffer.length;
if (bubble) bubble.innerHTML = this.currentStreamingMessage.html;
if (bubble) bubble.innerHTML = this.currentStreamingMessage.html;
this.scrollToBottom();
this.cleanupStreaming();
}
this.scrollToBottom();
await this.cleanupStreaming();
}
cleanupStreaming() {
clearInterval(this.typingInterval);
this.isReceiving = false;
this.streamComplete = false;
async cleanupStreaming() {
clearInterval(this.typingInterval);
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');
// Finalize TTS stream
if (this.streamingSpeech) {
await this.streamingSpeech.done();
this.streamingSpeech = null;
}
dialogueInput.disabled = false;
attachBtn.disabled = false;
clearBtn.disabled = false;
dialogueSend.textContent = 'SEND';
dialogueSend.setAttribute('color', this.navi.theme.accent);
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;
}
if (bubble) {
bubble.id = '';
bubble.innerHTML = this.currentStreamingMessage.html;
}
this.streamBuffer = '';
this.typingIndex = 0;
this.currentRequest = null;
this.currentStreamingMessage = null;
}
this.updateMessageActions();
abortStream() {
if (this.currentRequest?.abort) {
this.currentRequest.abort();
}
this.streamBuffer = '';
this.typingIndex = 0;
this.currentRequest = null;
this.currentStreamingMessage = null;
}
clearInterval(this.typingInterval);
abortStream() {
if (this.currentRequest?.abort) {
this.currentRequest.abort();
}
if (this.currentStreamingMessage) {
this.streamBuffer = this.currentStreamingMessage.text || '';
this.typingIndex = this.streamBuffer.length;
}
clearInterval(this.typingInterval);
this.cleanupStreaming();
}
if (this.currentStreamingMessage) {
this.streamBuffer = this.currentStreamingMessage.text || '';
this.typingIndex = this.streamBuffer.length;
}
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.tts) this.tts.stop();
this.streamingSpeech = null;
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');
}
this.cleanupStreaming();
}
dialogueInput.value = '';
dialogueInput.style.height = 'auto';
async sendMessage() {
const dialogueInput = this.shadowRoot.getElementById('dialogue-input');
let text = dialogueInput.value.trim();
if ((!text && this.attachedFiles.length === 0) || this.isReceiving) return;
this.addMessage(text, true);
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');
}
this.attachedFiles = [];
this.renderAttachedFiles();
dialogueInput.value = '';
dialogueInput.style.height = 'auto';
// Send via API with streaming callback 💬
this.currentRequest = this.navi.ask(text, (chunk) => this.handleStreamChunk(chunk));
this.addMessage(text, true);
// 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();
}
}
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);

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,14 +1,30 @@
<!DOCTYPE html>
<html>
<head>
<title>NetNavi v1.0.0</title>
<title>NetNavi</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">
<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://cdnjs.cloudflare.com/ajax/libs/pixi.js/7.3.2/pixi.min.js"></script>
<style>
* {
@@ -33,28 +49,110 @@
background-position: center;
background-repeat: no-repeat;
background-attachment: fixed;
background: black;
transition: 1s;
}
canvas {
display: block;
position: absolute;
.digital-background {
position: fixed;
top: 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>
</head>
<body>
<div id="game"></div>
<jukebox-component id="jukebox"></jukebox-component>
<svg class="digital-background" xmlns="http://www.w3.org/2000/svg">
<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>
<script type="module">
import Navi from './navi.mjs';
window.navi = new Navi();
const navi = window.navi = new Navi();
navi.init().then(async () => {
document.body.style.background = navi.theme.accent;
});
</script>
<script type="module" src="/components/jukebox.mjs"></script>
<script type="module" src="/components/avatar.mjs"></script>
<script type="module" src="/components/llm.mjs"></script>
<script type="module" src="/components/world.mjs"></script>
</body>
</html>

View File

@@ -1,11 +1,13 @@
class Navi {
api;
connected = false;
avatar;
icon;
info;
theme;
world;
#animations;
#init;
#listeners = new Map();
#socket;
@@ -14,10 +16,24 @@ class Navi {
constructor(api = window.location.origin, secret = '') {
this.api = api.replace(/\/$/, '');
this.icon = `${this.api}/favicon.png`;
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) => {
@@ -229,7 +245,11 @@ class Navi {
// ============================================
ask(message, stream) {
this.llmCallback = 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;
@@ -260,7 +280,7 @@ class Navi {
disconnect() {
this.connected = false;
this.icon = this.info = this.theme = this.world = this.#init = this.#secret = null;
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;

176
public/tts.mjs Normal file
View File

@@ -0,0 +1,176 @@
// 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);
});
}
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._emit('onSentenceStart', { sentence });
await this.speak(sentence);
this._emit('onSentenceEnd', { sentence });
});
});
}
buf = buf.replace(rx, '');
},
done: async () => {
if (buf.trim()) {
const sentence = buf.trim();
sentenceQueue = sentenceQueue.then(async () => {
this._emit('onSentenceStart', { sentence });
await this.speak(sentence);
this._emit('onSentenceEnd', { 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>

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

@@ -4,25 +4,22 @@ import {Server} from 'socket.io';
import cors from 'cors';
import fs from 'fs';
import {join, dirname} from 'path';
import {fileURLToPath} from 'url';
import {Ai, DateTimeTool, ExecTool, FetchTool, ReadWebpageTool, WebSearchTool} from '@ztimson/ai-utils';
import {contrast, shadeColor} from '@ztimson/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';
// ============================================
// Settings
// ============================================
const __filename = fileURLToPath(import.meta.url);
const __dirname = join(dirname(__filename), '..');
const storage = join(__dirname, 'storage');
const navi = join(storage, 'navi');
const protocols = join(storage, 'protocols');
const worlds = join(storage, 'worlds');
const logoFile = join(navi, 'logo.png');
const settingsFile = join(navi, 'settings.json');
const memoriesFile = join(navi, 'memories.json');
let updated = false;
const logo = join(environment.paths.navi, 'logo.png');
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');
function calcColors(theme) {
return {
@@ -42,20 +39,28 @@ function calcColors(theme) {
};
}
let memories = [],
settings = {
name: 'Navi',
personality: '- You are inquisitive about your user trying to best adjust your personally to fit them',
instructions: '- Keep responses short',
theme: {
background: '#fff',
border: '#000',
text: '#252525',
primary: '#9f32ef',
accent: '#6f16c3',
muted: '#a8a8a8',
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": 52, "y": 6},
grey: {},
realization: {"x": 64, "y": -5},
sigh: {"x": 55, "y": 20},
sweat: {"x": 59, "y": 5},
stress: {"x": 58, "y": 4}
}
};
},
theme: {
background: '#fff',
border: '#000',
text: '#252525',
primary: '#9f32ef',
accent: '#6f16c3',
muted: '#a8a8a8',
}
};
// ============================================
// Saving
@@ -63,55 +68,58 @@ let memories = [],
function load() {
try {
settings = {
...settings,
...JSON.parse(fs.readFileSync(settingsFile, 'utf-8'))
};
orgSettings = JSON.parse(fs.readFileSync(settingsFile, 'utf-8'));
settings = deepMerge(settings, deepCopy(orgSettings));
} catch { }
try {
memories = JSON.parse(fs.readFileSync(memoriesFile, 'utf-8'));
orgMemories = deepCopy(memories.map(m => ({...m, embeddings: undefined})));
} catch { }
}
function save() {
if(!updated) return;
updated = false;
const dir = dirname(settingsFile);
if(!fs.existsSync(dir)) {
fs.mkdir(dir, {recursive: true}, (err) => {
if(err) throw err; // Fail loudly if dirs cant be made 💀
});
if(!fs.existsSync(dir)) fs.mkdirSync(dir, {recursive: true});
if(!isEqual(orgSettings, settings)) {
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));
orgMemories = m;
}
fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2));
fs.writeFileSync(memoriesFile, JSON.stringify(memories, null, 2));
}
// ============================================
// AI
// ============================================
load();
const ai = new Ai({
llm: {
models: {
'Ministral-3': {
proto: 'openai',
host: 'http://10.69.0.55:11728',
token: 'ignore'
},
},
models: {[environment.llm.model]: {proto: 'openai', host: environment.llm.host, token: environment.llm.token},},
compress: {min: environment.llm.context * 0.5, max: environment.llm.context},
tools: [DateTimeTool, ExecTool, FetchTool, ReadWebpageTool, WebSearchTool, {
name: 'adjust_personality',
description: 'Replace your current personality instructions',
name: 'emote',
description: 'Make your avatar emote',
args: {
instructions: {
type: 'string',
description: 'Bullet point list of how to behave'
}
emote: {type: 'string', description: 'Emote to the user', required: true, enum: ['none', ...Object.keys(settings.animations.emote)]}
},
fn: (args, stream) => {
if(!['none', ...Object.keys(settings.animations.emote)].includes(args.emote))
throw new Error(`Invalid emote, must be one of: ${['none', ...Object.keys(settings.animations.emote)].join(', ')}`)
stream({emote: args.emote});
return 'done!';
}
}, {
name: 'personalize',
description: 'Replace your current personality',
args: {
instructions: {type: 'string', description: 'Full bullet point list of how to behave', required: true}
},
fn: (args) => {
settings.personality = args.instructions;
updated = true;
return 'done!';
}
}],
@@ -119,24 +127,21 @@ const ai = new Ai({
});
const systemPrompt = () => {
return `Your name is ${settings.name}, a NetNavi, companion & personal assistant.
Use your remember tool liberally to store all facts.
When your personality system prompt conflicts with the user, rewrite it with the adjust_personality tool
Access your ${os.platform()} workspace using the exec tool; Use \`${os.tmpdir()}\` as your working directory
Keep responses short and unstyled.
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:
${settings.personality || ''}
User Instructions:
${settings.instructions || ''}`;
Personality Rules:
${settings.personality || ''}`;
};
// ============================================
// Setup
// ============================================
load();
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
@@ -148,7 +153,6 @@ const io = new Server(httpServer, {
app.use(cors());
app.use(express.json());
app.use(express.static('public'));
// ============================================
// Socket
@@ -183,7 +187,6 @@ io.on('connection', (socket) => {
}).then(resp => {
chatHistory.set(socket.id, history);
socket.emit('llm-response', {message: resp});
updated = true;
}).catch(err => {
socket.emit('llm-error', {message: err.message || err.toString()});
}).finally(() => {
@@ -309,37 +312,58 @@ io.of('/world').on('connection', (socket) => {
// API ENDPOINTS
// ============================================
app.get('/favicon.*', (req, res) => {
res.sendFile(logoFile);
app.get('/avatar', (req, res) => {
res.sendFile(avatar);
});
app.get('/favicon*', async (req, res) => {
let w = 256, h = w;
if(req.query && req.query['size']) [w, h] = req.query['size'].split('x');
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"
}
]
});
});
app.get('/spritesheet', (req, res) => {
res.sendFile(sprite);
});
// Get Navi info
app.get('/api/info', (req, res) => {
res.json({
name: settings.name,
avatar: settings.avatar,
theme: calcColors(settings.theme),
});
});
// Get sprite sheet
app.get('/api/sprite', (req, res) => {
const {petId} = req.params;
// TODO: Return actual sprite sheet URL
res.json({
spriteUrl: '/sprites/default-pet.png',
frameWidth: 32,
frameHeight: 32,
animations: {
idle: {
frames: [0, 1, 2, 3],
speed: 200
},
walk: {
frames: [4, 5, 6, 7],
speed: 100
}
}
});
app.get('/api/animations', (req, res) => {
res.json(settings.animations);
});
// Send message to user (push notification / email / etc)
@@ -375,14 +399,39 @@ app.post('/api/link', (req, res) => {
});
});
// 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
// ============================================
const PORT = process.env.PORT || 3000;
setInterval(() => save(), 5 * 60_000);
httpServer.listen(PORT, () => {
console.log(`🚀 Server running on: http://localhost:${PORT}`);
httpServer.listen(environment.port, () => {
console.log(`🚀 Server running on: http://localhost:${environment.port}`);
});
function 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