chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
package-lock=false
|
||||
save-exact=true
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "leon-nodejs-bridge",
|
||||
"description": "Leon's Node.js bridge to communicate between the core and skills made with JavaScript",
|
||||
"main": "dist/bin/leon-nodejs-bridge.js",
|
||||
"type": "module",
|
||||
"author": {
|
||||
"name": "Louis Grenard",
|
||||
"email": "louis@getleon.ai",
|
||||
"url": "https://twitter.com/grenlouis"
|
||||
},
|
||||
"license": "MIT",
|
||||
"homepage": "https://getleon.ai",
|
||||
"bugs": {
|
||||
"url": "https://github.com/leon-ai/leon/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "1.15.0",
|
||||
"lodash": "4.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash": "4.14.194"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import type { SkillLocaleConfigSchema } from '@/schemas/skill-schemas'
|
||||
import type { IntentObject, NLPAction } from '@sdk/types'
|
||||
import {
|
||||
CODEBASE_PATH,
|
||||
LEON_HOME_PATH,
|
||||
LEON_PROFILE_PATH
|
||||
} from '@@/server/src/leon-roots'
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const runtimeIndex = args.indexOf('--runtime')
|
||||
const runtime =
|
||||
runtimeIndex >= 0 && args[runtimeIndex + 1] ? args[runtimeIndex + 1] : 'skill'
|
||||
const filteredArgs = args.filter((_, index) => {
|
||||
if (index === runtimeIndex || index === runtimeIndex + 1) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
const intentPathCandidate = filteredArgs.find((arg) => !arg.startsWith('--'))
|
||||
const INTENT_OBJ_FILE_PATH =
|
||||
runtime === 'skill' ? intentPathCandidate : undefined
|
||||
|
||||
export const LEON_VERSION = process.env['npm_package_version']
|
||||
|
||||
export const RUNTIME = runtime
|
||||
|
||||
export {
|
||||
CODEBASE_PATH,
|
||||
LEON_HOME_PATH,
|
||||
LEON_PROFILE_PATH
|
||||
}
|
||||
|
||||
export const LEON_TOOLKITS_PATH = path.join(LEON_HOME_PATH, 'toolkits')
|
||||
export const CODEBASE_CONTEXT_PATH = path.join(CODEBASE_PATH, 'core', 'context')
|
||||
export const PROFILE_CONTEXT_PATH = path.join(LEON_PROFILE_PATH, 'context')
|
||||
export const PROFILE_MEMORY_PATH = path.join(LEON_PROFILE_PATH, 'memory')
|
||||
export const PROFILE_MEMORY_DB_PATH = path.join(
|
||||
PROFILE_MEMORY_PATH,
|
||||
'index.sqlite'
|
||||
)
|
||||
export const PROFILE_SKILLS_PATH = path.join(LEON_PROFILE_PATH, 'skills')
|
||||
export const PROFILE_NATIVE_SKILLS_PATH = path.join(
|
||||
PROFILE_SKILLS_PATH,
|
||||
'native'
|
||||
)
|
||||
export const PROFILE_AGENT_SKILLS_PATH = path.join(
|
||||
PROFILE_SKILLS_PATH,
|
||||
'agent'
|
||||
)
|
||||
export const PROFILE_TOOLS_PATH = path.join(LEON_PROFILE_PATH, 'tools')
|
||||
|
||||
const BIN_PATH = path.join(LEON_HOME_PATH, 'bin')
|
||||
const BRIDGES_PATH = path.join(CODEBASE_PATH, 'bridges')
|
||||
const NODEJS_BRIDGE_ROOT_PATH = path.join(BRIDGES_PATH, 'nodejs')
|
||||
const NODEJS_BRIDGE_SRC_PATH = path.join(NODEJS_BRIDGE_ROOT_PATH, 'src')
|
||||
const NODEJS_BRIDGE_VERSION_FILE_PATH = path.join(
|
||||
NODEJS_BRIDGE_SRC_PATH,
|
||||
'version.ts'
|
||||
)
|
||||
|
||||
export const TOOLS_PATH = path.join(CODEBASE_PATH, 'tools')
|
||||
export const PROFILE_DISABLED_PATH = path.join(LEON_PROFILE_PATH, 'disabled.json')
|
||||
export const PROFILE_ALLOWED_PATH = path.join(LEON_PROFILE_PATH, 'allowed.json')
|
||||
|
||||
export const [, NODEJS_BRIDGE_VERSION] = fs
|
||||
.readFileSync(NODEJS_BRIDGE_VERSION_FILE_PATH, 'utf8')
|
||||
.split('\'')
|
||||
|
||||
let parsedIntentObject: IntentObject | null = null
|
||||
if (INTENT_OBJ_FILE_PATH) {
|
||||
if (!fs.existsSync(INTENT_OBJ_FILE_PATH)) {
|
||||
throw new Error(`Intent file not found: ${INTENT_OBJ_FILE_PATH}`)
|
||||
}
|
||||
parsedIntentObject = JSON.parse(
|
||||
fs.readFileSync(INTENT_OBJ_FILE_PATH, 'utf8')
|
||||
) as IntentObject
|
||||
}
|
||||
|
||||
export const INTENT_OBJECT: IntentObject = parsedIntentObject
|
||||
? parsedIntentObject
|
||||
: ({} as IntentObject)
|
||||
|
||||
export const NVIDIA_LIBS_PATH = path.join(BIN_PATH, 'nvidia')
|
||||
|
||||
export const PYTORCH_PATH = path.join(BIN_PATH, 'pytorch')
|
||||
export const PYTORCH_TORCH_PATH = path.join(PYTORCH_PATH, 'torch')
|
||||
|
||||
export const SKILLS_PATH = path.join(CODEBASE_PATH, 'skills')
|
||||
export const NATIVE_SKILLS_PATH = path.join(SKILLS_PATH, 'native')
|
||||
export const AGENT_SKILLS_PATH = path.join(SKILLS_PATH, 'agent')
|
||||
export const SKILL_PATH =
|
||||
runtime === 'skill' && parsedIntentObject
|
||||
? path.dirname(parsedIntentObject.skill_config_path)
|
||||
: ''
|
||||
const SKILL_LOCALE_CONFIG_CONTENT =
|
||||
runtime === 'skill' && INTENT_OBJ_FILE_PATH && parsedIntentObject
|
||||
? ((): SkillLocaleConfigSchema => {
|
||||
const skillLocalePath = path.join(
|
||||
SKILL_PATH,
|
||||
'locales',
|
||||
parsedIntentObject.extra_context.lang + '.json'
|
||||
)
|
||||
return JSON.parse(
|
||||
fs.existsSync(skillLocalePath)
|
||||
? fs.readFileSync(skillLocalePath, 'utf8')
|
||||
: `{"variables": {}, "common_answers": {}, "widget_contents": {}, "actions": {"${parsedIntentObject.action_name}": {}}}`
|
||||
) as SkillLocaleConfigSchema
|
||||
})()
|
||||
: {
|
||||
variables: {},
|
||||
common_answers: {},
|
||||
widget_contents: {},
|
||||
actions: {}
|
||||
} satisfies SkillLocaleConfigSchema
|
||||
|
||||
export const SKILL_LOCALE_CONFIG = {
|
||||
variables: SKILL_LOCALE_CONFIG_CONTENT.variables,
|
||||
common_answers: SKILL_LOCALE_CONFIG_CONTENT.common_answers,
|
||||
widget_contents: SKILL_LOCALE_CONFIG_CONTENT.widget_contents,
|
||||
...((runtime === 'skill' && parsedIntentObject
|
||||
? SKILL_LOCALE_CONFIG_CONTENT.actions[
|
||||
parsedIntentObject.action_name as NLPAction
|
||||
]
|
||||
: {}) || {})
|
||||
} as SkillLocaleConfigSchema & SkillLocaleConfigSchema['actions'][NLPAction]
|
||||
@@ -0,0 +1,97 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import { FileHelper } from '@/helpers/file-helper'
|
||||
|
||||
import type { ActionFunction, ActionParams } from '@sdk/types'
|
||||
import { INTENT_OBJECT, SKILL_PATH } from '@bridge/constants'
|
||||
import { ParamsHelper } from '@sdk/params-helper'
|
||||
import { leon } from '@sdk/leon'
|
||||
import { setToolReporter } from '@sdk/tool-reporter'
|
||||
|
||||
const resolveActionFunction = (actionModule: unknown): ActionFunction | null => {
|
||||
if (!actionModule || typeof actionModule !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const moduleObject = actionModule as Record<string, unknown>
|
||||
|
||||
if (typeof moduleObject['run'] === 'function') {
|
||||
return moduleObject['run'] as ActionFunction
|
||||
}
|
||||
|
||||
const defaultExport =
|
||||
moduleObject['default'] && typeof moduleObject['default'] === 'object'
|
||||
? (moduleObject['default'] as Record<string, unknown>)
|
||||
: null
|
||||
|
||||
if (defaultExport && typeof defaultExport['run'] === 'function') {
|
||||
return defaultExport['run'] as ActionFunction
|
||||
}
|
||||
|
||||
if (typeof moduleObject['default'] === 'function') {
|
||||
return moduleObject['default'] as ActionFunction
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
setToolReporter(async (input) => {
|
||||
await leon.answer(input)
|
||||
})
|
||||
|
||||
const {
|
||||
lang,
|
||||
sentiment,
|
||||
context_name,
|
||||
skill_name,
|
||||
action_name,
|
||||
skill_config_path,
|
||||
extra_context
|
||||
} = INTENT_OBJECT
|
||||
|
||||
const params: ActionParams = {
|
||||
lang,
|
||||
utterance: INTENT_OBJECT.utterance as ActionParams['utterance'],
|
||||
action_arguments:
|
||||
INTENT_OBJECT.action_arguments as ActionParams['action_arguments'],
|
||||
entities: INTENT_OBJECT.entities as ActionParams['entities'],
|
||||
sentiment,
|
||||
context_name,
|
||||
skill_name,
|
||||
action_name,
|
||||
context: INTENT_OBJECT.context as ActionParams['context'],
|
||||
skill_config: INTENT_OBJECT.skill_config as ActionParams['skill_config'],
|
||||
skill_config_path,
|
||||
extra_context
|
||||
}
|
||||
|
||||
try {
|
||||
const actionModule = await FileHelper.dynamicImportFromFile(
|
||||
path.join(
|
||||
SKILL_PATH,
|
||||
'src',
|
||||
'actions',
|
||||
`${action_name}.ts`
|
||||
)
|
||||
)
|
||||
const actionFunction = resolveActionFunction(actionModule)
|
||||
|
||||
if (!actionFunction) {
|
||||
throw new TypeError(
|
||||
`Action "${skill_name}:${action_name}" does not export a runnable action function`
|
||||
)
|
||||
}
|
||||
|
||||
const paramsHelper = new ParamsHelper(params)
|
||||
|
||||
await actionFunction(params, paramsHelper)
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Error while running "${skill_name}" skill "${action_name}" action:`,
|
||||
e
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
void main()
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type ButtonProps } from '@aurora'
|
||||
|
||||
import { WidgetComponent } from '../widget-component'
|
||||
|
||||
export class Button extends WidgetComponent<ButtonProps> {
|
||||
constructor(props: ButtonProps) {
|
||||
super(props)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type CardProps } from '@aurora'
|
||||
|
||||
import { WidgetComponent } from '../widget-component'
|
||||
|
||||
export class Card extends WidgetComponent<CardProps> {
|
||||
constructor(props: CardProps) {
|
||||
super(props)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type CheckboxProps } from '@aurora'
|
||||
|
||||
import { WidgetComponent } from '../widget-component'
|
||||
|
||||
export class Checkbox extends WidgetComponent<CheckboxProps> {
|
||||
constructor(props: CheckboxProps) {
|
||||
super(props)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type CircularProgressProps } from '@aurora'
|
||||
|
||||
import { WidgetComponent } from '../widget-component'
|
||||
|
||||
export class CircularProgress extends WidgetComponent<CircularProgressProps> {
|
||||
constructor(props: CircularProgressProps) {
|
||||
super(props)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type FlexboxProps } from '@aurora'
|
||||
|
||||
import { WidgetComponent } from '../widget-component'
|
||||
|
||||
export class Flexbox extends WidgetComponent<FlexboxProps> {
|
||||
constructor(props: FlexboxProps) {
|
||||
super(props)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type FormProps } from '@aurora'
|
||||
|
||||
import { WidgetComponent } from '../widget-component'
|
||||
|
||||
export class Form extends WidgetComponent<FormProps> {
|
||||
constructor(props: FormProps) {
|
||||
super(props)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type IconButtonProps } from '@aurora'
|
||||
|
||||
import { WidgetComponent } from '../widget-component'
|
||||
|
||||
export class IconButton extends WidgetComponent<IconButtonProps> {
|
||||
constructor(props: IconButtonProps) {
|
||||
super(props)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type IconProps } from '@aurora'
|
||||
|
||||
import { WidgetComponent } from '../widget-component'
|
||||
|
||||
export class Icon extends WidgetComponent<IconProps> {
|
||||
constructor(props: IconProps) {
|
||||
super(props)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type ImageProps } from '@aurora'
|
||||
|
||||
import { WidgetComponent } from '../widget-component'
|
||||
|
||||
export class Image extends WidgetComponent<ImageProps> {
|
||||
constructor(props: ImageProps) {
|
||||
super(props)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export * from './button'
|
||||
export * from './card'
|
||||
export * from './checkbox'
|
||||
export * from './circular-progress'
|
||||
export * from './flexbox'
|
||||
export * from './form'
|
||||
export * from './icon'
|
||||
export * from './icon-button'
|
||||
export * from './image'
|
||||
export * from './input'
|
||||
export * from './link'
|
||||
export * from './list'
|
||||
export * from './list-header'
|
||||
export * from './list-item'
|
||||
export * from './loader'
|
||||
export * from './progress'
|
||||
export * from './radio'
|
||||
export * from './radio-group'
|
||||
export * from './range-slider'
|
||||
export * from './scroll-container'
|
||||
export * from './select'
|
||||
export * from './select-option'
|
||||
export * from './status'
|
||||
export * from './switch'
|
||||
export * from './tab'
|
||||
export * from './tab-content'
|
||||
export * from './tab-group'
|
||||
export * from './tab-list'
|
||||
export * from './text'
|
||||
export * from './widget-wrapper'
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type InputProps } from '@aurora'
|
||||
|
||||
import { WidgetComponent } from '../widget-component'
|
||||
|
||||
export class Input extends WidgetComponent<InputProps> {
|
||||
constructor(props: InputProps) {
|
||||
super(props)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type LinkProps } from '@aurora'
|
||||
|
||||
import { WidgetComponent } from '../widget-component'
|
||||
|
||||
export class Link extends WidgetComponent<LinkProps> {
|
||||
constructor(props: LinkProps) {
|
||||
super(props)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type ListHeaderProps } from '@aurora'
|
||||
|
||||
import { WidgetComponent } from '../widget-component'
|
||||
|
||||
export class ListHeader extends WidgetComponent<ListHeaderProps> {
|
||||
constructor(props: ListHeaderProps) {
|
||||
super(props)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type ListItemProps } from '@aurora'
|
||||
|
||||
import { WidgetComponent } from '../widget-component'
|
||||
|
||||
export class ListItem extends WidgetComponent<ListItemProps> {
|
||||
constructor(props: ListItemProps) {
|
||||
super(props)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type ListProps } from '@aurora'
|
||||
|
||||
import { WidgetComponent } from '../widget-component'
|
||||
|
||||
export class List extends WidgetComponent<ListProps> {
|
||||
constructor(props: ListProps) {
|
||||
super(props)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type LoaderProps } from '@aurora'
|
||||
|
||||
import { WidgetComponent } from '../widget-component'
|
||||
|
||||
export class Loader extends WidgetComponent<LoaderProps> {
|
||||
constructor(props: LoaderProps) {
|
||||
super(props)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type ProgressProps } from '@aurora'
|
||||
|
||||
import { WidgetComponent } from '../widget-component'
|
||||
|
||||
export class Progress extends WidgetComponent<ProgressProps> {
|
||||
constructor(props: ProgressProps) {
|
||||
super(props)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type RadioGroupProps } from '@aurora'
|
||||
|
||||
import { WidgetComponent } from '../widget-component'
|
||||
|
||||
export class RadioGroup extends WidgetComponent<RadioGroupProps> {
|
||||
constructor(props: RadioGroupProps) {
|
||||
super(props)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type RadioProps } from '@aurora'
|
||||
|
||||
import { WidgetComponent } from '../widget-component'
|
||||
|
||||
export class Radio extends WidgetComponent<RadioProps> {
|
||||
constructor(props: RadioProps) {
|
||||
super(props)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type RangeSliderProps } from '@aurora'
|
||||
|
||||
import { WidgetComponent } from '../widget-component'
|
||||
|
||||
export class RangeSlider extends WidgetComponent<RangeSliderProps> {
|
||||
constructor(props: RangeSliderProps) {
|
||||
super(props)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type ScrollContainerProps } from '@aurora'
|
||||
|
||||
import { WidgetComponent } from '../widget-component'
|
||||
|
||||
export class ScrollContainer extends WidgetComponent<ScrollContainerProps> {
|
||||
constructor(props: ScrollContainerProps) {
|
||||
super(props)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type SelectOptionProps } from '@aurora'
|
||||
|
||||
import { WidgetComponent } from '../widget-component'
|
||||
|
||||
export class SelectOption extends WidgetComponent<SelectOptionProps> {
|
||||
constructor(props: SelectOptionProps) {
|
||||
super(props)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type SelectProps } from '@aurora'
|
||||
|
||||
import { WidgetComponent } from '../widget-component'
|
||||
|
||||
export class Select extends WidgetComponent<SelectProps> {
|
||||
constructor(props: SelectProps) {
|
||||
super(props)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type StatusProps } from '@aurora'
|
||||
|
||||
import { WidgetComponent } from '../widget-component'
|
||||
|
||||
export class Status extends WidgetComponent<StatusProps> {
|
||||
constructor(props: StatusProps) {
|
||||
super(props)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type SwitchProps } from '@aurora'
|
||||
|
||||
import { WidgetComponent } from '../widget-component'
|
||||
|
||||
export class Switch extends WidgetComponent<SwitchProps> {
|
||||
constructor(props: SwitchProps) {
|
||||
super(props)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type TabContentProps } from '@aurora'
|
||||
|
||||
import { WidgetComponent } from '../widget-component'
|
||||
|
||||
export class TabContent extends WidgetComponent<TabContentProps> {
|
||||
constructor(props: TabContentProps) {
|
||||
super(props)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type TabGroupProps } from '@aurora'
|
||||
|
||||
import { WidgetComponent } from '../widget-component'
|
||||
|
||||
export class TabGroup extends WidgetComponent<TabGroupProps> {
|
||||
constructor(props: TabGroupProps) {
|
||||
super(props)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type TabListProps } from '@aurora'
|
||||
|
||||
import { WidgetComponent } from '../widget-component'
|
||||
|
||||
export class TabList extends WidgetComponent<TabListProps> {
|
||||
constructor(props: TabListProps) {
|
||||
super(props)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type TabProps } from '@aurora'
|
||||
|
||||
import { WidgetComponent } from '../widget-component'
|
||||
|
||||
export class Tab extends WidgetComponent<TabProps> {
|
||||
constructor(props: TabProps) {
|
||||
super(props)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type TextProps } from '@aurora'
|
||||
|
||||
import { WidgetComponent } from '../widget-component'
|
||||
|
||||
export class Text extends WidgetComponent<TextProps> {
|
||||
constructor(props: TextProps) {
|
||||
super(props)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type WidgetWrapperProps } from '@aurora'
|
||||
|
||||
import { WidgetComponent } from '../widget-component'
|
||||
|
||||
export class WidgetWrapper extends WidgetComponent<WidgetWrapperProps> {
|
||||
constructor(props: WidgetWrapperProps) {
|
||||
super(props)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,202 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import type {
|
||||
AnswerData,
|
||||
AnswerInput,
|
||||
AnswerOutput,
|
||||
AnswerConfig
|
||||
} from '@sdk/types'
|
||||
import { INTENT_OBJECT, SKILL_LOCALE_CONFIG } from '@bridge/constants'
|
||||
import { WidgetWrapper } from '@sdk/aurora'
|
||||
import { SUPPORTED_WIDGET_EVENTS } from '@sdk/widget-component'
|
||||
|
||||
class Leon {
|
||||
private static instance: Leon
|
||||
private static globalAnswers = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(
|
||||
process.cwd(),
|
||||
'core',
|
||||
'data',
|
||||
INTENT_OBJECT.lang,
|
||||
'answers.json'
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
).answers
|
||||
|
||||
constructor() {
|
||||
if (!Leon.instance) {
|
||||
Leon.instance = this
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects variables into the answer string
|
||||
* @param answer The answer to inject variables into
|
||||
* @param data The data to apply
|
||||
* @example injectVariables('Hello {{ name }}', { name: 'Leon' }) // 'Hello Leon'
|
||||
*/
|
||||
private injectVariables(
|
||||
answer: AnswerConfig,
|
||||
data: AnswerData | null
|
||||
): AnswerConfig {
|
||||
let finalAnswer = answer
|
||||
|
||||
const applyData = (obj: AnswerData): void => {
|
||||
for (const key in obj) {
|
||||
if (typeof finalAnswer === 'string') {
|
||||
finalAnswer = finalAnswer.replaceAll(`{{ ${key} }}`, String(obj[key]))
|
||||
} else {
|
||||
if (finalAnswer.text) {
|
||||
finalAnswer.text = finalAnswer.text.replaceAll(
|
||||
`{{ ${key} }}`,
|
||||
String(obj[key])
|
||||
)
|
||||
}
|
||||
if (finalAnswer.speech) {
|
||||
finalAnswer.speech = finalAnswer.speech.replaceAll(
|
||||
`{{ ${key} }}`,
|
||||
String(obj[key])
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data) {
|
||||
applyData(data)
|
||||
}
|
||||
|
||||
if (SKILL_LOCALE_CONFIG.variables) {
|
||||
applyData(SKILL_LOCALE_CONFIG.variables)
|
||||
}
|
||||
|
||||
return finalAnswer
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an answer config to a text-only value for widget fallback
|
||||
* delivery and synchronized history.
|
||||
*/
|
||||
private getAnswerText(answer: AnswerConfig | ''): string {
|
||||
if (!answer) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (typeof answer === 'string') {
|
||||
return answer
|
||||
}
|
||||
|
||||
return answer.text || answer.speech || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
): AnswerConfig {
|
||||
try {
|
||||
const answers =
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
SKILL_LOCALE_CONFIG.answers?.[answerKey] ??
|
||||
SKILL_LOCALE_CONFIG.common_answers?.[answerKey] ??
|
||||
Leon.globalAnswers?.[answerKey]
|
||||
|
||||
if (!answers) {
|
||||
return answerKey
|
||||
}
|
||||
|
||||
const answer = Array.isArray(answers)
|
||||
? answers[Math.floor(Math.random() * answers.length)] ?? ''
|
||||
: answers
|
||||
|
||||
return this.injectVariables(answer, data)
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Error while setting answer data. Please verify that the answer key "${answerKey}" exists in the locale configuration. Details:`,
|
||||
e
|
||||
)
|
||||
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: { next_action: 'guess_the_number_skill:set_up' } }) // 'Would you like to retry?'
|
||||
* @example answer({ key: 'progress', data: { percentage: 50 }, replaceMessageId: 'progress_msg_123' }) // Replace previous progress message
|
||||
*/
|
||||
public async answer(answerInput: AnswerInput): Promise<string | null> {
|
||||
try {
|
||||
const resolvedAnswer =
|
||||
answerInput.key != null
|
||||
? this.setAnswerData(answerInput.key, answerInput.data)
|
||||
: ''
|
||||
const fallbackText = this.getAnswerText(resolvedAnswer)
|
||||
|
||||
if (answerInput.widget && !fallbackText) {
|
||||
throw new Error(
|
||||
'Widget answers must include a text fallback via `key`.'
|
||||
)
|
||||
}
|
||||
|
||||
const answerObject: AnswerOutput = {
|
||||
...INTENT_OBJECT,
|
||||
output: {
|
||||
codes:
|
||||
answerInput.widget && !answerInput.key
|
||||
? 'widget'
|
||||
: (answerInput.key as string),
|
||||
answer: resolvedAnswer,
|
||||
core: answerInput.core,
|
||||
replaceMessageId: answerInput.replaceMessageId || null
|
||||
}
|
||||
}
|
||||
|
||||
if (answerInput.widget) {
|
||||
answerObject.output.widget = {
|
||||
actionName: `${INTENT_OBJECT.skill_name}:${INTENT_OBJECT.action_name}`,
|
||||
widget: answerInput.widget.widget,
|
||||
id: answerInput.widget.id,
|
||||
onFetch: answerInput.widget.onFetch ?? null,
|
||||
fallbackText,
|
||||
historyMode: answerInput.widgetHistoryMode || 'persisted',
|
||||
componentTree: new WidgetWrapper({
|
||||
...answerInput.widget.wrapperProps,
|
||||
children: [answerInput.widget.render()]
|
||||
}),
|
||||
supportedEvents: SUPPORTED_WIDGET_EVENTS as string[]
|
||||
}
|
||||
}
|
||||
|
||||
// "Temporize" for the data buffer output on the core
|
||||
await new Promise((r) => setTimeout(r, 100))
|
||||
|
||||
// Write the answer object to stdout as a JSON string with a newline for brain chunk-by-chunk parsing
|
||||
process.stdout.write(JSON.stringify(answerObject) + '\n')
|
||||
|
||||
// Return the message ID for future replacement
|
||||
return (
|
||||
answerInput.widget?.id ||
|
||||
`msg-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
|
||||
)
|
||||
} catch (e) {
|
||||
console.error('Error while creating answer:', e)
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const leon = new Leon()
|
||||
@@ -0,0 +1,118 @@
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs'
|
||||
|
||||
import {
|
||||
PROFILE_NATIVE_SKILLS_PATH,
|
||||
SKILL_PATH
|
||||
} from '@bridge/constants'
|
||||
|
||||
const SKILL_NAME_SUFFIX = '_skill'
|
||||
|
||||
function normalizeSkillName(skillName: string): string {
|
||||
return skillName.endsWith(SKILL_NAME_SUFFIX)
|
||||
? skillName
|
||||
: `${skillName}${SKILL_NAME_SUFFIX}`
|
||||
}
|
||||
|
||||
interface MemoryOptions<T> {
|
||||
name: string
|
||||
defaultMemory?: T
|
||||
}
|
||||
|
||||
export class Memory<T = unknown> {
|
||||
private readonly memoryPath: string
|
||||
private readonly name: string
|
||||
private readonly defaultMemory: T | undefined
|
||||
private isFromAnotherSkill: boolean
|
||||
|
||||
constructor(options: MemoryOptions<T>) {
|
||||
const { name, defaultMemory } = options
|
||||
|
||||
this.name = name
|
||||
this.defaultMemory = defaultMemory
|
||||
this.memoryPath = path.join(
|
||||
PROFILE_NATIVE_SKILLS_PATH,
|
||||
path.basename(SKILL_PATH),
|
||||
'memory',
|
||||
`${this.name}.json`
|
||||
)
|
||||
this.isFromAnotherSkill = false
|
||||
|
||||
if (this.name.includes(':') && this.name.split(':').length === 3) {
|
||||
this.isFromAnotherSkill = true
|
||||
|
||||
const [, skillName, memoryName] = this.name.split(':')
|
||||
this.memoryPath = path.join(
|
||||
PROFILE_NATIVE_SKILLS_PATH,
|
||||
normalizeSkillName(skillName as string),
|
||||
'memory',
|
||||
`${memoryName}.json`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the memory and set it to the default memory value
|
||||
* @example clear()
|
||||
*/
|
||||
public async clear(): Promise<void> {
|
||||
if (!this.isFromAnotherSkill) {
|
||||
await this.write(this.defaultMemory as T)
|
||||
} else {
|
||||
throw new Error(
|
||||
`You cannot clear the memory "${this.name}" as it belongs to another skill`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the memory
|
||||
* @example read()
|
||||
*/
|
||||
public async read(): Promise<T> {
|
||||
if (this.isFromAnotherSkill && !fs.existsSync(this.memoryPath)) {
|
||||
throw new Error(
|
||||
`You cannot read the memory "${this.name}" as it belongs to another skill which haven't written to this memory yet`
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(this.memoryPath)) {
|
||||
await this.clear()
|
||||
}
|
||||
|
||||
return JSON.parse(await fs.promises.readFile(this.memoryPath, 'utf-8'))
|
||||
} catch (e) {
|
||||
console.error(`Error while reading memory for "${this.name}":`, e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the memory
|
||||
* @param memory The memory to write
|
||||
* @example write({ foo: 'bar' }) // { foo: 'bar' }
|
||||
*/
|
||||
public async write(memory: T): Promise<T> {
|
||||
if (!this.isFromAnotherSkill) {
|
||||
try {
|
||||
await fs.promises.mkdir(path.dirname(this.memoryPath), {
|
||||
recursive: true
|
||||
})
|
||||
await fs.promises.writeFile(
|
||||
this.memoryPath,
|
||||
JSON.stringify(memory, null, 2)
|
||||
)
|
||||
|
||||
return memory
|
||||
} catch (e) {
|
||||
console.error(`Error while writing memory for "${this.name}":`, e)
|
||||
throw e
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
`You cannot write into the memory "${this.name}" as it belongs to another skill`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import dns from 'node:dns'
|
||||
|
||||
import type { AxiosInstance } from 'axios'
|
||||
import axios from 'axios'
|
||||
|
||||
import { LEON_VERSION, NODEJS_BRIDGE_VERSION } from '@bridge/constants'
|
||||
|
||||
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?: unknown
|
||||
|
||||
/** Custom headers to be sent. */
|
||||
headers?: Record<string, string>
|
||||
|
||||
/** Optional files for multipart/form-data requests (parity with Python SDK). */
|
||||
files?: Record<string, unknown>
|
||||
|
||||
/** Whether to send JSON body (true by default). If false, send form data. */
|
||||
useJson?: boolean
|
||||
|
||||
/** Response type (defaults to 'json'). Use 'arraybuffer' for binary data like audio/video files. */
|
||||
responseType?:
|
||||
| 'json'
|
||||
| 'text'
|
||||
| 'arraybuffer'
|
||||
| 'blob'
|
||||
| 'document'
|
||||
| 'stream'
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const formatErrorData = (data: unknown): string => {
|
||||
if (typeof data === 'string') {
|
||||
return data
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(data)
|
||||
} catch {
|
||||
return String(data)
|
||||
}
|
||||
}
|
||||
|
||||
export class NetworkError<ResponseErrorData = unknown> extends Error {
|
||||
public readonly response: NetworkResponse<ResponseErrorData>
|
||||
|
||||
constructor(response: NetworkResponse<ResponseErrorData>) {
|
||||
super(`[NetworkError]: ${response.statusCode}`)
|
||||
this.response = response
|
||||
Object.setPrototypeOf(this, NetworkError.prototype)
|
||||
}
|
||||
}
|
||||
|
||||
export class Network {
|
||||
private readonly options: NetworkOptions
|
||||
private axios: AxiosInstance
|
||||
|
||||
constructor(options: NetworkOptions = {}) {
|
||||
this.options = options
|
||||
this.axios = axios.create({
|
||||
baseURL: this.options.baseURL
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Send HTTP request
|
||||
* @param options Request options
|
||||
* @example request({ url: '/send', method: 'POST', data: { message: 'Hi' } })
|
||||
*/
|
||||
public async request<ResponseData = unknown, ResponseErrorData = unknown>(
|
||||
options: NetworkRequestOptions
|
||||
): Promise<NetworkResponse<ResponseData>> {
|
||||
try {
|
||||
const response = await this.axios.request({
|
||||
url: options.url,
|
||||
method: options.method.toLowerCase(),
|
||||
// For parity, we accept any data type here (including FormData)
|
||||
data: options.data as never,
|
||||
responseType: options.responseType,
|
||||
headers: {
|
||||
'User-Agent': `Leon Personal Assistant ${LEON_VERSION} - Node.js Bridge ${NODEJS_BRIDGE_VERSION}`,
|
||||
...options.headers
|
||||
}
|
||||
})
|
||||
|
||||
let data = {} as ResponseData
|
||||
// For binary response types, return data as-is
|
||||
if (
|
||||
options.responseType === 'arraybuffer' ||
|
||||
options.responseType === 'blob' ||
|
||||
options.responseType === 'stream'
|
||||
) {
|
||||
data = response.data as ResponseData
|
||||
} else {
|
||||
// For text/json responses, try to parse as JSON
|
||||
try {
|
||||
if (typeof response.data === 'string') {
|
||||
data = JSON.parse(response.data)
|
||||
} else {
|
||||
data = response.data as ResponseData
|
||||
}
|
||||
} catch {
|
||||
data = response.data as ResponseData
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
statusCode: response.status,
|
||||
options: {
|
||||
...this.options,
|
||||
...options
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
let statusCode = 500
|
||||
let dataRawText = ''
|
||||
|
||||
if (axios.isAxiosError(error)) {
|
||||
dataRawText = error?.response?.data ?? ''
|
||||
statusCode = error?.response?.status ?? 500
|
||||
}
|
||||
|
||||
let data: ResponseErrorData
|
||||
try {
|
||||
data = JSON.parse(dataRawText)
|
||||
} catch {
|
||||
data = dataRawText as ResponseErrorData
|
||||
}
|
||||
|
||||
const response: NetworkResponse<ResponseErrorData> = {
|
||||
data,
|
||||
statusCode,
|
||||
options: {
|
||||
...this.options,
|
||||
...options
|
||||
}
|
||||
}
|
||||
|
||||
console.error(
|
||||
'[NetworkError]',
|
||||
response.statusCode,
|
||||
options.method,
|
||||
options.url,
|
||||
formatErrorData(response.data)
|
||||
)
|
||||
|
||||
throw new NetworkError<ResponseErrorData>(response)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is a network error
|
||||
* @param error Error to check
|
||||
* @example isNetworkError(error) // false
|
||||
*/
|
||||
public isNetworkError<ResponseErrorData = unknown>(
|
||||
error: unknown
|
||||
): error is NetworkError<ResponseErrorData> {
|
||||
return error instanceof NetworkError
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify whether there is an Internet connectivity
|
||||
* @example isNetworkAvailable() // true
|
||||
*/
|
||||
public async isNetworkAvailable(): Promise<boolean> {
|
||||
try {
|
||||
await dns.promises.resolve('getleon.ai')
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from 'lodash'
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { ActionParams, NEREntity } from '@sdk/types'
|
||||
import { INTENT_OBJECT } from '@bridge/constants'
|
||||
|
||||
export class ParamsHelper {
|
||||
private readonly params: ActionParams
|
||||
|
||||
constructor(params: ActionParams) {
|
||||
this.params = params
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the widget id if any
|
||||
* @example getWidgetId() // 'timerwidget-5q1xlzeh
|
||||
*/
|
||||
getWidgetId(): string | null {
|
||||
return (
|
||||
INTENT_OBJECT.entities?.find((entity) => entity.entity === 'widgetid')
|
||||
?.sourceText ?? null
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific action argument from the current turn by its name
|
||||
* @param name The name of the action argument to retrieve
|
||||
*/
|
||||
getActionArgument(name: string): string | undefined {
|
||||
return this.params.action_arguments[name] as string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first entity in the current turn that matches the given name
|
||||
* @param entityName The name of the entity to find (e.g., 'language', 'date')
|
||||
*/
|
||||
findEntity(entityName: string): NEREntity | undefined {
|
||||
return this.params.entities.find((entity) => entity.entity === entityName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the last entity in the current turn that matches the given name
|
||||
* Useful when an utterance contains duplicates
|
||||
* @param entityName The name of the entity to find (e.g., 'color')
|
||||
*/
|
||||
findLastEntity(entityName: string): NEREntity | undefined {
|
||||
return [...this.params.entities]
|
||||
.reverse()
|
||||
.find((entity) => entity.entity === entityName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all entities in the current turn that match the given name
|
||||
* @param entityName The name of the entities to find (e.g., 'date')
|
||||
*/
|
||||
findAllEntities(entityName: string): NEREntity[] {
|
||||
return this.params.entities.filter((entity) => entity.entity === entityName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first action argument in the conversation context that matches the given name
|
||||
* @param name The name of the action argument to find
|
||||
*/
|
||||
findActionArgumentFromContext(name: string): string | undefined {
|
||||
for (const args of this.params.context.action_arguments) {
|
||||
if (args && name in args) {
|
||||
return args[name] as string | undefined
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the most recent value for a given action argument from the conversation context.
|
||||
* It searches backwards from the most recent turn
|
||||
* @param name The name of the action argument to find
|
||||
*/
|
||||
findLastActionArgumentFromContext(name: string): string | undefined {
|
||||
// Iterate backwards through the history of action arguments
|
||||
for (
|
||||
let i = this.params.context.action_arguments.length - 1;
|
||||
i >= 0;
|
||||
i -= 1
|
||||
) {
|
||||
const args = this.params.context.action_arguments[i]
|
||||
|
||||
if (args && name in args) {
|
||||
return args[name] as string | undefined
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the most recently detected entity (the last one from the context) that matches the given name.
|
||||
* This is useful for recalling the last time an owner mentioned a specific piece of information
|
||||
* @param entityName The name of the entity to find in the conversation history
|
||||
*/
|
||||
findLastEntityFromContext(entityName: string): NEREntity | undefined {
|
||||
// The context.entities are stored chronologically, so reversing and finding the first is correct
|
||||
return [...this.params.context.entities]
|
||||
.reverse()
|
||||
.find((entity) => entity.entity === entityName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all historical entities that match the given name from the entire conversation context
|
||||
* @param entityName The name of the entities to find in the conversation history
|
||||
*/
|
||||
findAllEntitiesFromContext(entityName: string): NEREntity[] {
|
||||
return this.params.context.entities.filter(
|
||||
(entity) => entity.entity === entityName
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a value stored in the generic context data store
|
||||
* @param key The key to retrieve
|
||||
*/
|
||||
getContextData<T = unknown>(key: string): T | undefined {
|
||||
return this.params.context.data?.[key] as T | undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs'
|
||||
|
||||
import { PROFILE_NATIVE_SKILLS_PATH, SKILL_PATH } from '@bridge/constants'
|
||||
|
||||
export class Settings<T extends Record<string, unknown>> {
|
||||
private readonly settingsPath: string
|
||||
private readonly settingsSamplePath: string
|
||||
|
||||
constructor() {
|
||||
this.settingsPath = path.join(
|
||||
PROFILE_NATIVE_SKILLS_PATH,
|
||||
path.basename(SKILL_PATH),
|
||||
'settings.json'
|
||||
)
|
||||
this.settingsSamplePath = path.join(
|
||||
SKILL_PATH,
|
||||
'src',
|
||||
'settings.sample.json'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a setting is already set
|
||||
* @param key The key to verify whether its value is set
|
||||
* @returns isSettingSet('apiKey') // true
|
||||
*/
|
||||
public async isSettingSet(key: string): Promise<boolean> {
|
||||
const settingsSample = await this.getSettingsSample()
|
||||
const settings = await this.get()
|
||||
|
||||
return (
|
||||
!!settings[key] &&
|
||||
JSON.stringify(settings[key]) !== JSON.stringify(settingsSample[key])
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the settings and set it to the default settings.sample.json file
|
||||
* @example clear()
|
||||
*/
|
||||
public async clear(): Promise<void> {
|
||||
const settingsSample = await this.getSettingsSample()
|
||||
|
||||
await this.set(settingsSample)
|
||||
}
|
||||
|
||||
private async getSettingsSample(): Promise<T> {
|
||||
try {
|
||||
return JSON.parse(
|
||||
await fs.promises.readFile(this.settingsSamplePath, 'utf8')
|
||||
)
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Error while reading settings sample at "${this.settingsSamplePath}":`,
|
||||
e
|
||||
)
|
||||
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the settings
|
||||
* @param key The key of the setting to get
|
||||
* @example get('API_KEY') // 'value'
|
||||
* @example get() // { API_KEY: 'value' }
|
||||
*/
|
||||
public async get<Key extends keyof T>(key: Key): Promise<T[Key]>
|
||||
public async get(): Promise<T>
|
||||
public async get<Key extends keyof T>(key?: Key): Promise<T | T[Key]> {
|
||||
try {
|
||||
if (!fs.existsSync(this.settingsPath)) {
|
||||
await this.clear()
|
||||
}
|
||||
|
||||
const settings = JSON.parse(
|
||||
await fs.promises.readFile(this.settingsPath, 'utf8')
|
||||
)
|
||||
|
||||
if (key != null) {
|
||||
return settings[key]
|
||||
}
|
||||
|
||||
return settings
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Error while reading settings at "${this.settingsPath}":`,
|
||||
e
|
||||
)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the settings
|
||||
* @param key The key of the setting to set
|
||||
* @param value The value of the setting to set
|
||||
* @example set({ API_KEY: 'value' }) // { API_KEY: 'value' }
|
||||
*/
|
||||
public async set<Key extends keyof T>(key: Key, value: T[Key]): Promise<T>
|
||||
public async set(settings: T): Promise<T>
|
||||
public async set<Key extends keyof T>(
|
||||
keyOrSettings: Key | T,
|
||||
value?: T[Key]
|
||||
): Promise<T> {
|
||||
try {
|
||||
const newSettings =
|
||||
typeof keyOrSettings === 'object'
|
||||
? keyOrSettings
|
||||
: {
|
||||
...(await this.get()),
|
||||
[keyOrSettings]: value
|
||||
}
|
||||
|
||||
await fs.promises.mkdir(path.dirname(this.settingsPath), {
|
||||
recursive: true
|
||||
})
|
||||
await fs.promises.writeFile(
|
||||
this.settingsPath,
|
||||
JSON.stringify(newSettings, null, 2)
|
||||
)
|
||||
|
||||
return newSettings
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Error while writing settings at "${this.settingsPath}":`,
|
||||
e
|
||||
)
|
||||
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { formatFilePath } from '@sdk/utils'
|
||||
import { Tool } from '@sdk/base-tool'
|
||||
import { reportToolOutput } from '@sdk/tool-reporter'
|
||||
|
||||
export class MissingToolSettingsError extends Error {
|
||||
missing: string[]
|
||||
settingsPath: string
|
||||
|
||||
constructor(missing: string[], settingsPath: string) {
|
||||
super(`Missing tool settings: ${missing.join(', ')}`)
|
||||
this.name = 'MissingToolSettingsError'
|
||||
this.missing = missing
|
||||
this.settingsPath = settingsPath
|
||||
}
|
||||
}
|
||||
|
||||
export const isMissingToolSettingsError = (
|
||||
error: unknown
|
||||
): error is MissingToolSettingsError => {
|
||||
return error instanceof MissingToolSettingsError
|
||||
}
|
||||
|
||||
export default class ToolManager {
|
||||
static async initTool<TTool extends Tool>(
|
||||
ToolClass: new () => TTool
|
||||
): Promise<TTool> {
|
||||
const tool = new ToolClass()
|
||||
const missing = tool.getMissingSettings()
|
||||
|
||||
if (missing) {
|
||||
try {
|
||||
await reportToolOutput({
|
||||
key: 'bridges.tools.missing_settings',
|
||||
data: {
|
||||
tool_name: tool.aliasToolName,
|
||||
missing: missing.missing.join(', '),
|
||||
settings_path: formatFilePath(missing.settingsPath)
|
||||
},
|
||||
core: {
|
||||
should_stop_skill: true
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[LEON_TOOL_LOG] Failed to report missing tool settings: ${
|
||||
(error as Error).message
|
||||
}`
|
||||
)
|
||||
}
|
||||
throw new MissingToolSettingsError(missing.missing, missing.settingsPath)
|
||||
}
|
||||
|
||||
return tool
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export type ToolReporter = (input: Record<string, unknown>) => Promise<void>
|
||||
|
||||
let toolReporter: ToolReporter | null = null
|
||||
|
||||
export const setToolReporter = (reporter: ToolReporter): void => {
|
||||
toolReporter = reporter
|
||||
}
|
||||
|
||||
export const reportToolOutput = async (
|
||||
input: Record<string, unknown>
|
||||
): Promise<void> => {
|
||||
if (!toolReporter) {
|
||||
return
|
||||
}
|
||||
|
||||
await toolReporter(input)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
import { getPlatformName } from '@sdk/utils'
|
||||
import {
|
||||
PROFILE_TOOLS_PATH,
|
||||
TOOLS_PATH
|
||||
} from '@bridge/constants'
|
||||
|
||||
interface ToolConfig {
|
||||
tool_id: string
|
||||
toolkit_id: string
|
||||
name: string
|
||||
description: string
|
||||
binaries?: Record<string, string>
|
||||
resources?: Record<string, string[]>
|
||||
functions: Record<
|
||||
string,
|
||||
{ description: string, input_schema: Record<string, string> }
|
||||
>
|
||||
}
|
||||
|
||||
interface ToolkitConfigData {
|
||||
name: string
|
||||
description: string
|
||||
tools: string[]
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value)
|
||||
)
|
||||
}
|
||||
|
||||
function mergeMissingSettings(
|
||||
defaultSettings: Record<string, unknown>,
|
||||
existingSettings: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const mergedSettings = { ...existingSettings }
|
||||
|
||||
for (const [key, defaultValue] of Object.entries(defaultSettings)) {
|
||||
const existingValue = existingSettings[key]
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(existingSettings, key)) {
|
||||
mergedSettings[key] = defaultValue
|
||||
continue
|
||||
}
|
||||
|
||||
if (isPlainObject(defaultValue) && isPlainObject(existingValue)) {
|
||||
mergedSettings[key] = mergeMissingSettings(defaultValue, existingValue)
|
||||
}
|
||||
}
|
||||
|
||||
return mergedSettings
|
||||
}
|
||||
|
||||
export class ToolkitConfig {
|
||||
private static configCache = new Map<string, ToolkitConfigData>()
|
||||
private static settingsCache = new Map<string, Record<string, unknown>>()
|
||||
|
||||
/**
|
||||
* Load tool configuration from the flat tools structure.
|
||||
* @param toolkitName - The toolkit name (e.g., 'video_streaming')
|
||||
* @param toolName - Name of the tool (e.g., 'ffmpeg')
|
||||
*/
|
||||
static load(toolkitName: string, toolName: string): ToolConfig {
|
||||
const cacheKey = toolkitName
|
||||
|
||||
// Load toolkit config if not cached
|
||||
if (!this.configCache.has(cacheKey)) {
|
||||
const configPath = join(TOOLS_PATH, toolkitName, 'toolkit.json')
|
||||
const configContent = readFileSync(configPath, 'utf-8')
|
||||
const config = JSON.parse(configContent) as ToolkitConfigData
|
||||
|
||||
this.configCache.set(cacheKey, config)
|
||||
}
|
||||
|
||||
const toolkitConfig = this.configCache.get(cacheKey)!
|
||||
const toolConfigPath = join(TOOLS_PATH, toolkitName, toolName, 'tool.json')
|
||||
|
||||
if (!toolkitConfig.tools.includes(toolName) && !existsSync(toolConfigPath)) {
|
||||
throw new Error(
|
||||
`Tool '${toolName}' not found in toolkit '${toolkitConfig.name}'`
|
||||
)
|
||||
}
|
||||
|
||||
const toolConfigContent = readFileSync(toolConfigPath, 'utf-8')
|
||||
const toolConfig = JSON.parse(toolConfigContent) as ToolConfig
|
||||
|
||||
return toolConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* Load tool-specific settings from toolkit settings file
|
||||
* @param toolkitName - The toolkit name (e.g., 'video_streaming')
|
||||
* @param toolName - Name of the tool (e.g., 'ffmpeg')
|
||||
* @param defaults - Default tool settings to apply when missing
|
||||
*/
|
||||
static loadToolSettings(
|
||||
toolkitName: string,
|
||||
toolName: string,
|
||||
defaults: Record<string, unknown> = {}
|
||||
): Record<string, unknown> {
|
||||
const cacheKey = `${toolkitName}:${toolName}`
|
||||
if (this.settingsCache.has(cacheKey)) {
|
||||
return this.settingsCache.get(cacheKey) || {}
|
||||
}
|
||||
|
||||
const settingsPath = join(
|
||||
PROFILE_TOOLS_PATH,
|
||||
toolkitName,
|
||||
toolName,
|
||||
'settings.json'
|
||||
)
|
||||
const settingsSamplePath = join(
|
||||
TOOLS_PATH,
|
||||
toolkitName,
|
||||
toolName,
|
||||
'settings.sample.json'
|
||||
)
|
||||
const settingsDir = dirname(settingsPath)
|
||||
const defaultSettings = existsSync(settingsSamplePath)
|
||||
? (JSON.parse(
|
||||
readFileSync(settingsSamplePath, 'utf-8')
|
||||
) as Record<string, unknown>)
|
||||
: defaults
|
||||
|
||||
mkdirSync(settingsDir, { recursive: true })
|
||||
|
||||
let toolSettings: Record<string, unknown> = {}
|
||||
let shouldWrite = false
|
||||
|
||||
if (existsSync(settingsPath)) {
|
||||
const settingsContent = readFileSync(settingsPath, 'utf-8')
|
||||
toolSettings = JSON.parse(settingsContent) as Record<string, unknown>
|
||||
} else {
|
||||
shouldWrite = true
|
||||
}
|
||||
|
||||
const mergedSettings = mergeMissingSettings(defaultSettings, toolSettings)
|
||||
|
||||
if (!shouldWrite) {
|
||||
shouldWrite = JSON.stringify(toolSettings) !== JSON.stringify(mergedSettings)
|
||||
}
|
||||
|
||||
if (shouldWrite) {
|
||||
writeFileSync(settingsPath, JSON.stringify(mergedSettings, null, 2))
|
||||
}
|
||||
|
||||
this.settingsCache.set(cacheKey, mergedSettings)
|
||||
return mergedSettings
|
||||
}
|
||||
|
||||
/**
|
||||
* Get binary download URL for current platform with architecture granularity
|
||||
*/
|
||||
static getBinaryUrl(config: ToolConfig): string | undefined {
|
||||
const platformName = getPlatformName()
|
||||
|
||||
return config.binaries?.[platformName]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Action types
|
||||
*/
|
||||
import type {
|
||||
ActionParams,
|
||||
IntentObject,
|
||||
SkillAnswerCoreData,
|
||||
SkillAnswerOutput
|
||||
} from '@/core/brain/types'
|
||||
import type { SkillAnswerConfigSchema } from '@/schemas/skill-schemas'
|
||||
|
||||
import type { Widget } from '@sdk/widget'
|
||||
import { ParamsHelper } from '@sdk/params-helper'
|
||||
|
||||
export type { ActionParams, IntentObject }
|
||||
|
||||
export * from '@/core/nlp/types'
|
||||
|
||||
export type ActionFunction = (
|
||||
params: ActionParams,
|
||||
paramsHelper: ParamsHelper
|
||||
) => Promise<void>
|
||||
|
||||
/**
|
||||
* Answer types
|
||||
*/
|
||||
export interface Answer {
|
||||
key?: string
|
||||
widget?: Widget
|
||||
data?: AnswerData
|
||||
core?: SkillAnswerCoreData
|
||||
replaceMessageId?: string | null
|
||||
widgetHistoryMode?: 'persisted' | 'system_widget'
|
||||
}
|
||||
export interface TextAnswer extends Answer {
|
||||
key: string
|
||||
}
|
||||
export interface WidgetAnswer extends Answer {
|
||||
widget: Widget
|
||||
key?: string
|
||||
}
|
||||
export type AnswerData = Record<string, string | number> | null
|
||||
export type AnswerInput = TextAnswer | WidgetAnswer | Answer
|
||||
export type AnswerOutput = SkillAnswerOutput
|
||||
export type AnswerConfig = SkillAnswerConfigSchema
|
||||
@@ -0,0 +1,301 @@
|
||||
import { platform, arch, cpus } from 'node:os'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
const HUGGING_FACE_URL = 'https://huggingface.co'
|
||||
const HUGGING_FACE_MIRROR_URL = 'https://hf-mirror.com'
|
||||
const ZIP_ARCHIVE_EXTENSIONS = new Set(['.zip', '.whl'])
|
||||
|
||||
/**
|
||||
* Formats a file path as a clickable path with proper delimiters
|
||||
* @param filePath The absolute file path to format
|
||||
* @returns A formatted string that the client can detect and make clickable
|
||||
* @example formatFilePath('/Users/john/video.mp4') // returns '[FILE_PATH]/Users/john/video.mp4[/FILE_PATH]'
|
||||
*/
|
||||
export function formatFilePath(filePath: string): string {
|
||||
return `[FILE_PATH]${filePath}[/FILE_PATH]`
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats multiple file paths as a list of clickable paths
|
||||
* @param filePaths Array of absolute file paths
|
||||
* @returns A formatted string with multiple clickable paths
|
||||
* @example formatFilePaths(['/path1', '/path2']) // returns '[FILE_PATH]/path1[/FILE_PATH], [FILE_PATH]/path2[/FILE_PATH]'
|
||||
*/
|
||||
export function formatFilePaths(filePaths: string[]): string {
|
||||
return filePaths.map(formatFilePath).join(', ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a language input to an ISO 639-1 code.
|
||||
* Supports direct language codes and locale tags such as `fr-FR`.
|
||||
*/
|
||||
export function normalizeLanguageCode(value: string): string | null {
|
||||
const trimmedValue = value.trim()
|
||||
|
||||
if (trimmedValue === '') {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const locale = new Intl.Locale(trimmedValue)
|
||||
|
||||
return locale.language ? locale.language.toLowerCase() : null
|
||||
} catch {
|
||||
const normalizedValue = trimmedValue.toLowerCase()
|
||||
|
||||
if (
|
||||
normalizedValue.length === 2 &&
|
||||
[...normalizedValue].every((char) => char >= 'a' && char <= 'z')
|
||||
) {
|
||||
return normalizedValue
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Platform utilities for consistent platform and architecture detection
|
||||
* Matches the naming convention from system-helper.ts BinaryFolderNames enum
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get platform name with architecture granularity (matches system-helper.ts)
|
||||
* Returns same format as BinaryFolderNames enum from system-helper.ts
|
||||
*/
|
||||
export function getPlatformName(): string {
|
||||
const platformName = platform()
|
||||
const cpuArchitecture = arch()
|
||||
|
||||
if (platformName === 'linux') {
|
||||
if (cpuArchitecture === 'x64') {
|
||||
return 'linux-x86_64'
|
||||
}
|
||||
|
||||
return 'linux-aarch64'
|
||||
}
|
||||
|
||||
if (platformName === 'darwin') {
|
||||
const cpuCores = cpus()
|
||||
const isM1 = cpuCores[0]?.model.includes('Apple')
|
||||
|
||||
if (isM1 || cpuArchitecture === 'arm64') {
|
||||
return 'macosx-arm64'
|
||||
}
|
||||
|
||||
return 'macosx-x86_64'
|
||||
}
|
||||
|
||||
if (platformName === 'win32') {
|
||||
return 'win-amd64'
|
||||
}
|
||||
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current platform is Windows
|
||||
* @returns True if running on Windows, false otherwise
|
||||
* @example if (isWindows()) { executableName += '.exe' }
|
||||
*/
|
||||
export function isWindows(): boolean {
|
||||
return getPlatformName().startsWith('win')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current platform is macOS
|
||||
* @returns True if running on macOS, false otherwise
|
||||
* @example if (isMacOS()) { await removeQuarantineAttribute(binaryPath) }
|
||||
*/
|
||||
export function isMacOS(): boolean {
|
||||
return getPlatformName().startsWith('macosx')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current platform is Linux
|
||||
* @returns True if running on Linux, false otherwise
|
||||
* @example if (isLinux()) { await checkSystemPackage('ffmpeg') }
|
||||
*/
|
||||
export function isLinux(): boolean {
|
||||
return getPlatformName().startsWith('linux')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current network can access Hugging Face
|
||||
* @example canAccessHuggingFace() // true
|
||||
*/
|
||||
export async function canAccessHuggingFace(): Promise<boolean> {
|
||||
try {
|
||||
await axios.head(HUGGING_FACE_URL, { timeout: 5000 })
|
||||
return true
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the Hugging Face URL based on the network access
|
||||
* @param url The URL to set
|
||||
* @example setHuggingFaceURL('https://huggingface.co') // https://hf-mirror.com
|
||||
*/
|
||||
export async function setHuggingFaceURL(url: string): Promise<string> {
|
||||
if (!url.includes('huggingface.co')) {
|
||||
return url
|
||||
}
|
||||
|
||||
const canAccess = await canAccessHuggingFace()
|
||||
|
||||
if (!canAccess) {
|
||||
return url.replace(HUGGING_FACE_URL, HUGGING_FACE_MIRROR_URL)
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes into human-readable units
|
||||
* @param bytes The number of bytes to format
|
||||
* @returns A human-readable string representation
|
||||
* @example formatBytes(1024) // "1 KB"
|
||||
* @example formatBytes(1536) // "1.5 KB"
|
||||
*/
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) {
|
||||
return '0 B'
|
||||
}
|
||||
|
||||
const k = 1_024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
/**
|
||||
* Format speed from raw number to human-readable format
|
||||
* @param speed The speed in bytes per second (or already formatted string)
|
||||
* @returns A human-readable speed string
|
||||
* @example formatSpeed(1_024) // "1 KB/s"
|
||||
* @example formatSpeed("1.5 MB/s") // "1.5 MB/s" (already formatted)
|
||||
*/
|
||||
export function formatSpeed(speed: string | number): string {
|
||||
if (typeof speed === 'string') {
|
||||
// If it's already formatted (e.g., "1.5 MB/s"), return as is
|
||||
if (speed.includes('/s')) {
|
||||
return speed
|
||||
}
|
||||
// If it's a string number, convert to number
|
||||
speed = parseFloat(speed)
|
||||
}
|
||||
if (isNaN(speed) || speed === 0) {
|
||||
return '0 B/s'
|
||||
}
|
||||
|
||||
// Assume speed is in bytes per second
|
||||
return formatBytes(speed) + '/s'
|
||||
}
|
||||
|
||||
/**
|
||||
* Format ETA from seconds to human-readable format
|
||||
* @param eta The ETA in seconds (or already formatted string)
|
||||
* @returns A human-readable ETA string
|
||||
* @example formatETA(3661) // "1h 1m 1s"
|
||||
* @example formatETA(90) // "1m 30s"
|
||||
* @example formatETA("00:01:30") // "1m 30s" (parsed from HH:MM:SS)
|
||||
*/
|
||||
export function formatETA(eta: string | number): string {
|
||||
if (typeof eta === 'string') {
|
||||
// If it's already formatted (e.g., "00:02:45"), return as is
|
||||
if (eta.includes(':')) {
|
||||
return eta
|
||||
}
|
||||
// If it's a string number, convert to number
|
||||
eta = parseFloat(eta)
|
||||
}
|
||||
|
||||
if (isNaN(eta) || eta <= 0) {
|
||||
return '∞'
|
||||
}
|
||||
|
||||
const hours = Math.floor(eta / 3_600)
|
||||
const minutes = Math.floor((eta % 3_600) / 60)
|
||||
const seconds = Math.floor(eta % 60)
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m ${seconds}s`
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${seconds}s`
|
||||
}
|
||||
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract archive file using native system commands
|
||||
* Supports .zip, .tar, .tar.gz, .tar.xz, .tgz formats across all platforms
|
||||
* @param archivePath The path to the archive file
|
||||
* @param targetPath The path to extract to
|
||||
* @param options Extraction options
|
||||
* @example extractArchive('archive.zip', 'output/dir')
|
||||
* @example extractArchive('archive.tar.xz', 'output/dir', { stripComponents: 1 })
|
||||
*/
|
||||
export async function extractArchive(
|
||||
archivePath: string,
|
||||
targetPath: string,
|
||||
options?: {
|
||||
stripComponents?: number
|
||||
}
|
||||
): Promise<void> {
|
||||
const stripComponents = options?.stripComponents ?? 0
|
||||
|
||||
// Ensure target directory exists
|
||||
await fs.promises.mkdir(targetPath, { recursive: true })
|
||||
|
||||
const ext = path.extname(archivePath).toLowerCase()
|
||||
const basename = path.basename(archivePath).toLowerCase()
|
||||
|
||||
try {
|
||||
if (ZIP_ARCHIVE_EXTENSIONS.has(ext)) {
|
||||
if (isWindows()) {
|
||||
execFileSync('tar', ['-xf', archivePath, '-C', targetPath], {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true
|
||||
})
|
||||
} else {
|
||||
execFileSync('unzip', ['-o', '-q', archivePath, '-d', targetPath], {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true
|
||||
})
|
||||
}
|
||||
} else if (
|
||||
basename.endsWith('.tar.gz') ||
|
||||
basename.endsWith('.tar.xz') ||
|
||||
basename.endsWith('.tgz') ||
|
||||
ext === '.tar'
|
||||
) {
|
||||
const tarArgs = ['-xf', archivePath, '-C', targetPath]
|
||||
|
||||
if (stripComponents > 0) {
|
||||
tarArgs.push(`--strip-components=${stripComponents}`)
|
||||
}
|
||||
|
||||
execFileSync('tar', tarArgs, {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true
|
||||
})
|
||||
} else {
|
||||
throw new Error(`Unsupported archive format: ${archivePath}`)
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to extract archive "${archivePath}": ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
export type SupportedWidgetEvent = (typeof SUPPORTED_WIDGET_EVENTS)[number]
|
||||
interface WidgetEvent {
|
||||
type: SupportedWidgetEvent
|
||||
id: string
|
||||
}
|
||||
|
||||
export const SUPPORTED_WIDGET_EVENTS = [
|
||||
'onClick',
|
||||
'onSubmit',
|
||||
'onChange',
|
||||
'onStart',
|
||||
'onEnd'
|
||||
] as const
|
||||
|
||||
function generateId(): string {
|
||||
return Math.random().toString(36).substring(2, 7)
|
||||
}
|
||||
|
||||
export abstract class WidgetComponent<T = unknown> {
|
||||
public readonly component: string
|
||||
public readonly id: string
|
||||
public readonly props: T
|
||||
public readonly events: WidgetEvent[]
|
||||
|
||||
protected constructor(props: T) {
|
||||
this.component = this.constructor.name
|
||||
this.id = `${this.component.toLowerCase()}-${generateId()}`
|
||||
this.props = props
|
||||
this.events = this.parseEvents()
|
||||
}
|
||||
|
||||
private parseEvents(): WidgetEvent[] {
|
||||
if (!this.props) {
|
||||
return []
|
||||
}
|
||||
|
||||
const eventTypes = Object.keys(this.props).filter(
|
||||
(key) =>
|
||||
key.startsWith('on') &&
|
||||
SUPPORTED_WIDGET_EVENTS.includes(key as SupportedWidgetEvent)
|
||||
) as SupportedWidgetEvent[]
|
||||
|
||||
return eventTypes.map((type) => ({
|
||||
type,
|
||||
id: `${this.id}_${type.toLowerCase()}-${generateId()}`,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
method: this.props[type]()
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { type WidgetWrapperProps } from '@aurora'
|
||||
|
||||
import { INTENT_OBJECT, SKILL_LOCALE_CONFIG } from '@bridge/constants'
|
||||
import { WidgetComponent } from '@sdk/widget-component'
|
||||
|
||||
type UtteranceSender = 'leon' | 'owner'
|
||||
|
||||
interface SendUtteranceWidgetEventMethodParams {
|
||||
from: UtteranceSender
|
||||
utterance: string
|
||||
}
|
||||
interface RunSkillActionWidgetEventMethodParams {
|
||||
actionName: string
|
||||
params: Record<string, unknown>
|
||||
}
|
||||
interface SendUtteranceOptions {
|
||||
from?: UtteranceSender
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface WidgetEventMethod {
|
||||
methodName: 'send_utterance' | 'run_skill_action'
|
||||
methodParams:
|
||||
| SendUtteranceWidgetEventMethodParams
|
||||
| RunSkillActionWidgetEventMethodParams
|
||||
}
|
||||
export interface WidgetOptions<T = unknown> {
|
||||
wrapperProps?: Omit<WidgetWrapperProps, 'children'>
|
||||
onFetch?: {
|
||||
widgetId?: string | undefined
|
||||
actionName: string
|
||||
}
|
||||
params: T
|
||||
}
|
||||
|
||||
export abstract class Widget<T = unknown> {
|
||||
public actionName: string
|
||||
public id: string
|
||||
public widget: string
|
||||
public onFetch: WidgetOptions<T>['onFetch'] | null = null
|
||||
public wrapperProps: WidgetOptions<T>['wrapperProps']
|
||||
public params: WidgetOptions<T>['params']
|
||||
|
||||
protected constructor(options: WidgetOptions<T>) {
|
||||
if (options?.wrapperProps) {
|
||||
this.wrapperProps = options.wrapperProps
|
||||
}
|
||||
this.actionName = `${INTENT_OBJECT.skill_name}:${INTENT_OBJECT.action_name}`
|
||||
this.params = options.params
|
||||
this.widget = this.constructor.name
|
||||
if (options?.onFetch) {
|
||||
this.onFetch = {
|
||||
widgetId: options.onFetch.widgetId,
|
||||
actionName: `${INTENT_OBJECT.skill_name}:${options.onFetch.actionName}`
|
||||
}
|
||||
}
|
||||
this.id =
|
||||
options.onFetch?.widgetId ||
|
||||
`${this.widget.toLowerCase()}-${Math.random()
|
||||
.toString(36)
|
||||
.substring(2, 10)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the widget
|
||||
*/
|
||||
public abstract render(): WidgetComponent<unknown>
|
||||
|
||||
/**
|
||||
* Indicate the core to send a given utterance
|
||||
* @param key The key of the content
|
||||
* @param options The options of the utterance
|
||||
* @example content('provider_selected', { data: { provider: 'Spotify' } }) // 'I chose the Spotify provider'
|
||||
*/
|
||||
protected sendUtterance(
|
||||
key: string,
|
||||
options?: SendUtteranceOptions
|
||||
): WidgetEventMethod {
|
||||
const utteranceContent = this.content(key, options?.data)
|
||||
const from = options?.from || 'owner'
|
||||
|
||||
return {
|
||||
methodName: 'send_utterance',
|
||||
methodParams: {
|
||||
from,
|
||||
utterance: utteranceContent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate the core to run a given skill action
|
||||
* @param actionName The name of the action
|
||||
* @param params The parameters of the action
|
||||
* @example runSkillAction('music_player_skill:next', { provider: 'Spotify' })
|
||||
*/
|
||||
protected runSkillAction(
|
||||
actionName: string,
|
||||
params: Record<string, unknown>
|
||||
): WidgetEventMethod {
|
||||
return {
|
||||
methodName: 'run_skill_action',
|
||||
methodParams: {
|
||||
actionName,
|
||||
params
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Grab and compute the target content of the widget
|
||||
* @param key The key of the content
|
||||
* @param data The data to apply
|
||||
* @example content('select_provider') // 'Please select a provider'
|
||||
* @example content('provider_selected', { provider: 'Spotify' }) // 'I chose the Spotify provider'
|
||||
*/
|
||||
protected content(key: string, data?: Record<string, unknown>): string {
|
||||
const { widget_contents: widgetContents } = SKILL_LOCALE_CONFIG
|
||||
|
||||
if (!widgetContents || !widgetContents[key]) {
|
||||
return 'INVALID'
|
||||
}
|
||||
|
||||
let content = widgetContents[key]
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
content = content[Math.floor(Math.random() * content.length)] as string
|
||||
}
|
||||
|
||||
if (data) {
|
||||
for (const key in data) {
|
||||
content = content.replaceAll(`{{ ${key} }}`, String(data[key]))
|
||||
}
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Tool runtime for executing Node.js tools.
|
||||
* This runtime exists only for Node.js because the core server is built on Node.js
|
||||
* and the ReAct loop only needs a single bridge for now.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
import type { Tool } from '@sdk/base-tool'
|
||||
import {
|
||||
TOOLS_PATH
|
||||
} from '@bridge/constants'
|
||||
import { setToolReporter } from '@sdk/tool-reporter'
|
||||
|
||||
interface ToolRuntimeCliInput {
|
||||
toolkitId: string
|
||||
toolId: string
|
||||
functionName: string
|
||||
args: unknown[]
|
||||
}
|
||||
|
||||
const parseArgs = (): ToolRuntimeCliInput => {
|
||||
const args = process.argv.slice(2)
|
||||
const getValue = (flag: string): string => {
|
||||
const index = args.indexOf(flag)
|
||||
if (index === -1 || index === args.length - 1) {
|
||||
return ''
|
||||
}
|
||||
return args[index + 1] || ''
|
||||
}
|
||||
|
||||
const toolkitId = getValue('--toolkit')
|
||||
const toolId = getValue('--tool')
|
||||
const functionName = getValue('--function')
|
||||
const rawArgs = getValue('--args')
|
||||
|
||||
if (!toolkitId || !toolId || !functionName) {
|
||||
throw new Error('Missing required arguments: --toolkit, --tool, --function')
|
||||
}
|
||||
|
||||
let parsedArgs: unknown[] = []
|
||||
if (rawArgs) {
|
||||
const decoded = JSON.parse(rawArgs)
|
||||
if (Array.isArray(decoded)) {
|
||||
parsedArgs = decoded
|
||||
} else if (decoded && typeof decoded === 'object') {
|
||||
parsedArgs = Object.values(decoded)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
toolkitId,
|
||||
toolId,
|
||||
functionName,
|
||||
args: parsedArgs
|
||||
}
|
||||
}
|
||||
|
||||
const resolveToolModulePath = (
|
||||
toolkitId: string,
|
||||
toolId: string
|
||||
): string | null => {
|
||||
const flatBuiltInToolPath = path.join(
|
||||
TOOLS_PATH,
|
||||
toolkitId,
|
||||
toolId,
|
||||
'src',
|
||||
'nodejs',
|
||||
'index.ts'
|
||||
)
|
||||
if (fs.existsSync(flatBuiltInToolPath)) {
|
||||
return flatBuiltInToolPath
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const setProjectCwd = (): void => {
|
||||
const runtimeDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const projectRoot = path.join(runtimeDir, '..', '..', '..')
|
||||
if (process.cwd() !== projectRoot) {
|
||||
process.chdir(projectRoot)
|
||||
}
|
||||
}
|
||||
|
||||
const setRuntimeToolReporter = (): void => {
|
||||
setToolReporter(async (input) => {
|
||||
process.stderr.write(`[LEON_TOOL_REPORT] ${JSON.stringify(input)}\n`)
|
||||
})
|
||||
}
|
||||
|
||||
const run = async (): Promise<void> => {
|
||||
try {
|
||||
setProjectCwd()
|
||||
setRuntimeToolReporter()
|
||||
const input = parseArgs()
|
||||
const toolModulePath = resolveToolModulePath(
|
||||
input.toolkitId,
|
||||
input.toolId
|
||||
)
|
||||
if (!toolModulePath) {
|
||||
throw new Error(`Tool module not found for ${input.toolId}.`)
|
||||
}
|
||||
|
||||
const toolManagerModule = await import('@sdk/tool-manager')
|
||||
const ToolManager = toolManagerModule.default
|
||||
const isMissingToolSettingsError =
|
||||
toolManagerModule.isMissingToolSettingsError
|
||||
const toolModule = await import(pathToFileURL(toolModulePath).href)
|
||||
const ToolClass = toolModule?.default
|
||||
if (!ToolClass) {
|
||||
throw new Error(`Tool ${input.toolId} has no default export.`)
|
||||
}
|
||||
|
||||
let toolInstance: Tool
|
||||
try {
|
||||
toolInstance = (await ToolManager.initTool(
|
||||
ToolClass as new () => Tool
|
||||
)) as Tool
|
||||
} catch (error) {
|
||||
if (isMissingToolSettingsError(error)) {
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
message: error.message,
|
||||
output: {
|
||||
missing_settings: error.missing,
|
||||
settings_path: error.settingsPath
|
||||
}
|
||||
})
|
||||
)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
const method = (toolInstance as unknown as Record<string, unknown>)?.[
|
||||
input.functionName
|
||||
]
|
||||
if (typeof method !== 'function') {
|
||||
throw new Error(
|
||||
`Function ${input.functionName} not found on ${input.toolId}.`
|
||||
)
|
||||
}
|
||||
|
||||
const result = await method.apply(toolInstance, input.args)
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: 'Tool executed successfully.',
|
||||
output: { result }
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
const message = (error as Error).message || 'Unknown tool runtime error.'
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
message,
|
||||
output: {}
|
||||
})
|
||||
)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
void run()
|
||||
@@ -0,0 +1 @@
|
||||
export const VERSION = '1.3.0'
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/bin",
|
||||
"rootDir": "../../",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@@/*": ["../../*"],
|
||||
"@/*": ["../../server/src/*"],
|
||||
"@aurora/style.css": ["../../aurora/style.css"],
|
||||
"@aurora": ["../../aurora/dist/index.d.ts"],
|
||||
"@aurora/*": ["../../aurora/dist/*"],
|
||||
"@server/*": ["../../server/src/*"],
|
||||
"@bridge/*": ["./src/*"],
|
||||
"@sdk/*": ["./src/sdk/*"],
|
||||
"@tools/*": ["../../tools/*/src/nodejs/index.ts", "../../tools/*.ts"]
|
||||
},
|
||||
"exactOptionalPropertyTypes": false,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user