mirror of
https://github.com/leon-ai/leon.git
synced 2024-12-18 22:31:32 +03:00
Merge branch 'over-http' into develop
This commit is contained in:
commit
6b9d53e0cc
@ -24,6 +24,11 @@ LEON_TTS=false
|
||||
# Text-to-speech provider
|
||||
LEON_TTS_PROVIDER=flite
|
||||
|
||||
# Enable/disable packages to be available over HTTP
|
||||
LEON_OVER_HTTP=true
|
||||
# HTTP API key (use "npm run generate:http-api-key" to regenerate one)
|
||||
LEON_HTTP_API_KEY=
|
||||
|
||||
# Enable/disable collaborative logger
|
||||
LEON_LOGGER=true
|
||||
|
||||
|
@ -96,7 +96,7 @@ export default class Client {
|
||||
})
|
||||
|
||||
this.socket.on('download', (data) => {
|
||||
window.location = `${this.serverUrl}/v1/downloads?package=${data.package}&module=${data.module}`
|
||||
window.location = `${this.serverUrl}/api/v1/downloads?package=${data.package}&module=${data.module}`
|
||||
})
|
||||
|
||||
if (this.history !== null) {
|
||||
|
@ -20,7 +20,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
loader.start()
|
||||
|
||||
request.get(`${serverUrl}/v1/info`)
|
||||
request.get(`${serverUrl}/api/v1/info`)
|
||||
.end((err, res) => {
|
||||
if (err || !res.ok) {
|
||||
console.error(err.response.error.message)
|
||||
|
146
core/pkgs-endpoints.json
Normal file
146
core/pkgs-endpoints.json
Normal file
@ -0,0 +1,146 @@
|
||||
{
|
||||
"endpoints": [
|
||||
{
|
||||
"method": "POST",
|
||||
"route": "/api/p/calendar/todolist/create_list",
|
||||
"params": [
|
||||
"list"
|
||||
],
|
||||
"entitiesType": "trim"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"route": "/api/p/calendar/todolist/view_lists",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"route": "/api/p/calendar/todolist/view_list",
|
||||
"params": [
|
||||
"list"
|
||||
],
|
||||
"entitiesType": "trim"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"route": "/api/p/calendar/todolist/rename_list",
|
||||
"params": [
|
||||
"old_list",
|
||||
"new_list"
|
||||
],
|
||||
"entitiesType": "trim"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"route": "/api/p/calendar/todolist/delete_list",
|
||||
"params": [
|
||||
"list"
|
||||
],
|
||||
"entitiesType": "trim"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"route": "/api/p/calendar/todolist/add_todos",
|
||||
"params": [
|
||||
"todos",
|
||||
"list"
|
||||
],
|
||||
"entitiesType": "trim"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"route": "/api/p/calendar/todolist/complete_todos",
|
||||
"params": [
|
||||
"todos",
|
||||
"list"
|
||||
],
|
||||
"entitiesType": "trim"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"route": "/api/p/calendar/todolist/uncheck_todos",
|
||||
"params": [
|
||||
"todos",
|
||||
"list"
|
||||
],
|
||||
"entitiesType": "trim"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"route": "/api/p/checker/isitdown/run",
|
||||
"params": [
|
||||
"url"
|
||||
],
|
||||
"entitiesType": "builtIn"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"route": "/api/p/checker/haveibeenpwned/run",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"route": "/api/p/leon/whoami/run",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"route": "/api/p/leon/joke/run",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"route": "/api/p/leon/greeting/run",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"route": "/api/p/leon/welcome/run",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"route": "/api/p/leon/meaningoflife/run",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"route": "/api/p/leon/randomnumber/run",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"route": "/api/p/leon/bye/run",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"route": "/api/p/leon/partnerassistant/run",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"route": "/api/p/network/speedtest/run",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"route": "/api/p/trend/github/run",
|
||||
"params": [
|
||||
"number",
|
||||
"daterange"
|
||||
],
|
||||
"entitiesType": "builtIn"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"route": "/api/p/trend/producthunt/run",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"route": "/api/p/videodownloader/youtube/run",
|
||||
"params": []
|
||||
}
|
||||
]
|
||||
}
|
@ -21,7 +21,7 @@ socket.on('connect', () => {
|
||||
console.log('Waiting for hotword...')
|
||||
})
|
||||
|
||||
request.get(`${url}/v1/info`)
|
||||
request.get(`${url}/api/v1/info`)
|
||||
.end((err, res) => {
|
||||
if (err || !res.ok) {
|
||||
if (!err.response) {
|
||||
|
3730
package-lock.json
generated
3730
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@ -22,12 +22,13 @@
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "babel-node scripts/lint.js",
|
||||
"test": "npm run test:json && npm run test:unit && npm run test:e2e",
|
||||
"test": "npm run test:json && npm run test:over-http && npm run test:unit && npm run test:e2e",
|
||||
"test:unit": "npm run train en && cross-env PIPENV_PIPFILE=bridges/python/Pipfile LEON_NODE_ENV=testing jest --forceExit --silent --projects test/unit/unit.jest.json && npm run train",
|
||||
"test:e2e": "npm run test:e2e:nlp-modules && npm run test:e2e:modules",
|
||||
"test:e2e:modules": "babel-node scripts/run-clean-test-dbs.js && npm run train en && cross-env PIPENV_PIPFILE=bridges/python/Pipfile LEON_NODE_ENV=testing jest --forceExit --silent --verbose --projects test/e2e/modules/e2e.modules.jest.json && babel-node scripts/run-clean-test-dbs.js && npm run train",
|
||||
"test:e2e:nlp-modules": "npm run train en && cross-env PIPENV_PIPFILE=bridges/python/Pipfile LEON_NODE_ENV=testing jest --forceExit --silent --verbose --setupTestFrameworkScriptFile=./test/paths.setup.js test/e2e/nlp-modules.spec.js && npm run train",
|
||||
"test:json": "jest --silent --projects test/json/json.jest.json",
|
||||
"test:over-http": "npm run generate:pkgs-endpoints && cross-env PIPENV_PIPFILE=bridges/python/Pipfile LEON_NODE_ENV=testing LEON_HOST=http://localhost LEON_PORT=1338 LEON_HTTP_API_KEY=72aeb5ba324580963114481144385d7179c106fc jest --forceExit --silent --verbose --notify=false --bail --collectCoverage=false test/e2e/over-http.spec.js",
|
||||
"test:module": "babel-node scripts/test-module.js",
|
||||
"setup:offline": "babel-node scripts/setup-offline/setup-offline.js",
|
||||
"setup:offline-stt": "babel-node scripts/setup-offline/run-setup-stt.js",
|
||||
@ -36,13 +37,15 @@
|
||||
"preinstall": "node scripts/setup/preinstall.js",
|
||||
"postinstall": "babel-node scripts/setup/setup.js",
|
||||
"dev:app": "vite --config app/vite.config.js",
|
||||
"dev:server": "npm run train && cross-env LEON_NODE_ENV=development nodemon --watch server ./server/src/index.js --ignore server/src/tmp/ --exec babel-node",
|
||||
"dev:server": "npm run train && npm run generate:pkgs-endpoints && cross-env LEON_NODE_ENV=development nodemon --watch server ./server/src/index.js --ignore server/src/tmp/ --exec babel-node",
|
||||
"wake": "cross-env LEON_HOST=http://localhost LEON_PORT=1337 node hotword/index.js",
|
||||
"delete-dist:server": "shx rm -rf ./server/dist",
|
||||
"prepare": "husky install",
|
||||
"generate:pkgs-endpoints": "babel-node scripts/generate/run-generate-pkgs-endpoints.js",
|
||||
"generate:http-api-key": "babel-node scripts/generate/run-generate-http-api-key.js",
|
||||
"build": "npm run build:app && npm run build:server",
|
||||
"build:app": "cross-env LEON_NODE_ENV=production babel-node scripts/app/run-build-app.js",
|
||||
"build:server": "npm run delete-dist:server && npm run train && babel ./server/src -d ./server/dist --copy-files && shx mkdir -p server/dist/tmp",
|
||||
"build:server": "npm run delete-dist:server && npm run train && npm run generate:pkgs-endpoints && babel ./server/src -d ./server/dist --copy-files && shx mkdir -p server/dist/tmp",
|
||||
"start": "cross-env LEON_NODE_ENV=production node ./server/dist/index.js",
|
||||
"train": "babel-node scripts/run-train.js",
|
||||
"prepare-release": "babel-node scripts/release/prepare-release.js",
|
||||
@ -95,9 +98,9 @@
|
||||
"git-changelog": "^2.0.0",
|
||||
"husky": "^7.0.0",
|
||||
"inquirer": "^8.1.0",
|
||||
"jest": "^27.4.5",
|
||||
"jest": "^27.4.7",
|
||||
"jest-canvas-mock": "^2.3.1",
|
||||
"jest-extended": "^1.2.0",
|
||||
"jest-extended": "^2.0.0",
|
||||
"json": "^10.0.0",
|
||||
"nodemon": "^2.0.7",
|
||||
"semver": "^7.3.5",
|
||||
|
@ -14,7 +14,17 @@
|
||||
"Check if nodejs.org is up",
|
||||
"Check if nodejs.org is working",
|
||||
"Check if amazon.com is up or down"
|
||||
]
|
||||
],
|
||||
"http_api": {
|
||||
"entities": [
|
||||
{
|
||||
"entity": "url",
|
||||
"resolution": [
|
||||
"value"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"haveibeenpwned": {
|
||||
|
@ -8,7 +8,23 @@
|
||||
"What are the trends on GH?",
|
||||
"Give me the GH trends",
|
||||
"What's trending on GH?"
|
||||
]
|
||||
],
|
||||
"http_api": {
|
||||
"entities": [
|
||||
{
|
||||
"entity": "number",
|
||||
"resolution": [
|
||||
"value"
|
||||
]
|
||||
},
|
||||
{
|
||||
"entity": "daterange",
|
||||
"resolution": [
|
||||
"timex"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"producthunt": {
|
||||
|
71
scripts/generate/generate-http-api-key.js
Normal file
71
scripts/generate/generate-http-api-key.js
Normal file
@ -0,0 +1,71 @@
|
||||
import dotenv from 'dotenv'
|
||||
import crypto from 'crypto'
|
||||
import fs from 'fs'
|
||||
import { prompt } from 'inquirer'
|
||||
import path from 'path'
|
||||
|
||||
import log from '@/helpers/log'
|
||||
import string from '@/helpers/string'
|
||||
|
||||
dotenv.config()
|
||||
|
||||
/**
|
||||
* Generate HTTP API key script
|
||||
* save it in the .env file
|
||||
*/
|
||||
const generateHttpApiKey = () => new Promise(async (resolve, reject) => {
|
||||
log.info('Generating the HTTP API key...')
|
||||
|
||||
try {
|
||||
const shasum = crypto.createHash('sha1')
|
||||
const str = string.random(11)
|
||||
const dotEnvPath = path.join(process.cwd(), '.env')
|
||||
const envVarKey = 'LEON_HTTP_API_KEY'
|
||||
let content = fs.readFileSync(dotEnvPath, 'utf8')
|
||||
|
||||
shasum.update(str)
|
||||
const sha1 = shasum.digest('hex')
|
||||
|
||||
let lines = content.split('\n')
|
||||
lines = lines.map((line) => {
|
||||
if (line.indexOf(`${envVarKey}=`) !== -1) {
|
||||
line = `${envVarKey}=${sha1}`
|
||||
}
|
||||
|
||||
return line
|
||||
})
|
||||
|
||||
content = lines.join('\n')
|
||||
|
||||
fs.writeFileSync(dotEnvPath, content)
|
||||
log.success('HTTP API key generated')
|
||||
|
||||
resolve()
|
||||
} catch (e) {
|
||||
log.error(e.message)
|
||||
reject(e)
|
||||
}
|
||||
})
|
||||
|
||||
export default () => new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
if (process.env.LEON_HTTP_API_KEY === '') {
|
||||
await generateHttpApiKey()
|
||||
} else {
|
||||
const answer = await prompt({
|
||||
type: 'confirm',
|
||||
name: 'generate.httpApiKey',
|
||||
message: 'Do you want to regenerate the HTTP API key?',
|
||||
default: false
|
||||
})
|
||||
|
||||
if (answer.generate.httpApiKey === true) {
|
||||
await generateHttpApiKey()
|
||||
}
|
||||
}
|
||||
|
||||
resolve()
|
||||
} catch (e) {
|
||||
reject(e)
|
||||
}
|
||||
})
|
119
scripts/generate/generate-pkgs-endpoints.js
Normal file
119
scripts/generate/generate-pkgs-endpoints.js
Normal file
@ -0,0 +1,119 @@
|
||||
import dotenv from 'dotenv'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
import log from '@/helpers/log'
|
||||
|
||||
import { langs } from '@@/core/langs.json'
|
||||
|
||||
dotenv.config()
|
||||
|
||||
/**
|
||||
* Generate packages endpoints script
|
||||
* Parse and convert packages configuration into a JSON file understandable by Fastify
|
||||
* to dynamically generate endpoints so packages can be accessible over HTTP
|
||||
*/
|
||||
export default () => new Promise(async (resolve, reject) => {
|
||||
const supportedMethods = ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT', 'OPTIONS']
|
||||
const packagesDir = 'packages'
|
||||
const outputFile = '/core/pkgs-endpoints.json'
|
||||
const outputFilePath = path.join(__dirname, `../..${outputFile}`)
|
||||
const lang = langs[process.env.LEON_LANG].short.toLowerCase().substr(0, 2)
|
||||
|
||||
try {
|
||||
const packages = fs.readdirSync(packagesDir)
|
||||
.filter((entity) => fs.statSync(path.join(packagesDir, entity)).isDirectory())
|
||||
const finalObj = {
|
||||
endpoints: []
|
||||
}
|
||||
let isFileNeedToBeGenerated = true
|
||||
let pkgObj = { }
|
||||
|
||||
// Check if a new routing generation is necessary
|
||||
if (fs.existsSync(outputFilePath)) {
|
||||
const mtimeEndpoints = fs.statSync(outputFilePath).mtime.getTime()
|
||||
|
||||
for (let i = 0; i < packages.length; i += 1) {
|
||||
const pkg = packages[i]
|
||||
const fileInfo = fs.statSync(`${packagesDir}/${pkg}/data/expressions/${lang}.json`)
|
||||
const mtime = fileInfo.mtime.getTime()
|
||||
|
||||
if (mtime > mtimeEndpoints) {
|
||||
break
|
||||
}
|
||||
|
||||
if ((i + 1) === packages.length) {
|
||||
log.success(`${outputFile} is already up-to-date`)
|
||||
isFileNeedToBeGenerated = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isFileNeedToBeGenerated) {
|
||||
log.info('Parsing packages configuration...')
|
||||
|
||||
for (let i = 0; i < packages.length; i += 1) {
|
||||
const pkg = packages[i]
|
||||
|
||||
pkgObj = JSON.parse(fs.readFileSync(`${packagesDir}/${pkg}/data/expressions/${lang}.json`, 'utf8'))
|
||||
|
||||
const modules = Object.keys(pkgObj)
|
||||
for (let j = 0; j < modules.length; j += 1) {
|
||||
const module = modules[j]
|
||||
const actions = Object.keys(pkgObj[module])
|
||||
|
||||
for (let k = 0; k < actions.length; k += 1) {
|
||||
const action = actions[k]
|
||||
const actionObj = pkgObj[module][action]
|
||||
const { entities, http_api } = actionObj // eslint-disable-line camelcase
|
||||
let finalMethod = entities || http_api?.entities ? 'POST' : 'GET'
|
||||
|
||||
// Only generate this route if it is not disabled from the package config
|
||||
if (!http_api?.disabled || (http_api?.disabled && http_api?.disabled === false)) {
|
||||
if (http_api?.method) {
|
||||
finalMethod = http_api.method.toUpperCase()
|
||||
}
|
||||
|
||||
if (!supportedMethods.includes(finalMethod)) {
|
||||
reject(`The "${finalMethod}" HTTP method of the ${pkg}/${module}/${action} action is not supported`)
|
||||
}
|
||||
|
||||
const endpoint = {
|
||||
method: finalMethod.toUpperCase(),
|
||||
route: `/api/p/${pkg}/${module}/${action}`,
|
||||
params: []
|
||||
}
|
||||
|
||||
if (http_api?.timeout) {
|
||||
endpoint.timeout = http_api.timeout
|
||||
}
|
||||
if (entities) {
|
||||
// Handle explicit trim entities
|
||||
endpoint.entitiesType = 'trim'
|
||||
endpoint.params = entities.map((entity) => entity.name)
|
||||
} else if (http_api?.entities) {
|
||||
// Handle built-in entities
|
||||
endpoint.entitiesType = 'builtIn'
|
||||
endpoint.params = http_api.entities.map((entity) => entity.entity)
|
||||
}
|
||||
|
||||
finalObj.endpoints.push(endpoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info(`Writing ${outputFile} file...`)
|
||||
try {
|
||||
fs.writeFileSync(outputFilePath, JSON.stringify(finalObj, null, 2))
|
||||
log.success(`${outputFile} file generated`)
|
||||
resolve()
|
||||
} catch (e) {
|
||||
reject(`Failed to generate ${outputFile} file: ${e.message}`)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log.error(e.message)
|
||||
reject(e)
|
||||
}
|
||||
})
|
14
scripts/generate/run-generate-http-api-key.js
Normal file
14
scripts/generate/run-generate-http-api-key.js
Normal file
@ -0,0 +1,14 @@
|
||||
import log from '@/helpers/log'
|
||||
|
||||
import generateHttpApiKey from './generate-http-api-key'
|
||||
|
||||
/**
|
||||
* Execute the generating HTTP API key script
|
||||
*/
|
||||
(async () => {
|
||||
try {
|
||||
await generateHttpApiKey()
|
||||
} catch (e) {
|
||||
log.error(`Failed to generate the HTTP API key: ${e}`)
|
||||
}
|
||||
})()
|
14
scripts/generate/run-generate-pkgs-endpoints.js
Normal file
14
scripts/generate/run-generate-pkgs-endpoints.js
Normal file
@ -0,0 +1,14 @@
|
||||
import log from '@/helpers/log'
|
||||
|
||||
import generatePkgsEndpoints from './generate-pkgs-endpoints'
|
||||
|
||||
/**
|
||||
* Execute the generating packages endpoints script
|
||||
*/
|
||||
(async () => {
|
||||
try {
|
||||
await generatePkgsEndpoints()
|
||||
} catch (e) {
|
||||
log.error(`Failed to generate packages endpoints: ${e}`)
|
||||
}
|
||||
})()
|
@ -2,6 +2,7 @@ import loader from '@/helpers/loader'
|
||||
import log from '@/helpers/log'
|
||||
|
||||
import train from '../train'
|
||||
import generateHttpApiKey from '../generate/generate-http-api-key'
|
||||
import setupDotenv from './setup-dotenv'
|
||||
import setupCore from './setup-core'
|
||||
import setupPackagesConfig from './setup-packages-config'
|
||||
@ -26,6 +27,9 @@ import setupPythonPackages from './setup-python-packages'
|
||||
setupPackagesConfig()
|
||||
])
|
||||
await setupPythonPackages()
|
||||
loader.stop()
|
||||
await generateHttpApiKey()
|
||||
loader.start()
|
||||
await train()
|
||||
|
||||
log.default('')
|
||||
|
@ -6,7 +6,7 @@ import path from 'path'
|
||||
import log from '@/helpers/log'
|
||||
import string from '@/helpers/string'
|
||||
|
||||
import { langs } from '../core/langs.json'
|
||||
import { langs } from '@@/core/langs.json'
|
||||
|
||||
dotenv.config()
|
||||
|
||||
|
@ -5,16 +5,16 @@ import { langs } from '@@/core/langs.json'
|
||||
import log from '@/helpers/log'
|
||||
import string from '@/helpers/string'
|
||||
import Synchronizer from '@/core/synchronizer'
|
||||
import Tts from '@/tts/tts'
|
||||
|
||||
class Brain {
|
||||
constructor (socket, lang) {
|
||||
this.socket = socket
|
||||
constructor (lang) {
|
||||
this.lang = lang
|
||||
this.broca = JSON.parse(fs.readFileSync(`${__dirname}/../data/en.json`, 'utf8'))
|
||||
this.process = { }
|
||||
this.interOutput = { }
|
||||
this.finalOutput = { }
|
||||
this._socket = { }
|
||||
this._tts = { }
|
||||
|
||||
// Read into the language file
|
||||
const file = `${__dirname}/../data/${this.lang}.json`
|
||||
@ -24,12 +24,22 @@ class Brain {
|
||||
|
||||
log.title('Brain')
|
||||
log.success('New instance')
|
||||
}
|
||||
|
||||
if (process.env.LEON_TTS === 'true') {
|
||||
// Init TTS
|
||||
this.tts = new Tts(this.socket, process.env.LEON_TTS_PROVIDER)
|
||||
this.tts.init()
|
||||
}
|
||||
get socket () {
|
||||
return this._socket
|
||||
}
|
||||
|
||||
set socket (newSocket) {
|
||||
this._socket = newSocket
|
||||
}
|
||||
|
||||
get tts () {
|
||||
return this._tts
|
||||
}
|
||||
|
||||
set tts (newTts) {
|
||||
this._tts = newTts
|
||||
}
|
||||
|
||||
/**
|
||||
@ -55,10 +65,10 @@ class Brain {
|
||||
// Stripe HTML to a whitespace. Whitespace to let the TTS respects punctuation
|
||||
const speech = rawSpeech.replace(/<(?:.|\n)*?>/gm, ' ')
|
||||
|
||||
this.tts.add(speech, end)
|
||||
this._tts.add(speech, end)
|
||||
}
|
||||
|
||||
this.socket.emit('answer', rawSpeech)
|
||||
this._socket.emit('answer', rawSpeech)
|
||||
}
|
||||
}
|
||||
|
||||
@ -92,17 +102,34 @@ class Brain {
|
||||
/**
|
||||
* Execute Python modules
|
||||
*/
|
||||
execute (obj) {
|
||||
execute (obj, opts) {
|
||||
const executionTimeStart = Date.now()
|
||||
opts = opts || {
|
||||
mute: false // Close Leon mouth e.g. over HTTP
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const queryId = `${Date.now()}-${string.random(4)}`
|
||||
const queryObjectPath = `${__dirname}/../tmp/${queryId}.json`
|
||||
const speeches = []
|
||||
|
||||
// Ask to repeat if Leon is not sure about the request
|
||||
if (obj.classification.confidence < langs[process.env.LEON_LANG].min_confidence) {
|
||||
this.talk(`${this.wernicke('random_not_sure')}.`, true)
|
||||
this.socket.emit('is-typing', false)
|
||||
if (!opts.mute) {
|
||||
const speech = `${this.wernicke('random_not_sure')}.`
|
||||
|
||||
resolve()
|
||||
speeches.push(speech)
|
||||
this.talk(speech, true)
|
||||
this._socket.emit('is-typing', false)
|
||||
}
|
||||
|
||||
const executionTimeEnd = Date.now()
|
||||
const executionTime = executionTimeEnd - executionTimeStart
|
||||
|
||||
resolve({
|
||||
speeches,
|
||||
executionTime
|
||||
})
|
||||
} else {
|
||||
// Ensure the process is empty (to be able to execute other processes outside of Brain)
|
||||
if (Object.keys(this.process).length === 0) {
|
||||
@ -146,25 +173,51 @@ class Brain {
|
||||
log.info(data.toString())
|
||||
|
||||
this.interOutput = obj.output
|
||||
this.talk(obj.output.speech.toString())
|
||||
|
||||
const speech = obj.output.speech.toString()
|
||||
if (!opts.mute) {
|
||||
this.talk(speech)
|
||||
}
|
||||
speeches.push(speech)
|
||||
} else {
|
||||
output += data
|
||||
}
|
||||
} else {
|
||||
const executionTimeEnd = Date.now()
|
||||
const executionTime = executionTimeEnd - executionTimeStart
|
||||
|
||||
/* istanbul ignore next */
|
||||
reject({ type: 'warning', obj: new Error(`The ${moduleName} module of the ${packageName} package is not well configured. Check the configuration file.`) })
|
||||
reject({
|
||||
type: 'warning',
|
||||
obj: new Error(`The ${moduleName} module of the ${packageName} package is not well configured. Check the configuration file.`),
|
||||
speeches,
|
||||
executionTime
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Handle error
|
||||
this.process.stderr.on('data', (data) => {
|
||||
this.talk(`${this.wernicke('random_package_module_errors', '',
|
||||
{ '%module_name%': moduleName, '%package_name%': packageName })}!`)
|
||||
const speech = `${this.wernicke('random_package_module_errors', '',
|
||||
{ '%module_name%': moduleName, '%package_name%': packageName })}!`
|
||||
if (!opts.mute) {
|
||||
this.talk(speech)
|
||||
this._socket.emit('is-typing', false)
|
||||
}
|
||||
speeches.push(speech)
|
||||
|
||||
Brain.deleteQueryObjFile(queryObjectPath)
|
||||
this.socket.emit('is-typing', false)
|
||||
|
||||
log.title(packageName)
|
||||
reject({ type: 'error', obj: new Error(data) })
|
||||
|
||||
const executionTimeEnd = Date.now()
|
||||
const executionTime = executionTimeEnd - executionTimeStart
|
||||
reject({
|
||||
type: 'error',
|
||||
obj: new Error(data),
|
||||
speeches,
|
||||
executionTime
|
||||
})
|
||||
})
|
||||
|
||||
// Catch the end of the module execution
|
||||
@ -177,7 +230,12 @@ class Brain {
|
||||
// Check if there is an output (no module error)
|
||||
if (this.finalOutput !== '') {
|
||||
this.finalOutput = JSON.parse(this.finalOutput).output
|
||||
this.talk(this.finalOutput.speech.toString(), true)
|
||||
|
||||
const speech = this.finalOutput.speech.toString()
|
||||
if (!opts.mute) {
|
||||
this.talk(speech, true)
|
||||
}
|
||||
speeches.push(speech)
|
||||
|
||||
/* istanbul ignore next */
|
||||
// Synchronize the downloaded content if enabled
|
||||
@ -191,14 +249,30 @@ class Brain {
|
||||
|
||||
// When the synchronization is finished
|
||||
sync.synchronize((speech) => {
|
||||
this.talk(speech)
|
||||
if (!opts.mute) {
|
||||
this.talk(speech)
|
||||
}
|
||||
speeches.push(speech)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Brain.deleteQueryObjFile(queryObjectPath)
|
||||
this.socket.emit('is-typing', false)
|
||||
resolve()
|
||||
|
||||
if (!opts.mute) {
|
||||
this._socket.emit('is-typing', false)
|
||||
}
|
||||
|
||||
const executionTimeEnd = Date.now()
|
||||
const executionTime = executionTimeEnd - executionTimeStart
|
||||
|
||||
resolve({
|
||||
queryId,
|
||||
lang: langs[process.env.LEON_LANG].short,
|
||||
...obj,
|
||||
speeches,
|
||||
executionTime // In ms, module execution time only
|
||||
})
|
||||
})
|
||||
|
||||
// Reset the child process
|
||||
|
@ -5,7 +5,7 @@ import log from '@/helpers/log'
|
||||
import string from '@/helpers/string'
|
||||
|
||||
const getDownloads = async (fastify, options) => {
|
||||
fastify.get(`/${options.apiVersion}/downloads`, (request, reply) => {
|
||||
fastify.get(`/api/${options.apiVersion}/downloads`, (request, reply) => {
|
||||
log.title('GET /downloads')
|
||||
|
||||
const clean = (dir, files) => {
|
@ -1,4 +1,4 @@
|
||||
import getDownloads from '@/api/downloads/get'
|
||||
import getDownloads from '@/core/http-server/api/downloads/get'
|
||||
|
||||
const downloadsPlugin = async (fastify, options) => {
|
||||
// Get downloads to download module content
|
@ -3,7 +3,7 @@ import { version } from '@@/package.json'
|
||||
import log from '@/helpers/log'
|
||||
|
||||
const getInfo = async (fastify, options) => {
|
||||
fastify.get(`/${options.apiVersion}/info`, (_request, reply) => {
|
||||
fastify.get(`/api/${options.apiVersion}/info`, (request, reply) => {
|
||||
log.title('GET /info')
|
||||
|
||||
const message = 'Information pulled.'
|
@ -1,4 +1,4 @@
|
||||
import getInfo from '@/api/info/get'
|
||||
import getInfo from '@/core/http-server/api/info/get'
|
||||
|
||||
const infoPlugin = async (fastify, options) => {
|
||||
// Get information to init client
|
@ -1,4 +1,4 @@
|
||||
const corsMidd = async (_request, reply) => {
|
||||
const corsMidd = async (request, reply) => {
|
||||
// Allow only a specific client to request to the API (depending of the env)
|
||||
if (process.env.LEON_NODE_ENV !== 'production') {
|
||||
reply.header(
|
12
server/src/core/http-server/plugins/key.js
Normal file
12
server/src/core/http-server/plugins/key.js
Normal file
@ -0,0 +1,12 @@
|
||||
const keyMidd = async (request, reply) => {
|
||||
const apiKey = request.headers['x-api-key']
|
||||
if (!apiKey || apiKey !== process.env.LEON_HTTP_API_KEY) {
|
||||
reply.statusCode = 401
|
||||
reply.send({
|
||||
message: 'Unauthorized, please check the HTTP API key is correct',
|
||||
success: false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default keyMidd
|
305
server/src/core/http-server/server.js
Normal file
305
server/src/core/http-server/server.js
Normal file
@ -0,0 +1,305 @@
|
||||
import Fastify from 'fastify'
|
||||
import fastifyStatic from 'fastify-static'
|
||||
import socketio from 'socket.io'
|
||||
import { join } from 'path'
|
||||
|
||||
import { version } from '@@/package.json'
|
||||
import { langs } from '@@/core/langs.json'
|
||||
import { endpoints } from '@@/core/pkgs-endpoints.json'
|
||||
import Nlu from '@/core/nlu'
|
||||
import Brain from '@/core/brain'
|
||||
import Asr from '@/core/asr'
|
||||
import Stt from '@/stt/stt'
|
||||
import Tts from '@/tts/tts'
|
||||
import corsMidd from '@/core/http-server/plugins/cors'
|
||||
import otherMidd from '@/core/http-server/plugins/other'
|
||||
import keyMidd from '@/core/http-server/plugins/key'
|
||||
import infoPlugin from '@/core/http-server/api/info'
|
||||
import downloadsPlugin from '@/core/http-server/api/downloads'
|
||||
import log from '@/helpers/log'
|
||||
import date from '@/helpers/date'
|
||||
|
||||
const server = { }
|
||||
let brain = { }
|
||||
let nlu = { }
|
||||
|
||||
server.fastify = Fastify()
|
||||
server.httpServer = { }
|
||||
|
||||
/**
|
||||
* Generate packages routes
|
||||
*/
|
||||
/* istanbul ignore next */
|
||||
server.generatePackagesRoutes = (instance) => {
|
||||
// Dynamically expose Leon modules over HTTP
|
||||
endpoints.forEach((endpoint) => {
|
||||
instance.route({
|
||||
method: endpoint.method,
|
||||
url: endpoint.route,
|
||||
async handler (request, reply) {
|
||||
const timeout = endpoint.timeout || 60000
|
||||
const [, , , pkg, module, action] = endpoint.route.split('/')
|
||||
const handleRoute = async () => {
|
||||
const { params } = endpoint
|
||||
const entities = []
|
||||
|
||||
params.forEach((param) => {
|
||||
const value = request.body[param]
|
||||
const trimEntity = {
|
||||
entity: param,
|
||||
sourceText: value,
|
||||
utteranceText: value,
|
||||
resolution: { value }
|
||||
}
|
||||
const builtInEntity = {
|
||||
entity: param,
|
||||
resolution: { ...value }
|
||||
}
|
||||
let entity = endpoint?.entitiesType === 'trim' ? trimEntity : builtInEntity
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v) => {
|
||||
entity = {
|
||||
entity: param,
|
||||
resolution: { ...v }
|
||||
}
|
||||
|
||||
entities.push(entity)
|
||||
})
|
||||
} else {
|
||||
entities.push(entity)
|
||||
}
|
||||
})
|
||||
|
||||
const obj = {
|
||||
query: '',
|
||||
entities,
|
||||
classification: {
|
||||
package: pkg,
|
||||
module,
|
||||
action,
|
||||
confidence: 1
|
||||
}
|
||||
}
|
||||
const responseData = {
|
||||
package: pkg,
|
||||
module,
|
||||
action,
|
||||
speeches: []
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await brain.execute(obj, { mute: true })
|
||||
|
||||
reply.send({
|
||||
...data,
|
||||
success: true
|
||||
})
|
||||
} catch (e) /* istanbul ignore next */ {
|
||||
log[e.type](e.obj.message)
|
||||
reply.statusCode = 500
|
||||
reply.send({
|
||||
...responseData,
|
||||
speeches: e.speeches,
|
||||
executionTime: e.executionTime,
|
||||
message: e.obj.message,
|
||||
success: false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
handleRoute()
|
||||
setTimeout(() => {
|
||||
reply.statusCode = 408
|
||||
reply.send({
|
||||
package: pkg,
|
||||
module,
|
||||
action,
|
||||
message: 'The action has timed out',
|
||||
timeout,
|
||||
success: false
|
||||
})
|
||||
}, timeout)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap socket
|
||||
*/
|
||||
server.handleOnConnection = (socket) => {
|
||||
log.title('Client')
|
||||
log.success('Connected')
|
||||
|
||||
// Init
|
||||
socket.on('init', async (data) => {
|
||||
log.info(`Type: ${data}`)
|
||||
log.info(`Socket id: ${socket.id}`)
|
||||
|
||||
if (data === 'hotword-node') {
|
||||
// Hotword triggered
|
||||
socket.on('hotword-detected', (data) => {
|
||||
log.title('Socket')
|
||||
log.success(`Hotword ${data.hotword} detected`)
|
||||
|
||||
socket.broadcast.emit('enable-record')
|
||||
})
|
||||
} else {
|
||||
const asr = new Asr()
|
||||
let stt = { }
|
||||
let tts = { }
|
||||
let sttState = 'disabled'
|
||||
let ttsState = 'disabled'
|
||||
|
||||
brain.socket = socket
|
||||
|
||||
/* istanbul ignore if */
|
||||
if (process.env.LEON_STT === 'true') {
|
||||
sttState = 'enabled'
|
||||
|
||||
stt = new Stt(socket, process.env.LEON_STT_PROVIDER)
|
||||
stt.init(() => null)
|
||||
}
|
||||
|
||||
if (process.env.LEON_TTS === 'true') {
|
||||
ttsState = 'enabled'
|
||||
|
||||
tts = new Tts(socket, process.env.LEON_TTS_PROVIDER)
|
||||
tts.init((ttsInstance) => {
|
||||
brain.tts = ttsInstance
|
||||
})
|
||||
}
|
||||
|
||||
log.title('Initialization')
|
||||
log.success(`STT ${sttState}`)
|
||||
log.success(`TTS ${ttsState}`)
|
||||
|
||||
// Listen for new query
|
||||
socket.on('query', async (data) => {
|
||||
log.title('Socket')
|
||||
log.info(`${data.client} emitted: ${data.value}`)
|
||||
|
||||
socket.emit('is-typing', true)
|
||||
await nlu.process(data.value)
|
||||
})
|
||||
|
||||
// Handle automatic speech recognition
|
||||
socket.on('recognize', async (data) => {
|
||||
try {
|
||||
await asr.run(data, stt)
|
||||
} catch (e) {
|
||||
log[e.type](e.obj.message)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch server
|
||||
*/
|
||||
server.listen = async (port) => {
|
||||
const io = process.env.LEON_NODE_ENV === 'development'
|
||||
? socketio(server.httpServer, { cors: { origin: `${process.env.LEON_HOST}:3000` } })
|
||||
: socketio(server.httpServer)
|
||||
|
||||
io.on('connection', server.handleOnConnection)
|
||||
|
||||
await server.fastify.listen(port, '0.0.0.0')
|
||||
log.title('Initialization')
|
||||
log.success(`Server is available at ${process.env.LEON_HOST}:${port}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap API
|
||||
*/
|
||||
server.bootstrap = async () => {
|
||||
const apiVersion = 'v1'
|
||||
|
||||
// Render the web app
|
||||
server.fastify.register(fastifyStatic, {
|
||||
root: join(__dirname, '../../../../app/dist'),
|
||||
prefix: '/'
|
||||
})
|
||||
server.fastify.get('/', (request, reply) => {
|
||||
reply.sendFile('index.html')
|
||||
})
|
||||
|
||||
server.fastify.register(infoPlugin, { apiVersion })
|
||||
server.fastify.register(downloadsPlugin, { apiVersion })
|
||||
|
||||
if (process.env.LEON_OVER_HTTP === 'true') {
|
||||
server.fastify.register((instance, opts, next) => {
|
||||
instance.addHook('preHandler', keyMidd)
|
||||
|
||||
instance.post('/api/query', async (request, reply) => {
|
||||
const { query } = request.body
|
||||
|
||||
try {
|
||||
const data = await nlu.process(query, { mute: true })
|
||||
|
||||
reply.send({
|
||||
...data,
|
||||
success: true
|
||||
})
|
||||
} catch (e) {
|
||||
reply.statusCode = 500
|
||||
reply.send({
|
||||
message: e.message,
|
||||
success: false
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
server.generatePackagesRoutes(instance)
|
||||
|
||||
next()
|
||||
})
|
||||
}
|
||||
|
||||
server.httpServer = server.fastify.server
|
||||
|
||||
try {
|
||||
await server.listen(process.env.LEON_PORT)
|
||||
} catch (e) {
|
||||
log.error(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Server entry point
|
||||
*/
|
||||
server.init = async () => {
|
||||
server.fastify.addHook('onRequest', corsMidd)
|
||||
server.fastify.addHook('preValidation', otherMidd)
|
||||
|
||||
log.title('Initialization')
|
||||
log.success(`The current env is ${process.env.LEON_NODE_ENV}`)
|
||||
log.success(`The current version is ${version}`)
|
||||
|
||||
if (!Object.keys(langs).includes(process.env.LEON_LANG) === true) {
|
||||
process.env.LEON_LANG = 'en-US'
|
||||
log.warning('The language you chose is not supported, then the default language has been applied')
|
||||
}
|
||||
|
||||
log.success(`The current language is ${process.env.LEON_LANG}`)
|
||||
log.success(`The current time zone is ${date.timeZone()}`)
|
||||
|
||||
const sLogger = (process.env.LEON_LOGGER !== 'true') ? 'disabled' : 'enabled'
|
||||
log.success(`Collaborative logger ${sLogger}`)
|
||||
|
||||
brain = new Brain(langs[process.env.LEON_LANG].short)
|
||||
nlu = new Nlu(brain)
|
||||
|
||||
// Train modules expressions
|
||||
try {
|
||||
await nlu.loadModel(join(__dirname, '../../data/leon-model.nlp'))
|
||||
} catch (e) {
|
||||
log[e.type](e.obj.message)
|
||||
}
|
||||
|
||||
await server.bootstrap()
|
||||
}
|
||||
|
||||
export default server
|
@ -55,93 +55,125 @@ class Nlu {
|
||||
* pick-up the right classification
|
||||
* and extract entities
|
||||
*/
|
||||
async process (query) {
|
||||
log.title('NLU')
|
||||
log.info('Processing...')
|
||||
process (query, opts) {
|
||||
const processingTimeStart = Date.now()
|
||||
|
||||
query = string.ucfirst(query)
|
||||
return new Promise(async (resolve, reject) => {
|
||||
log.title('NLU')
|
||||
log.info('Processing...')
|
||||
|
||||
if (Object.keys(this.nlp).length === 0) {
|
||||
this.brain.talk(`${this.brain.wernicke('random_errors')}!`)
|
||||
this.brain.socket.emit('is-typing', false)
|
||||
|
||||
log.error('The NLP model is missing, please rebuild the project or if you are in dev run: npm run train')
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const lang = langs[process.env.LEON_LANG].short
|
||||
const result = await this.nlp.process(lang, query)
|
||||
|
||||
const {
|
||||
domain, intent, score
|
||||
} = result
|
||||
const [moduleName, actionName] = intent.split('.')
|
||||
let obj = {
|
||||
query,
|
||||
entities: [],
|
||||
classification: {
|
||||
package: domain,
|
||||
module: moduleName,
|
||||
action: actionName,
|
||||
confidence: score
|
||||
opts = opts || {
|
||||
mute: false // Close Leon mouth e.g. over HTTP
|
||||
}
|
||||
}
|
||||
query = string.ucfirst(query)
|
||||
|
||||
/* istanbul ignore next */
|
||||
if (process.env.LEON_LOGGER === 'true' && process.env.LEON_NODE_ENV !== 'testing') {
|
||||
this.request
|
||||
.post('https://logger.getleon.ai/v1/expressions')
|
||||
.set('X-Origin', 'leon-core')
|
||||
.send({
|
||||
version,
|
||||
query,
|
||||
if (Object.keys(this.nlp).length === 0) {
|
||||
if (!opts.mute) {
|
||||
this.brain.talk(`${this.brain.wernicke('random_errors')}!`)
|
||||
this.brain.socket.emit('is-typing', false)
|
||||
}
|
||||
|
||||
const msg = 'The NLP model is missing, please rebuild the project or if you are in dev run: npm run train'
|
||||
log.error(msg)
|
||||
return reject(msg)
|
||||
}
|
||||
|
||||
const lang = langs[process.env.LEON_LANG].short
|
||||
const result = await this.nlp.process(lang, query)
|
||||
|
||||
const {
|
||||
domain, intent, score
|
||||
} = result
|
||||
const [moduleName, actionName] = intent.split('.')
|
||||
let obj = {
|
||||
query,
|
||||
entities: [],
|
||||
classification: {
|
||||
package: domain,
|
||||
module: moduleName,
|
||||
action: actionName,
|
||||
confidence: score
|
||||
}
|
||||
}
|
||||
|
||||
/* istanbul ignore next */
|
||||
if (process.env.LEON_LOGGER === 'true' && process.env.LEON_NODE_ENV !== 'testing') {
|
||||
this.request
|
||||
.post('https://logger.getleon.ai/v1/expressions')
|
||||
.set('X-Origin', 'leon-core')
|
||||
.send({
|
||||
version,
|
||||
query,
|
||||
lang,
|
||||
classification: obj.classification
|
||||
})
|
||||
.then(() => { /* */ })
|
||||
.catch(() => { /* */ })
|
||||
}
|
||||
|
||||
if (intent === 'None') {
|
||||
const fallback = Nlu.fallback(obj, langs[process.env.LEON_LANG].fallbacks)
|
||||
|
||||
if (fallback === false) {
|
||||
if (!opts.mute) {
|
||||
this.brain.talk(`${this.brain.wernicke('random_unknown_queries')}.`, true)
|
||||
this.brain.socket.emit('is-typing', false)
|
||||
}
|
||||
|
||||
log.title('NLU')
|
||||
const msg = 'Query not found'
|
||||
log.warning(msg)
|
||||
|
||||
const processingTimeEnd = Date.now()
|
||||
const processingTime = processingTimeEnd - processingTimeStart
|
||||
|
||||
return resolve({
|
||||
processingTime,
|
||||
message: msg
|
||||
})
|
||||
}
|
||||
|
||||
obj = fallback
|
||||
}
|
||||
|
||||
log.title('NLU')
|
||||
log.success('Query found')
|
||||
|
||||
try {
|
||||
obj.entities = await this.ner.extractEntities(
|
||||
lang,
|
||||
classification: obj.classification
|
||||
})
|
||||
.then(() => { /* */ })
|
||||
.catch(() => { /* */ })
|
||||
}
|
||||
join(__dirname, '../../../packages', obj.classification.package, `data/expressions/${lang}.json`),
|
||||
obj
|
||||
)
|
||||
} catch (e) /* istanbul ignore next */ {
|
||||
log[e.type](e.obj.message)
|
||||
|
||||
if (intent === 'None') {
|
||||
const fallback = Nlu.fallback(obj, langs[process.env.LEON_LANG].fallbacks)
|
||||
|
||||
if (fallback === false) {
|
||||
this.brain.talk(`${this.brain.wernicke('random_unknown_queries')}.`, true)
|
||||
this.brain.socket.emit('is-typing', false)
|
||||
|
||||
log.title('NLU')
|
||||
log.warning('Query not found')
|
||||
|
||||
return false
|
||||
if (!opts.mute) {
|
||||
this.brain.talk(`${this.brain.wernicke(e.code, '', e.data)}!`)
|
||||
}
|
||||
}
|
||||
|
||||
obj = fallback
|
||||
}
|
||||
try {
|
||||
// Inject action entities with the others if there is
|
||||
const data = await this.brain.execute(obj, { mute: opts.mute })
|
||||
const processingTimeEnd = Date.now()
|
||||
const processingTime = processingTimeEnd - processingTimeStart
|
||||
|
||||
log.title('NLU')
|
||||
log.success('Query found')
|
||||
return resolve({
|
||||
processingTime, // In ms, total time
|
||||
...data,
|
||||
nluProcessingTime: processingTime - data?.executionTime // In ms, NLU processing time only
|
||||
})
|
||||
} catch (e) /* istanbul ignore next */ {
|
||||
log[e.type](e.obj.message)
|
||||
|
||||
try {
|
||||
obj.entities = await this.ner.extractEntities(
|
||||
lang,
|
||||
join(__dirname, '../../../packages', obj.classification.package, `data/expressions/${lang}.json`),
|
||||
obj
|
||||
)
|
||||
} catch (e) /* istanbul ignore next */ {
|
||||
log[e.type](e.obj.message)
|
||||
this.brain.talk(`${this.brain.wernicke(e.code, '', e.data)}!`)
|
||||
}
|
||||
if (!opts.mute) {
|
||||
this.brain.socket.emit('is-typing', false)
|
||||
}
|
||||
|
||||
try {
|
||||
// Inject action entities with the others if there is
|
||||
await this.brain.execute(obj)
|
||||
} catch (e) /* istanbul ignore next */ {
|
||||
log[e.type](e.obj.message)
|
||||
this.brain.socket.emit('is-typing', false)
|
||||
}
|
||||
|
||||
return true
|
||||
return reject(e.obj)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1,168 +0,0 @@
|
||||
import Fastify from 'fastify'
|
||||
import fastifyStatic from 'fastify-static'
|
||||
import socketio from 'socket.io'
|
||||
import { join } from 'path'
|
||||
|
||||
import { version } from '@@/package.json'
|
||||
import { langs } from '@@/core/langs.json'
|
||||
import Nlu from '@/core/nlu'
|
||||
import Brain from '@/core/brain'
|
||||
import Asr from '@/core/asr'
|
||||
import Stt from '@/stt/stt'
|
||||
import corsMidd from '@/plugins/cors'
|
||||
import otherMidd from '@/plugins/other'
|
||||
import infoPlugin from '@/api/info/index'
|
||||
import downloadsPlugin from '@/api/downloads/index'
|
||||
import log from '@/helpers/log'
|
||||
import date from '@/helpers/date'
|
||||
|
||||
class Server {
|
||||
constructor () {
|
||||
this.fastify = Fastify()
|
||||
this.httpServer = { }
|
||||
this.brain = { }
|
||||
this.nlu = { }
|
||||
this.asr = { }
|
||||
this.stt = { }
|
||||
}
|
||||
|
||||
/**
|
||||
* Server entry point
|
||||
*/
|
||||
async init () {
|
||||
this.fastify.addHook('onRequest', corsMidd)
|
||||
this.fastify.addHook('onRequest', otherMidd)
|
||||
|
||||
log.title('Initialization')
|
||||
log.success(`The current env is ${process.env.LEON_NODE_ENV}`)
|
||||
log.success(`The current version is ${version}`)
|
||||
|
||||
if (!Object.keys(langs).includes(process.env.LEON_LANG) === true) {
|
||||
process.env.LEON_LANG = 'en-US'
|
||||
log.warning('The language you chose is not supported, then the default language has been applied')
|
||||
}
|
||||
|
||||
log.success(`The current language is ${process.env.LEON_LANG}`)
|
||||
log.success(`The current time zone is ${date.timeZone()}`)
|
||||
|
||||
const sLogger = (process.env.LEON_LOGGER !== 'true') ? 'disabled' : 'enabled'
|
||||
log.success(`Collaborative logger ${sLogger}`)
|
||||
|
||||
await this.bootstrap()
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap API
|
||||
*/
|
||||
async bootstrap () {
|
||||
const apiVersion = 'v1'
|
||||
|
||||
// Render the web app
|
||||
this.fastify.register(fastifyStatic, {
|
||||
root: join(__dirname, '..', '..', '..', 'app', 'dist'),
|
||||
prefix: '/'
|
||||
})
|
||||
this.fastify.get('/', (_request, reply) => {
|
||||
reply.sendFile('index.html')
|
||||
})
|
||||
|
||||
this.fastify.register(infoPlugin, { apiVersion })
|
||||
this.fastify.register(downloadsPlugin, { apiVersion })
|
||||
|
||||
this.httpServer = this.fastify.server
|
||||
|
||||
try {
|
||||
await this.listen(process.env.LEON_PORT)
|
||||
} catch (e) {
|
||||
log.error(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch server
|
||||
*/
|
||||
async listen (port) {
|
||||
const io = process.env.LEON_NODE_ENV === 'development'
|
||||
? socketio(this.httpServer, { cors: { origin: `${process.env.LEON_HOST}:3000` } })
|
||||
: socketio(this.httpServer)
|
||||
|
||||
io.on('connection', this.connection)
|
||||
|
||||
await this.fastify.listen(port, '0.0.0.0')
|
||||
log.success(`Server is available at ${process.env.LEON_HOST}:${port}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap socket
|
||||
*/
|
||||
async connection (socket) {
|
||||
log.title('Client')
|
||||
log.success('Connected')
|
||||
|
||||
// Init
|
||||
socket.on('init', async (data) => {
|
||||
log.info(`Type: ${data}`)
|
||||
log.info(`Socket id: ${socket.id}`)
|
||||
|
||||
if (data === 'hotword-node') {
|
||||
// Hotword triggered
|
||||
socket.on('hotword-detected', (data) => {
|
||||
log.title('Socket')
|
||||
log.success(`Hotword ${data.hotword} detected`)
|
||||
|
||||
socket.broadcast.emit('enable-record')
|
||||
})
|
||||
} else {
|
||||
let sttState = 'disabled'
|
||||
let ttsState = 'disabled'
|
||||
|
||||
this.brain = new Brain(socket, langs[process.env.LEON_LANG].short)
|
||||
this.nlu = new Nlu(this.brain)
|
||||
this.asr = new Asr()
|
||||
|
||||
/* istanbul ignore if */
|
||||
if (process.env.LEON_STT === 'true') {
|
||||
sttState = 'enabled'
|
||||
|
||||
this.stt = new Stt(socket, process.env.LEON_STT_PROVIDER)
|
||||
this.stt.init()
|
||||
}
|
||||
|
||||
if (process.env.LEON_TTS === 'true') {
|
||||
ttsState = 'enabled'
|
||||
}
|
||||
|
||||
log.title('Initialization')
|
||||
log.success(`STT ${sttState}`)
|
||||
log.success(`TTS ${ttsState}`)
|
||||
|
||||
// Train modules expressions
|
||||
try {
|
||||
await this.nlu.loadModel(join(__dirname, '../data/leon-model.nlp'))
|
||||
} catch (e) {
|
||||
log[e.type](e.obj.message)
|
||||
}
|
||||
|
||||
// Listen for new query
|
||||
socket.on('query', async (data) => {
|
||||
log.title('Socket')
|
||||
log.info(`${data.client} emitted: ${data.value}`)
|
||||
|
||||
socket.emit('is-typing', true)
|
||||
await this.nlu.process(data.value)
|
||||
})
|
||||
|
||||
// Handle automatic speech recognition
|
||||
socket.on('recognize', async (data) => {
|
||||
try {
|
||||
await this.asr.run(data, this.stt)
|
||||
} catch (e) {
|
||||
log[e.type](e.obj.message)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default Server
|
@ -31,7 +31,7 @@ log.error = (value) => {
|
||||
|
||||
log.warning = (value) => console.warn('\x1b[33m❗ %s\x1b[0m', value)
|
||||
|
||||
log.title = (value) => console.log('\n---\n\n\x1b[7m.: %s :.\x1b[0m\n', value.toUpperCase())
|
||||
log.title = (value) => console.log('\n\n\x1b[7m.: %s :.\x1b[0m', value.toUpperCase())
|
||||
|
||||
log.default = (value) => console.log('%s', value)
|
||||
|
||||
|
@ -1,10 +1,9 @@
|
||||
import dotenv from 'dotenv'
|
||||
|
||||
import Server from '@/core/server'
|
||||
import server from '@/core/http-server/server'
|
||||
|
||||
(async () => {
|
||||
dotenv.config()
|
||||
|
||||
const server = new Server()
|
||||
await server.init()
|
||||
})()
|
||||
|
@ -21,7 +21,7 @@ class Stt {
|
||||
/**
|
||||
* Initialize the STT provider
|
||||
*/
|
||||
init () {
|
||||
init (cb) {
|
||||
log.info('Initializing STT...')
|
||||
|
||||
if (!this.providers.includes(this.provider)) {
|
||||
@ -48,6 +48,8 @@ class Stt {
|
||||
log.title('STT')
|
||||
log.success('STT initialized')
|
||||
|
||||
cb(this)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
@ -24,7 +24,7 @@ class Tts {
|
||||
/**
|
||||
* Initialize the TTS provider
|
||||
*/
|
||||
init () {
|
||||
init (cb) {
|
||||
log.info('Initializing TTS...')
|
||||
|
||||
if (!this.providers.includes(this.provider)) {
|
||||
@ -50,6 +50,8 @@ class Tts {
|
||||
log.title('TTS')
|
||||
log.success('TTS initialized')
|
||||
|
||||
cb(this)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
@ -7,7 +7,8 @@ import Brain from '@/core/brain'
|
||||
jest.setTimeout(60000)
|
||||
|
||||
global.nlu = new Nlu()
|
||||
global.brain = new Brain({ emit: jest.fn() }, 'en')
|
||||
global.brain = new Brain('en')
|
||||
global.brain.socket.emit = jest.fn()
|
||||
global.nlu.brain = { wernicke: jest.fn(), talk: jest.fn(), socket: { emit: jest.fn() } }
|
||||
global.brain.tts = {
|
||||
synthesizer: jest.fn(),
|
||||
|
@ -26,7 +26,7 @@ describe('NLU modules', () => {
|
||||
describe(`${langKeys[i]} language`, () => {
|
||||
const lang = langs[langKeys[i]]
|
||||
const nlu = new Nlu()
|
||||
const brain = new Brain({ emit: jest.fn() }, lang.short)
|
||||
const brain = new Brain(lang.short)
|
||||
let expressionsObj = { }
|
||||
|
||||
nlu.brain = { wernicke: jest.fn(), talk: jest.fn(), socket: { emit: jest.fn() } }
|
||||
|
33
test/e2e/over-http.spec.js
Normal file
33
test/e2e/over-http.spec.js
Normal file
@ -0,0 +1,33 @@
|
||||
import superagent from 'superagent'
|
||||
|
||||
import server from '@/core/http-server/server'
|
||||
|
||||
const urlPrefix = `${process.env.LEON_HOST}:${process.env.LEON_PORT}/api`
|
||||
const queryUrl = `${urlPrefix}/query`
|
||||
const actionModuleUrl = `${urlPrefix}/p/leon/randomnumber/run`;
|
||||
|
||||
/**
|
||||
* Test the query endpoint endpoint over HTTP
|
||||
* and a simple module action over HTTP
|
||||
*/
|
||||
|
||||
(async () => {
|
||||
await server.init()
|
||||
})()
|
||||
|
||||
describe('Over HTTP', () => {
|
||||
test(`Request query endpoint POST ${queryUrl}`, async () => {
|
||||
const { body } = await superagent.post(queryUrl)
|
||||
.send({ query: 'Hello' })
|
||||
.set('X-API-Key', process.env.LEON_HTTP_API_KEY)
|
||||
|
||||
expect(body).toHaveProperty('success', true)
|
||||
})
|
||||
|
||||
test(`Request an action module: GET ${actionModuleUrl}`, async () => {
|
||||
const { body } = await superagent.get(actionModuleUrl)
|
||||
.set('X-API-Key', process.env.LEON_HTTP_API_KEY)
|
||||
|
||||
expect(body).toHaveProperty('success', true)
|
||||
})
|
||||
})
|
@ -5,7 +5,7 @@ import Brain from '@/core/brain'
|
||||
describe('brain', () => {
|
||||
describe('constructor()', () => {
|
||||
test('creates a new instance of Brain', () => {
|
||||
const brain = new Brain({ emit: jest.fn() }, 'en')
|
||||
const brain = new Brain('en')
|
||||
|
||||
expect(brain).toBeInstanceOf(Brain)
|
||||
})
|
||||
@ -13,15 +13,18 @@ describe('brain', () => {
|
||||
|
||||
describe('talk()', () => {
|
||||
test('does not emit answer to the client when the speech is empty', () => {
|
||||
const brain = new Brain({ emit: jest.fn() }, 'en')
|
||||
const brain = new Brain('en')
|
||||
|
||||
brain.socket.emit = jest.fn()
|
||||
|
||||
brain.talk('')
|
||||
expect(brain.socket.emit).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
test('emits string answer to the client', () => {
|
||||
const brain = new Brain({ emit: jest.fn() }, 'en')
|
||||
const brain = new Brain('en')
|
||||
brain.tts = { add: jest.fn() }
|
||||
brain.socket.emit = jest.fn()
|
||||
|
||||
brain.talk('Hello world')
|
||||
expect(brain.tts.add).toHaveBeenCalledTimes(1)
|
||||
@ -31,13 +34,13 @@ describe('brain', () => {
|
||||
|
||||
describe('wernicke()', () => {
|
||||
test('picks specific string according to object properties', () => {
|
||||
const brain = new Brain({ emit: jest.fn() }, 'en')
|
||||
const brain = new Brain('en')
|
||||
|
||||
expect(brain.wernicke('errors', 'not_found', { })).toBe('Sorry, it seems I cannot find that')
|
||||
})
|
||||
|
||||
test('picks random string from an array', () => {
|
||||
const brain = new Brain({ emit: jest.fn() }, 'en')
|
||||
const brain = new Brain('en')
|
||||
|
||||
expect(global.enExpressions.answers.random_errors).toIncludeAnyMembers([brain.wernicke('random_errors', '', { })])
|
||||
})
|
||||
@ -45,7 +48,8 @@ describe('brain', () => {
|
||||
|
||||
describe('execute()', () => {
|
||||
test('asks to repeat', async () => {
|
||||
const brain = new Brain({ emit: jest.fn() }, 'en')
|
||||
const brain = new Brain('en')
|
||||
brain.socket.emit = jest.fn()
|
||||
brain.talk = jest.fn()
|
||||
|
||||
await brain.execute({ classification: { confidence: 0.1 } })
|
||||
@ -54,8 +58,9 @@ describe('brain', () => {
|
||||
.toIncludeAnyMembers([string[0].substr(0, (string[0].length - 1))])
|
||||
})
|
||||
|
||||
test('creates child process', async () => {
|
||||
const brain = new Brain({ emit: jest.fn() }, 'en')
|
||||
test('spawns child process', async () => {
|
||||
const brain = new Brain('en')
|
||||
brain.socket.emit = jest.fn()
|
||||
brain.tts = {
|
||||
synthesizer: jest.fn(),
|
||||
default: jest.fn(),
|
||||
@ -80,7 +85,8 @@ describe('brain', () => {
|
||||
})
|
||||
|
||||
test('executes module', async () => {
|
||||
const brain = new Brain({ emit: jest.fn() }, 'en')
|
||||
const brain = new Brain('en')
|
||||
brain.socket.emit = jest.fn()
|
||||
brain.talk = jest.fn()
|
||||
|
||||
const obj = {
|
||||
@ -107,7 +113,8 @@ describe('brain', () => {
|
||||
})
|
||||
|
||||
test('rejects promise because of spawn failure', async () => {
|
||||
const brain = new Brain({ emit: jest.fn() }, 'en')
|
||||
const brain = new Brain('en')
|
||||
brain.socket.emit = jest.fn()
|
||||
brain.talk = jest.fn()
|
||||
|
||||
const obj = {
|
||||
|
@ -42,19 +42,19 @@ describe('NLU', () => {
|
||||
describe('process()', () => {
|
||||
const nluFallbackTmp = Nlu.fallback
|
||||
|
||||
test('returns false because the NLP model is empty', async () => {
|
||||
test('rejects because the NLP model is empty', async () => {
|
||||
const nlu = new Nlu()
|
||||
nlu.brain = { talk: jest.fn(), wernicke: jest.fn(), socket: { emit: jest.fn() } }
|
||||
|
||||
expect(await nlu.process('Hello')).toBeFalsy()
|
||||
await expect(nlu.process('Hello')).rejects.toEqual('The NLP model is missing, please rebuild the project or if you are in dev run: npm run train')
|
||||
})
|
||||
|
||||
test('returns false because of query not found', async () => {
|
||||
test('resolves with query not found', async () => {
|
||||
const nlu = new Nlu()
|
||||
nlu.brain = { talk: jest.fn(), wernicke: jest.fn(), socket: { emit: jest.fn() } }
|
||||
|
||||
await nlu.loadModel(global.paths.nlp_model)
|
||||
expect(await nlu.process('Unknown query')).toBeFalsy()
|
||||
await expect(nlu.process('Unknown query')).resolves.toHaveProperty('message', 'Query not found')
|
||||
expect(nlu.brain.talk).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
@ -70,7 +70,8 @@ describe('NLU', () => {
|
||||
Nlu.fallback = jest.fn(() => fallbackObj)
|
||||
|
||||
await nlu.loadModel(global.paths.nlp_model)
|
||||
expect(await nlu.process(query)).toBeTruthy()
|
||||
|
||||
await expect(nlu.process(query)).resolves.toHaveProperty('processingTime')
|
||||
expect(nlu.brain.execute.mock.calls[0][0]).toBe(fallbackObj)
|
||||
Nlu.fallback = nluFallbackTmp // Need to give back the real fallback method
|
||||
})
|
||||
@ -80,7 +81,7 @@ describe('NLU', () => {
|
||||
nlu.brain = { execute: jest.fn() }
|
||||
|
||||
await nlu.loadModel(global.paths.nlp_model)
|
||||
expect(await nlu.process('Hello')).toBeTruthy()
|
||||
await expect(nlu.process('Hello')).toResolve()
|
||||
expect(nlu.brain.execute).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
@ -1,77 +1,46 @@
|
||||
import net from 'net'
|
||||
import { EventEmitter } from 'events'
|
||||
|
||||
import Server from '@/core/server'
|
||||
import server from '@/core/http-server/server'
|
||||
|
||||
describe('server', () => {
|
||||
describe('constructor()', () => {
|
||||
test('creates a new instance of Server', () => {
|
||||
const server = new Server()
|
||||
|
||||
expect(server).toBeInstanceOf(Server)
|
||||
expect(server.brain).toBeEmpty()
|
||||
})
|
||||
})
|
||||
|
||||
describe('init()', () => {
|
||||
test('uses default language if there is an unsupported one', async () => {
|
||||
const server = new Server()
|
||||
|
||||
test('uses default language if the given one is unsupported', async () => {
|
||||
server.bootstrap = jest.fn() // Need to mock bootstrap method to not continue the init
|
||||
process.env.LEON_LANG = 'fake-lang'
|
||||
|
||||
await server.init()
|
||||
expect(process.env.LEON_LANG).toBe('en-US')
|
||||
})
|
||||
|
||||
test('initializes server configurations', async () => {
|
||||
await expect(server.init()).resolves.not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('bootstrap()', () => {
|
||||
test('initializes HTTP server', async () => {
|
||||
const server = new Server()
|
||||
|
||||
await server.bootstrap()
|
||||
expect(server.httpServer).not.toBeEmpty()
|
||||
await server.httpServer.close()
|
||||
expect(server.httpServer).not.toBe({ })
|
||||
})
|
||||
})
|
||||
|
||||
describe('listen()', () => {
|
||||
test('listens port already in use', async () => {
|
||||
const fakeServer = net.createServer()
|
||||
|
||||
fakeServer.once('error', (err) => {
|
||||
expect(err.code).toBe('EADDRINUSE')
|
||||
fakeServer.close()
|
||||
})
|
||||
|
||||
const server = new Server()
|
||||
await server.init()
|
||||
|
||||
fakeServer.listen(process.env.LEON_PORT)
|
||||
await server.httpServer.close()
|
||||
})
|
||||
|
||||
test('listens for request', async () => {
|
||||
const server = new Server()
|
||||
console.log = jest.fn()
|
||||
|
||||
await server.listen(process.env.LEON_PORT)
|
||||
expect(console.log.mock.calls[0][1].indexOf(process.env.LEON_PORT)).not.toBe(-1)
|
||||
expect(console.log.mock.calls[1][1].indexOf(`${process.env.LEON_HOST}:${process.env.LEON_PORT}`)).not.toEqual(-1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('connection()', () => {
|
||||
describe('handleOnConnection()', () => {
|
||||
test('initializes main nodes', async () => {
|
||||
const server = new Server()
|
||||
|
||||
await server.init()
|
||||
|
||||
// Mock the WebSocket with an EventEmitter
|
||||
const ee = new EventEmitter()
|
||||
ee.broadcast = { emit: jest.fn() }
|
||||
console.log = jest.fn()
|
||||
|
||||
await server.connection(ee)
|
||||
server.handleOnConnection(ee)
|
||||
|
||||
expect(console.log.mock.calls[0][1]).toBe('CLIENT')
|
||||
console.log = jest.fn()
|
||||
@ -84,9 +53,6 @@ describe('server', () => {
|
||||
console.log = jest.fn()
|
||||
|
||||
ee.emit('init', 'jest')
|
||||
expect(server.brain).not.toBeEmpty()
|
||||
expect(server.nlu).not.toBeEmpty()
|
||||
expect(server.asr).not.toBeEmpty()
|
||||
|
||||
/* setTimeout(() => {
|
||||
ee.emit('query', { client: 'jest', value: 'Hello' })
|
||||
|
@ -19,7 +19,7 @@ describe('STT', () => {
|
||||
test('initializes the STT parser', () => {
|
||||
const stt = new Stt({ }, 'coqui-stt')
|
||||
|
||||
expect(stt.init()).toBeTruthy()
|
||||
expect(stt.init(() => null)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -19,7 +19,7 @@ describe('TTS', () => {
|
||||
test('initializes the TTS synthesizer', () => {
|
||||
const tts = new Tts({ }, 'flite')
|
||||
|
||||
expect(tts.init()).toBeTruthy()
|
||||
expect(tts.init(() => null)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -3,6 +3,7 @@
|
||||
"verbose": true,
|
||||
"notify": false,
|
||||
"collectCoverage": true,
|
||||
"resetMocks": true,
|
||||
"rootDir": "../..",
|
||||
"testMatch": [
|
||||
"<rootDir>/test/unit/**/*.spec.js"
|
||||
@ -18,8 +19,8 @@
|
||||
"coverageDirectory": "<rootDir>/test/coverage",
|
||||
"collectCoverageFrom": [
|
||||
"<rootDir>/server/src/**/*.js",
|
||||
"!<rootDir>/server/src/api/**/*.js",
|
||||
"!<rootDir>/server/src/plugins/**/*.js",
|
||||
"!<rootDir>/server/src/core/http-server/api/**/*.js",
|
||||
"!<rootDir>/server/src/core/http-server/plugins/**/*.js",
|
||||
"!<rootDir>/server/src/stt/google-cloud-stt/**/*.js",
|
||||
"!<rootDir>/server/src/stt/watson-stt/**/*.js",
|
||||
"!<rootDir>/server/src/tts/amazon-polly/**/*.js",
|
||||
|
Loading…
Reference in New Issue
Block a user