chore: import upstream snapshot with attribution
This commit is contained in:
Vendored
@@ -0,0 +1,93 @@
|
||||
import sys
|
||||
import json
|
||||
import os
|
||||
|
||||
import version
|
||||
|
||||
DEFAULT_LEON_PROFILE = "just-me"
|
||||
LEON_HOME_DIRNAME = ".leon"
|
||||
|
||||
|
||||
argv = sys.argv[1:]
|
||||
if "--runtime" in argv:
|
||||
runtime_index = argv.index("--runtime")
|
||||
argv = [
|
||||
arg
|
||||
for index, arg in enumerate(argv)
|
||||
if index not in (runtime_index, runtime_index + 1)
|
||||
]
|
||||
|
||||
INTENT_OBJ_FILE_PATH = argv[0] if argv else None
|
||||
if not INTENT_OBJ_FILE_PATH:
|
||||
raise Exception("Missing intent object path for skill runtime.")
|
||||
|
||||
with open(INTENT_OBJ_FILE_PATH, "r", encoding="utf-8") as f:
|
||||
INTENT_OBJECT = json.load(f)
|
||||
|
||||
CODEBASE_PATH = os.path.abspath(
|
||||
os.getenv("LEON_CODEBASE_PATH", "").strip() or os.getcwd()
|
||||
)
|
||||
LEON_HOME_PATH = os.path.abspath(
|
||||
os.getenv("LEON_HOME", "").strip()
|
||||
or os.path.join(os.path.expanduser("~"), LEON_HOME_DIRNAME)
|
||||
)
|
||||
LEON_PROFILE_NAME = os.getenv("LEON_PROFILE", "").strip() or DEFAULT_LEON_PROFILE
|
||||
LEON_PROFILES_PATH = os.path.join(LEON_HOME_PATH, "profiles")
|
||||
LEON_PROFILE_PATH = os.path.join(LEON_PROFILES_PATH, LEON_PROFILE_NAME)
|
||||
LEON_TOOLKITS_PATH = os.path.join(LEON_HOME_PATH, "toolkits")
|
||||
PROFILE_CONTEXT_PATH = os.path.join(LEON_PROFILE_PATH, "context")
|
||||
PROFILE_MEMORY_PATH = os.path.join(LEON_PROFILE_PATH, "memory")
|
||||
PROFILE_MEMORY_DB_PATH = os.path.join(PROFILE_MEMORY_PATH, "index.sqlite")
|
||||
PROFILE_SKILLS_PATH = os.path.join(LEON_PROFILE_PATH, "skills")
|
||||
PROFILE_NATIVE_SKILLS_PATH = os.path.join(PROFILE_SKILLS_PATH, "native")
|
||||
PROFILE_AGENT_SKILLS_PATH = os.path.join(PROFILE_SKILLS_PATH, "agent")
|
||||
PROFILE_TOOLS_PATH = os.path.join(LEON_PROFILE_PATH, "tools")
|
||||
PROFILE_DISABLED_PATH = os.path.join(LEON_PROFILE_PATH, "disabled.json")
|
||||
PROFILE_ALLOWED_PATH = os.path.join(LEON_PROFILE_PATH, "allowed.json")
|
||||
|
||||
SKILLS_ROOT_PATH = os.path.join(CODEBASE_PATH, "skills")
|
||||
NATIVE_SKILLS_PATH = os.path.join(SKILLS_ROOT_PATH, "native")
|
||||
AGENT_SKILLS_PATH = os.path.join(SKILLS_ROOT_PATH, "agent")
|
||||
TOOLS_PATH = os.path.join(CODEBASE_PATH, "tools")
|
||||
BIN_PATH = os.path.join(LEON_HOME_PATH, "bin")
|
||||
BRIDGES_PATH = os.path.join(CODEBASE_PATH, "bridges")
|
||||
|
||||
NVIDIA_LIBS_PATH = os.path.join(BIN_PATH, "nvidia")
|
||||
|
||||
PYTORCH_PATH = os.path.join(BIN_PATH, "pytorch")
|
||||
PYTORCH_TORCH_PATH = os.path.join(PYTORCH_PATH, "torch")
|
||||
|
||||
SKILL_PATH = os.path.dirname(INTENT_OBJECT["skill_config_path"])
|
||||
|
||||
SKILLS_PATH = SKILLS_ROOT_PATH
|
||||
|
||||
SKILL_LOCALE_PATH = os.path.join(
|
||||
SKILL_PATH, "locales", f"{INTENT_OBJECT['extra_context']['lang']}.json"
|
||||
)
|
||||
if INTENT_OBJECT["skill_name"] and os.path.exists(SKILL_LOCALE_PATH):
|
||||
with open(SKILL_LOCALE_PATH, "r", encoding="utf-8") as f:
|
||||
SKILL_LOCALE_CONFIG_CONTENT = json.load(f)
|
||||
else:
|
||||
SKILL_LOCALE_CONFIG_CONTENT = {
|
||||
"variables": {},
|
||||
"common_answers": {},
|
||||
"widget_contents": {},
|
||||
"actions": {INTENT_OBJECT["action_name"]: {}},
|
||||
}
|
||||
|
||||
SKILL_LOCALE_CONFIG = (
|
||||
SKILL_LOCALE_CONFIG_CONTENT.get("actions", {})
|
||||
.get(INTENT_OBJECT["action_name"], {})
|
||||
.copy()
|
||||
)
|
||||
SKILL_LOCALE_CONFIG["variables"] = SKILL_LOCALE_CONFIG_CONTENT.get("variables", {})
|
||||
SKILL_LOCALE_CONFIG["common_answers"] = SKILL_LOCALE_CONFIG_CONTENT.get(
|
||||
"common_answers", {}
|
||||
)
|
||||
SKILL_LOCALE_CONFIG["widget_contents"] = SKILL_LOCALE_CONFIG_CONTENT.get(
|
||||
"widget_contents", {}
|
||||
)
|
||||
|
||||
LEON_VERSION = os.getenv("npm_package_version")
|
||||
|
||||
PYTHON_BRIDGE_VERSION = version.__version__
|
||||
@@ -0,0 +1,124 @@
|
||||
import sys
|
||||
import os
|
||||
import inspect
|
||||
from traceback import print_exc
|
||||
from importlib import util
|
||||
|
||||
from constants import INTENT_OBJECT, SKILL_PATH
|
||||
from sdk.params_helper import ParamsHelper
|
||||
|
||||
|
||||
# Mirror the Node bridge loader so Python actions can expose `run`,
|
||||
# `default.run`, or a callable `default`.
|
||||
def resolve_action_function(skill_action_module):
|
||||
run_function = getattr(skill_action_module, 'run', None)
|
||||
if callable(run_function):
|
||||
return run_function
|
||||
|
||||
default_export = getattr(skill_action_module, 'default', None)
|
||||
default_run_function = getattr(default_export, 'run', None)
|
||||
if callable(default_run_function):
|
||||
return default_run_function
|
||||
|
||||
if callable(default_export):
|
||||
return default_export
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_skill_venv_site_packages_path():
|
||||
venv_path = os.path.join(SKILL_PATH, 'src', '.venv')
|
||||
candidates = [
|
||||
os.path.join(
|
||||
venv_path,
|
||||
'Lib',
|
||||
'site-packages'
|
||||
),
|
||||
os.path.join(
|
||||
venv_path,
|
||||
'lib',
|
||||
f'python{sys.version_info.major}.{sys.version_info.minor}',
|
||||
'site-packages'
|
||||
)
|
||||
]
|
||||
|
||||
for candidate in candidates:
|
||||
if os.path.isdir(candidate):
|
||||
return os.path.abspath(candidate)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
skill_site_packages_path = get_skill_venv_site_packages_path()
|
||||
|
||||
if skill_site_packages_path:
|
||||
sys.path.insert(0, skill_site_packages_path)
|
||||
|
||||
params = {
|
||||
'lang': INTENT_OBJECT['lang'],
|
||||
'utterance': INTENT_OBJECT['utterance'],
|
||||
'action_arguments': INTENT_OBJECT['action_arguments'],
|
||||
'entities': INTENT_OBJECT['entities'],
|
||||
'sentiment': INTENT_OBJECT['sentiment'],
|
||||
'context_name': INTENT_OBJECT['context_name'],
|
||||
'skill_name': INTENT_OBJECT['skill_name'],
|
||||
'action_name': INTENT_OBJECT['action_name'],
|
||||
'context': INTENT_OBJECT['context'],
|
||||
'skill_config': INTENT_OBJECT['skill_config'],
|
||||
'skill_config_path': INTENT_OBJECT['skill_config_path'],
|
||||
'extra_context': INTENT_OBJECT['extra_context']
|
||||
}
|
||||
|
||||
try:
|
||||
sys.path.append('.')
|
||||
sys.path.insert(0, os.path.dirname(SKILL_PATH))
|
||||
|
||||
action_path = os.path.join(
|
||||
SKILL_PATH,
|
||||
'src',
|
||||
'actions',
|
||||
INTENT_OBJECT['action_name'] + '.py'
|
||||
)
|
||||
spec = util.spec_from_file_location(
|
||||
INTENT_OBJECT['skill_name']
|
||||
+ '.src.actions.'
|
||||
+ INTENT_OBJECT['action_name'],
|
||||
action_path
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError(f'Cannot load action module from "{action_path}"')
|
||||
|
||||
skill_action_module = util.module_from_spec(spec)
|
||||
spec.loader.exec_module(skill_action_module)
|
||||
|
||||
run_function = resolve_action_function(skill_action_module)
|
||||
if not callable(run_function):
|
||||
raise TypeError(
|
||||
f'Action "{INTENT_OBJECT["skill_name"]}:{INTENT_OBJECT["action_name"]}" '
|
||||
'does not export a runnable action function'
|
||||
)
|
||||
|
||||
params_helper = ParamsHelper(params)
|
||||
|
||||
# Inspect to decide how many args to pass
|
||||
signature = inspect.signature(run_function)
|
||||
param_count = len(signature.parameters)
|
||||
|
||||
if param_count >= 2:
|
||||
run_function(params, params_helper)
|
||||
elif param_count == 1:
|
||||
run_function(params)
|
||||
else:
|
||||
run_function()
|
||||
except Exception as e:
|
||||
print(f"Error while running {INTENT_OBJECT['skill_name']} skill {INTENT_OBJECT['action_name']} action: {e}")
|
||||
print_exc()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except Exception:
|
||||
# Print full traceback error report if skills triggers an error from the call stack.
|
||||
print_exc()
|
||||
@@ -0,0 +1,14 @@
|
||||
[project]
|
||||
name = "leon-python-bridge"
|
||||
# Keep this metadata version for uv project compatibility only.
|
||||
# Leon runtime versioning still comes from `version.py`.
|
||||
version = "1.0.0"
|
||||
requires-python = "==3.11.9"
|
||||
dependencies = [
|
||||
"requests==2.32.3",
|
||||
"beautifulsoup4==4.7.1",
|
||||
"pypdl==1.5.6",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
package = false
|
||||
@@ -0,0 +1,6 @@
|
||||
from ..widget_component import WidgetComponent
|
||||
|
||||
|
||||
class Button(WidgetComponent[dict]):
|
||||
def __init__(self, props: dict):
|
||||
super().__init__(props)
|
||||
@@ -0,0 +1,6 @@
|
||||
from ..widget_component import WidgetComponent
|
||||
|
||||
|
||||
class Card(WidgetComponent[dict]):
|
||||
def __init__(self, props: dict):
|
||||
super().__init__(props)
|
||||
@@ -0,0 +1,6 @@
|
||||
from ..widget_component import WidgetComponent
|
||||
|
||||
|
||||
class Checkbox(WidgetComponent[dict]):
|
||||
def __init__(self, props: dict):
|
||||
super().__init__(props)
|
||||
@@ -0,0 +1,6 @@
|
||||
from ..widget_component import WidgetComponent
|
||||
|
||||
|
||||
class CircularProgress(WidgetComponent[dict]):
|
||||
def __init__(self, props: dict):
|
||||
super().__init__(props)
|
||||
@@ -0,0 +1,6 @@
|
||||
from ..widget_component import WidgetComponent
|
||||
|
||||
|
||||
class Flexbox(WidgetComponent[dict]):
|
||||
def __init__(self, props: dict):
|
||||
super().__init__(props)
|
||||
@@ -0,0 +1,6 @@
|
||||
from ..widget_component import WidgetComponent
|
||||
|
||||
|
||||
class Form(WidgetComponent[dict]):
|
||||
def __init__(self, props: dict):
|
||||
super().__init__(props)
|
||||
@@ -0,0 +1,6 @@
|
||||
from ..widget_component import WidgetComponent
|
||||
|
||||
|
||||
class Icon(WidgetComponent[dict]):
|
||||
def __init__(self, props: dict):
|
||||
super().__init__(props)
|
||||
@@ -0,0 +1,6 @@
|
||||
from ..widget_component import WidgetComponent
|
||||
|
||||
|
||||
class IconButton(WidgetComponent[dict]):
|
||||
def __init__(self, props: dict):
|
||||
super().__init__(props)
|
||||
@@ -0,0 +1,6 @@
|
||||
from ..widget_component import WidgetComponent
|
||||
|
||||
|
||||
class Image(WidgetComponent[dict]):
|
||||
def __init__(self, props: dict):
|
||||
super().__init__(props)
|
||||
@@ -0,0 +1,6 @@
|
||||
from ..widget_component import WidgetComponent
|
||||
|
||||
|
||||
class Input(WidgetComponent[dict]):
|
||||
def __init__(self, props: dict):
|
||||
super().__init__(props)
|
||||
@@ -0,0 +1,6 @@
|
||||
from ..widget_component import WidgetComponent
|
||||
|
||||
|
||||
class Link(WidgetComponent[dict]):
|
||||
def __init__(self, props: dict):
|
||||
super().__init__(props)
|
||||
@@ -0,0 +1,6 @@
|
||||
from ..widget_component import WidgetComponent
|
||||
|
||||
|
||||
class List(WidgetComponent[dict]):
|
||||
def __init__(self, props: dict):
|
||||
super().__init__(props)
|
||||
@@ -0,0 +1,6 @@
|
||||
from ..widget_component import WidgetComponent
|
||||
|
||||
|
||||
class ListHeader(WidgetComponent[dict]):
|
||||
def __init__(self, props: dict):
|
||||
super().__init__(props)
|
||||
@@ -0,0 +1,6 @@
|
||||
from ..widget_component import WidgetComponent
|
||||
|
||||
|
||||
class ListItem(WidgetComponent[dict]):
|
||||
def __init__(self, props: dict):
|
||||
super().__init__(props)
|
||||
@@ -0,0 +1,6 @@
|
||||
from ..widget_component import WidgetComponent
|
||||
|
||||
|
||||
class Loader(WidgetComponent[dict]):
|
||||
def __init__(self, props: dict):
|
||||
super().__init__(props)
|
||||
@@ -0,0 +1,6 @@
|
||||
from ..widget_component import WidgetComponent
|
||||
|
||||
|
||||
class Progress(WidgetComponent[dict]):
|
||||
def __init__(self, props: dict):
|
||||
super().__init__(props)
|
||||
@@ -0,0 +1,6 @@
|
||||
from ..widget_component import WidgetComponent
|
||||
|
||||
|
||||
class Radio(WidgetComponent[dict]):
|
||||
def __init__(self, props: dict):
|
||||
super().__init__(props)
|
||||
@@ -0,0 +1,6 @@
|
||||
from ..widget_component import WidgetComponent
|
||||
|
||||
|
||||
class RadioGroup(WidgetComponent[dict]):
|
||||
def __init__(self, props: dict):
|
||||
super().__init__(props)
|
||||
@@ -0,0 +1,6 @@
|
||||
from ..widget_component import WidgetComponent
|
||||
|
||||
|
||||
class RangeSlider(WidgetComponent[dict]):
|
||||
def __init__(self, props: dict):
|
||||
super().__init__(props)
|
||||
@@ -0,0 +1,6 @@
|
||||
from ..widget_component import WidgetComponent
|
||||
|
||||
|
||||
class ScrollContainer(WidgetComponent[dict]):
|
||||
def __init__(self, props: dict):
|
||||
super().__init__(props)
|
||||
@@ -0,0 +1,6 @@
|
||||
from ..widget_component import WidgetComponent
|
||||
|
||||
|
||||
class Select(WidgetComponent[dict]):
|
||||
def __init__(self, props: dict):
|
||||
super().__init__(props)
|
||||
@@ -0,0 +1,6 @@
|
||||
from ..widget_component import WidgetComponent
|
||||
|
||||
|
||||
class SelectOption(WidgetComponent[dict]):
|
||||
def __init__(self, props: dict):
|
||||
super().__init__(props)
|
||||
@@ -0,0 +1,6 @@
|
||||
from ..widget_component import WidgetComponent
|
||||
|
||||
|
||||
class Status(WidgetComponent[dict]):
|
||||
def __init__(self, props: dict):
|
||||
super().__init__(props)
|
||||
@@ -0,0 +1,6 @@
|
||||
from ..widget_component import WidgetComponent
|
||||
|
||||
|
||||
class Switch(WidgetComponent[dict]):
|
||||
def __init__(self, props: dict):
|
||||
super().__init__(props)
|
||||
@@ -0,0 +1,6 @@
|
||||
from ..widget_component import WidgetComponent
|
||||
|
||||
|
||||
class Tab(WidgetComponent[dict]):
|
||||
def __init__(self, props: dict):
|
||||
super().__init__(props)
|
||||
@@ -0,0 +1,6 @@
|
||||
from ..widget_component import WidgetComponent
|
||||
|
||||
|
||||
class TabContent(WidgetComponent[dict]):
|
||||
def __init__(self, props: dict):
|
||||
super().__init__(props)
|
||||
@@ -0,0 +1,6 @@
|
||||
from ..widget_component import WidgetComponent
|
||||
|
||||
|
||||
class TabGroup(WidgetComponent[dict]):
|
||||
def __init__(self, props: dict):
|
||||
super().__init__(props)
|
||||
@@ -0,0 +1,6 @@
|
||||
from ..widget_component import WidgetComponent
|
||||
|
||||
|
||||
class TabList(WidgetComponent[dict]):
|
||||
def __init__(self, props: dict):
|
||||
super().__init__(props)
|
||||
@@ -0,0 +1,6 @@
|
||||
from ..widget_component import WidgetComponent
|
||||
|
||||
|
||||
class Text(WidgetComponent[dict]):
|
||||
def __init__(self, props: dict):
|
||||
super().__init__(props)
|
||||
@@ -0,0 +1,6 @@
|
||||
from ..widget_component import WidgetComponent
|
||||
|
||||
|
||||
class WidgetWrapper(WidgetComponent[dict]):
|
||||
def __init__(self, props: dict):
|
||||
super().__init__(props)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,164 @@
|
||||
import random
|
||||
import sys
|
||||
from typing import Union, Dict, Any, Optional
|
||||
from time import sleep
|
||||
import json
|
||||
import time
|
||||
import os
|
||||
|
||||
from .aurora.widget_wrapper import WidgetWrapper
|
||||
from .types import AnswerInput, AnswerData, AnswerConfig
|
||||
from .widget_component import SUPPORTED_WIDGET_EVENTS
|
||||
from ..constants import SKILL_LOCALE_CONFIG, INTENT_OBJECT
|
||||
|
||||
|
||||
class Leon:
|
||||
instance: 'Leon' = None
|
||||
global_answers: Dict[str, Any] = {}
|
||||
|
||||
def __init__(self) -> None:
|
||||
if not Leon.instance:
|
||||
Leon.instance = self
|
||||
self._load_global_answers()
|
||||
|
||||
def _load_global_answers(self) -> None:
|
||||
"""Load global answers from core data directory"""
|
||||
try:
|
||||
lang = INTENT_OBJECT.get('lang', 'en')
|
||||
answers_path = os.path.join(os.getcwd(), 'core', 'data', lang, 'answers.json')
|
||||
|
||||
if os.path.exists(answers_path):
|
||||
with open(answers_path, 'r', encoding='utf-8') as f:
|
||||
answers_data = json.load(f)
|
||||
Leon.global_answers = answers_data.get('answers', {})
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not load global answers: {e}")
|
||||
Leon.global_answers = {}
|
||||
|
||||
@staticmethod
|
||||
def _get_answer_text(answer: Union[str, AnswerConfig, None]) -> str:
|
||||
"""Convert an answer config to a text-only value."""
|
||||
if not answer:
|
||||
return ''
|
||||
|
||||
if isinstance(answer, str):
|
||||
return answer
|
||||
|
||||
return answer.get('text') or answer.get('speech') or ''
|
||||
|
||||
@staticmethod
|
||||
def _inject_variables(answer: AnswerConfig, data_to_inject: Union[Dict[str, Any], None]) -> AnswerConfig:
|
||||
"""A private helper to inject variables into an answer string or object"""
|
||||
if not data_to_inject:
|
||||
return answer
|
||||
|
||||
for key, value in data_to_inject.items():
|
||||
if isinstance(answer, str):
|
||||
answer = answer.replace(f"{{{{ {key} }}}}", str(value))
|
||||
elif isinstance(answer, dict):
|
||||
if 'text' in answer and answer['text']:
|
||||
answer['text'] = answer['text'].replace(f"{{{{ {key} }}}}", str(value))
|
||||
if 'speech' in answer and answer['speech']:
|
||||
answer['speech'] = answer['speech'].replace(f"{{{{ {key} }}}}", str(value))
|
||||
|
||||
return answer
|
||||
|
||||
def set_answer_data(self, answer_key: str, data: Union[AnswerData, None] = None) -> Union[str, AnswerConfig]:
|
||||
"""
|
||||
Apply data to the answer
|
||||
:param answer_key: The answer key
|
||||
:param data: The data to apply
|
||||
"""
|
||||
try:
|
||||
# Prioritize skill-specific answers, then fall back to common answers, then global answers
|
||||
answers_config = (
|
||||
SKILL_LOCALE_CONFIG.get('answers', {}).get(answer_key) or
|
||||
SKILL_LOCALE_CONFIG.get('common_answers', {}).get(answer_key) or
|
||||
Leon.global_answers.get(answer_key)
|
||||
)
|
||||
|
||||
# In case the answer key is not found or is a raw answer
|
||||
if not answers_config:
|
||||
return answer_key
|
||||
|
||||
# Pick a random answer if it's a list
|
||||
answer = random.choice(answers_config) if isinstance(answers_config, list) else answers_config
|
||||
|
||||
# Inject variables from the data parameter and from the global variables config
|
||||
answer = self._inject_variables(answer, data)
|
||||
answer = self._inject_variables(answer, SKILL_LOCALE_CONFIG.get('variables'))
|
||||
|
||||
return answer
|
||||
except Exception as e:
|
||||
print(f'Error while setting answer data. Please verify that the answer key "{answer_key}" exists in the locale configuration. Details:', e)
|
||||
raise e
|
||||
|
||||
def answer(self, answer_input: AnswerInput) -> Optional[str]:
|
||||
"""
|
||||
Send an answer to the core
|
||||
:param answer_input: The answer input
|
||||
:return: Message ID for potential future replacement
|
||||
"""
|
||||
try:
|
||||
key = answer_input.get('key')
|
||||
resolved_answer = self.set_answer_data(key, answer_input.get('data')) if key is not None else ''
|
||||
fallback_text = self._get_answer_text(resolved_answer)
|
||||
|
||||
if answer_input.get('widget') is not None and not fallback_text:
|
||||
raise ValueError(
|
||||
'Widget answers must include a text fallback via `key`.'
|
||||
)
|
||||
|
||||
output = {
|
||||
'output': {
|
||||
'codes': 'widget' if answer_input.get('widget') and not answer_input.get('key') else answer_input.get('key'),
|
||||
'answer': resolved_answer,
|
||||
'core': answer_input.get('core'),
|
||||
'replaceMessageId': answer_input.get('replaceMessageId')
|
||||
}
|
||||
}
|
||||
|
||||
widget = answer_input.get('widget')
|
||||
if widget is not None:
|
||||
wrapper_props = widget.wrapper_props if widget.wrapper_props else {}
|
||||
output['output']['widget'] = {
|
||||
'actionName': f"{INTENT_OBJECT['skill_name']}:{INTENT_OBJECT['action_name']}",
|
||||
'widget': widget.widget,
|
||||
'id': widget.id,
|
||||
'onFetch': widget.on_fetch if hasattr(widget, 'on_fetch') else None,
|
||||
'fallbackText': fallback_text,
|
||||
'historyMode': answer_input.get('widgetHistoryMode') or 'persisted',
|
||||
'componentTree': WidgetWrapper({
|
||||
**wrapper_props,
|
||||
'children': [widget.render()]
|
||||
}).__dict__(),
|
||||
'supportedEvents': SUPPORTED_WIDGET_EVENTS
|
||||
}
|
||||
|
||||
answer_object = {
|
||||
**INTENT_OBJECT,
|
||||
**output
|
||||
}
|
||||
|
||||
# "Temporize" for the data buffer output on the core
|
||||
sleep(0.1)
|
||||
|
||||
# Write the answer object to stdout as a JSON string with a newline for brain chunk-by-chunk parsing
|
||||
sys.stdout.write(json.dumps(answer_object) + '\n')
|
||||
sys.stdout.flush()
|
||||
|
||||
# Return the message ID for future replacement (matches Node.js SDK)
|
||||
return (
|
||||
widget.id if widget else
|
||||
f"msg-{int(time.time() * 1000)}-{hex(random.randint(0, 0xffffff))[2:]}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print('Error while creating answer:', e)
|
||||
if 'not JSON serializable' in str(e):
|
||||
print("Hint: make sure that widget children components are a list. "
|
||||
"E.g. { 'children': [Text({ 'children': 'Hello' })] }")
|
||||
return None
|
||||
|
||||
|
||||
leon = Leon()
|
||||
@@ -0,0 +1,87 @@
|
||||
import json
|
||||
import os
|
||||
from typing import TypedDict, Any
|
||||
|
||||
from ..constants import PROFILE_NATIVE_SKILLS_PATH, SKILL_PATH
|
||||
|
||||
SKILL_NAME_SUFFIX = "_skill"
|
||||
|
||||
|
||||
def normalize_skill_name(skill_name: str) -> str:
|
||||
return (
|
||||
skill_name
|
||||
if skill_name.endswith(SKILL_NAME_SUFFIX)
|
||||
else f"{skill_name}{SKILL_NAME_SUFFIX}"
|
||||
)
|
||||
|
||||
|
||||
class MemoryOptions(TypedDict, total=False):
|
||||
name: str
|
||||
default_memory: Any
|
||||
|
||||
|
||||
class Memory:
|
||||
def __init__(self, options: MemoryOptions):
|
||||
self.name = options['name']
|
||||
self.default_memory = options['default_memory'] if 'default_memory' in options else None
|
||||
self.memory_path = os.path.join(
|
||||
PROFILE_NATIVE_SKILLS_PATH,
|
||||
os.path.basename(SKILL_PATH),
|
||||
'memory',
|
||||
f'{self.name}.json'
|
||||
)
|
||||
self.__is_from_another_skill = False
|
||||
|
||||
if ':' in self.name and self.name.count(':') == 2:
|
||||
self.__is_from_another_skill = True
|
||||
_, skill_name, memory_name = self.name.split(':')
|
||||
self.memory_path = os.path.join(
|
||||
PROFILE_NATIVE_SKILLS_PATH,
|
||||
normalize_skill_name(skill_name),
|
||||
'memory',
|
||||
memory_name + '.json'
|
||||
)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""
|
||||
Clear the memory and set it to the default memory value
|
||||
"""
|
||||
if not self.__is_from_another_skill:
|
||||
self.write(self.default_memory)
|
||||
else:
|
||||
raise ValueError(f'You cannot clear the memory "{self.name}" as it belongs to another skill')
|
||||
|
||||
def read(self):
|
||||
"""
|
||||
Read the memory
|
||||
"""
|
||||
if self.__is_from_another_skill and not os.path.exists(self.memory_path):
|
||||
raise ValueError(f'You cannot read the memory "{self.name}" as it belongs to another skill which hasn\'t written to this memory yet')
|
||||
|
||||
try:
|
||||
if not os.path.exists(self.memory_path):
|
||||
self.clear()
|
||||
|
||||
with open(self.memory_path, 'r') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
print(f'Error while reading memory for "{self.name}": {e}')
|
||||
raise e
|
||||
|
||||
def write(self, memory):
|
||||
"""
|
||||
Write the memory
|
||||
:param memory: The memory to write
|
||||
"""
|
||||
if not self.__is_from_another_skill:
|
||||
try:
|
||||
os.makedirs(os.path.dirname(self.memory_path), exist_ok=True)
|
||||
with open(self.memory_path, 'w') as f:
|
||||
json.dump(memory, f, indent=2)
|
||||
|
||||
return memory
|
||||
except Exception as e:
|
||||
print(f'Error while writing memory for "{self.name}": {e}')
|
||||
raise e
|
||||
else:
|
||||
raise ValueError(f'You cannot write into the memory "{self.name}" as it belongs to another skill')
|
||||
@@ -0,0 +1,148 @@
|
||||
import json
|
||||
import requests
|
||||
import socket
|
||||
import sys
|
||||
from typing import Any, Dict, TypedDict, Union, Literal, Optional
|
||||
|
||||
from ..constants import LEON_VERSION, PYTHON_BRIDGE_VERSION
|
||||
|
||||
|
||||
class NetworkOptions(TypedDict, total=False):
|
||||
base_url: Optional[str]
|
||||
|
||||
|
||||
class NetworkResponse(TypedDict):
|
||||
data: Any
|
||||
status_code: int
|
||||
options: Dict[str, Any]
|
||||
|
||||
|
||||
class NetworkError(Exception):
|
||||
def __init__(self, response: NetworkResponse) -> None:
|
||||
self.response = response
|
||||
super().__init__(f"[NetworkError]: {response['status_code']}")
|
||||
|
||||
@staticmethod
|
||||
def _format_error_data(data: Any) -> str:
|
||||
if isinstance(data, str):
|
||||
return data
|
||||
|
||||
try:
|
||||
return json.dumps(data)
|
||||
except Exception:
|
||||
return str(data)
|
||||
|
||||
|
||||
class NetworkRequestOptions(TypedDict, total=False):
|
||||
url: str
|
||||
method: Union[Literal['GET'], Literal['POST'], Literal['PUT'], Literal['PATCH'], Literal['DELETE']]
|
||||
data: Dict[str, Any]
|
||||
headers: Dict[str, str]
|
||||
files: Dict[str, Any]
|
||||
use_json: bool
|
||||
response_type: Optional[Union[Literal['json'], Literal['text'], Literal['arraybuffer'], Literal['bytes']]]
|
||||
|
||||
class Network:
|
||||
def __init__(self, options: NetworkOptions = {'base_url': None}) -> None:
|
||||
self.options = options
|
||||
|
||||
def request(self, options: NetworkRequestOptions) -> NetworkResponse:
|
||||
try:
|
||||
url = options['url']
|
||||
|
||||
if self.options['base_url'] is not None:
|
||||
url = (self.options['base_url'] or '') + url
|
||||
|
||||
method = options['method']
|
||||
data = options.get('data', {})
|
||||
headers = options.get('headers', {})
|
||||
files = options.get('files')
|
||||
use_json = options.get('use_json', True)
|
||||
response_type = options.get('response_type', 'json')
|
||||
|
||||
request_kwargs: Dict[str, Any] = {
|
||||
'headers': {
|
||||
'User-Agent': f"Leon Personal Assistant {LEON_VERSION} - Python Bridge {PYTHON_BRIDGE_VERSION}",
|
||||
**headers
|
||||
}
|
||||
}
|
||||
|
||||
# If files are provided or JSON is explicitly disabled, send form data
|
||||
if files or not use_json:
|
||||
request_kwargs['data'] = data
|
||||
if files:
|
||||
request_kwargs['files'] = files
|
||||
else:
|
||||
request_kwargs['json'] = data
|
||||
|
||||
response = requests.request(
|
||||
method,
|
||||
url,
|
||||
**request_kwargs
|
||||
)
|
||||
|
||||
parsed_data: Any
|
||||
if response_type in ['arraybuffer', 'bytes']:
|
||||
parsed_data = response.content
|
||||
else:
|
||||
try:
|
||||
parsed_data = response.json()
|
||||
except Exception:
|
||||
parsed_data = response.text
|
||||
|
||||
network_response: NetworkResponse = {
|
||||
'data': parsed_data,
|
||||
'status_code': response.status_code,
|
||||
'options': {**self.options, **options}
|
||||
}
|
||||
|
||||
if response.ok:
|
||||
return network_response
|
||||
else:
|
||||
print(
|
||||
'[NetworkError]',
|
||||
network_response['status_code'],
|
||||
options.get('method'),
|
||||
options.get('url'),
|
||||
NetworkError._format_error_data(network_response['data']),
|
||||
file=sys.stderr
|
||||
)
|
||||
raise NetworkError(network_response)
|
||||
except requests.exceptions.RequestException as error:
|
||||
status_code = 500
|
||||
raw_data: Any = ''
|
||||
|
||||
if error.response is not None:
|
||||
status_code = error.response.status_code
|
||||
try:
|
||||
raw_data = error.response.json()
|
||||
except Exception:
|
||||
raw_data = error.response.text
|
||||
|
||||
response_payload: NetworkResponse = {
|
||||
'data': raw_data,
|
||||
'status_code': status_code,
|
||||
'options': {**self.options, **options}
|
||||
}
|
||||
|
||||
print(
|
||||
'[NetworkError]',
|
||||
response_payload['status_code'],
|
||||
options.get('method'),
|
||||
options.get('url'),
|
||||
NetworkError._format_error_data(response_payload['data']),
|
||||
file=sys.stderr
|
||||
)
|
||||
|
||||
raise NetworkError(response_payload) from error
|
||||
|
||||
def is_network_error(self, error: Exception) -> bool:
|
||||
return isinstance(error, NetworkError)
|
||||
|
||||
def is_network_available(self) -> bool:
|
||||
try:
|
||||
socket.gethostbyname('getleon.ai')
|
||||
|
||||
return True
|
||||
except socket.error:
|
||||
return False
|
||||
@@ -0,0 +1,131 @@
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from constants import INTENT_OBJECT
|
||||
|
||||
NEREntity = Dict[str, Any]
|
||||
ActionParams = Dict[str, Any]
|
||||
|
||||
|
||||
class ParamsHelper:
|
||||
"""
|
||||
A helper class to simplify accessing data from the action's params object
|
||||
"""
|
||||
|
||||
def __init__(self, params: ActionParams):
|
||||
self._params = params
|
||||
|
||||
def get_widget_id(self) -> Optional[str]:
|
||||
"""
|
||||
Get the widget ID if any
|
||||
"""
|
||||
for entity in INTENT_OBJECT['entities']:
|
||||
if entity['entity'] == 'widgetid':
|
||||
return entity['sourceText']
|
||||
|
||||
return None
|
||||
|
||||
def get_action_argument(self, name: str) -> Optional[Any]:
|
||||
"""
|
||||
Get a specific action argument from the current turn by its name
|
||||
|
||||
:param name: The name of the action argument to retrieve
|
||||
"""
|
||||
|
||||
return self._params.get('action_arguments', {}).get(name)
|
||||
|
||||
def find_entity(self, entity_name: str) -> Optional[NEREntity]:
|
||||
"""
|
||||
Find the first entity in the current turn that matches the given name
|
||||
|
||||
:param entity_name: The name of the entity to find (e.g., 'language')
|
||||
"""
|
||||
|
||||
entities = self._params.get('entities', [])
|
||||
|
||||
# A generator expression with next() is an efficient way to find the first item
|
||||
return next((entity for entity in entities if entity.get('entity') == entity_name), None)
|
||||
|
||||
def find_last_entity(self, entity_name: str) -> Optional[NEREntity]:
|
||||
"""
|
||||
Find the last entity in the current turn that matches the given name.
|
||||
Useful when an utterance contains duplicates
|
||||
|
||||
:param entity_name: The name of the entity to find (e.g., 'color')
|
||||
"""
|
||||
|
||||
entities = self._params.get('entities', [])
|
||||
|
||||
# Iterate over a reversed list to find the last occurrence first
|
||||
return next((entity for entity in reversed(entities) if entity.get('entity') == entity_name), None)
|
||||
|
||||
def find_all_entities(self, entity_name: str) -> List[NEREntity]:
|
||||
"""
|
||||
Find all entities in the current turn that match the given name
|
||||
|
||||
:param entity_name: The name of the entities to find (e.g., 'date')
|
||||
"""
|
||||
|
||||
entities = self._params.get('entities', [])
|
||||
|
||||
return [entity for entity in entities if entity.get('entity') == entity_name]
|
||||
|
||||
def find_action_argument_from_context(self, name: str) -> Optional[Any]:
|
||||
"""
|
||||
Find the first action argument in the conversation context that matches the given name
|
||||
|
||||
:param name: The name of the action argument to find
|
||||
"""
|
||||
|
||||
action_args_history = self._params.get('context', {}).get('action_arguments', [])
|
||||
for args in action_args_history:
|
||||
if args and name in args:
|
||||
return args[name]
|
||||
|
||||
return None
|
||||
|
||||
def find_last_action_argument_from_context(self, name: str) -> Optional[Any]:
|
||||
"""
|
||||
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
|
||||
"""
|
||||
|
||||
action_args_history = self._params.get('context', {}).get('action_arguments', [])
|
||||
for args in reversed(action_args_history):
|
||||
if args and name in args:
|
||||
return args[name]
|
||||
|
||||
return None
|
||||
|
||||
def find_last_entity_from_context(self, entity_name: str) -> Optional[NEREntity]:
|
||||
"""
|
||||
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 a user mentioned a specific piece of information
|
||||
|
||||
:param entity_name: The name of the entity to find in the conversation history
|
||||
"""
|
||||
|
||||
context_entities = self._params.get('context', {}).get('entities', [])
|
||||
|
||||
return next((entity for entity in reversed(context_entities) if entity.get('entity') == entity_name), None)
|
||||
|
||||
def find_all_entities_from_context(self, entity_name: str) -> List[NEREntity]:
|
||||
"""
|
||||
Find all historical entities that match the given name from the entire conversation context
|
||||
|
||||
:param entity_name: The name of the entities to find in the conversation history
|
||||
"""
|
||||
|
||||
context_entities = self._params.get('context', {}).get('entities', [])
|
||||
|
||||
return [entity for entity in context_entities if entity.get('entity') == entity_name]
|
||||
|
||||
def get_context_data(self, key: str) -> Optional[Any]:
|
||||
"""
|
||||
Get a value stored in the generic context data store
|
||||
|
||||
:param key: The key to retrieve
|
||||
"""
|
||||
|
||||
return self._params.get('context', {}).get('data', {}).get(key)
|
||||
@@ -0,0 +1,95 @@
|
||||
import json
|
||||
import os
|
||||
from os import path
|
||||
from typing import Union, Any, overload
|
||||
|
||||
from ..constants import PROFILE_NATIVE_SKILLS_PATH, SKILL_PATH
|
||||
|
||||
|
||||
class Settings:
|
||||
def __init__(self):
|
||||
self.settings_path = path.join(
|
||||
PROFILE_NATIVE_SKILLS_PATH,
|
||||
path.basename(SKILL_PATH),
|
||||
'settings.json'
|
||||
)
|
||||
self.settings_sample_path = path.join(SKILL_PATH, 'src', 'settings.sample.json')
|
||||
|
||||
def is_setting_set(self, key: str) -> bool:
|
||||
"""
|
||||
Check if a setting is already set
|
||||
:param key: The key to verify whether its value is set
|
||||
"""
|
||||
settings_sample = self.get_settings_sample()
|
||||
settings = self.get()
|
||||
|
||||
return key in settings and json.dumps(settings[key]) != json.dumps(settings_sample[key])
|
||||
|
||||
def clear(self) -> None:
|
||||
"""
|
||||
Clear the settings and set it to the default settings.sample.json file
|
||||
"""
|
||||
settings_sample = self.get_settings_sample()
|
||||
self.set(settings_sample)
|
||||
|
||||
def get_settings_sample(self) -> dict[str, Any]:
|
||||
try:
|
||||
with open(self.settings_sample_path, 'r') as file:
|
||||
return json.load(file)
|
||||
except Exception as e:
|
||||
print(f"Error while reading settings sample at '{self.settings_sample_path}': {e}")
|
||||
raise e
|
||||
|
||||
@overload
|
||||
def get(self, key: str) -> Any: ...
|
||||
|
||||
@overload
|
||||
def get(self, key: None = None) -> dict[str, Any]: ...
|
||||
|
||||
def get(self, key: Union[str, None] = None) -> Union[dict[str, Any], Any]:
|
||||
"""
|
||||
Get the settings
|
||||
:param key: The key to get from the settings
|
||||
"""
|
||||
try:
|
||||
if not os.path.exists(self.settings_path):
|
||||
self.clear()
|
||||
|
||||
with open(self.settings_path, 'r') as file:
|
||||
settings = json.load(file)
|
||||
|
||||
if key is not None:
|
||||
return settings[key]
|
||||
|
||||
return settings
|
||||
except Exception as e:
|
||||
print(f"Error while reading settings at '{self.settings_path}': {e}")
|
||||
raise e
|
||||
|
||||
@overload
|
||||
def set(self, key_or_settings: dict[str, Any]) -> dict[str, Any]: ...
|
||||
|
||||
@overload
|
||||
def set(self, key_or_settings: str, value: Any) -> dict[str, Any]: ...
|
||||
|
||||
def set(self, key_or_settings: Union[str, dict[str, Any]], value: Any = None) -> dict[str, Any]:
|
||||
"""
|
||||
Set the settings
|
||||
:param key_or_settings: The key to set or the settings to set
|
||||
:param value: The value to set
|
||||
"""
|
||||
try:
|
||||
if isinstance(key_or_settings, dict):
|
||||
new_settings = key_or_settings
|
||||
else:
|
||||
settings = self.get()
|
||||
new_settings = {**settings, key_or_settings: value}
|
||||
|
||||
os.makedirs(path.dirname(self.settings_path), exist_ok=True)
|
||||
with open(self.settings_path, 'w') as file:
|
||||
json.dump(new_settings, file, indent=2)
|
||||
|
||||
return new_settings
|
||||
except Exception as e:
|
||||
print(f"Error while writing settings at '{self.settings_path}': {e}")
|
||||
raise e
|
||||
@@ -0,0 +1,46 @@
|
||||
from typing import Optional, Type
|
||||
|
||||
from .base_tool import BaseTool
|
||||
from .leon import leon
|
||||
from .utils import format_file_path
|
||||
|
||||
|
||||
class MissingToolSettingsError(Exception):
|
||||
def __init__(self, missing: list[str], settings_path: str):
|
||||
super().__init__(f"Missing tool settings: {', '.join(missing)}")
|
||||
self.missing = missing
|
||||
self.settings_path = settings_path
|
||||
|
||||
|
||||
class ToolManager:
|
||||
@staticmethod
|
||||
def init_tool(tool_class: Type[BaseTool]) -> BaseTool:
|
||||
tool = tool_class()
|
||||
missing = tool.get_missing_settings()
|
||||
|
||||
if missing:
|
||||
leon.answer(
|
||||
{
|
||||
"key": "bridges.tools.missing_settings",
|
||||
"data": {
|
||||
"tool_name": tool.alias_tool_name,
|
||||
"missing": ", ".join(missing.get("missing", [])),
|
||||
"settings_path": format_file_path(
|
||||
missing.get("settings_path", "")
|
||||
),
|
||||
},
|
||||
"core": {
|
||||
"should_stop_skill": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
raise MissingToolSettingsError(
|
||||
missing.get("missing", []),
|
||||
missing.get("settings_path", ""),
|
||||
)
|
||||
|
||||
return tool
|
||||
|
||||
|
||||
def is_missing_tool_settings_error(error: Exception) -> bool:
|
||||
return isinstance(error, MissingToolSettingsError)
|
||||
@@ -0,0 +1,150 @@
|
||||
import json
|
||||
import os
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from ..constants import PROFILE_TOOLS_PATH, TOOLS_PATH
|
||||
from .utils import get_platform_name
|
||||
|
||||
|
||||
def merge_missing_settings(
|
||||
default_settings: Dict[str, Any], existing_settings: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
merged_settings = {**existing_settings}
|
||||
|
||||
for key, default_value in default_settings.items():
|
||||
if key not in existing_settings:
|
||||
merged_settings[key] = default_value
|
||||
continue
|
||||
|
||||
existing_value = existing_settings[key]
|
||||
if isinstance(default_value, dict) and isinstance(existing_value, dict):
|
||||
merged_settings[key] = merge_missing_settings(
|
||||
default_value, existing_value
|
||||
)
|
||||
|
||||
return merged_settings
|
||||
|
||||
|
||||
class ToolkitConfig:
|
||||
"""Toolkit configuration loader"""
|
||||
|
||||
_config_cache: Dict[str, Dict[str, Any]] = {}
|
||||
_settings_cache: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
@classmethod
|
||||
def load(cls, toolkit_name: str, tool_name: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Load tool configuration from the flat tools structure.
|
||||
|
||||
Args:
|
||||
toolkit_name: The toolkit name (e.g., 'video_streaming')
|
||||
tool_name: Name of the tool (e.g., 'ffmpeg')
|
||||
"""
|
||||
cache_key = toolkit_name
|
||||
|
||||
# Load toolkit config if not cached
|
||||
if cache_key not in cls._config_cache:
|
||||
config_path = os.path.join(TOOLS_PATH, toolkit_name, "toolkit.json")
|
||||
|
||||
try:
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
toolkit_config = json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError) as e:
|
||||
raise Exception(
|
||||
f"Failed to load toolkit config from '{config_path}': {str(e)}"
|
||||
)
|
||||
|
||||
cls._config_cache[cache_key] = toolkit_config
|
||||
|
||||
toolkit_config = cls._config_cache[cache_key]
|
||||
tools_list = toolkit_config.get("tools", [])
|
||||
|
||||
tool_config_path = os.path.join(TOOLS_PATH, toolkit_name, tool_name, "tool.json")
|
||||
|
||||
if tool_name not in tools_list and not os.path.exists(tool_config_path):
|
||||
toolkit_name_display = toolkit_config.get("name", "unknown")
|
||||
raise Exception(
|
||||
f"Tool '{tool_name}' not found in toolkit '{toolkit_name_display}'"
|
||||
)
|
||||
|
||||
try:
|
||||
with open(tool_config_path, "r", encoding="utf-8") as f:
|
||||
tool_config = json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError) as e:
|
||||
raise Exception(
|
||||
f"Failed to load tool config from '{tool_config_path}': {str(e)}"
|
||||
)
|
||||
|
||||
return tool_config
|
||||
|
||||
@classmethod
|
||||
def load_tool_settings(
|
||||
cls,
|
||||
toolkit_name: str,
|
||||
tool_name: str,
|
||||
defaults: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Load tool-specific settings from toolkit settings file
|
||||
|
||||
Args:
|
||||
toolkit_name: The toolkit name (e.g., 'video_streaming')
|
||||
tool_name: Name of the tool (e.g., 'ffmpeg')
|
||||
defaults: Default tool settings to apply when missing
|
||||
"""
|
||||
cache_key = f"{toolkit_name}:{tool_name}"
|
||||
if cache_key in cls._settings_cache:
|
||||
return cls._settings_cache[cache_key]
|
||||
|
||||
settings_path = os.path.join(
|
||||
PROFILE_TOOLS_PATH, toolkit_name, tool_name, "settings.json"
|
||||
)
|
||||
settings_sample_path = os.path.join(
|
||||
TOOLS_PATH, toolkit_name, tool_name, "settings.sample.json"
|
||||
)
|
||||
settings_dir = os.path.dirname(settings_path)
|
||||
os.makedirs(settings_dir, exist_ok=True)
|
||||
default_settings = defaults or {}
|
||||
|
||||
if os.path.exists(settings_sample_path):
|
||||
try:
|
||||
with open(settings_sample_path, "r", encoding="utf-8") as f:
|
||||
default_settings = json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
raise Exception(
|
||||
f"Failed to load tool settings sample from '{settings_sample_path}': {str(e)}"
|
||||
)
|
||||
|
||||
tool_settings: Dict[str, Any] = {}
|
||||
should_write = False
|
||||
|
||||
if os.path.exists(settings_path):
|
||||
try:
|
||||
with open(settings_path, "r", encoding="utf-8") as f:
|
||||
tool_settings = json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
raise Exception(
|
||||
f"Failed to load toolkit settings from '{settings_path}': {str(e)}"
|
||||
)
|
||||
else:
|
||||
should_write = True
|
||||
|
||||
merged_settings = merge_missing_settings(default_settings, tool_settings)
|
||||
|
||||
if not should_write:
|
||||
should_write = tool_settings != merged_settings
|
||||
|
||||
if should_write:
|
||||
with open(settings_path, "w", encoding="utf-8") as f:
|
||||
json.dump(merged_settings, f, indent=2)
|
||||
|
||||
cls._settings_cache[cache_key] = merged_settings
|
||||
return merged_settings
|
||||
|
||||
@classmethod
|
||||
def get_binary_url(cls, config: Dict[str, Any]) -> Optional[str]:
|
||||
"""Get binary download URL for current platform with architecture granularity"""
|
||||
platform_name = get_platform_name()
|
||||
binaries = config.get("binaries", {})
|
||||
|
||||
return binaries.get(platform_name)
|
||||
@@ -0,0 +1,82 @@
|
||||
from typing import Dict, Any, Optional, Union, Literal, TypedDict
|
||||
|
||||
from .widget import Widget
|
||||
|
||||
|
||||
class NLUResultSentiment(TypedDict):
|
||||
vote: Optional[Union[Literal['positive'], Literal['neutral'], Literal['negative']]]
|
||||
score: Optional[float]
|
||||
|
||||
|
||||
class Context(TypedDict):
|
||||
utterances: list[str]
|
||||
action_arguments: list[Dict[str, Any]]
|
||||
entities: list[Any]
|
||||
sentiments: list[NLUResultSentiment]
|
||||
data: Dict[str, Any]
|
||||
|
||||
|
||||
class SkillConfig(TypedDict):
|
||||
name: str
|
||||
bridge: Union[Literal['python'], Literal['nodejs']]
|
||||
version: str
|
||||
workflow: list[str]
|
||||
|
||||
|
||||
class ExtraContext(TypedDict):
|
||||
lang: str
|
||||
date: str
|
||||
time: str
|
||||
timestamp: int
|
||||
date_time: str
|
||||
week_day: str
|
||||
|
||||
|
||||
class ActionParams(TypedDict):
|
||||
lang: str
|
||||
utterance: str
|
||||
action_arguments: Dict[str, Any]
|
||||
entities: list[Any]
|
||||
sentiment: NLUResultSentiment
|
||||
context_name: str
|
||||
skill_name: str
|
||||
action_name: str
|
||||
context: Context
|
||||
skill_config: SkillConfig
|
||||
skill_config_path: str
|
||||
extra_context: ExtraContext
|
||||
|
||||
|
||||
AnswerData = Optional[Union[Dict[str, Union[str, int]], None]]
|
||||
|
||||
|
||||
class Answer(TypedDict, total=False):
|
||||
key: Optional[str]
|
||||
widget: Optional[Widget]
|
||||
data: Optional[AnswerData]
|
||||
core: Optional[Dict[str, Any]]
|
||||
replaceMessageId: Optional[str]
|
||||
widgetHistoryMode: Optional[Literal['persisted', 'system_widget']]
|
||||
|
||||
|
||||
class TextAnswer(Answer):
|
||||
key: str
|
||||
|
||||
|
||||
class WidgetAnswer(Answer):
|
||||
widget: Widget
|
||||
key: Optional[str]
|
||||
|
||||
|
||||
class AnswerInput(TypedDict, total=False):
|
||||
key: Optional[str]
|
||||
widget: Optional[Widget]
|
||||
data: Optional[AnswerData]
|
||||
core: Optional[Dict[str, Any]]
|
||||
replaceMessageId: Optional[str]
|
||||
widgetHistoryMode: Optional[Literal['persisted', 'system_widget']]
|
||||
|
||||
|
||||
class AnswerConfig(TypedDict, total=False):
|
||||
text: Optional[str]
|
||||
speech: Optional[str]
|
||||
@@ -0,0 +1,318 @@
|
||||
import platform
|
||||
from typing import List, Optional
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import math
|
||||
from typing import Union
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
HUGGING_FACE_URL = 'https://huggingface.co'
|
||||
HUGGING_FACE_MIRROR_URL = 'https://hf-mirror.com'
|
||||
ZIP_ARCHIVE_EXTENSIONS = {'.zip', '.whl'}
|
||||
|
||||
|
||||
def can_access_hugging_face() -> bool:
|
||||
"""Check if the current network can access Hugging Face
|
||||
|
||||
Returns:
|
||||
True if Hugging Face is accessible, False otherwise
|
||||
|
||||
Example:
|
||||
can_access_hugging_face() # returns True if accessible
|
||||
"""
|
||||
try:
|
||||
req = urllib.request.Request(HUGGING_FACE_URL, method='HEAD')
|
||||
with urllib.request.urlopen(req, timeout=5) as response:
|
||||
return response.status == 200
|
||||
except (urllib.error.URLError, urllib.error.HTTPError, Exception):
|
||||
return False
|
||||
|
||||
|
||||
def set_hugging_face_url(url: str) -> str:
|
||||
"""Set the Hugging Face URL based on the network access
|
||||
|
||||
Args:
|
||||
url: The URL to set
|
||||
|
||||
Returns:
|
||||
The original URL if accessible, or the mirror URL if not accessible
|
||||
|
||||
Example:
|
||||
set_hugging_face_url('https://huggingface.co') # returns 'https://hf-mirror.com' if not accessible
|
||||
"""
|
||||
if 'huggingface.co' not in url:
|
||||
return url
|
||||
|
||||
can_access = can_access_hugging_face()
|
||||
|
||||
if not can_access:
|
||||
return url.replace(HUGGING_FACE_URL, HUGGING_FACE_MIRROR_URL)
|
||||
|
||||
return url
|
||||
|
||||
|
||||
def format_file_path(file_path: str) -> str:
|
||||
"""Formats a file path as a clickable path with proper delimiters
|
||||
|
||||
Args:
|
||||
file_path: The absolute file path to format
|
||||
|
||||
Returns:
|
||||
A formatted string that the client can detect and make clickable
|
||||
|
||||
Example:
|
||||
format_file_path('/Users/john/video.mp4') # returns '[FILE_PATH]/Users/john/video.mp4[/FILE_PATH]'
|
||||
"""
|
||||
return f"[FILE_PATH]{file_path}[/FILE_PATH]"
|
||||
|
||||
|
||||
def format_file_paths(file_paths: List[str]) -> str:
|
||||
"""Formats multiple file paths as a list of clickable paths
|
||||
|
||||
Args:
|
||||
file_paths: List of absolute file paths
|
||||
|
||||
Returns:
|
||||
A formatted string with multiple clickable paths
|
||||
|
||||
Example:
|
||||
format_file_paths(['/path1', '/path2']) # returns '[FILE_PATH]/path1[/FILE_PATH], [FILE_PATH]/path2[/FILE_PATH]'
|
||||
"""
|
||||
return ', '.join(format_file_path(path) for path in file_paths)
|
||||
|
||||
|
||||
def normalize_language_code(value: str) -> Optional[str]:
|
||||
"""Normalize a language input to an ISO 639-1 code.
|
||||
|
||||
Supports direct language codes and locale tags such as ``fr-FR``.
|
||||
Returns ``None`` when the input is not a supported code-like value.
|
||||
"""
|
||||
trimmed_value = value.strip()
|
||||
|
||||
if not trimmed_value:
|
||||
return None
|
||||
|
||||
normalized_value = trimmed_value.replace('_', '-').lower()
|
||||
language = normalized_value.split('-', 1)[0].strip()
|
||||
|
||||
if len(language) == 2 and language.isalpha():
|
||||
return language
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_platform_name() -> str:
|
||||
"""Get platform name with architecture granularity (matches system-helper.ts)
|
||||
|
||||
Returns:
|
||||
Platform name string (e.g., 'linux-x86_64', 'macosx-arm64', 'win-amd64')
|
||||
|
||||
Example:
|
||||
get_platform_name() # returns 'macosx-arm64' on Apple Silicon Mac
|
||||
"""
|
||||
system = platform.system().lower()
|
||||
architecture = platform.machine().lower()
|
||||
|
||||
if system == 'linux':
|
||||
if architecture in ['x86_64', 'amd64']:
|
||||
return 'linux-x86_64'
|
||||
elif architecture in ['aarch64', 'arm64']:
|
||||
return 'linux-aarch64'
|
||||
else:
|
||||
# Default to x86_64 for unknown architectures on Linux
|
||||
return 'linux-x86_64'
|
||||
|
||||
elif system == 'darwin':
|
||||
if architecture in ['arm64', 'aarch64'] or 'apple' in platform.processor().lower():
|
||||
return 'macosx-arm64'
|
||||
else:
|
||||
return 'macosx-x86_64'
|
||||
|
||||
elif system == 'windows':
|
||||
return 'win-amd64'
|
||||
|
||||
else:
|
||||
return 'unknown'
|
||||
|
||||
|
||||
def is_windows() -> bool:
|
||||
"""Check if current platform is Windows
|
||||
|
||||
Returns:
|
||||
True if running on Windows, False otherwise
|
||||
|
||||
Example:
|
||||
if is_windows(): executable_name += '.exe'
|
||||
"""
|
||||
return get_platform_name().startswith('win')
|
||||
|
||||
|
||||
def is_macos() -> bool:
|
||||
"""Check if current platform is macOS
|
||||
|
||||
Returns:
|
||||
True if running on macOS, False otherwise
|
||||
|
||||
Example:
|
||||
if is_macos(): remove_quarantine_attribute(binary_path)
|
||||
"""
|
||||
return get_platform_name().startswith('macosx')
|
||||
|
||||
|
||||
def is_linux() -> bool:
|
||||
"""Check if current platform is Linux
|
||||
|
||||
Returns:
|
||||
True if running on Linux, False otherwise
|
||||
|
||||
Example:
|
||||
if is_linux(): check_system_package('ffmpeg')
|
||||
"""
|
||||
return get_platform_name().startswith('linux')
|
||||
|
||||
|
||||
def format_bytes(bytes_val: float) -> str:
|
||||
"""Format bytes into human-readable units
|
||||
|
||||
Args:
|
||||
bytes_val: The number of bytes to format
|
||||
|
||||
Returns:
|
||||
A human-readable string representation
|
||||
|
||||
Example:
|
||||
format_bytes(1024) # returns "1 KB"
|
||||
format_bytes(1536) # returns "1.5 KB"
|
||||
"""
|
||||
if bytes_val == 0:
|
||||
return "0 B"
|
||||
|
||||
k = 1024
|
||||
sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
i = int(math.log(bytes_val) / math.log(k)) if bytes_val > 0 else 0
|
||||
return f"{round(bytes_val / (k ** i), 2)} {sizes[i]}"
|
||||
|
||||
|
||||
def format_speed(speed: Union[float, str]) -> str:
|
||||
"""Format speed from MB/s to human-readable format
|
||||
|
||||
Args:
|
||||
speed: The speed in MB/s (pypdl format) or already formatted string
|
||||
|
||||
Returns:
|
||||
A human-readable speed string
|
||||
|
||||
Example:
|
||||
format_speed(1.5) # returns "1.5 MB/s" (pypdl returns in MB/s)
|
||||
format_speed("1.5 MB/s") # returns "1.5 MB/s" (already formatted)
|
||||
"""
|
||||
if isinstance(speed, str):
|
||||
# If it's already formatted (e.g., "1.5 MB/s"), return as is
|
||||
if '/s' in speed:
|
||||
return speed
|
||||
# If it's a string number, convert to float
|
||||
try:
|
||||
speed = float(speed)
|
||||
except ValueError:
|
||||
return '0 B/s'
|
||||
|
||||
if speed == 0:
|
||||
return '0 B/s'
|
||||
|
||||
# pypdl returns speed in MB/s, convert to bytes/s for formatting
|
||||
bytes_per_sec = speed * 1024 * 1024
|
||||
return format_bytes(bytes_per_sec) + '/s'
|
||||
|
||||
|
||||
def format_eta(eta_str: str) -> str:
|
||||
"""Format ETA from HH:MM:SS to human-readable format
|
||||
|
||||
Args:
|
||||
eta_str: The ETA in HH:MM:SS format (pypdl format)
|
||||
|
||||
Returns:
|
||||
A human-readable ETA string
|
||||
|
||||
Example:
|
||||
format_eta("01:02:30") # returns "1h 2m 30s"
|
||||
format_eta("00:02:30") # returns "2m 30s"
|
||||
format_eta("00:00:30") # returns "30s"
|
||||
"""
|
||||
if not eta_str or eta_str == '∞':
|
||||
return '∞'
|
||||
|
||||
try:
|
||||
# Parse HH:MM:SS format
|
||||
parts = eta_str.split(':')
|
||||
if len(parts) == 3:
|
||||
hours = int(parts[0])
|
||||
minutes = int(parts[1])
|
||||
seconds = int(parts[2])
|
||||
|
||||
if hours > 0:
|
||||
return f"{hours}h {minutes}m {seconds}s"
|
||||
elif minutes > 0:
|
||||
return f"{minutes}m {seconds}s"
|
||||
|
||||
return f"{seconds}s"
|
||||
|
||||
return eta_str
|
||||
except (ValueError, IndexError):
|
||||
return eta_str
|
||||
|
||||
|
||||
def extract_archive(
|
||||
archive_path: str,
|
||||
target_path: str,
|
||||
strip_components: Optional[int] = 0
|
||||
) -> None:
|
||||
"""Extract archive file using native system commands
|
||||
|
||||
Supports .zip, .tar, .tar.gz, .tar.xz, .tgz formats across all platforms
|
||||
|
||||
Args:
|
||||
archive_path: The path to the archive file
|
||||
target_path: The path to extract to
|
||||
strip_components: Number of leading path components to strip (for tar archives)
|
||||
|
||||
Example:
|
||||
extract_archive('archive.zip', 'output/dir')
|
||||
extract_archive('archive.tar.xz', 'output/dir', strip_components=1)
|
||||
"""
|
||||
# Ensure target directory exists
|
||||
os.makedirs(target_path, exist_ok=True)
|
||||
|
||||
ext = os.path.splitext(archive_path)[1].lower()
|
||||
basename = os.path.basename(archive_path).lower()
|
||||
|
||||
try:
|
||||
if ext in ZIP_ARCHIVE_EXTENSIONS:
|
||||
if is_windows():
|
||||
subprocess.run(
|
||||
['tar', '-xf', archive_path, '-C', target_path],
|
||||
check=True,
|
||||
capture_output=True
|
||||
)
|
||||
else:
|
||||
subprocess.run(
|
||||
['unzip', '-o', '-q', archive_path, '-d', target_path],
|
||||
check=True,
|
||||
capture_output=True
|
||||
)
|
||||
elif (basename.endswith('.tar.gz') or
|
||||
basename.endswith('.tar.xz') or
|
||||
basename.endswith('.tgz') or
|
||||
ext == '.tar'):
|
||||
tar_args = ['tar', '-xf', archive_path, '-C', target_path]
|
||||
if strip_components and strip_components > 0:
|
||||
tar_args.append(f'--strip-components={strip_components}')
|
||||
|
||||
subprocess.run(tar_args, check=True, capture_output=True)
|
||||
else:
|
||||
raise Exception(f"Unsupported archive format: {archive_path}")
|
||||
except subprocess.CalledProcessError as e:
|
||||
error_output = e.stderr.decode('utf-8') if e.stderr else str(e)
|
||||
raise Exception(f"Failed to extract archive '{archive_path}': {error_output}")
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to extract archive '{archive_path}': {str(e)}")
|
||||
@@ -0,0 +1,119 @@
|
||||
from typing import Any, Optional, Generic, TypeVar, Literal, TypedDict, Union, Dict
|
||||
from dataclasses import dataclass
|
||||
from abc import ABC, abstractmethod
|
||||
import random
|
||||
import string
|
||||
|
||||
from .widget_component import WidgetComponent
|
||||
from ..constants import SKILL_LOCALE_CONFIG, INTENT_OBJECT
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
UtteranceSender = Literal['leon', 'owner']
|
||||
|
||||
|
||||
class SendUtteranceWidgetEventMethodParams(TypedDict):
|
||||
from_: UtteranceSender
|
||||
utterance: str
|
||||
|
||||
|
||||
class RunSkillActionWidgetEventMethodParams(TypedDict):
|
||||
action_name: str
|
||||
params: Dict[str, Any]
|
||||
|
||||
|
||||
class SendUtteranceOptions(TypedDict, total=False):
|
||||
from_: Optional[UtteranceSender]
|
||||
data: Optional[Dict[str, Any]]
|
||||
|
||||
|
||||
class WidgetEventMethod(TypedDict):
|
||||
methodName: Literal['send_utterance', 'run_skill_action']
|
||||
methodParams: Union[
|
||||
SendUtteranceWidgetEventMethodParams,
|
||||
RunSkillActionWidgetEventMethodParams
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class WidgetOptions(Generic[T]):
|
||||
wrapper_props: dict[str, Any] = None
|
||||
params: T = None
|
||||
on_fetch: Optional[dict[str, Any]] = None
|
||||
|
||||
|
||||
class Widget(ABC, Generic[T]):
|
||||
def __init__(self, options: WidgetOptions[T]):
|
||||
if options.wrapper_props:
|
||||
self.wrapper_props = options.wrapper_props
|
||||
else:
|
||||
self.wrapper_props = None
|
||||
self.action_name = f"{INTENT_OBJECT['skill_name']}:{INTENT_OBJECT['action_name']}"
|
||||
self.params = options.params
|
||||
self.widget = self.__class__.__name__
|
||||
if options.on_fetch:
|
||||
self.on_fetch = {
|
||||
'widgetId': options.on_fetch.get('widget_id'),
|
||||
'actionName': f"{INTENT_OBJECT['skill_name']}:{options.on_fetch.get('action_name')}"
|
||||
}
|
||||
else:
|
||||
self.on_fetch = None
|
||||
self.id = options.on_fetch.get('widget_id') if options.on_fetch \
|
||||
else f"{self.widget.lower()}-{''.join(random.choices(string.ascii_lowercase + string.digits, k=8))}"
|
||||
|
||||
@abstractmethod
|
||||
def render(self) -> WidgetComponent:
|
||||
pass
|
||||
|
||||
def send_utterance(self, key: str, options: Optional[Dict[str, Any]] = None) -> WidgetEventMethod:
|
||||
"""
|
||||
Indicate the core to send a given utterance
|
||||
:param key: The key of the content
|
||||
:param options: The options of the utterance
|
||||
"""
|
||||
utterance_content = self.content(key, options.get('data') if options else None)
|
||||
from_ = options.get('from', 'owner') if options else 'owner'
|
||||
|
||||
return WidgetEventMethod(
|
||||
methodName='send_utterance',
|
||||
methodParams={
|
||||
'from': from_,
|
||||
'utterance': utterance_content
|
||||
}
|
||||
)
|
||||
|
||||
def run_skill_action(self, action_name: str, params: Dict[str, Any]) -> WidgetEventMethod:
|
||||
"""
|
||||
Indicate the core to run a given skill action
|
||||
:param action_name: The name of the action
|
||||
:param params: The parameters of the action
|
||||
"""
|
||||
return WidgetEventMethod(
|
||||
methodName='run_skill_action',
|
||||
methodParams={
|
||||
'actionName': action_name,
|
||||
'params': params
|
||||
}
|
||||
)
|
||||
|
||||
def content(self, key: str, data: Optional[Dict[str, Any]] = None) -> str:
|
||||
"""
|
||||
Grab and compute the target content of the widget
|
||||
:param key: The key of the content
|
||||
:param data: The data to apply
|
||||
"""
|
||||
widget_contents = SKILL_LOCALE_CONFIG.get('widget_contents', {})
|
||||
|
||||
if key not in widget_contents:
|
||||
return 'INVALID'
|
||||
|
||||
content = widget_contents[key]
|
||||
|
||||
if isinstance(content, list):
|
||||
content = random.choice(content)
|
||||
|
||||
if data:
|
||||
for k, v in data.items():
|
||||
content = content.replace(f"{{{{ {key} }}}}", str(v))
|
||||
|
||||
return content
|
||||
@@ -0,0 +1,76 @@
|
||||
from typing import TypeVar, Generic, TypedDict, List, Any
|
||||
import random
|
||||
import string
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
SUPPORTED_WIDGET_EVENTS = [
|
||||
'onClick',
|
||||
'onSubmit',
|
||||
'onChange',
|
||||
'onStart',
|
||||
'onEnd'
|
||||
]
|
||||
|
||||
|
||||
def generate_id() -> str:
|
||||
return ''.join(random.choices(string.ascii_lowercase + string.digits, k=5))
|
||||
|
||||
|
||||
class WidgetEvent(TypedDict):
|
||||
type: str
|
||||
id: str
|
||||
method: Any
|
||||
|
||||
|
||||
class WidgetComponent(Generic[T]):
|
||||
def __init__(self, props: T):
|
||||
self.component = type(self).__name__
|
||||
self.id = f'{self.component.lower()}-{generate_id()}'
|
||||
self.props = props
|
||||
self.events = self.parse_events()
|
||||
|
||||
def parse_events(self) -> List[WidgetEvent]:
|
||||
if not self.props:
|
||||
return []
|
||||
|
||||
event_types = [key for key in self.props if key.startswith('on') and key in SUPPORTED_WIDGET_EVENTS]
|
||||
|
||||
return [
|
||||
WidgetEvent(
|
||||
type=event_type,
|
||||
id=f'{self.id}_{event_type.lower()}-{generate_id()}',
|
||||
method=self.props[event_type]
|
||||
)
|
||||
for event_type in event_types
|
||||
]
|
||||
|
||||
def __dict__(self):
|
||||
children_value = self.props.get('children')
|
||||
rest_of_values = {key: value for key, value in self.props.items() if key != 'children'
|
||||
and key not in SUPPORTED_WIDGET_EVENTS}
|
||||
|
||||
children = None
|
||||
|
||||
if children_value is not None:
|
||||
if isinstance(children_value, list):
|
||||
children = []
|
||||
for child in children_value:
|
||||
if isinstance(child, WidgetComponent):
|
||||
children.append(child.__dict__())
|
||||
else:
|
||||
children.append(child)
|
||||
else:
|
||||
children = children_value
|
||||
|
||||
result = {
|
||||
'component': self.component,
|
||||
'id': self.id,
|
||||
'props': {
|
||||
**rest_of_values,
|
||||
'children': children
|
||||
},
|
||||
'events': [{'type': event['type'], 'id': event['id'], 'method': event['method']} for event in self.events]
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -0,0 +1 @@
|
||||
__version__ = '1.4.0'
|
||||
Reference in New Issue
Block a user