diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..dd20a6d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,852 @@ +{ + "name": "openclaw-pi-assistant", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "openclaw-pi-assistant", + "version": "0.1.0", + "dependencies": { + "express": "^4.21.0", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/scripts/setup-pi.sh b/scripts/setup-pi.sh new file mode 100755 index 0000000..118e6bd --- /dev/null +++ b/scripts/setup-pi.sh @@ -0,0 +1,363 @@ +#!/bin/bash +set -e + +# OpenClaw Pi Assistant - Full Setup Script +# For Raspberry Pi 5 with 3.5" TFT, SPH0645 mic, PCM5102A DAC + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +HOME_DIR="${HOME:-/home/pi}" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +log() { echo -e "${GREEN}[✓]${NC} $1"; } +warn() { echo -e "${YELLOW}[!]${NC} $1"; } +error() { echo -e "${RED}[✗]${NC} $1"; exit 1; } +info() { echo -e "${BLUE}[i]${NC} $1"; } + +check_pi() { + if [[ ! -f /proc/device-tree/model ]]; then + warn "Not running on Raspberry Pi - some steps will be skipped" + return 1 + fi + local model=$(cat /proc/device-tree/model 2>/dev/null || echo "") + if [[ "$model" == *"Raspberry Pi"* ]]; then + log "Detected: $model" + return 0 + fi + warn "Not a Raspberry Pi - some steps will be skipped" + return 1 +} + +IS_PI=false +check_pi && IS_PI=true + +# ============================================ +# System Updates +# ============================================ +section_system() { + info "Updating system packages..." + sudo apt-get update + sudo apt-get upgrade -y + sudo apt-get install -y \ + git curl wget build-essential cmake \ + alsa-utils libasound2-dev \ + chromium-browser \ + unclutter xdotool \ + libffi-dev libssl-dev + log "System packages installed" +} + +# ============================================ +# Node.js 22 +# ============================================ +section_node() { + if command -v node &>/dev/null; then + local node_version=$(node --version | cut -d'v' -f2 | cut -d'.' -f1) + if [[ "$node_version" -ge 20 ]]; then + log "Node.js $(node --version) already installed" + return 0 + fi + fi + + info "Installing Node.js 22..." + + if [[ "$(uname -m)" == "aarch64" ]]; then + curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - + sudo apt-get install -y nodejs + else + curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - + sudo apt-get install -y nodejs + fi + + log "Node.js $(node --version) installed" +} + +# ============================================ +# whisper.cpp (ARM NEON optimized) +# ============================================ +section_whisper() { + local WHISPER_DIR="$HOME_DIR/whisper.cpp" + local WHISPER_MODEL_DIR="$HOME_DIR/.whisper" + local WHISPER_BIN="/usr/local/bin/whisper" + + if [[ -f "$WHISPER_BIN" ]]; then + log "whisper.cpp already installed at $WHISPER_BIN" + else + info "Building whisper.cpp..." + + if [[ -d "$WHISPER_DIR" ]]; then + cd "$WHISPER_DIR" + git pull + else + git clone https://github.com/ggerganov/whisper.cpp.git "$WHISPER_DIR" + cd "$WHISPER_DIR" + fi + + mkdir -p build && cd build + + if [[ "$(uname -m)" == "aarch64" ]]; then + cmake .. -DWHISPER_NO_ACCELERATE=ON -DWHISPER_OPENBLAS=OFF + else + cmake .. + fi + + cmake --build . --config Release -j$(nproc) + + sudo cp bin/main "$WHISPER_BIN" + sudo chmod +x "$WHISPER_BIN" + + log "whisper.cpp built and installed" + fi + + mkdir -p "$WHISPER_MODEL_DIR" + + if [[ ! -f "$WHISPER_MODEL_DIR/ggml-base.en.bin" ]]; then + info "Downloading whisper base.en model..." + cd "$WHISPER_DIR" + bash ./models/download-ggml-model.sh base.en + mv models/ggml-base.en.bin "$WHISPER_MODEL_DIR/" + log "Whisper model downloaded" + else + log "Whisper model already exists" + fi +} + +# ============================================ +# Piper TTS +# ============================================ +section_piper() { + local PIPER_DIR="$HOME_DIR/piper" + local PIPER_MODEL_DIR="$HOME_DIR/.piper" + local PIPER_BIN="/usr/local/bin/piper" + + if [[ -f "$PIPER_BIN" ]]; then + log "Piper already installed at $PIPER_BIN" + else + info "Installing Piper TTS..." + + mkdir -p "$PIPER_DIR" + cd "$PIPER_DIR" + + local ARCH="aarch64" + [[ "$(uname -m)" != "aarch64" ]] && ARCH="x86_64" + + local PIPER_URL="https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_linux_${ARCH}.tar.gz" + + if [[ ! -f "piper" ]]; then + wget -q "$PIPER_URL" -O piper.tar.gz + tar -xzf piper.tar.gz + rm piper.tar.gz + fi + + sudo cp piper/piper "$PIPER_BIN" + sudo chmod +x "$PIPER_BIN" + + if [[ -d "piper/lib" ]]; then + sudo cp -r piper/lib/* /usr/local/lib/ 2>/dev/null || true + sudo ldconfig + fi + + log "Piper installed" + fi + + mkdir -p "$PIPER_MODEL_DIR" + + if [[ ! -f "$PIPER_MODEL_DIR/en_US-lessac-medium.onnx" ]]; then + info "Downloading Piper voice model..." + cd "$PIPER_MODEL_DIR" + + wget -q "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/lessac/medium/en_US-lessac-medium.onnx" + wget -q "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/lessac/medium/en_US-lessac-medium.onnx.json" + + log "Piper voice model downloaded" + else + log "Piper voice model already exists" + fi +} + +# ============================================ +# ALSA Configuration +# ============================================ +section_alsa() { + if [[ "$IS_PI" != "true" ]]; then + warn "Skipping ALSA config (not on Pi)" + return 0 + fi + + info "Configuring ALSA..." + + if [[ -f "$PROJECT_DIR/config/asound.conf" ]]; then + sudo cp "$PROJECT_DIR/config/asound.conf" /etc/asound.conf + log "ALSA config installed" + else + warn "asound.conf not found in project" + fi + + if [[ -f "$PROJECT_DIR/config/config.txt.patch" ]]; then + local BOOT_CONFIG="/boot/firmware/config.txt" + [[ ! -f "$BOOT_CONFIG" ]] && BOOT_CONFIG="/boot/config.txt" + + if ! grep -q "hifiberry-dac" "$BOOT_CONFIG" 2>/dev/null; then + info "Adding I2S overlay to boot config..." + echo "" | sudo tee -a "$BOOT_CONFIG" + echo "# OpenClaw I2S Audio" | sudo tee -a "$BOOT_CONFIG" + echo "dtoverlay=hifiberry-dac" | sudo tee -a "$BOOT_CONFIG" + echo "dtoverlay=i2s-mmap" | sudo tee -a "$BOOT_CONFIG" + echo "dtparam=audio=off" | sudo tee -a "$BOOT_CONFIG" + log "Boot config updated (reboot required)" + else + log "I2S overlay already configured" + fi + fi +} + +# ============================================ +# Chromium Kiosk Setup +# ============================================ +section_kiosk() { + if [[ "$IS_PI" != "true" ]]; then + warn "Skipping kiosk setup (not on Pi)" + return 0 + fi + + info "Setting up Chromium kiosk mode..." + + mkdir -p "$HOME_DIR/.config/autostart" + + cat > "$HOME_DIR/.config/autostart/openclaw-kiosk.desktop" << EOF +[Desktop Entry] +Type=Application +Name=OpenClaw Kiosk +Exec=$PROJECT_DIR/scripts/start.sh +Hidden=false +NoDisplay=false +X-GNOME-Autostart-enabled=true +EOF + + cat > "$HOME_DIR/.config/autostart/disable-screensaver.desktop" << EOF +[Desktop Entry] +Type=Application +Name=Disable Screensaver +Exec=xset s off -dpms +Hidden=false +NoDisplay=false +X-GNOME-Autostart-enabled=true +EOF + + log "Kiosk autostart configured" +} + +# ============================================ +# Project Setup +# ============================================ +section_project() { + info "Setting up project..." + + cd "$PROJECT_DIR" + + if [[ -f "package.json" ]]; then + npm install --production + log "Node dependencies installed" + fi + + chmod +x scripts/*.sh 2>/dev/null || true + + log "Project setup complete" +} + +# ============================================ +# Systemd Service +# ============================================ +section_systemd() { + if [[ "$IS_PI" != "true" ]]; then + warn "Skipping systemd setup (not on Pi)" + return 0 + fi + + info "Installing systemd service..." + + if [[ -f "$PROJECT_DIR/systemd/openclaw-assistant.service" ]]; then + local SERVICE_FILE="/etc/systemd/system/openclaw-assistant.service" + + cat > /tmp/openclaw-assistant.service << EOF +[Unit] +Description=OpenClaw Pi Assistant +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=$(whoami) +WorkingDirectory=$PROJECT_DIR +ExecStart=/usr/bin/node server/index.js +Restart=always +RestartSec=5 +Environment=NODE_ENV=production +Environment=DISPLAY=:0 +Environment=WHISPER_PATH=/usr/local/bin/whisper +Environment=WHISPER_MODEL=$HOME_DIR/.whisper/ggml-base.en.bin +Environment=PIPER_PATH=/usr/local/bin/piper +Environment=PIPER_MODEL=$HOME_DIR/.piper/en_US-lessac-medium.onnx + +[Install] +WantedBy=multi-user.target +EOF + + sudo mv /tmp/openclaw-assistant.service "$SERVICE_FILE" + sudo systemctl daemon-reload + sudo systemctl enable openclaw-assistant + + log "Systemd service installed and enabled" + fi +} + +# ============================================ +# Main +# ============================================ +main() { + echo "" + echo "╔═══════════════════════════════════════════╗" + echo "║ OpenClaw Pi Assistant Setup ║" + echo "╚═══════════════════════════════════════════╝" + echo "" + + section_system + section_node + section_whisper + section_piper + section_alsa + section_project + section_kiosk + section_systemd + + echo "" + echo "╔═══════════════════════════════════════════╗" + echo "║ Setup Complete! ║" + echo "╚═══════════════════════════════════════════╝" + echo "" + log "All components installed successfully" + echo "" + info "Next steps:" + echo " 1. Reboot to apply audio config: sudo reboot" + echo " 2. Start manually: ./scripts/start.sh" + echo " 3. Or enable service: sudo systemctl start openclaw-assistant" + echo "" + + if [[ "$IS_PI" == "true" ]]; then + warn "A reboot is recommended to apply I2S audio configuration" + read -p "Reboot now? [y/N] " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + sudo reboot + fi + fi +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi diff --git a/scripts/start.sh b/scripts/start.sh new file mode 100755 index 0000000..3d75251 --- /dev/null +++ b/scripts/start.sh @@ -0,0 +1,228 @@ +#!/bin/bash +set -e + +# OpenClaw Pi Assistant - Start Script +# Launches the server and optionally Chromium in kiosk mode + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +HOME_DIR="${HOME:-/home/pi}" + +PORT="${PORT:-3001}" +KIOSK="${KIOSK:-true}" +DISPLAY="${DISPLAY:-:0}" + +export WHISPER_PATH="${WHISPER_PATH:-/usr/local/bin/whisper}" +export WHISPER_MODEL="${WHISPER_MODEL:-$HOME_DIR/.whisper/ggml-base.en.bin}" +export PIPER_PATH="${PIPER_PATH:-/usr/local/bin/piper}" +export PIPER_MODEL="${PIPER_MODEL:-$HOME_DIR/.piper/en_US-lessac-medium.onnx}" +export OLLAMA_HOST="${OLLAMA_HOST:-http://localhost:11434}" +export OLLAMA_MODEL="${OLLAMA_MODEL:-openclaw}" +export NODE_ENV="${NODE_ENV:-production}" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +log() { echo -e "${GREEN}[✓]${NC} $1"; } +warn() { echo -e "${YELLOW}[!]${NC} $1"; } +error() { echo -e "${RED}[✗]${NC} $1"; exit 1; } +info() { echo -e "${BLUE}[i]${NC} $1"; } + +cleanup() { + info "Shutting down..." + + if [[ -n "$SERVER_PID" ]] && kill -0 "$SERVER_PID" 2>/dev/null; then + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + + if [[ -n "$CHROMIUM_PID" ]] && kill -0 "$CHROMIUM_PID" 2>/dev/null; then + kill "$CHROMIUM_PID" 2>/dev/null || true + fi + + log "Shutdown complete" + exit 0 +} + +trap cleanup SIGINT SIGTERM EXIT + +check_dependencies() { + local missing=() + + if ! command -v node &>/dev/null; then + missing+=("node") + fi + + if [[ ! -f "$WHISPER_PATH" ]] && ! command -v whisper &>/dev/null; then + warn "whisper.cpp not found at $WHISPER_PATH" + fi + + if [[ ! -f "$PIPER_PATH" ]] && ! command -v piper &>/dev/null; then + warn "Piper not found at $PIPER_PATH" + fi + + if [[ ${#missing[@]} -gt 0 ]]; then + error "Missing dependencies: ${missing[*]}" + fi +} + +wait_for_server() { + local max_attempts=30 + local attempt=0 + + info "Waiting for server to start..." + + while [[ $attempt -lt $max_attempts ]]; do + if curl -s "http://localhost:$PORT/health" >/dev/null 2>&1; then + log "Server is ready" + return 0 + fi + sleep 0.5 + ((attempt++)) + done + + error "Server failed to start" +} + +start_server() { + info "Starting OpenClaw server on port $PORT..." + + cd "$PROJECT_DIR" + + if [[ ! -d "node_modules" ]]; then + info "Installing dependencies..." + npm install --production + fi + + node server/index.js & + SERVER_PID=$! + + wait_for_server +} + +start_kiosk() { + if [[ "$KIOSK" != "true" ]]; then + info "Kiosk mode disabled" + return 0 + fi + + if [[ -z "$DISPLAY" ]]; then + warn "No DISPLAY set, skipping kiosk" + return 0 + fi + + if ! command -v chromium-browser &>/dev/null && ! command -v chromium &>/dev/null; then + warn "Chromium not found, skipping kiosk" + return 0 + fi + + info "Starting Chromium kiosk..." + + xset s off 2>/dev/null || true + xset -dpms 2>/dev/null || true + xset s noblank 2>/dev/null || true + + if command -v unclutter &>/dev/null; then + unclutter -idle 0.5 -root & + fi + + local CHROMIUM_CMD="chromium-browser" + command -v chromium-browser &>/dev/null || CHROMIUM_CMD="chromium" + + $CHROMIUM_CMD \ + --kiosk \ + --noerrdialogs \ + --disable-infobars \ + --disable-session-crashed-bubble \ + --disable-restore-session-state \ + --disable-translate \ + --no-first-run \ + --fast \ + --fast-start \ + --disable-features=TranslateUI \ + --disk-cache-dir=/dev/null \ + --overscroll-history-navigation=0 \ + --disable-pinch \ + --window-size=480,320 \ + --window-position=0,0 \ + "http://localhost:$PORT" & + + CHROMIUM_PID=$! + log "Chromium kiosk started (PID: $CHROMIUM_PID)" +} + +main() { + echo "" + echo "╔═══════════════════════════════════════════╗" + echo "║ OpenClaw Pi Assistant ║" + echo "╚═══════════════════════════════════════════╝" + echo "" + + check_dependencies + + info "Configuration:" + echo " Port: $PORT" + echo " Kiosk: $KIOSK" + echo " Whisper: $WHISPER_PATH" + echo " Piper: $PIPER_PATH" + echo " Ollama: $OLLAMA_HOST" + echo " Model: $OLLAMA_MODEL" + echo "" + + start_server + start_kiosk + + log "OpenClaw is running!" + info "Web UI: http://localhost:$PORT" + info "Press Ctrl+C to stop" + echo "" + + wait $SERVER_PID +} + +usage() { + echo "Usage: $0 [options]" + echo "" + echo "Options:" + echo " --no-kiosk Don't start Chromium kiosk" + echo " --port PORT Server port (default: 3001)" + echo " --help Show this help" + echo "" + echo "Environment variables:" + echo " PORT Server port" + echo " KIOSK Enable kiosk mode (true/false)" + echo " WHISPER_PATH Path to whisper binary" + echo " WHISPER_MODEL Path to whisper model" + echo " PIPER_PATH Path to piper binary" + echo " PIPER_MODEL Path to piper voice model" + echo " OLLAMA_HOST Ollama API URL" + echo " OLLAMA_MODEL Model name" +} + +while [[ $# -gt 0 ]]; do + case $1 in + --no-kiosk) + KIOSK="false" + shift + ;; + --port) + PORT="$2" + shift 2 + ;; + --help|-h) + usage + exit 0 + ;; + *) + warn "Unknown option: $1" + shift + ;; + esac +done + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi diff --git a/server/audio.js b/server/audio.js new file mode 100644 index 0000000..7b96d4f --- /dev/null +++ b/server/audio.js @@ -0,0 +1,222 @@ +import { spawn, execSync } from 'child_process'; +import { join } from 'path'; +import { mkdirSync, existsSync, unlinkSync, readdirSync, statSync } from 'fs'; +import { tmpdir } from 'os'; + +const RECORDINGS_DIR = process.env.RECORDINGS_DIR || join(tmpdir(), 'openclaw-recordings'); +const SAMPLE_RATE = 16000; +const CHANNELS = 1; +const FORMAT = 'S16_LE'; + +if (!existsSync(RECORDINGS_DIR)) { + mkdirSync(RECORDINGS_DIR, { recursive: true }); +} + +function isPi() { + if (process.env.FORCE_PI === 'true') return true; + if (process.env.FORCE_DEV === 'true') return false; + + try { + const cpuInfo = execSync('cat /proc/cpuinfo 2>/dev/null || echo ""', { encoding: 'utf8' }); + return cpuInfo.includes('Raspberry Pi') || cpuInfo.includes('BCM'); + } catch { + return false; + } +} + +function getRecordCommand() { + const isRaspberryPi = process.platform === 'linux' && + (process.arch === 'arm64' || process.arch === 'arm'); + + if (isRaspberryPi || process.env.FORCE_PI === 'true') { + return { + cmd: 'arecord', + args: [ + '-D', 'default', + '-f', FORMAT, + '-r', String(SAMPLE_RATE), + '-c', String(CHANNELS), + '-t', 'wav', + ] + }; + } + + if (process.platform === 'darwin') { + return { + cmd: 'sox', + args: [ + '-d', + '-r', String(SAMPLE_RATE), + '-c', String(CHANNELS), + '-b', '16', + '-e', 'signed-integer', + ] + }; + } + + return { + cmd: 'arecord', + args: [ + '-f', FORMAT, + '-r', String(SAMPLE_RATE), + '-c', String(CHANNELS), + '-t', 'wav', + ] + }; +} + +function getPlayCommand() { + const isRaspberryPi = process.platform === 'linux' && + (process.arch === 'arm64' || process.arch === 'arm'); + + if (isRaspberryPi || process.env.FORCE_PI === 'true') { + return { cmd: 'aplay', args: ['-D', 'default'] }; + } + + if (process.platform === 'darwin') { + return { cmd: 'afplay', args: [] }; + } + + return { cmd: 'aplay', args: [] }; +} + +export function startRecording() { + const timestamp = Date.now(); + const wavPath = join(RECORDINGS_DIR, `recording_${timestamp}.wav`); + const { cmd, args } = getRecordCommand(); + + const fullArgs = [...args, wavPath]; + console.log(`[Audio] Starting: ${cmd} ${fullArgs.join(' ')}`); + + const proc = spawn(cmd, fullArgs, { + stdio: ['ignore', 'pipe', 'pipe'] + }); + + proc.stderr.on('data', (data) => { + const msg = data.toString().trim(); + if (msg && !msg.includes('Recording WAVE')) { + console.log(`[Audio] ${msg}`); + } + }); + + proc.on('error', (err) => { + console.error(`[Audio] Record process error: ${err.message}`); + }); + + return { + process: proc, + wavPath, + startTime: timestamp + }; +} + +export function stopRecording(recording) { + return new Promise((resolve, reject) => { + if (!recording || !recording.process) { + reject(new Error('No recording process')); + return; + } + + const { process: proc, wavPath, startTime } = recording; + const duration = Date.now() - startTime; + + if (duration < 500) { + console.log('[Audio] Recording too short, waiting...'); + setTimeout(() => { + doStop(); + }, 500 - duration); + } else { + doStop(); + } + + function doStop() { + let resolved = false; + + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true; + proc.kill('SIGKILL'); + resolve(wavPath); + } + }, 2000); + + proc.on('close', (code) => { + if (!resolved) { + resolved = true; + clearTimeout(timeout); + console.log(`[Audio] Recording saved: ${wavPath} (${duration}ms)`); + resolve(wavPath); + } + }); + + proc.kill('SIGINT'); + } + }); +} + +export function playAudio(audioPath) { + return new Promise((resolve, reject) => { + if (!existsSync(audioPath)) { + reject(new Error(`Audio file not found: ${audioPath}`)); + return; + } + + const { cmd, args } = getPlayCommand(); + const fullArgs = [...args, audioPath]; + + console.log(`[Audio] Playing: ${cmd} ${fullArgs.join(' ')}`); + + const proc = spawn(cmd, fullArgs, { + stdio: ['ignore', 'pipe', 'pipe'] + }); + + proc.stderr.on('data', (data) => { + const msg = data.toString().trim(); + if (msg && !msg.includes('Playing WAVE')) { + console.log(`[Audio] ${msg}`); + } + }); + + proc.on('close', (code) => { + if (code === 0) { + cleanupFile(audioPath); + resolve(); + } else { + reject(new Error(`Playback failed with code ${code}`)); + } + }); + + proc.on('error', (err) => { + reject(new Error(`Playback error: ${err.message}`)); + }); + }); +} + +function cleanupFile(filePath) { + try { + if (existsSync(filePath)) { + unlinkSync(filePath); + console.log(`[Audio] Cleaned up: ${filePath}`); + } + } catch (err) { + console.error(`[Audio] Cleanup failed: ${err.message}`); + } +} + +export function cleanupOldRecordings(maxAgeMs = 3600000) { + try { + const files = readdirSync(RECORDINGS_DIR); + const now = Date.now(); + + for (const file of files) { + const filePath = join(RECORDINGS_DIR, file); + const stats = statSync(filePath); + if (now - stats.mtimeMs > maxAgeMs) { + unlinkSync(filePath); + console.log(`[Audio] Cleaned old file: ${file}`); + } + } + } catch (err) { + console.error(`[Audio] Cleanup error: ${err.message}`); + } +} diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..6bae216 --- /dev/null +++ b/server/index.js @@ -0,0 +1,181 @@ +import express from 'express'; +import { WebSocketServer } from 'ws'; +import { createServer } from 'http'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; +import { startRecording, stopRecording, playAudio } from './audio.js'; +import { transcribe } from './stt.js'; +import { synthesize } from './tts.js'; +import { chat } from './openclaw.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const PORT = process.env.PORT || 3001; + +const app = express(); +const server = createServer(app); +const wss = new WebSocketServer({ server }); + +app.use(express.static(join(__dirname, '../web'))); + +app.get('/health', (req, res) => { + res.json({ status: 'ok', timestamp: Date.now() }); +}); + +const conversationHistory = new Map(); +const MAX_HISTORY = 10; + +function getHistory(clientId) { + if (!conversationHistory.has(clientId)) { + conversationHistory.set(clientId, []); + } + return conversationHistory.get(clientId); +} + +function addToHistory(clientId, role, content) { + const history = getHistory(clientId); + history.push({ role, content }); + if (history.length > MAX_HISTORY) { + history.splice(0, history.length - MAX_HISTORY); + } +} + +function sendMessage(ws, type, data = {}) { + if (ws.readyState === ws.OPEN) { + ws.send(JSON.stringify({ type, ...data })); + } +} + +wss.on('connection', (ws) => { + const clientId = `client_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; + console.log(`[WS] Client connected: ${clientId}`); + + let isRecording = false; + let recordingProcess = null; + + ws.on('message', async (message) => { + let data; + try { + data = JSON.parse(message.toString()); + } catch (err) { + console.error('[WS] Invalid JSON:', err.message); + return; + } + + console.log(`[WS] Received: ${data.type}`); + + switch (data.type) { + case 'start_recording': + if (isRecording) { + sendMessage(ws, 'error', { message: 'Already recording' }); + return; + } + try { + recordingProcess = startRecording(); + isRecording = true; + sendMessage(ws, 'recording_started'); + console.log('[Audio] Recording started'); + } catch (err) { + console.error('[Audio] Failed to start recording:', err.message); + sendMessage(ws, 'error', { message: 'Failed to start recording' }); + } + break; + + case 'stop_recording': + if (!isRecording) { + sendMessage(ws, 'error', { message: 'Not recording' }); + return; + } + try { + sendMessage(ws, 'recording_stopped'); + sendMessage(ws, 'state', { state: 'thinking' }); + console.log('[Audio] Recording stopped, processing...'); + + const wavPath = await stopRecording(recordingProcess); + isRecording = false; + recordingProcess = null; + + sendMessage(ws, 'state', { state: 'transcribing' }); + const transcript = await transcribe(wavPath); + console.log(`[STT] Transcript: "${transcript}"`); + + if (!transcript || transcript.trim().length === 0) { + sendMessage(ws, 'error', { message: 'Could not understand audio' }); + sendMessage(ws, 'state', { state: 'idle' }); + return; + } + + sendMessage(ws, 'transcript', { text: transcript }); + addToHistory(clientId, 'user', transcript); + + sendMessage(ws, 'state', { state: 'thinking' }); + const history = getHistory(clientId); + + let fullResponse = ''; + await chat(transcript, history, (chunk) => { + fullResponse += chunk; + sendMessage(ws, 'response_chunk', { text: chunk }); + }); + + addToHistory(clientId, 'assistant', fullResponse); + sendMessage(ws, 'response_complete', { text: fullResponse }); + console.log(`[OpenClaw] Response: "${fullResponse.slice(0, 100)}..."`); + + sendMessage(ws, 'state', { state: 'speaking' }); + const audioPath = await synthesize(fullResponse); + console.log(`[TTS] Audio generated: ${audioPath}`); + + await playAudio(audioPath); + console.log('[Audio] Playback complete'); + + sendMessage(ws, 'state', { state: 'idle' }); + } catch (err) { + console.error('[Pipeline] Error:', err.message); + sendMessage(ws, 'error', { message: err.message || 'Processing failed' }); + sendMessage(ws, 'state', { state: 'idle' }); + isRecording = false; + recordingProcess = null; + } + break; + + case 'clear_history': + conversationHistory.delete(clientId); + sendMessage(ws, 'history_cleared'); + console.log(`[History] Cleared for ${clientId}`); + break; + + default: + console.log(`[WS] Unknown message type: ${data.type}`); + } + }); + + ws.on('close', () => { + console.log(`[WS] Client disconnected: ${clientId}`); + if (isRecording && recordingProcess) { + stopRecording(recordingProcess).catch(() => {}); + } + }); + + ws.on('error', (err) => { + console.error(`[WS] Error for ${clientId}:`, err.message); + }); + + sendMessage(ws, 'connected', { clientId }); +}); + +server.listen(PORT, () => { + console.log(`[Server] OpenClaw Pi Assistant running on port ${PORT}`); + console.log(`[Server] Web UI: http://localhost:${PORT}`); +}); + +process.on('SIGINT', () => { + console.log('\n[Server] Shutting down...'); + wss.clients.forEach((client) => { + client.close(); + }); + server.close(() => { + console.log('[Server] Goodbye!'); + process.exit(0); + }); +}); diff --git a/server/openclaw.js b/server/openclaw.js new file mode 100644 index 0000000..60a3b0e --- /dev/null +++ b/server/openclaw.js @@ -0,0 +1,214 @@ +const OLLAMA_HOST = process.env.OLLAMA_HOST || 'http://localhost:11434'; +const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'openclaw'; +const REQUEST_TIMEOUT = parseInt(process.env.OLLAMA_TIMEOUT || '120000', 10); + +const SYSTEM_PROMPT = process.env.SYSTEM_PROMPT || `You are OpenClaw, a helpful voice assistant running on a Raspberry Pi. Keep responses concise and conversational since they will be spoken aloud. Avoid using markdown formatting, code blocks, or special characters. Speak naturally as if having a conversation.`; + +const MAX_HISTORY = 10; + +export async function chat(message, history = [], onChunk = null) { + const messages = buildMessages(message, history); + + console.log(`[OpenClaw] Sending to ${OLLAMA_HOST}/api/chat`); + console.log(`[OpenClaw] Model: ${OLLAMA_MODEL}`); + console.log(`[OpenClaw] History: ${history.length} messages`); + + try { + const response = await fetch(`${OLLAMA_HOST}/api/chat`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: OLLAMA_MODEL, + messages, + stream: !!onChunk, + options: { + temperature: 0.7, + top_p: 0.9, + num_predict: 500, + }, + }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Ollama API error (${response.status}): ${errorText}`); + } + + if (onChunk && response.body) { + return await handleStreamingResponse(response, onChunk); + } else { + const data = await response.json(); + return data.message?.content || ''; + } + } catch (err) { + console.error(`[OpenClaw] Error: ${err.message}`); + + if (err.name === 'TimeoutError' || err.message.includes('timeout')) { + throw new Error('Request timed out. The AI is taking too long to respond.'); + } + + if (err.message.includes('ECONNREFUSED') || err.message.includes('fetch failed')) { + throw new Error('Cannot connect to OpenClaw. Make sure Ollama is running.'); + } + + throw err; + } +} + +function buildMessages(message, history) { + const messages = [ + { role: 'system', content: SYSTEM_PROMPT }, + ]; + + const recentHistory = history.slice(-MAX_HISTORY); + for (const msg of recentHistory) { + if (msg.role === 'user' || msg.role === 'assistant') { + messages.push({ + role: msg.role, + content: msg.content, + }); + } + } + + messages.push({ + role: 'user', + content: message, + }); + + return messages; +} + +async function handleStreamingResponse(response, onChunk) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let fullResponse = ''; + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.trim()) continue; + + try { + const data = JSON.parse(line); + + if (data.message?.content) { + const chunk = data.message.content; + fullResponse += chunk; + + if (onChunk) { + onChunk(chunk); + } + } + + if (data.done) { + console.log(`[OpenClaw] Stream complete, ${fullResponse.length} chars`); + } + } catch (parseErr) { + console.warn(`[OpenClaw] Failed to parse chunk: ${line}`); + } + } + } + + if (buffer.trim()) { + try { + const data = JSON.parse(buffer); + if (data.message?.content) { + fullResponse += data.message.content; + if (onChunk) { + onChunk(data.message.content); + } + } + } catch { + } + } + + return fullResponse; + } finally { + reader.releaseLock(); + } +} + +export async function checkOllamaConnection() { + try { + const response = await fetch(`${OLLAMA_HOST}/api/tags`, { + signal: AbortSignal.timeout(5000), + }); + + if (!response.ok) { + return { connected: false, error: `HTTP ${response.status}` }; + } + + const data = await response.json(); + const models = data.models || []; + const hasModel = models.some(m => m.name === OLLAMA_MODEL || m.name.startsWith(`${OLLAMA_MODEL}:`)); + + return { + connected: true, + models: models.map(m => m.name), + hasModel, + requiredModel: OLLAMA_MODEL, + }; + } catch (err) { + return { + connected: false, + error: err.message, + }; + } +} + +export async function pullModel(modelName = OLLAMA_MODEL) { + console.log(`[OpenClaw] Pulling model: ${modelName}`); + + try { + const response = await fetch(`${OLLAMA_HOST}/api/pull`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name: modelName }), + }); + + if (!response.ok) { + throw new Error(`Failed to pull model: ${response.status}`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const lines = decoder.decode(value).split('\n'); + for (const line of lines) { + if (!line.trim()) continue; + try { + const data = JSON.parse(line); + if (data.status) { + console.log(`[OpenClaw] Pull: ${data.status}`); + } + } catch { + } + } + } + + console.log(`[OpenClaw] Model ${modelName} ready`); + return true; + } catch (err) { + console.error(`[OpenClaw] Pull failed: ${err.message}`); + return false; + } +} diff --git a/server/stt.js b/server/stt.js new file mode 100644 index 0000000..e1c7c0d --- /dev/null +++ b/server/stt.js @@ -0,0 +1,168 @@ +import { spawn } from 'child_process'; +import { existsSync, unlinkSync, readFileSync } from 'fs'; +import { join } from 'path'; + +const WHISPER_PATH = process.env.WHISPER_PATH || '/usr/local/bin/whisper'; +const WHISPER_MODEL = process.env.WHISPER_MODEL || join(process.env.HOME || '/home/pi', '.whisper', 'ggml-base.en.bin'); + +const WHISPER_THREADS = process.env.WHISPER_THREADS || '4'; +const WHISPER_PROCESSORS = process.env.WHISPER_PROCESSORS || '1'; + +export async function transcribe(wavPath) { + if (!existsSync(wavPath)) { + throw new Error(`WAV file not found: ${wavPath}`); + } + + const whisperBin = findWhisperBinary(); + const modelPath = findModelPath(); + + console.log(`[STT] Transcribing: ${wavPath}`); + console.log(`[STT] Using whisper: ${whisperBin}`); + console.log(`[STT] Using model: ${modelPath}`); + + return new Promise((resolve, reject) => { + const args = [ + '-m', modelPath, + '-f', wavPath, + '-t', WHISPER_THREADS, + '-p', WHISPER_PROCESSORS, + '--no-timestamps', + '-l', 'en', + '--output-txt', + ]; + + console.log(`[STT] Running: ${whisperBin} ${args.join(' ')}`); + + const proc = spawn(whisperBin, args, { + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 60000, + }); + + let stdout = ''; + let stderr = ''; + + proc.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + proc.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + proc.on('close', (code) => { + cleanupWav(wavPath); + + if (code !== 0) { + console.error(`[STT] Whisper failed (code ${code}): ${stderr}`); + reject(new Error(`Transcription failed: ${stderr || 'Unknown error'}`)); + return; + } + + const transcript = parseWhisperOutput(stdout, wavPath); + console.log(`[STT] Result: "${transcript}"`); + resolve(transcript); + }); + + proc.on('error', (err) => { + cleanupWav(wavPath); + console.error(`[STT] Whisper error: ${err.message}`); + reject(new Error(`Failed to run whisper: ${err.message}`)); + }); + }); +} + +function findWhisperBinary() { + const candidates = [ + WHISPER_PATH, + '/usr/local/bin/whisper', + '/usr/local/bin/main', + join(process.env.HOME || '/home/pi', 'whisper.cpp', 'main'), + join(process.env.HOME || '/home/pi', 'whisper.cpp', 'build', 'bin', 'main'), + '/opt/whisper/main', + ]; + + for (const path of candidates) { + if (existsSync(path)) { + return path; + } + } + + console.warn(`[STT] Whisper binary not found, using default: ${WHISPER_PATH}`); + return WHISPER_PATH; +} + +function findModelPath() { + const candidates = [ + WHISPER_MODEL, + join(process.env.HOME || '/home/pi', '.whisper', 'ggml-base.en.bin'), + join(process.env.HOME || '/home/pi', 'whisper.cpp', 'models', 'ggml-base.en.bin'), + '/usr/local/share/whisper/ggml-base.en.bin', + '/opt/whisper/models/ggml-base.en.bin', + ]; + + for (const path of candidates) { + if (existsSync(path)) { + return path; + } + } + + console.warn(`[STT] Model not found, using default: ${WHISPER_MODEL}`); + return WHISPER_MODEL; +} + +function parseWhisperOutput(stdout, wavPath) { + const txtPath = wavPath.replace('.wav', '.wav.txt'); + if (existsSync(txtPath)) { + try { + const content = readFileSync(txtPath, 'utf8'); + unlinkSync(txtPath); + return cleanTranscript(content); + } catch (err) { + console.warn(`[STT] Could not read txt output: ${err.message}`); + } + } + + const lines = stdout.split('\n'); + const textLines = lines.filter(line => { + const trimmed = line.trim(); + return trimmed && + !trimmed.startsWith('[') && + !trimmed.startsWith('whisper_') && + !trimmed.includes('main:') && + !trimmed.includes('system_info:'); + }); + + return cleanTranscript(textLines.join(' ')); +} + +function cleanTranscript(text) { + return text + .replace(/\[.*?\]/g, '') + .replace(/\s+/g, ' ') + .replace(/^\s+|\s+$/g, '') + .trim(); +} + +function cleanupWav(wavPath) { + try { + if (existsSync(wavPath)) { + unlinkSync(wavPath); + console.log(`[STT] Cleaned up: ${wavPath}`); + } + } catch (err) { + console.warn(`[STT] Cleanup failed: ${err.message}`); + } +} + +export function checkWhisperInstallation() { + const binary = findWhisperBinary(); + const model = findModelPath(); + + return { + binaryFound: existsSync(binary), + binaryPath: binary, + modelFound: existsSync(model), + modelPath: model, + ready: existsSync(binary) && existsSync(model), + }; +} diff --git a/server/tts.js b/server/tts.js new file mode 100644 index 0000000..759a076 --- /dev/null +++ b/server/tts.js @@ -0,0 +1,171 @@ +import { spawn } from 'child_process'; +import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +const PIPER_PATH = process.env.PIPER_PATH || '/usr/local/bin/piper'; +const PIPER_MODEL = process.env.PIPER_MODEL || join(process.env.HOME || '/home/pi', '.piper', 'en_US-lessac-medium.onnx'); + +const TTS_OUTPUT_DIR = process.env.TTS_OUTPUT_DIR || join(tmpdir(), 'openclaw-tts'); +const SAMPLE_RATE = 22050; + +if (!existsSync(TTS_OUTPUT_DIR)) { + mkdirSync(TTS_OUTPUT_DIR, { recursive: true }); +} + +export async function synthesize(text) { + if (!text || text.trim().length === 0) { + throw new Error('No text to synthesize'); + } + + const cleanedText = cleanTextForTTS(text); + const piperBin = findPiperBinary(); + const modelPath = findModelPath(); + + console.log(`[TTS] Synthesizing: "${cleanedText.slice(0, 50)}..."`); + console.log(`[TTS] Using piper: ${piperBin}`); + console.log(`[TTS] Using model: ${modelPath}`); + + const timestamp = Date.now(); + const outputPath = join(TTS_OUTPUT_DIR, `tts_${timestamp}.wav`); + + return new Promise((resolve, reject) => { + const args = [ + '--model', modelPath, + '--output_file', outputPath, + ]; + + const configPath = modelPath.replace('.onnx', '.onnx.json'); + if (existsSync(configPath)) { + args.push('--config', configPath); + } + + console.log(`[TTS] Running: echo "..." | ${piperBin} ${args.join(' ')}`); + + const proc = spawn(piperBin, args, { + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 30000, + }); + + let stderr = ''; + + proc.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + proc.on('close', (code) => { + if (code !== 0) { + console.error(`[TTS] Piper failed (code ${code}): ${stderr}`); + reject(new Error(`TTS failed: ${stderr || 'Unknown error'}`)); + return; + } + + if (!existsSync(outputPath)) { + reject(new Error('TTS output file not created')); + return; + } + + console.log(`[TTS] Generated: ${outputPath}`); + resolve(outputPath); + }); + + proc.on('error', (err) => { + console.error(`[TTS] Piper error: ${err.message}`); + reject(new Error(`Failed to run piper: ${err.message}`)); + }); + + proc.stdin.write(cleanedText); + proc.stdin.end(); + }); +} + +function findPiperBinary() { + const candidates = [ + PIPER_PATH, + '/usr/local/bin/piper', + join(process.env.HOME || '/home/pi', 'piper', 'piper'), + join(process.env.HOME || '/home/pi', '.local', 'bin', 'piper'), + '/opt/piper/piper', + ]; + + for (const path of candidates) { + if (existsSync(path)) { + return path; + } + } + + console.warn(`[TTS] Piper binary not found, using default: ${PIPER_PATH}`); + return PIPER_PATH; +} + +function findModelPath() { + const candidates = [ + PIPER_MODEL, + join(process.env.HOME || '/home/pi', '.piper', 'en_US-lessac-medium.onnx'), + join(process.env.HOME || '/home/pi', 'piper', 'models', 'en_US-lessac-medium.onnx'), + '/usr/local/share/piper/en_US-lessac-medium.onnx', + '/opt/piper/models/en_US-lessac-medium.onnx', + ]; + + for (const path of candidates) { + if (existsSync(path)) { + return path; + } + } + + console.warn(`[TTS] Model not found, using default: ${PIPER_MODEL}`); + return PIPER_MODEL; +} + +function cleanTextForTTS(text) { + return text + .replace(/```[\s\S]*?```/g, ' code block ') + .replace(/`[^`]+`/g, ' code ') + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/\*([^*]+)\*/g, '$1') + .replace(/__([^_]+)__/g, '$1') + .replace(/_([^_]+)_/g, '$1') + .replace(/#{1,6}\s*/g, '') + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + .replace(/https?:\/\/\S+/g, ' link ') + .replace(/[<>{}[\]]/g, '') + .replace(/\n{2,}/g, '. ') + .replace(/\n/g, ' ') + .replace(/\s+/g, ' ') + .replace(/\.{2,}/g, '.') + .replace(/\s+([.,!?])/g, '$1') + .trim() + .slice(0, 2000); +} + +export function checkPiperInstallation() { + const binary = findPiperBinary(); + const model = findModelPath(); + + return { + binaryFound: existsSync(binary), + binaryPath: binary, + modelFound: existsSync(model), + modelPath: model, + ready: existsSync(binary) && existsSync(model), + }; +} + +export function cleanupOldTTSFiles(maxAgeMs = 3600000) { + try { + const { readdirSync, statSync } = require('fs'); + const files = readdirSync(TTS_OUTPUT_DIR); + const now = Date.now(); + + for (const file of files) { + const filePath = join(TTS_OUTPUT_DIR, file); + const stats = statSync(filePath); + if (now - stats.mtimeMs > maxAgeMs) { + unlinkSync(filePath); + console.log(`[TTS] Cleaned old file: ${file}`); + } + } + } catch (err) { + console.error(`[TTS] Cleanup error: ${err.message}`); + } +} diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..e6b7b76 --- /dev/null +++ b/web/app.js @@ -0,0 +1,258 @@ +(function() { + 'use strict'; + + const WS_URL = `ws://${window.location.hostname}:3001`; + const RECONNECT_DELAY = 2000; + const MAX_RECONNECT_DELAY = 30000; + + let ws = null; + let reconnectAttempts = 0; + let isRecording = false; + let currentState = 'idle'; + + const elements = { + orb: document.getElementById('orb'), + statusDot: document.getElementById('status-dot'), + statusText: document.getElementById('status-text'), + clock: document.getElementById('clock'), + transcript: document.getElementById('transcript'), + transcriptText: document.getElementById('transcript-text'), + response: document.getElementById('response'), + responseText: document.getElementById('response-text'), + hint: document.getElementById('hint'), + tapZone: document.getElementById('tap-zone'), + errorToast: document.getElementById('error-toast'), + errorMessage: document.getElementById('error-message'), + }; + + function init() { + updateClock(); + setInterval(updateClock, 1000); + + connect(); + + elements.tapZone.addEventListener('click', handleTap); + elements.tapZone.addEventListener('touchstart', handleTouchStart, { passive: true }); + elements.tapZone.addEventListener('touchend', handleTouchEnd, { passive: true }); + + document.addEventListener('keydown', handleKeyDown); + } + + function connect() { + if (ws && ws.readyState === WebSocket.OPEN) { + return; + } + + console.log('[WS] Connecting to', WS_URL); + + try { + ws = new WebSocket(WS_URL); + } catch (err) { + console.error('[WS] Failed to create WebSocket:', err); + scheduleReconnect(); + return; + } + + ws.onopen = () => { + console.log('[WS] Connected'); + reconnectAttempts = 0; + setConnectionStatus(true); + }; + + ws.onclose = (event) => { + console.log('[WS] Disconnected:', event.code, event.reason); + setConnectionStatus(false); + scheduleReconnect(); + }; + + ws.onerror = (err) => { + console.error('[WS] Error:', err); + }; + + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + handleMessage(data); + } catch (err) { + console.error('[WS] Invalid message:', err); + } + }; + } + + function scheduleReconnect() { + reconnectAttempts++; + const delay = Math.min(RECONNECT_DELAY * Math.pow(1.5, reconnectAttempts - 1), MAX_RECONNECT_DELAY); + console.log(`[WS] Reconnecting in ${delay}ms (attempt ${reconnectAttempts})`); + setTimeout(connect, delay); + } + + function send(type, data = {}) { + if (!ws || ws.readyState !== WebSocket.OPEN) { + console.warn('[WS] Not connected'); + showError('Not connected to server'); + return false; + } + ws.send(JSON.stringify({ type, ...data })); + return true; + } + + function handleMessage(data) { + console.log('[WS] Received:', data.type); + + switch (data.type) { + case 'connected': + console.log('[WS] Client ID:', data.clientId); + break; + + case 'recording_started': + isRecording = true; + setState('recording'); + break; + + case 'recording_stopped': + isRecording = false; + break; + + case 'state': + setState(data.state); + break; + + case 'transcript': + showTranscript(data.text); + break; + + case 'response_chunk': + appendResponse(data.text); + break; + + case 'response_complete': + break; + + case 'error': + showError(data.message); + setState('idle'); + isRecording = false; + break; + + case 'history_cleared': + clearTexts(); + break; + } + } + + function handleTap(event) { + event.preventDefault(); + toggleRecording(); + } + + let touchStartTime = 0; + + function handleTouchStart(event) { + touchStartTime = Date.now(); + } + + function handleTouchEnd(event) { + const touchDuration = Date.now() - touchStartTime; + if (touchDuration < 500) { + toggleRecording(); + } + } + + function handleKeyDown(event) { + if (event.code === 'Space' || event.key === ' ') { + event.preventDefault(); + toggleRecording(); + } + } + + function toggleRecording() { + if (currentState === 'thinking' || currentState === 'speaking' || currentState === 'transcribing') { + return; + } + + if (isRecording) { + send('stop_recording'); + } else { + clearTexts(); + send('start_recording'); + } + } + + function setState(state) { + currentState = state; + + elements.orb.className = ''; + if (state !== 'idle') { + elements.orb.classList.add(state); + } + + const stateLabels = { + idle: 'Ready', + recording: 'Listening...', + transcribing: 'Processing...', + thinking: 'Thinking...', + speaking: 'Speaking...', + }; + + elements.statusText.textContent = stateLabels[state] || 'Ready'; + + if (state === 'recording') { + elements.hint.textContent = 'Tap to stop'; + elements.hint.classList.add('recording'); + } else { + elements.hint.textContent = 'Tap anywhere to talk'; + elements.hint.classList.remove('recording'); + } + } + + function setConnectionStatus(connected) { + if (connected) { + elements.statusDot.classList.remove('disconnected'); + elements.statusText.textContent = 'Ready'; + } else { + elements.statusDot.classList.add('disconnected'); + elements.statusText.textContent = 'Disconnected'; + } + } + + function showTranscript(text) { + elements.transcriptText.textContent = text; + elements.transcript.classList.remove('hidden'); + } + + function appendResponse(text) { + elements.response.classList.remove('hidden'); + elements.responseText.textContent += text; + } + + function clearTexts() { + elements.transcript.classList.add('hidden'); + elements.response.classList.add('hidden'); + elements.transcriptText.textContent = ''; + elements.responseText.textContent = ''; + } + + function showError(message) { + elements.errorMessage.textContent = message; + elements.errorToast.classList.remove('hidden'); + + setTimeout(() => { + elements.errorToast.classList.add('hidden'); + }, 4000); + } + + function updateClock() { + const now = new Date(); + const hours = now.getHours(); + const minutes = now.getMinutes().toString().padStart(2, '0'); + const ampm = hours >= 12 ? 'PM' : 'AM'; + const displayHours = hours % 12 || 12; + elements.clock.textContent = `${displayHours}:${minutes} ${ampm}`; + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..aa428c0 --- /dev/null +++ b/web/index.html @@ -0,0 +1,57 @@ + + + + + + + + OpenClaw + + + +
+ + +
+
+
+
+
+
+
+
+
+
+ +
+ + +
+
+ + + +
+
+ + + + + + diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..c324abf --- /dev/null +++ b/web/style.css @@ -0,0 +1,442 @@ +:root { + --bg-primary: #0a0a0a; + --bg-secondary: #141414; + --text-primary: #ffffff; + --text-secondary: #888888; + --text-muted: #555555; + --blue: #4da6ff; + --blue-glow: rgba(77, 166, 255, 0.4); + --blue-dim: rgba(77, 166, 255, 0.15); + --red: #ff4444; + --red-glow: rgba(255, 68, 68, 0.4); + --red-dim: rgba(255, 68, 68, 0.15); + --green: #44ff88; + --yellow: #ffcc44; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; + -webkit-tap-highlight-color: transparent; + -webkit-touch-callout: none; + -webkit-user-select: none; + user-select: none; +} + +html, body { + width: 480px; + height: 320px; + overflow: hidden; + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-size: 16px; + background: var(--bg-primary); + color: var(--text-primary); +} + +#app { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + position: relative; +} + +#header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 16px; + height: 36px; + background: var(--bg-secondary); + border-bottom: 1px solid #222; +} + +#status { + display: flex; + align-items: center; + gap: 8px; +} + +#status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--green); + animation: pulse-dot 2s ease-in-out infinite; +} + +#status-dot.disconnected { + background: var(--red); + animation: none; +} + +#status-text { + font-size: 14px; + color: var(--text-secondary); +} + +#clock { + font-size: 14px; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + +#main { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 12px; + gap: 12px; + position: relative; +} + +#orb-container { + position: relative; + width: 120px; + height: 120px; + display: flex; + align-items: center; + justify-content: center; +} + +#orb { + position: relative; + width: 80px; + height: 80px; + display: flex; + align-items: center; + justify-content: center; +} + +.orb-core { + position: absolute; + width: 40px; + height: 40px; + border-radius: 50%; + background: radial-gradient(circle at 30% 30%, var(--blue), #1a5c99); + box-shadow: + 0 0 20px var(--blue-glow), + 0 0 40px var(--blue-dim), + inset 0 0 15px rgba(255, 255, 255, 0.2); + animation: core-pulse 3s ease-in-out infinite; +} + +.orb-ring { + position: absolute; + border-radius: 50%; + border: 2px solid var(--blue); + opacity: 0.3; + animation: ring-pulse 3s ease-in-out infinite; +} + +.orb-ring-1 { + width: 55px; + height: 55px; + animation-delay: 0s; +} + +.orb-ring-2 { + width: 70px; + height: 70px; + animation-delay: 0.5s; +} + +.orb-ring-3 { + width: 85px; + height: 85px; + animation-delay: 1s; +} + +.orb-glow { + position: absolute; + width: 100px; + height: 100px; + border-radius: 50%; + background: radial-gradient(circle, var(--blue-dim) 0%, transparent 70%); + animation: glow-pulse 3s ease-in-out infinite; +} + +/* Idle state - blue pulse */ +@keyframes core-pulse { + 0%, 100% { transform: scale(1); opacity: 1; } + 50% { transform: scale(1.05); opacity: 0.9; } +} + +@keyframes ring-pulse { + 0%, 100% { transform: scale(1); opacity: 0.3; } + 50% { transform: scale(1.1); opacity: 0.5; } +} + +@keyframes glow-pulse { + 0%, 100% { transform: scale(1); opacity: 0.5; } + 50% { transform: scale(1.15); opacity: 0.7; } +} + +@keyframes pulse-dot { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* Recording state - red pulse */ +#orb.recording .orb-core { + background: radial-gradient(circle at 30% 30%, var(--red), #991a1a); + box-shadow: + 0 0 25px var(--red-glow), + 0 0 50px var(--red-dim), + inset 0 0 15px rgba(255, 255, 255, 0.2); + animation: core-pulse-recording 0.8s ease-in-out infinite; +} + +#orb.recording .orb-ring { + border-color: var(--red); + animation: ring-pulse-recording 0.8s ease-in-out infinite; +} + +#orb.recording .orb-glow { + background: radial-gradient(circle, var(--red-dim) 0%, transparent 70%); + animation: glow-pulse-recording 0.8s ease-in-out infinite; +} + +@keyframes core-pulse-recording { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.15); } +} + +@keyframes ring-pulse-recording { + 0%, 100% { transform: scale(1); opacity: 0.4; } + 50% { transform: scale(1.2); opacity: 0.7; } +} + +@keyframes glow-pulse-recording { + 0%, 100% { transform: scale(1); opacity: 0.6; } + 50% { transform: scale(1.3); opacity: 0.9; } +} + +/* Thinking state - spinning */ +#orb.thinking .orb-core { + background: radial-gradient(circle at 30% 30%, var(--yellow), #997a1a); + box-shadow: + 0 0 20px rgba(255, 204, 68, 0.4), + 0 0 40px rgba(255, 204, 68, 0.15), + inset 0 0 15px rgba(255, 255, 255, 0.2); + animation: core-spin 2s linear infinite; +} + +#orb.thinking .orb-ring { + border-color: var(--yellow); + animation: ring-spin 3s linear infinite; +} + +#orb.thinking .orb-ring-2 { + animation-direction: reverse; +} + +#orb.thinking .orb-glow { + background: radial-gradient(circle, rgba(255, 204, 68, 0.15) 0%, transparent 70%); + animation: glow-spin 2s linear infinite; +} + +@keyframes core-spin { + 0% { transform: rotate(0deg) scale(1); } + 50% { transform: rotate(180deg) scale(1.05); } + 100% { transform: rotate(360deg) scale(1); } +} + +@keyframes ring-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +@keyframes glow-spin { + 0%, 100% { opacity: 0.5; } + 50% { opacity: 0.8; } +} + +/* Speaking state - wave effect */ +#orb.speaking .orb-core { + background: radial-gradient(circle at 30% 30%, var(--green), #1a9944); + box-shadow: + 0 0 20px rgba(68, 255, 136, 0.4), + 0 0 40px rgba(68, 255, 136, 0.15), + inset 0 0 15px rgba(255, 255, 255, 0.2); + animation: core-wave 0.5s ease-in-out infinite; +} + +#orb.speaking .orb-ring { + border-color: var(--green); + animation: ring-wave 0.6s ease-in-out infinite; +} + +#orb.speaking .orb-ring-1 { animation-delay: 0s; } +#orb.speaking .orb-ring-2 { animation-delay: 0.1s; } +#orb.speaking .orb-ring-3 { animation-delay: 0.2s; } + +#orb.speaking .orb-glow { + background: radial-gradient(circle, rgba(68, 255, 136, 0.15) 0%, transparent 70%); + animation: glow-wave 0.5s ease-in-out infinite; +} + +@keyframes core-wave { + 0%, 100% { transform: scale(1); } + 25% { transform: scale(1.1) translateY(-2px); } + 75% { transform: scale(0.95) translateY(2px); } +} + +@keyframes ring-wave { + 0%, 100% { transform: scale(1); opacity: 0.3; } + 50% { transform: scale(1.15); opacity: 0.6; } +} + +@keyframes glow-wave { + 0%, 100% { transform: scale(1); opacity: 0.5; } + 50% { transform: scale(1.2); opacity: 0.8; } +} + +#text-container { + width: 100%; + max-height: 120px; + overflow: hidden; + display: flex; + flex-direction: column; + gap: 8px; +} + +.text-area { + background: var(--bg-secondary); + border-radius: 8px; + padding: 8px 12px; + max-height: 56px; + overflow: hidden; +} + +.text-area.hidden { + display: none; +} + +.text-area .label { + font-size: 11px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + display: block; + margin-bottom: 4px; +} + +.text-area p { + font-size: 14px; + line-height: 1.3; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +#transcript .label { color: var(--blue); } +#response .label { color: var(--green); } + +#footer { + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-secondary); + border-top: 1px solid #222; +} + +#hint { + font-size: 13px; + color: var(--text-muted); +} + +#hint.recording { + color: var(--red); +} + +#tap-zone { + position: absolute; + top: 36px; + left: 0; + right: 0; + bottom: 32px; + cursor: pointer; + z-index: 10; +} + +#tap-zone:active { + background: rgba(255, 255, 255, 0.02); +} + +#error-toast { + position: absolute; + bottom: 48px; + left: 16px; + right: 16px; + background: rgba(255, 68, 68, 0.9); + color: white; + padding: 10px 16px; + border-radius: 8px; + font-size: 13px; + text-align: center; + z-index: 100; + animation: toast-in 0.3s ease-out; +} + +#error-toast.hidden { + display: none; +} + +@keyframes toast-in { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Responsive adjustments for actual 480x320 */ +@media (max-width: 480px) and (max-height: 320px) { + #orb-container { + width: 100px; + height: 100px; + } + + #orb { + width: 70px; + height: 70px; + } + + .orb-core { + width: 35px; + height: 35px; + } + + .orb-ring-1 { width: 48px; height: 48px; } + .orb-ring-2 { width: 60px; height: 60px; } + .orb-ring-3 { width: 72px; height: 72px; } + + .orb-glow { + width: 85px; + height: 85px; + } + + #text-container { + max-height: 100px; + } + + .text-area { + max-height: 48px; + padding: 6px 10px; + } + + .text-area p { + font-size: 13px; + } +}