1
1
mirror of https://github.com/leon-ai/leon.git synced 2024-12-19 23:01:31 +03:00

Merge branch 'nodejs-bridge' into develop

This commit is contained in:
louistiti 2023-05-07 21:28:28 +08:00
commit 2c2f49290a
No known key found for this signature in database
GPG Key ID: 92CD6A2E497E1669
30 changed files with 588 additions and 88 deletions

View File

@ -11,5 +11,8 @@
"homepage": "https://getleon.ai",
"bugs": {
"url": "https://github.com/leon-ai/leon/issues"
},
"dependencies": {
"axios": "1.4.0"
}
}

View File

@ -0,0 +1,40 @@
import fs from 'node:fs'
import path from 'node:path'
import type { SkillConfigSchema } from '@server/schemas/skill-schemas'
import type { IntentObject } from '@sdk/types'
const {
argv: [, , INTENT_OBJ_FILE_PATH]
} = process
export const INTENT_OBJECT: IntentObject = JSON.parse(
fs.readFileSync(INTENT_OBJ_FILE_PATH as string, 'utf8')
)
export const SKILL_CONFIG: SkillConfigSchema = JSON.parse(
fs.readFileSync(
path.join(
process.cwd(),
'skills',
INTENT_OBJECT.domain,
INTENT_OBJECT.skill,
'config',
INTENT_OBJECT.lang + '.json'
),
'utf8'
)
)
export const SKILL_SRC_CONFIG: Record<string, unknown> = JSON.parse(
fs.readFileSync(
path.join(
process.cwd(),
'skills',
INTENT_OBJECT.domain,
INTENT_OBJECT.skill,
'src',
'config.json'
),
'utf8'
)
).configurations

View File

@ -1,5 +1,47 @@
import { VERSION } from './version'
import path from 'node:path'
console.log('[WIP] Node.js bridge', VERSION)
import type { ActionFunction, ActionParams } from '@sdk/types'
import { INTENT_OBJECT } from '@bridge/constants'
;(async (): Promise<void> => {
const {
domain,
skill,
action,
lang,
utterance,
current_entities,
entities,
current_resolvers,
resolvers,
slots
} = INTENT_OBJECT
// TODO
const params: ActionParams = {
lang,
utterance,
current_entities,
entities,
current_resolvers,
resolvers,
slots
}
try {
const actionModule = await import(
path.join(
process.cwd(),
'skills',
domain,
skill,
'src',
'actions',
`${action}.ts`
)
)
const actionFunction: ActionFunction = actionModule[action]
await actionFunction(params)
} catch (e) {
console.error(`Error while running "${skill}" skill "${action}" action:`, e)
}
})()

View File

@ -0,0 +1,6 @@
// TODO: contains the button API. rendering engine <-> SDK
export class Button {
constructor() {
console.log('Button constructor')
}
}

View File

@ -0,0 +1,5 @@
import { Button } from './button'
export default {
Button
}

View File

