Test
Some checks failed
Build / build-web (push) Failing after 15s
Build / build-backend (push) Successful in 10s
Build / release (push) Has been skipped
Build APK / build (push) Successful in 8m28s
Build APK / release (push) Successful in 3s

This commit is contained in:
2026-06-19 11:06:11 +02:00
parent 63903e6f7e
commit 23235e8249
20 changed files with 4166 additions and 69 deletions

View File

@@ -369,7 +369,6 @@ export default function RecorderScreen() {
if (data.transcription) { if (data.transcription) {
setTranscriptionText(data.transcription); setTranscriptionText(data.transcription);
textsToSpeak.push(data.transcription);
} }
if (data.llmResponse) { if (data.llmResponse) {

View File

@@ -8,6 +8,13 @@ QUIBOT_TOKEN=MY_SECRET_TOKEN
# Backend server config # Backend server config
PORT=5000 PORT=5000
# Piper TTS config (optional — local model)
PIPER_MODELS_DIR=./piper
PIPER_MODEL=./src/ca_ES-upc_ona-medium.onnx
# Remote Piper TTS service (alternative to local model)
PIPER_URL=
LLAMA_CPP_URL=https://ollama.epsem.aranroig.com/v1/chat/completitions LLAMA_CPP_URL=https://ollama.epsem.aranroig.com/v1/chat/completitions
LLAMA_PREAMBLE=./prompts/preamble.md LLAMA_PREAMBLE=./prompts/preamble.md
LLAMA_API_KEY=your_api_key LLAMA_API_KEY=your_api_key

View File

@@ -8,6 +8,7 @@
"name": "quibot-backend", "name": "quibot-backend",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"axios": "^1.7.0", "axios": "^1.7.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
@@ -466,6 +467,388 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@hono/node-server": {
"version": "1.19.14",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
"license": "MIT",
"engines": {
"node": ">=18.14.1"
},
"peerDependencies": {
"hono": "^4"
}
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.29.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
"integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==",
"license": "MIT",
"dependencies": {
"@hono/node-server": "^1.19.9",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"content-type": "^1.0.5",
"cors": "^2.8.5",
"cross-spawn": "^7.0.5",
"eventsource": "^3.0.2",
"eventsource-parser": "^3.0.0",
"express": "^5.2.1",
"express-rate-limit": "^8.2.1",
"hono": "^4.11.4",
"jose": "^6.1.3",
"json-schema-typed": "^8.0.2",
"pkce-challenge": "^5.0.0",
"raw-body": "^3.0.0",
"zod": "^3.25 || ^4.0",
"zod-to-json-schema": "^3.25.1"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@cfworker/json-schema": "^4.1.1",
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"@cfworker/json-schema": {
"optional": true
},
"zod": {
"optional": false
}
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^2.0.0",
"debug": "^4.4.3",
"http-errors": "^2.0.1",
"iconv-lite": "^0.7.2",
"on-finished": "^2.4.1",
"qs": "^6.15.2",
"raw-body": "^3.0.2",
"type-is": "^2.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/body-parser/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"depd": "^2.0.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"finalhandler": "^2.1.0",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/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/@modelcontextprotocol/sdk/node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.7.0",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"fresh": "^2.0.0",
"http-errors": "^2.0.1",
"mime-types": "^3.0.2",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
"statuses": "^2.0.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/type-is": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"license": "MIT",
"dependencies": {
"content-type": "^2.0.0",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/type-is/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@types/body-parser": { "node_modules/@types/body-parser": {
"version": "1.19.6", "version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -652,6 +1035,39 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/append-field": { "node_modules/append-field": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
@@ -847,6 +1263,20 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "2.6.9", "version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -1027,6 +1457,27 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/eventsource": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
"license": "MIT",
"dependencies": {
"eventsource-parser": "^3.0.1"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/eventsource-parser": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz",
"integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/express": { "node_modules/express": {
"version": "4.22.2", "version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
@@ -1073,6 +1524,46 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/express-rate-limit": {
"version": "8.5.2",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.2.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/finalhandler": { "node_modules/finalhandler": {
"version": "1.3.2", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -1257,6 +1748,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/hono": {
"version": "4.12.26",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.26.tgz",
"integrity": "sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
}
},
"node_modules/http-errors": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -1331,6 +1831,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -1340,12 +1849,45 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
"node_modules/isarray": { "node_modules/isarray": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/jose": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/json-schema-typed": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
"license": "BSD-2-Clause"
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -1503,6 +2045,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/openai": { "node_modules/openai": {
"version": "6.44.0", "version": "6.44.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.44.0.tgz", "resolved": "https://registry.npmjs.org/openai/-/openai-6.44.0.tgz",
@@ -1530,12 +2081,30 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-to-regexp": { "node_modules/path-to-regexp": {
"version": "0.1.13", "version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/pkce-challenge": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
"license": "MIT",
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/process-nextick-args": { "node_modules/process-nextick-args": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@@ -1624,6 +2193,64 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
"is-promise": "^4.0.0",
"parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/router/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/router/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/router/node_modules/path-to-regexp": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/safe-buffer": { "node_modules/safe-buffer": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -1701,6 +2328,27 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/side-channel": { "node_modules/side-channel": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
@@ -1906,6 +2554,27 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/xtend": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
@@ -1914,6 +2583,24 @@
"engines": { "engines": {
"node": ">=0.4" "node": ">=0.4"
} }
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zod-to-json-schema": {
"version": "3.25.2",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
"integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
"license": "ISC",
"peerDependencies": {
"zod": "^3.25.28 || ^4"
}
} }
} }
} }

View File

@@ -10,6 +10,7 @@
"start": "node dist/index.js" "start": "node dist/index.js"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"axios": "^1.7.0", "axios": "^1.7.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",

Binary file not shown.

View File

@@ -0,0 +1,493 @@
{
"audio": {
"sample_rate": 22050,
"quality": "medium"
},
"espeak": {
"voice": "ca"
},
"inference": {
"noise_scale": 0.667,
"length_scale": 1,
"noise_w": 0.8
},
"phoneme_type": "espeak",
"phoneme_map": {},
"phoneme_id_map": {
"_": [
0
],
"^": [
1
],
"$": [
2
],
" ": [
3
],
"!": [
4
],
"'": [
5
],
"(": [
6
],
")": [
7
],
",": [
8
],
"-": [
9
],
".": [
10
],
":": [
11
],
";": [
12
],
"?": [
13
],
"a": [
14
],
"b": [
15
],
"c": [
16
],
"d": [
17
],
"e": [
18
],
"f": [
19
],
"h": [
20
],
"i": [
21
],
"j": [
22
],
"k": [
23
],
"l": [
24
],
"m": [
25
],
"n": [
26
],
"o": [
27
],
"p": [
28
],
"q": [
29
],
"r": [
30
],
"s": [
31
],
"t": [
32
],
"u": [
33
],
"v": [
34
],
"w": [
35
],
"x": [
36
],
"y": [
37
],
"z": [
38
],
"æ": [
39
],
"ç": [
40
],
"ð": [
41
],
"ø": [
42
],
"ħ": [
43
],
"ŋ": [
44
],
"œ": [
45
],
"ǀ": [
46
],
"ǁ": [
47
],
"ǂ": [
48
],
"ǃ": [
49
],
"ɐ": [
50
],
"ɑ": [
51
],
"ɒ": [
52
],
"ɓ": [
53
],
"ɔ": [
54
],
"ɕ": [
55
],
"ɖ": [
56
],
"ɗ": [
57
],
"ɘ": [
58
],
"ə": [
59
],
"ɚ": [
60
],
"ɛ": [
61
],
"ɜ": [
62
],
"ɞ": [
63
],
"ɟ": [
64
],
"ɠ": [
65
],
"ɡ": [
66
],
"ɢ": [
67
],
"ɣ": [
68
],
"ɤ": [
69
],
"ɥ": [
70
],
"ɦ": [
71
],
"ɧ": [
72
],
"ɨ": [
73
],
"ɪ": [
74
],
"ɫ": [
75
],
"ɬ": [
76
],
"ɭ": [
77
],
"ɮ": [
78
],
"ɯ": [
79
],
"ɰ": [
80
],
"ɱ": [
81
],
"ɲ": [
82
],
"ɳ": [
83
],
"ɴ": [
84
],
"ɵ": [
85
],
"ɶ": [
86
],
"ɸ": [
87
],
"ɹ": [
88
],
"ɺ": [
89
],
"ɻ": [
90
],
"ɽ": [
91
],
"ɾ": [
92
],
"ʀ": [
93
],
"ʁ": [
94
],
"ʂ": [
95
],
"ʃ": [
96
],
"ʄ": [
97
],
"ʈ": [
98
],
"ʉ": [
99
],
"ʊ": [
100
],
"ʋ": [
101
],
"ʌ": [
102
],
"ʍ": [
103
],
"ʎ": [
104
],
"ʏ": [
105
],
"ʐ": [
106
],
"ʑ": [
107
],
"ʒ": [
108
],
"ʔ": [
109
],
"ʕ": [
110
],
"ʘ": [
111
],
"ʙ": [
112
],
"ʛ": [
113
],
"ʜ": [
114
],
"ʝ": [
115
],
"ʟ": [
116
],
"ʡ": [
117
],
"ʢ": [
118
],
"ʲ": [
119
],
"ˈ": [
120
],
"ˌ": [
121
],
"ː": [
122
],
"ˑ": [
123
],
"˞": [
124
],
"β": [
125
],
"θ": [
126
],
"χ": [
127
],
"ᵻ": [
128
],
"ⱱ": [
129
],
"0": [
130
],
"1": [
131
],
"2": [
132
],
"3": [
133
],
"4": [
134
],
"5": [
135
],
"6": [
136
],
"7": [
137
],
"8": [
138
],
"9": [
139
],
"̧": [
140
],
"̃": [
141
],
"̪": [
142
],
"̯": [
143
],
"̩": [
144
],
"ʰ": [
145
],
"ˤ": [
146
],
"ε": [
147
],
"↓": [
148
],
"#": [
149
],
"\"": [
150
],
"↑": [
151
],
"̺": [
152
],
"̻": [
153
]
},
"num_symbols": 256,
"num_speakers": 1,
"speaker_id_map": {},
"piper_version": "1.0.0",
"language": {
"code": "ca_ES",
"family": "ca",
"region": "ES",
"name_native": "Català",
"name_english": "Catalan",
"country_english": "Spain"
},
"dataset": "upc_ona"
}

View File

@@ -9,6 +9,7 @@ let _raspberryPort = Number(process.env.RASPBERRY_PI_PORT) || 8000;
let _token = process.env.QUIBOT_TOKEN ?? 'MY_SECRET_TOKEN'; let _token = process.env.QUIBOT_TOKEN ?? 'MY_SECRET_TOKEN';
const APP_PORT = Number(process.env.PORT) || 5000; const APP_PORT = Number(process.env.PORT) || 5000;
const piperUrl = process.env.PIPER_URL ?? ''; const piperUrl = process.env.PIPER_URL ?? '';
const mcpUrl = process.env.MCP_URL ?? '';
const llamacppUrl = process.env.LLAMA_CPP_URL ?? ''; const llamacppUrl = process.env.LLAMA_CPP_URL ?? '';
const llamacppApiKey = process.env.LLAMA_API_KEY ?? ''; const llamacppApiKey = process.env.LLAMA_API_KEY ?? '';
const llamaPreambleRaw = process.env.LLAMA_PREAMBLE ?? ''; const llamaPreambleRaw = process.env.LLAMA_PREAMBLE ?? '';
@@ -45,8 +46,11 @@ export const getLlamacppApiKey = () => llamacppApiKey;
export const getLlamacppPreamble = () => llamacppPreamble; export const getLlamacppPreamble = () => llamacppPreamble;
export const getPiperUrl = () => piperUrl; export const getPiperUrl = () => piperUrl;
export const getPiperModelDir = () =>
process.env.PIPER_MODELS_DIR || join('/tmp', 'quibot-piper-models');
export const getPiperModel = () => export const getPiperModel = () =>
process.env.PIPER_MODEL || process.env.PIPER_MODEL ||
join('/tmp', 'quibot-piper-models', 'ca_ES-upc_ona-medium.onnx'); join(getPiperModelDir(), 'ca_ES-upc_ona-medium.onnx');
export const getMcpUrl = () => mcpUrl;
export const getAppPort = () => APP_PORT; export const getAppPort = () => APP_PORT;

View File

@@ -4,6 +4,7 @@ import router from './routes/router.js';
import { getAppPort, getConfig } from './config.js'; import { getAppPort, getConfig } from './config.js';
import { whisperService } from './services/whisper.service.js'; import { whisperService } from './services/whisper.service.js';
import { piperService as piperWorker } from './services/piper.service.js'; import { piperService as piperWorker } from './services/piper.service.js';
import { mcpClient } from './services/mcpClient.service.js';
const app = express(); const app = express();
@@ -25,16 +26,22 @@ app.get('/health', (_req, res) => {
res.json({ status: 'ok', settings }); res.json({ status: 'ok', settings });
}); });
const server = app.listen(getAppPort(), () => { const server = app.listen(getAppPort(), async () => {
console.log(`QuiBot backend listening on port ${getAppPort()}`); console.log(`QuiBot backend listening on port ${getAppPort()}`);
whisperService.spawn(); whisperService.spawn();
piperWorker.initWav().catch(() => { /* model may not exist yet → lazy init on first TTS call */ }); piperWorker.initWav().catch(() => { /* model may not exist yet → lazy init on first TTS call */ });
try {
await mcpClient.start();
console.log('[server] MCP client started');
} catch (err) {
console.error(`[server] MCP client failed to start: ${err instanceof Error ? err.message : String(err)}`);
}
}); });
async function shutdown(signal: string) { async function shutdown(signal: string) {
console.log(`[server] ${signal} received, shutting down...`); console.log(`[server] ${signal} received, shutting down...`);
server.close(async () => { server.close(async () => {
await Promise.all([whisperService.shutdown(), piperWorker.shutdown()]); await Promise.all([whisperService.shutdown(), piperWorker.shutdown(), mcpClient.shutdown()]);
process.exit(0); process.exit(0);
}); });
} }

View File

@@ -1,6 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Persistent Piper TTS worker single subprocess, model loaded once.""" """Persistent Piper TTS worker single subprocess, model loaded once."""
import os
import sys import sys
import json import json
import wave import wave
@@ -32,8 +33,9 @@ def main():
voice = None voice = None
# Defaults (will be overridden by init message) # Defaults (will be overridden by init message)
DEFAULT_MODEL = "/tmp/quibot-piper-models/ca_ES-upc_ona-medium.onnx" _default_dir = os.environ.get("PIPER_MODELS_DIR", "/tmp/quibot-piper-models")
DEFAULT_CONFIG = "/tmp/quibot-piper-models/ca_ES-upc_ona-medium.onnx.json" DEFAULT_MODEL = os.path.join(_default_dir, "ca_ES-upc_ona-medium.onnx")
DEFAULT_CONFIG = os.path.join(_default_dir, "ca_ES-upc_ona-medium.onnx.json")
# Signal node that the process is alive and listening # Signal node that the process is alive and listening
print(json.dumps({"type": "ready"}), flush=True) print(json.dumps({"type": "ready"}), flush=True)
@@ -62,6 +64,10 @@ def main():
elif msg.get("type") == "synthesize": elif msg.get("type") == "synthesize":
text = msg.get("text", "") text = msg.get("text", "")
msg_id = msg.get("msgId", "") msg_id = msg.get("msgId", "")
# NEW: output file path from message
out_path = msg.get("outPath")
if not voice: if not voice:
print(json.dumps({ print(json.dumps({
"type": "error", "type": "error",
@@ -70,25 +76,38 @@ def main():
}), flush=True) }), flush=True)
continue continue
if not out_path:
print(json.dumps({
"type": "error",
"text": "Missing outPath",
"msgId": msg_id,
}), flush=True)
continue
try: try:
import io
import wave
buf = io.BytesIO() buf = io.BytesIO()
wf = wave.open(buf, 'wb') wf = wave.open(buf, 'wb')
# set_wav_config is called inside synthesize_wav via set_wav_format=True (default)
voice.synthesize_wav(text, wf) voice.synthesize_wav(text, wf)
wf.close() wf.close()
wav_bytes = buf.getvalue() wav_bytes = buf.getvalue()
# Frame: send length prefix as ASCII number + newline, then raw bytes # Write to file instead of stdout
header = f"{len(wav_bytes)}\n".encode('ascii') os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
sys.stdout.buffer.write(header) with open(out_path, "wb") as f:
sys.stdout.buffer.write(wav_bytes) f.write(wav_bytes)
sys.stdout.buffer.flush()
print(json.dumps({ print(json.dumps({
"type": "synthesized", "type": "synthesized",
"bytes": len(wav_bytes), "bytes": len(wav_bytes),
"msgId": msg_id, "msgId": msg_id,
"outPath": out_path
}), flush=True) }), flush=True)
except Exception as exc: except Exception as exc:
err_msg = str(exc).replace('"', '\\"') err_msg = str(exc).replace('"', '\\"')
print(json.dumps({ print(json.dumps({
@@ -97,6 +116,5 @@ def main():
"msgId": msg_id, "msgId": msg_id,
}), flush=True) }), flush=True)
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@@ -0,0 +1,74 @@
import { getMcpUrl } from '../config';
class McpHttpService {
private sessionId: string | null = null;
async callTool(name: string, args: Record<string, unknown>): Promise<{ text: string; isError?: boolean }> {
const baseUrl = getMcpUrl();
if (!baseUrl) {
throw new Error('MCP HTTP service not configured (set MCP_URL env var)');
}
const url = `${baseUrl}/mcp`;
if (!this.sessionId) {
const initRes = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-03-26',
capabilities: {},
clientInfo: { name: 'quibot-backend', version: '1.0.0' },
},
}),
});
const initData = await initRes.json();
this.sessionId = String(initData.sessionId || initData.result?.sessionId);
await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 2,
method: 'notifications/initialized',
}),
...(this.sessionId && { headers: { 'Mcp-SessionId': this.sessionId } }),
});
}
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(this.sessionId && { 'Mcp-SessionId': this.sessionId }),
},
body: JSON.stringify({
jsonrpc: '2.0',
id: Date.now(),
method: 'tools/call',
params: { name, arguments: args },
}),
});
const data = await res.json();
if (data.error) {
return { text: JSON.stringify(data.error), isError: true };
}
const content = data.result?.content?.[0];
if (!content?.text) {
throw new Error('MCP tool returned no content');
}
return { text: content.text };
}
async shutdown(): Promise<void> {
this.sessionId = null;
}
}
export const mcpHttpService = new McpHttpService();

View File

@@ -0,0 +1,202 @@
import { spawn, ChildProcess } from 'child_process';
import { join } from 'path';
import { fileURLToPath } from 'url';
import { getMcpUrl } from '../config';
import { mcpHttpService } from './mcp.http.service';
const __filename = fileURLToPath(import.meta.url);
const __dirname = join(__filename, '..');
// Path to the compiled MCP server (two levels up from backend/src/)
const MCP_BIN = join(__dirname, '..', '..', 'mcp', 'dist', 'index.js');
let _proc: ChildProcess | null = null;
let nextId = 1;
let pending = new Map<number | string, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
function send(msg: Record<string, unknown>): number {
const id = nextId++;
_proc!.stdin!.write(JSON.stringify({ jsonrpc: '2.0', id, ...msg }) + '\n');
return id;
}
export const mcpClient = {
async start(): Promise<void> {
const hasMcpBin = (() => {
try {
require('fs').accessSync(MCP_BIN);
return true;
} catch {
return false;
}
})();
if (!hasMcpBin) {
const url = getMcpUrl();
if (url) {
console.log('[mcp] Local MCP binary not found, using HTTP service at', url);
return;
}
throw new Error('MCP local binary and HTTP URL both unavailable');
}
if (_proc) return;
return new Promise<void>((resolve, reject) => {
_proc = spawn('node', [MCP_BIN], { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env } });
_proc.stdout!.on('data', (chunk: Buffer) => {
const text = chunk.toString();
for (const line of text.split('\n')) {
if (!line.trim()) continue;
let parsed: { jsonrpc?: string; id?: number | string; method?: string; result?: unknown; error?: unknown };
try { parsed = JSON.parse(line); } catch { continue; }
if (parsed.jsonrpc !== '2.0') continue;
if (parsed.method) {
// notifications or responses without matching id — ignore for now
continue;
}
if (!parsed.id) continue;
const p = pending.get(parsed.id);
if (!p) continue;
pending.delete(parsed.id);
if (parsed.error) {
p.reject(new Error(`MCP error: ${JSON.stringify(parsed.error)}`));
} else {
p.resolve(parsed.result);
}
}
});
_proc.stderr!.on('data', (chunk: Buffer) => {
console.log(`[mcp-client] stderr: ${chunk.toString().trim()}`);
});
_proc.on('exit', (code, signal) => {
console.error(`[mcp-client] Exited code=${code} signal=${signal}`);
_proc = null;
for (const [, p] of pending) {
p.reject(new Error('MCP client process exited'));
}
pending.clear();
});
_proc.on('error', (err: Error) => {
console.error(`[mcp-client] Error: ${err.message}`);
reject(err);
});
// Send initialize request
const initId = send({
method: 'initialize',
params: {
protocolVersion: '2025-03-26',
capabilities: {},
clientInfo: { name: 'quibot-backend', version: '1.0.0' },
},
});
pending.set(initId, {
resolve: () => {
// Send initialized notification
_proc!.stdin!.write(
JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n',
);
resolve();
},
reject,
});
setTimeout(() => {
const p = pending.get(initId);
if (p) {
pending.delete(initId);
p.reject(new Error('MCP initialize timed out'));
}
}, 15_000);
});
},
async callTool(name: string, args: Record<string, unknown>): Promise<{ text: string; isError?: boolean }> {
if (!_proc) {
await this.start();
}
try {
return await this._callToolLocal(name, args);
} catch (localErr) {
const url = getMcpUrl();
if (url) {
console.log(`[mcp] Local MCP failed: ${localErr instanceof Error ? localErr.message : localErr}. Falling back to HTTP service.`);
return await mcpHttpService.callTool(name, args);
}
throw localErr;
}
},
async _callToolLocal(name: string, args: Record<string, unknown>): Promise<{ text: string; isError?: boolean }> {
if (!_proc?.stdin) {
throw new Error('MCP client not ready');
}
return new Promise((resolve, reject) => {
const id = send({
method: 'tools/call',
params: { name, arguments: args },
});
let cleared = false;
const timer = setTimeout(() => {
if (cleared) return;
cleared = true;
pending.delete(id);
reject(new Error(`MCP tool "${name}" timed out`));
}, 180_000);
pending.set(id, {
resolve: (result: unknown) => {
if (cleared) return;
cleared = true;
clearTimeout(timer);
pending.delete(id);
const res = result as { content?: Array<{ type: string; text?: string }> };
if (res?.content?.[0]?.text !== undefined) {
resolve({ text: res.content[0].text });
} else {
reject(new Error('MCP tool returned no content'));
}
},
reject: (err: Error) => {
if (cleared) return;
cleared = true;
clearTimeout(timer);
reject(err);
},
});
});
},
async shutdown(): Promise<void> {
if (!_proc) return;
_proc.kill('SIGTERM');
const current = _proc;
await new Promise<void>((resolve) => {
let done = false;
const cleanup = () => {
if (done) return;
done = true;
_proc = null;
for (const [, p] of pending) {
p.reject(new Error('MCP client shut down'));
}
pending.clear();
resolve();
};
current.once('exit', () => cleanup());
setTimeout(() => {
const proc = _proc;
if (proc && !proc.killed) proc.kill('SIGKILL');
cleanup();
}, 3000);
});
},
};

View File

@@ -18,7 +18,7 @@ type PiperMsg =
| { type: 'ready' } | { type: 'ready' }
| { type: 'init_ok' } | { type: 'init_ok' }
| { type: 'init_error'; error: string } | { type: 'init_error'; error: string }
| { type: 'synthesized'; wavPath: string; bytes?: number; msgId: string } | { type: 'synthesized'; outPath: string; bytes?: number; msgId: string }
| { type: 'error'; text: string; msgId: string }; | { type: 'error'; text: string; msgId: string };
class PiperLocalService { class PiperLocalService {
@@ -45,41 +45,95 @@ class PiperLocalService {
} }
private handleLine(line: string): void { private handleLine(line: string): void {
try { const msg = JSON.parse(line) as PiperMsg; /* handled below */ } catch { return; } console.log('[RX]', line);
const msg = JSON.parse(line) as PiperMsg; let msg: PiperMsg;
if (msg.type === 'ready') return;
if (msg.type === 'init_ok') { this.resolveInit(); } try {
if (msg.type === 'init_error') { msg = JSON.parse(line) as PiperMsg;
if (this.pendingInit) { this.pendingInit = null; this.initResolve = null; } } catch {
this.initReject(new Error(msg.error)); console.warn('[piper-svc] Invalid JSON:', line);
return;
} }
if (msg.type === 'synthesized') {
this.resolveResponse(msg.msgId, msg.wavPath); switch (msg.type) {
case 'ready':
break;
case 'init_ok':
this.resolveInit();
break;
case 'init_error':
this.resolveInitError(new Error(msg.error));
break;
case 'synthesized':
this.resolveResponse(msg.msgId, msg.outPath);
break;
case 'error':
this.rejectResponse(
msg.msgId,
new Error(msg.text)
);
break;
} }
} }
private async writeStdin(line: string): Promise<void> { private async writeStdin(line: string): Promise<void> {
if (!this.proc?.stdin) throw new Error('piper-svc: stdin unavailable'); if (!this.proc?.stdin) {
throw new Error('piper-svc: stdin unavailable');
}
console.log('[TX]', line);
this.proc.stdin.write(line + '\n'); this.proc.stdin.write(line + '\n');
} }
// ── pending-init promises (simplest possible design) ── // ── pending-init promises (separate from synth to avoid clearing respMap on init failure) ──
private initResolve: (() => void) | null = null; private initResolve: (() => void) | null = null;
private initReject: (e: Error) => void = () => { throw new Error('no reject handler'); }; private initReject: ((e: Error) => void) | null = null;
private resolveInit(): void { private resolveInit(): void {
if (!this.pendingInit) return; if (!this.pendingInit) return;
this.initResolve?.(); this.pendingInit = null;
this.initResolve?.();
this.pendingInit = null;
this.initResolve = null; this.initResolve = null;
this.initReject = null;
} }
// ── pending synth responses ── private rejectResponse(msgId: string, err: Error): void {
private respMap = new Map<string, (wavPath: string) => void>(); const entry = this.respMap.get(msgId);
this.respMap.delete(msgId);
if (entry) {
entry.reject(err);
}
}
private resolveInitError(err: Error): void {
if (!this.pendingInit) return;
this.initReject?.(err);
this.pendingInit = null;
this.initResolve = null;
this.initReject = null;
}
// ── pending synth responses (separate from init so init failure doesn't clear them) ──
private respMap = new Map<string, {
resolve: (wavPath: string) => void;
reject: (e: Error) => void;
}>();
private resolveResponse(msgId: string, wavPath: string): void { private resolveResponse(msgId: string, wavPath: string): void {
const fn = this.respMap.get(msgId); const entry = this.respMap.get(msgId);
this.respMap.delete(msgId); this.respMap.delete(msgId);
if (fn) fn(wavPath); if (entry?.resolve) entry.resolve(wavPath);
} }
// ── public spawn / initWav / synthWav ── // ── public spawn / initWav / synthWav ──
@@ -87,21 +141,31 @@ class PiperLocalService {
private async _spawn(): Promise<void> { private async _spawn(): Promise<void> {
if (this.proc) return; if (this.proc) return;
const workerPath = join(SCRIPT_DIR, 'src', 'piper-worker.py'); const workerPath = join(SCRIPT_DIR, 'piper-worker.py');
const venv = process.env.VIRTUAL_ENV || join(SCRIPT_DIR, '.venv', 'bin', 'python3'); const venv = process.env.VIRTUAL_ENV || join(SCRIPT_DIR, '..', '.venv', 'bin', 'python3');
this.proc = spawn(venv, [workerPath], { stdio: ['pipe', 'pipe', 'pipe'] }); this.proc = spawn(venv, [workerPath], { stdio: ['pipe', 'pipe', 'pipe'] });
this.setupStdout(); this.setupStdout();
if (this.proc.stderr) {
this.proc.stderr.on('data', (chunk: Buffer) => {
console.error(
'[piper-worker]',
chunk.toString().trim()
);
});
}
this.proc.on('exit', () => { this.proc.on('exit', () => {
// reject all pending console.log('[piper-svc] Process exited, rejecting all pending synths');
for (const [, fn] of this.respMap) fn(''); // reject all pending (new format: {resolve, reject})
for (const [, entry] of this.respMap) entry.reject(new Error('piper process exited'));
this.respMap.clear(); this.respMap.clear();
if (this.pendingInit) { this.initReject(new Error('piper process exited')); this.pendingInit = null; } if (this.pendingInit) { this.initReject(new Error('piper process exited')); this.pendingInit = null; }
}); });
// ── cleanup old WAV files every 5 min ── // ── cleanup old WAV files every 5 min ──
const timer = setInterval(() => { this.cleanupTimer = setInterval(() => {
try { try {
const now = Date.now(); const now = Date.now();
for (const entry of readdirSync(TTS_DIR)) { for (const entry of readdirSync(TTS_DIR)) {
@@ -120,17 +184,9 @@ class PiperLocalService {
if (this.pendingInit) return this.pendingInit; if (this.pendingInit) return this.pendingInit;
this.pendingInit = new Promise<void>((resolve, reject) => { this.pendingInit = new Promise<void>((resolve, reject) => {
// override the global reject
const origReject = this.initReject;
this.initResolve = resolve; this.initResolve = resolve;
this.initReject = (e: Error) => { this.initReject = reject;
// cleanup
for (const [, fn] of this.respMap) fn('');
this.respMap.clear();
reject(e);
};
}); });
const modelPath = getPiperModel() || join('/tmp', 'quibot-piper-models', 'ca_ES-upc_ona-medium.onnx'); const modelPath = getPiperModel() || join('/tmp', 'quibot-piper-models', 'ca_ES-upc_ona-medium.onnx');
const cfgPath = modelPath.replace(/\.onnx$/, '.onnx.json'); const cfgPath = modelPath.replace(/\.onnx$/, '.onnx.json');
await this.writeStdin( await this.writeStdin(
@@ -155,8 +211,10 @@ class PiperLocalService {
} }
async synthWav(text: string): Promise<Buffer> { async synthWav(text: string): Promise<Buffer> {
await this.initWav();
if (!this.proc) await this._spawn(); // auto-spawn; init runs concurrently if (!this.proc) await this._spawn(); // auto-spawn; init runs concurrently
const msgId = randomUUID() + '-' + Date.now(); const msgId = randomUUID() + '-' + Date.now();
const outPath = join(TTS_DIR, `${msgId}.wav`);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let cleared = false; let cleared = false;
@@ -164,13 +222,15 @@ class PiperLocalService {
if (cleared) return; if (cleared) return;
cleared = true; cleared = true;
this.respMap.delete(msgId); this.respMap.delete(msgId);
reject(new Error('piper-svc: synthesis timed out')); reject(new Error('piper-svc: synthesis timed out after 120s'));
}, 30_000); }, 120_000);
this.respMap.set(msgId, (wavPath: string) => { this.respMap.set(msgId, {
resolve: (wavPath: string) => {
if (cleared) return; if (cleared) return;
cleared = true; cleared = true;
clearTimeout(timer); clearTimeout(timer);
console.log(`[piper-svc] Synthesized ${wavPath} (${Date.now()})`);
try { try {
const buf = readFileSync(wavPath); const buf = readFileSync(wavPath);
rmSync(wavPath, { force: true }); rmSync(wavPath, { force: true });
@@ -178,10 +238,19 @@ class PiperLocalService {
} catch (err: unknown) { } catch (err: unknown) {
reject(err instanceof Error ? err : new Error('read WAV failed')); reject(err instanceof Error ? err : new Error('read WAV failed'));
} }
},
reject: (e: Error) => {
if (cleared) return;
cleared = true;
clearTimeout(timer);
this.respMap.delete(msgId);
reject(e);
},
}); });
console.log(`[piper-svc] synthesize ${text.substring(0, 40)}... (msgId=${msgId})`);
this.writeStdin( this.writeStdin(
JSON.stringify({ type: 'synthesize', text, msgId }), JSON.stringify({ type: 'synthesize', text, msgId, outPath }),
).catch((e) => { ).catch((e) => {
if (cleared) return; if (cleared) return;
cleared = true; cleared = true;
@@ -194,7 +263,10 @@ class PiperLocalService {
// ── shutdown ── // ── shutdown ──
async shutdown(): Promise<void> { async shutdown(): Promise<void> {
this.cleanupTimer?.refresh(); this.cleanupTimer = null; if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
this.cleanupTimer = null;
}
if (!this.proc) return; if (!this.proc) return;
const p = this.proc; this.proc = null; const p = this.proc; this.proc = null;
await new Promise<void>((res) => { await new Promise<void>((res) => {

View File

@@ -8,7 +8,7 @@ const __dirname = join(__filename, '..');
const SCRIPT_DIR = join(__dirname, '..'); const SCRIPT_DIR = join(__dirname, '..');
const PYTHON = join(SCRIPT_DIR, '../.venv/bin/python3'); const PYTHON = join(SCRIPT_DIR, '..', '.venv', 'bin', 'python3');
const whisperModel = process.env.WHISPER_MODEL ?? 'base'; const whisperModel = process.env.WHISPER_MODEL ?? 'base';
const whisperLanguage = process.env.WHISPER_LANGUAGE ?? 'ca'; const whisperLanguage = process.env.WHISPER_LANGUAGE ?? 'ca';
@@ -153,7 +153,7 @@ class WhisperService {
this.spawn(); this.spawn();
} }
await this.waitForInit(); // await this.waitForInit();
const msgId = randomUUID() + '-' + Date.now(); const msgId = randomUUID() + '-' + Date.now();

2
mcp/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
dist/

1895
mcp/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
mcp/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "quibot-mcp",
"version": "1.0.0",
"description": "QuiBot MCP server — exposes robot controls as MCP tools and resources",
"type": "module",
"bin": {
"quibot-mcp": "./dist/index.js"
},
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx src/index.ts"
},
"dependencies": {
"@cfworker/json-schema": "^4.1.1",
"@modelcontextprotocol/sdk": "^1.29.0",
"axios": "^1.7.0",
"form-data": "^4.0.0",
"zod": "^3.25"
},
"devDependencies": {
"@types/node": "^22.19.21",
"tsx": "^4.19.0",
"typescript": "^5.6.0"
}
}

581
mcp/src/index.ts Normal file
View File

@@ -0,0 +1,581 @@
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import axios, { AxiosError } from "axios";
import * as z from "zod";
import fs from "node:fs";
// --- Config from env (same as backend) ---
const RASPBERRY_PI_HOST = process.env.RASPBERRY_PI_HOST ?? "http://raspberrypi.local";
const RASPBERRY_PI_PORT = Number(process.env.RASPBERRY_PI_PORT) || 8000;
const QUIBOT_TOKEN = process.env.QUIBOT_TOKEN ?? "MY_SECRET_TOKEN";
const RPI_URL = `${RASPBERRY_PI_HOST}:${RASPBERRY_PI_PORT}`;
function rpiUrl(path: string, query?: Record<string, string>): string {
const url = `${RPI_URL}${path}`;
if (!query) return url;
const q = new URLSearchParams({ token: QUIBOT_TOKEN, ...query });
return `${url}?${q}`;
}
// --- Helpers ---
async function rpiPost(path: string, query?: Record<string, string>, body?: unknown): Promise<unknown> {
try {
const res = await axios.post(rpiUrl(path, query), body, { timeout: 10000 });
return res.data;
} catch (err) {
if (err instanceof AxiosError && err.response) {
throw new Error(`Pi error ${err.response.status}: ${JSON.stringify(err.response.data)}`);
}
throw err;
}
}
async function rpiGet(path: string, query?: Record<string, string>): Promise<unknown> {
try {
const res = await axios.get(rpiUrl(path, query), { timeout: 10000 });
return res.data;
} catch (err) {
if (err instanceof AxiosError && err.response) {
throw new Error(`Pi error ${err.response.status}: ${JSON.stringify(err.response.data)}`);
}
throw err;
}
}
async function rpiPostMultipart(path: string, formData: FormData): Promise<unknown> {
try {
const res = await axios.post(rpiUrl(path), formData, {
timeout: 30000,
headers: { "Content-Type": "multipart/form-data" },
});
return res.data;
} catch (err) {
if (err instanceof AxiosError && err.response) {
throw new Error(`Pi error ${err.response.status}: ${JSON.stringify(err.response.data)}`);
}
throw err;
}
}
// --- MCP Server ---
const server = new McpServer({
name: "quibot",
version: "1.0.0",
});
// === TOOLS ===
server.registerTool(
"motor_step",
{
description: "Move stepper motors in a direction (fire-and-forget — motor runs until stop)",
inputSchema: z.object({
direction: z.enum(["forward", "backward", "left", "right"]),
}),
},
async ({ direction }) => {
const piPath = direction === "backward" ? "/motor/step/backwards" : `/motor/step/${direction}`;
try {
const result = await rpiPost(piPath);
return {
content: [{ type: "text", text: JSON.stringify(result) }],
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return {
content: [{ type: "text", text: `Error: ${msg}` }],
isError: true,
};
}
},
);
server.registerTool(
"motor_stop",
{
description: "Stop all stepper motors immediately (disables driver via GPIO EN)",
inputSchema: z.object({}),
},
async () => {
try {
const result = await rpiPost("/motor/stop");
return { content: [{ type: "text", text: JSON.stringify(result) }] };
} catch (err) {
return {
content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
isError: true,
};
}
},
);
server.registerTool(
"audio_upload_transcribe",
{
description:
"Upload an audio file to the Raspberry Pi and get transcription + LLM response. The Pi runs Whisper for transcription and llamacpp with preamble for response.",
inputSchema: z.object({
audioBase64: z.string().describe("Base64-encoded audio file"),
format: z.string().describe("Audio format (wav, m4a, mp3, etc.)"),
}),
},
async ({ audioBase64, format }) => {
try {
const buffer = Buffer.from(audioBase64, "base64");
const formData = new FormData();
const fname = `audio-upload-${Date.now()}.${format}`;
formData.append("file", new Blob([buffer]), fname);
const result = await rpiPostMultipart("/transcribe", formData);
return { content: [{ type: "text", text: JSON.stringify(result) }] };
} catch (err) {
return {
content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
isError: true,
};
}
},
);
server.registerTool(
"motor_upload",
{
description: "Upload an audio file to the Raspberry Pi for processing",
inputSchema: z.object({
filePath: z.string().describe("Path to audio file on local machine"),
format: z.string().describe("Audio format (wav, m4a, mp3, etc.)"),
}),
},
async ({ filePath, format }) => {
try {
if (!fs.existsSync(filePath)) {
return { content: [{ type: "text", text: `File not found: ${filePath}` }], isError: true };
}
const fd = new (await import("form-data")).default();
fd.append("file", fs.createReadStream(filePath));
fd.append("format", format);
await axios.post(`${RPI_URL}/audio/upload?format=${format}&token=${QUIBOT_TOKEN}`, fd, {
headers: fd.getHeaders(),
timeout: 30000,
});
return { content: [{ type: "text", text: `Uploaded: ${filePath}` }] };
} catch (err) {
return {
content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
isError: true,
};
}
},
);
server.registerTool(
"audio_list",
{
description: "List incoming audio files on the Raspberry Pi",
inputSchema: z.object({}),
},
async () => {
try {
const files = await rpiGet("/audio/incoming");
return { content: [{ type: "text", text: JSON.stringify(files, null, 2) }] };
} catch (err) {
return {
content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
isError: true,
};
}
},
);
server.registerTool(
"audio_lifecycle",
{
description: "Manage audio file lifecycle: lock, unlock, cancel, or process a file",
inputSchema: z.object({
filename: z.string().describe("Audio filename"),
action: z.enum(["lock", "unlock", "cancel", "process"]).describe("Lifecycle action to perform"),
}),
},
async ({ filename, action }) => {
try {
const result = await rpiPost(`/audio/${action}/${filename}`);
return { content: [{ type: "text", text: JSON.stringify(result) }] };
} catch (err) {
return {
content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
isError: true,
};
}
},
);
server.registerTool(
"eye_set_shape",
{
description: "Set the LED eye shape on the robot's WS2811 matrix",
inputSchema: z.object({
shape: z.enum(["EYES_OPEN", "EYES_FW", "EYES_DOWN", "EYES_GESTURE"]).describe("Eye shape pattern"),
}),
},
async ({ shape }) => {
try {
const result = await rpiPost(`/eye/shape/${shape}`);
return { content: [{ type: "text", text: JSON.stringify(result) }] };
} catch (err) {
return {
content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
isError: true,
};
}
},
);
server.registerTool(
"eye_set_color",
{
description: "Set the LED eye color on the robot's WS2811 matrix",
inputSchema: z.object({
color: z.enum(["RED", "GREEN", "BLUE", "YELLOW", "CYAN", "MAGENTA", "WHITE", "OFF", "ORANGE", "VIOLET", "DARK_RED"]).describe("Eye color name"),
}),
},
async ({ color }) => {
try {
const result = await rpiPost(`/eye/color/${color}`);
return { content: [{ type: "text", text: JSON.stringify(result) }] };
} catch (err) {
return {
content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
isError: true,
};
}
},
);
server.registerTool(
"eye_toggle",
{
description: "Turn eyes on or off (enable/disable LED matrix breathing thread)",
inputSchema: z.object({
state: z.enum(["on", "off"]).describe("Eye power state"),
}),
},
async ({ state }) => {
try {
const result = await rpiPost(`/eye/${state}`);
return { content: [{ type: "text", text: JSON.stringify(result) }] };
} catch (err) {
return {
content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
isError: true,
};
}
},
);
server.registerTool(
"gesture_toggle_mode",
{
description: "Toggle between block mode and gesture mode on the robot",
inputSchema: z.object({}),
},
async () => {
try {
const result = await rpiPost("/gesture/toggle");
return { content: [{ type: "text", text: JSON.stringify(result) }] };
} catch (err) {
return {
content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
isError: true,
};
}
},
);
server.registerTool(
"gesture_on",
{
description: "Enable gesture sensor polling (PAJ7620U2)",
inputSchema: z.object({}),
},
async () => {
try {
const result = await rpiPost("/gesture/on");
return { content: [{ type: "text", text: JSON.stringify(result) }] };
} catch (err) {
return {
content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
isError: true,
};
}
},
);
server.registerTool(
"gesture_off",
{
description: "Disable gesture sensor polling",
inputSchema: z.object({}),
},
async () => {
try {
const result = await rpiPost("/gesture/off");
return { content: [{ type: "text", text: JSON.stringify(result) }] };
} catch (err) {
return {
content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
isError: true,
};
}
},
);
server.registerTool(
"run_command",
{
description: "Run a whitelisted system command on the Raspberry Pi",
inputSchema: z.object({
task: z.enum(["restart_nginx", "uptime", "update"]).describe("Command to run"),
}),
},
async ({ task }) => {
try {
const result = await rpiPost("/run", { task });
return { content: [{ type: "text", text: JSON.stringify(result) }] };
} catch (err) {
return {
content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
isError: true,
};
}
},
);
server.registerTool(
"tts_speak",
{
description: "Synthesize speech via Piper TTS and play it on the Raspberry Pi",
inputSchema: z.object({
text: z.string().describe("Text to speak"),
lang: z.string().default("ca").describe("Language code (ca, es, en)"),
}),
},
async ({ text, lang }) => {
try {
const result = await rpiGet("/tts", { text, lang: lang || "ca" });
return { content: [{ type: "text", text: JSON.stringify(result) }] };
} catch (err) {
return {
content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
isError: true,
};
}
},
);
// === RESOURCES ===
server.registerResource(
"config",
"quibot://config",
{ description: "Current Raspberry Pi connection config and token" },
async () => ({
contents: [
{
uri: "quibot://config",
name: "QuiBot Configuration",
mimeType: "application/json",
text: JSON.stringify(
{ raspberryPiHost: RASPBERRY_PI_HOST, raspberryPiPort: RASPBERRY_PI_PORT, token: QUIBOT_TOKEN },
null,
2,
),
},
],
}),
);
server.registerResource(
"available-directions",
"quibot://directions",
{ description: "Available motor movement directions" },
async () => ({
contents: [
{
uri: "quibot://directions",
name: "Available Motor Directions",
mimeType: "text/plain",
text: ["forward", "backward"].map((d) => ` POST /motor/step/${d}`).join("\n"),
},
],
}),
);
server.registerResource(
"eye-shapes",
"quibot://eyes/shapes",
{ description: "Available LED eye shapes and their meanings" },
async () => ({
contents: [
{
uri: "quibot://eyes/shapes",
name: "Available Eye Shapes",
mimeType: "text/plain",
text: [
" EYES_OPEN — Normal resting eyes (default)",
" EYES_FW — Forward-looking eyes",
" EYES_DOWN — Downward/downcast eyes",
" EYES_GESTURE — Gesture-acknowledge eyes",
].join("\n"),
},
],
}),
);
server.registerResource(
"color-actions",
"quibot://blocks/colors",
{ description: "Color-to-action mapping for block recognition" },
async () => ({
contents: [
{
uri: "quibot://blocks/colors",
name: "Color Block Actions",
mimeType: "text/plain",
text: [
" RED → Advance forward",
" GREEN → Turn right",
" BLUE → Turn left",
" YELLOW → Take / pick up block",
" ORANGE → Leave / eject block",
" VIOLET → Idle",
" BLACK → Reference / no block",
].join("\n"),
},
],
}),
);
server.registerResource(
"gestures",
"quibot://gestures",
{ description: "Available PAJ7620U2 gestures" },
async () => ({
contents: [
{
uri: "quibot://gestures",
name: "Available Gestures",
mimeType: "text/plain",
text: [
" GS_FORWARD → Hand moving forward (toward sensor)",
" GS_BACKWARD → Hand moving backward (away from sensor)",
" GS_LEFT → Hand moving left",
" GS_RIGHT → Hand moving right",
" GS_UP → Hand moving up",
" GS_DOWN → Hand moving down",
" GS_CLOCKWISE → Clockwise wave motion",
" GS_ANTICLOCKWISE→ Counter-clockwise wave motion",
" GS_WAVE → Wave hello gesture",
].join("\n"),
},
],
}),
);
server.registerResource(
"pi-status",
"quibot://status/pi",
{ description: "Check if Raspberry Pi HTTP server is reachable" },
async () => {
try {
const res = await axios.get(`${RPI_URL}/health`, { timeout: 5000 });
return {
contents: [
{
uri: "quibot://status/pi",
name: "Pi Status",
mimeType: "application/json",
text: JSON.stringify({ status: "connected", data: res.data }, null, 2),
},
],
};
} catch {
return {
contents: [
{
uri: "quibot://status/pi",
name: "Pi Status",
mimeType: "application/json",
text: JSON.stringify({ status: "disconnected", error: "Cannot reach Raspberry Pi" }, null, 2),
},
],
};
}
},
);
// === PROMPTS ===
server.registerPrompt(
"quibot-setup",
{
description: "Get a complete reference for controlling the QuiBot robot via MCP",
},
() => ({
messages: [
{
role: "user",
content: {
type: "text",
text: `# QuiBot MCP Server
You can control the physical QuiBot robot with these tools:
## Motor Control
- motor_step(direction) — Move forward, backward, left, or right
- motor_stop() — Stop all motors
- motor_upload(filePath, format) — Upload audio file to Pi
## Audio
- audio_upload_transcribe(audioBase64, format) — Upload + Whisper transcribe + llamacpp response
- audio_list() — List incoming audio files
- audio_lifecycle(filename, action) — lock/unlock/cancel/process audio
- tts_speak(text, lang) — Synthesize and play speech via Piper TTS
## Eyes (WS2811 LED Matrix)
- eye_set_shape(shape) — Set face expression shape
- eye_set_color(color) — Change eye color
- eye_toggle(state) — Turn eyes on/off
## Gesture Sensor
- gesture_on() / gesture_off() — Enable/disable gesture polling
- gesture_toggle_mode() — Toggle between block/gesture mode
## System
- run_command(task) — Run system commands on Pi (uptime, restart_nginx)
## Resources
- quibot://status/pi — Check if Pi is reachable
- quibot://config — Current connection config
- quibot://blocks/colors — Color-to-action mapping
- quibot://gestures — Gesture reference
`,
},
},
],
}),
);
// === START ===
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("[quibot-mcp] Server connected, waiting for requests via stdio...");
}
main().catch((err) => {
console.error("[quibot-mcp] Failed to start:", err);
process.exit(1);
});

18
mcp/tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"sourceMap": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

10
package-lock.json generated
View File

@@ -5,6 +5,7 @@
"packages": { "packages": {
"": { "": {
"dependencies": { "dependencies": {
"formdata-node": "^6.0.3",
"vue-i18n": "^11.4.5" "vue-i18n": "^11.4.5"
} }
}, },
@@ -268,6 +269,15 @@
"license": "MIT", "license": "MIT",
"peer": true "peer": true
}, },
"node_modules/formdata-node": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-6.0.3.tgz",
"integrity": "sha512-8e1++BCiTzUno9v5IZ2J6bv4RU+3UKDmqWUQD0MIMVCd9AdhWkO1gw57oo1mNEX1dMq2EGI+FbWz4B92pscSQg==",
"license": "MIT",
"engines": {
"node": ">= 18"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",

View File

@@ -1,5 +1,6 @@
{ {
"dependencies": { "dependencies": {
"formdata-node": "^6.0.3",
"vue-i18n": "^11.4.5" "vue-i18n": "^11.4.5"
} }
} }