@ -0,0 +1,112 @@
import type { AnswerData, AnswerInput, AnswerOutput } from '@sdk/types'
import {
INTENT_OBJECT,
SKILL_CONFIG,
SKILL_SRC_CONFIG
} from '@bridge/constants'
class Leon {
private static instance: Leon
constructor() {
if (!Leon.instance) {
Leon.instance = this
}
}
/**
* Get source configuration
* @example getSRCConfig() // { credentials: { apiKey: 'abc' } }
*/
public getSRCConfig<T>(key: string): T
public getSRCConfig<T extends Record<string, unknown>>(key?: undefined): T
public getSRCConfig<T extends Record<string, unknown> | unknown>(
key?: string
): T {
try {
if (key) {
return SKILL_SRC_CONFIG[key] as T
}
return SKILL_SRC_CONFIG as T
} catch (e) {
console.error('Error while getting source configuration:', e)
return {} as T
}
}
/**
* Apply data to the answer
* @param answerKey The answer key
* @param data The data to apply
* @example setAnswerData('key', { name: 'Leon' })
*/
public setAnswerData(
answerKey: string,
data: AnswerData = null
): string | null {
try {
// In case the answer key is a raw answer
if (SKILL_CONFIG.answers == null || !SKILL_CONFIG.answers[answerKey]) {
return answerKey
}
const answers = SKILL_CONFIG.answers[answerKey] ?? ''
let answer: string
if (Array.isArray(answers)) {
answer = answers[Math.floor(Math.random() * answers.length)] ?? ''
} else {
answer = answers
}
if (data) {
for (const key in data) {
answer = answer.replaceAll(`%${key}%`, String(data[key]))
}
}
if (SKILL_CONFIG.variables) {
const { variables } = SKILL_CONFIG
for (const key in variables) {
answer = answer.replaceAll(`%${key}%`, String(variables[key]))
}
}
return answer
} catch (e) {
console.error('Error while setting answer data:', e)
return null
}
}
/**
* Send an answer to the core
* @param answerInput The answer input
* @example answer({ key: 'greet' }) // 'Hello world'
* @example answer({ key: 'welcome', data: { name: 'Louis' } }) // 'Welcome Louis'
* @example answer({ key: 'confirm', core: { restart: true } }) // 'Would you like to retry?'
*/
public async answer(answerInput: AnswerInput): Promise<void> {
try {
const answerObject: AnswerOutput = {
...INTENT_OBJECT,
output: {
codes: answerInput.key,
speech: this.setAnswerData(answerInput.key, answerInput.data) ?? '',
core: answerInput.core,
options: this.getSRCConfig('options')
}
}
process.stdout.write(JSON.stringify(answerObject))
} catch (e) {
console.error('Error while creating answer:', e)
}
}
}
export const leon = new Leon()

View File

@ -0,0 +1,96 @@
import axios from 'axios'
import type { AxiosInstance } from 'axios'
interface NetworkOptions {
/** `baseURL` will be prepended to `url`. It can be convenient to set `baseURL` for an instance of `Network` to pass relative URLs. */
baseURL?: string
}
interface NetworkRequestOptions {
/** Server URL that will be used for the request. */
url: string
/** Request method to be used when making the request. */
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
/** Data to be sent as the request body. */
data?: Record<string, unknown>
/** Custom headers to be sent. */
headers?: Record<string, string>
}
interface NetworkResponse<ResponseData> {
/** Data provided by the server. */
data: ResponseData
/** HTTP status code from the server response. */
statusCode: number
/** Options that was provided for the request. */
options: NetworkRequestOptions & NetworkOptions
}
export class NetworkError<ResponseErrorData = unknown> extends Error {
public readonly response: NetworkResponse<ResponseErrorData>
public constructor(response: NetworkResponse<ResponseErrorData>) {
super(`[NetworkError]: ${response.statusCode}`)
this.response = response
Object.setPrototypeOf(this, NetworkError.prototype)
}
}
export class Network {
private options: NetworkOptions
private axios: AxiosInstance
public constructor(options: NetworkOptions = {}) {
this.options = options
this.axios = axios.create({
baseURL: this.options.baseURL
})
}
public async request<ResponseData = unknown, ResponseErrorData = unknown>(
options: NetworkRequestOptions
): Promise<NetworkResponse<ResponseData>> {
try {
const response = await this.axios.request<ResponseData>({
url: options.url,
method: options.method.toLowerCase(),
data: options.data,
headers: options.headers
})
return {
data: response.data,
statusCode: response.status,
options: {
...this.options,
...options
}
}
} catch (error) {
let statusCode = 500
let data = {} as ResponseErrorData
if (axios.isAxiosError(error)) {
data = error?.response?.data
statusCode = error?.response?.status ?? 500
}
throw new NetworkError<ResponseErrorData>({
data,
statusCode,
options: {
...this.options,
...options
}
})
}
}
public isNetworkError<ResponseErrorData = unknown>(
error: unknown
): error is NetworkError<ResponseErrorData> {
return error instanceof NetworkError
}
}

View File

@ -0,0 +1,39 @@
/**
* Action types
*/
import type { ActionParams, IntentObject } from '@/core/brain/types'
export type { ActionParams, IntentObject }
export type ActionFunction = (params: ActionParams) => Promise<void>
/**
* Answer types
*/
export interface AnswerOutput extends IntentObject {
output: {
codes: string
speech: string
core?: AnswerCoreData
options: Record<string, string>
}
}
export interface AnswerCoreData {
restart?: boolean
isInActionLoop?: boolean
showNextActionSuggestions?: boolean
showSuggestions?: boolean
}
export interface TextAnswer {
key: string
data?: AnswerData
core?: AnswerCoreData
}
export interface WidgetAnswer {
// TODO
key: 'widget'
data?: AnswerData
core?: AnswerCoreData
}
export type AnswerData = Record<string, string | number> | null
export type AnswerInput = TextAnswer | WidgetAnswer

View File

@ -1,23 +1,24 @@
{
"extends": "@tsconfig/node16-strictest/tsconfig.json",
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist/bin",
"rootDir": "../../",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
"@@/*": ["../../*"],
"@/*": ["../../server/src/*"],
"@server/*": ["../../server/src/*"],
"@bridge/*": ["./src/*"],
"@sdk/*": ["./src/sdk/*"]
},
"ignoreDeprecations": "5.0",
"allowJs": true,
"checkJs": false,
"resolveJsonModule": true
"exactOptionalPropertyTypes": false,
"declaration": true
},
"ts-node": {
"swc": true,
"require": ["tsconfig-paths/register"],
"files": true
},
"files": [],
"include": ["src/**/*"],
"exclude": ["dist"]
"exclude": ["node_modules", "dist"]
}

View File

@ -30,7 +30,7 @@ def translate(key, dict = { }):
"""Pickup the language file according to the cmd arg
and return the value according to the params"""
# "Temporize" for the data buffer ouput on the core
# "Temporize" for the data buffer output on the core
sleep(0.1)
output = ''
@ -82,6 +82,7 @@ def output(type, content = '', core = { }):
'entities': intent_obj['entities'],
'slots': intent_obj['slots'],
'output': {
# TODO: remove type as it is not needed anymore
'type': type,
'codes': codes,
'speech': speech,
@ -90,8 +91,7 @@ def output(type, content = '', core = { }):
}
}))
if (type == 'inter'):
sys.stdout.flush()
sys.stdout.flush()
def http(method, url, headers = None):
"""Send HTTP request with the Leon user agent"""

View File

@ -141,6 +141,11 @@
"route": "/api/action/games/rochambeau/rematch",
"params": []
},
{
"method": "GET",
"route": "/api/action/leon/age/run",
"params": []
},
{
"method": "GET",
"route": "/api/action/leon/color/favorite_color",

View File

@ -117,6 +117,7 @@
"@types/node-wav": "0.0.0",
"@typescript-eslint/eslint-plugin": "5.55.0",
"@typescript-eslint/parser": "5.55.0",
"@vercel/ncc": "0.36.1",
"cli-spinner": "0.2.10",
"eslint": "8.22.0",
"eslint-config-prettier": "8.5.0",

View File

@ -0,0 +1,12 @@
{
"lang": "en",
"domain": "leon",
"skill": "age",
"action": "run",
"utterance": "How old are you?",
"slots": {},
"entities": [],
"current_entities": [],
"resolvers": [],
"current_resolvers": []
}

View File

@ -137,19 +137,25 @@ BUILD_TARGETS.set('tcp-server', {
* Build for binaries not requiring a Python environment
*/
try {
const tsconfigPath = path.join(NODEJS_BRIDGE_ROOT_PATH, 'tsconfig.json')
const distBinPath = path.join(NODEJS_BRIDGE_DIST_PATH, 'bin')
const distMainFilePath = path.join(
NODEJS_BRIDGE_DIST_PATH,
'bin',
'main.js'
distBinPath,
'index.js'
)
const distRenamedMainFilePath = path.join(
NODEJS_BRIDGE_DIST_PATH,
'bin',
distBinPath,
NODEJS_BRIDGE_BIN_NAME
)
await command(`tsc --project ${tsconfigPath}`, {
await fs.promises.rm(buildPath, { recursive: true, force: true })
const inputMainFilePath = path.join(
NODEJS_BRIDGE_ROOT_PATH,
'src',
'main.ts'
)
await command(`ncc build ${inputMainFilePath} --out ${distBinPath}`, {
shell: true,
stdio: 'inherit'
})

View File

@ -16,9 +16,11 @@ import { SystemHelper } from '@/helpers/system-helper'
import {
MINIMUM_REQUIRED_RAM,
LEON_VERSION,
NODEJS_BRIDGE_BIN_PATH,
PYTHON_BRIDGE_BIN_PATH,
TCP_SERVER_BIN_PATH,
TCP_SERVER_VERSION,
NODEJS_BRIDGE_VERSION,
PYTHON_BRIDGE_VERSION,
INSTANCE_ID
} from '@/constants'
@ -102,6 +104,13 @@ dotenv.config()
skillsResolversModelState: null,
mainModelState: null
},
nodeJSBridge: {
version: null,
executionTime: null,
command: null,
output: null,
error: null
},
pythonBridge: {
version: null,
executionTime: null,
@ -209,7 +218,41 @@ dotenv.config()
})
/**
* Skill execution checking
* Skill execution checking with Node.js bridge
*/
LogHelper.success(`Node.js bridge version: ${NODEJS_BRIDGE_VERSION}`)
reportDataInput.nodeJSBridge.version = NODEJS_BRIDGE_VERSION
LogHelper.info('Executing a skill...')
try {
const executionStart = Date.now()
const p = await command(
`${NODEJS_BRIDGE_BIN_PATH} "${path.join(
process.cwd(),
'scripts',
'assets',
'nodejs-bridge-intent-object.json'
)}"`,
{ shell: true }
)
const executionEnd = Date.now()
const executionTime = executionEnd - executionStart
LogHelper.info(p.command)
reportDataInput.nodeJSBridge.command = p.command
LogHelper.success(p.stdout)
reportDataInput.nodeJSBridge.output = p.stdout
LogHelper.info(`Skill execution time: ${executionTime}ms\n`)
reportDataInput.nodeJSBridge.executionTime = `${executionTime}ms`
} catch (e) {
LogHelper.info(e.command)
report.can_run_skill.v = false
LogHelper.error(`${e}\n`)
reportDataInput.nodeJSBridge.error = JSON.stringify(e)
}
/**
* Skill execution checking with Python bridge
*/
LogHelper.success(`Python bridge version: ${PYTHON_BRIDGE_VERSION}`)
@ -223,7 +266,7 @@ dotenv.config()
process.cwd(),
'scripts',
'assets',
'intent-object.json'
'python-bridge-intent-object.json'
)}"`,
{ shell: true }
)

View File

@ -72,11 +72,13 @@ export const PYTHON_BRIDGE_BIN_PATH = path.join(
BINARIES_FOLDER_NAME,
PYTHON_BRIDGE_BIN_NAME
)
export const NODEJS_BRIDGE_BIN_PATH = `${process.execPath} ${path.join(
NODEJS_BRIDGE_DIST_PATH,
'bin',
NODEJS_BRIDGE_BIN_NAME
)}`
export const NODEJS_BRIDGE_BIN_PATH = `${path.join(
process.cwd(),
'node_modules',
'ts-node',
'dist',
'bin.js'
)} --swc ${path.join(NODEJS_BRIDGE_DIST_PATH, 'bin', NODEJS_BRIDGE_BIN_NAME)}`
export const LEON_VERSION = process.env['npm_package_version']

View File

@ -15,11 +15,7 @@ import type {
IntentObject,
SkillResult
} from '@/core/brain/types'
import {
SkillActionTypes,
SkillBridges,
SkillOutputTypes
} from '@/core/brain/types'
import { SkillActionTypes, SkillBridges } from '@/core/brain/types'
import { langs } from '@@/core/langs.json'
import {
HAS_TTS,
@ -199,18 +195,15 @@ export default class Brain {
const obj = JSON.parse(data.toString())
if (typeof obj === 'object') {
if (obj.output.type === SkillOutputTypes.Intermediate) {
LogHelper.title(`${this.skillFriendlyName} skill`)
LogHelper.info(data.toString())
LogHelper.title(`${this.skillFriendlyName} skill (on data)`)
LogHelper.info(data.toString())
const speech = obj.output.speech.toString()
if (!this.isMuted) {
this.talk(speech)
}
this.speeches.push(speech)
} else {
this.skillOutput = data.toString()
const speech = obj.output.speech.toString()
if (!this.isMuted) {
this.talk(speech)
}
this.speeches.push(speech)
this.skillOutput = data.toString()
return Promise.resolve(null)
} else {
@ -388,7 +381,7 @@ export default class Brain {
// Catch the end of the skill execution
this.skillProcess?.stdout.on('end', () => {
LogHelper.title(`${this.skillFriendlyName} skill`)
LogHelper.title(`${this.skillFriendlyName} skill (on end)`)
LogHelper.info(this.skillOutput)
let skillResult: SkillResult | undefined = undefined
@ -398,36 +391,26 @@ export default class Brain {
try {
skillResult = JSON.parse(this.skillOutput)
if (skillResult?.output.speech) {
skillResult.output.speech =
skillResult.output.speech.toString()
if (!this.isMuted) {
this.talk(skillResult.output.speech, true)
}
speeches.push(skillResult.output.speech)
// Synchronize the downloaded content if enabled
if (
skillResult &&
skillResult.output.options['synchronization'] &&
skillResult.output.options['synchronization'].enabled &&
skillResult.output.options['synchronization'].enabled === true
) {
const sync = new Synchronizer(
this,
nluResult.classification,
skillResult.output.options['synchronization']
)
// Synchronize the downloaded content if enabled
if (
skillResult.output.type === SkillOutputTypes.End &&
skillResult.output.options['synchronization'] &&
skillResult.output.options['synchronization'].enabled &&
skillResult.output.options['synchronization'].enabled ===
true
) {
const sync = new Synchronizer(
this,
nluResult.classification,
skillResult.output.options['synchronization']
)
// When the synchronization is finished
sync.synchronize((speech: string) => {
if (!this.isMuted) {
this.talk(speech)
}
speeches.push(speech)
})
}
// When the synchronization is finished
sync.synchronize((speech: string) => {
if (!this.isMuted) {
this.talk(speech)
}
speeches.push(speech)
})
}
} catch (e) {
LogHelper.title(`${this.skillFriendlyName} skill`)

View File

@ -28,7 +28,6 @@ export interface SkillResult {
entities: NEREntity[]
slots: NLUSlots
output: {
type: SkillOutputTypes
codes: string[]
speech: string
core: SkillCoreData | undefined
@ -41,21 +40,13 @@ export enum SkillBridges {
Python = 'python',
NodeJS = 'nodejs'
}
export enum SkillOutputTypes {
Intermediate = 'inter',
End = 'end'
}
export enum SkillActionTypes {
Logic = 'logic',
Dialog = 'dialog'
}
export interface IntentObject {
id: string
export interface ActionParams {
lang: ShortLanguageCode
domain: NLPDomain
skill: NLPSkill
action: NLPAction
utterance: NLPUtterance
current_entities: NEREntity[]
entities: NEREntity[]
@ -64,6 +55,13 @@ export interface IntentObject {
slots: { [key: string]: NLUSlot['value'] | undefined }
}
export interface IntentObject extends ActionParams {
id: string
domain: NLPDomain
skill: NLPSkill
action: NLPAction
}
export interface BrainProcessResult extends NLUResult {
speeches: string[]
executionTime: number

View File

@ -8,7 +8,7 @@ enum OSNames {
Linux = 'Linux',
Unknown = 'Unknown'
}
enum BinaryFolderNames {
export enum BinaryFolderNames {
Linux64Bit = 'linux-x86_64', // Linux 64-bit (Intel)
LinuxARM64 = 'linux-aarch64', // Linux 64-bit (ARM)
MacOS64Bit = 'macosx-x86_64', // Apple 64-bit (Intel)

View File

View File

@ -0,0 +1,14 @@
{
"$schema": "../../../../schemas/skill-schemas/skill-config.json",
"actions": {
"run": {
"type": "logic",
"utterance_samples": ["How old are you?"]
}
},
"answers": {
"default": ["I'm..."],
"greet": ["Hey, just a try %name% again %name%", "Another try, hi"],
"answer": ["%answer%"]
}
}

View File

View File

@ -0,0 +1,12 @@
{
"$schema": "../../../schemas/skill-schemas/skill.json",
"name": "Age",
"bridge": "nodejs",
"version": "1.0.0",
"description": "Leon tells his age.",
"author": {
"name": "Louis Grenard",
"email": "louis@getleon.ai",
"url": "https://github.com/louistiti"
}
}

View File

@ -0,0 +1,62 @@
import type { ActionFunction } from '@sdk/types'
import { leon } from '@sdk/leon'
import { Network } from '@sdk/network'
import { Button } from '@sdk/aurora/button'
export const run: ActionFunction = async function () {
await leon.answer({ key: 'default' })
await leon.answer({
key: 'greet',
data: {
name: 'Louis'
}
})
console.log('button', Button)
const { someSampleConfig } = leon.getSRCConfig<{
options: { someSampleConfig: string }
}>()['options']
const options = leon.getSRCConfig<{ someSampleConfig: string }>('options')
await leon.answer({
key: 'answer',
data: {
answer: options.someSampleConfig + someSampleConfig
}
})
const network = new Network({
baseURL: 'https://jsonplaceholder.typicode.com'
})
try {
const response = await network.request<{ title: string }>({
url: '/todos/1',
method: 'GET'
})
await leon.answer({
key: 'answer',
data: {
answer: `Todo: ${response.data.title}`
}
})
} catch (error) {
await leon.answer({
key: 'answer',
data: {
answer: 'Something went wrong...'
}
})
if (network.isNetworkError(error)) {
const errorData = JSON.stringify(error.response.data, null, 2)
await leon.answer({
key: 'answer',
data: {
answer: `${error.message}: ${errorData}`
}
})
}
}
}

View File

@ -0,0 +1,6 @@
{
"configurations": {
"options": {},
"credentials": {}
}
}

View File

View File

9
skills/tsconfig.json Normal file
View File

@ -0,0 +1,9 @@
{
"extends": "@tsconfig/node16-strictest/tsconfig.json",
"compilerOptions": {
"paths": {
"@bridge/*": ["../bridges/nodejs/src/*"],
"@sdk/*": ["../bridges/nodejs/src/sdk/*"]
}
}
}

View File

@ -6,12 +6,15 @@
"baseUrl": ".",
"paths": {
"@@/*": ["./*"],
"@/*": ["./server/src/*"]
"@/*": ["./server/src/*"],
"@bridge/*": ["./bridges/nodejs/src/*"],
"@sdk/*": ["./bridges/nodejs/src/sdk/*"]
},
"ignoreDeprecations": "5.0",
"allowJs": true,
"checkJs": false,
"resolveJsonModule": true
"resolveJsonModule": true,
"declaration": true
},
"ts-node": {
"swc": true,
@ -20,5 +23,5 @@
},
"files": ["server/src/global.d.ts"],
"include": ["server/src/**/*"],
"exclude": ["node_modules", "server/dist", "bridges/python", "tcp_server"]
"exclude": ["node_modules", "server/dist", "bridges", "tcp_server"]
}