diff --git a/.github/workflows/package-and-upload-new-linux.yaml b/.github/workflows/package-and-upload-new-linux.yaml index 730a345f..5e645974 100755 --- a/.github/workflows/package-and-upload-new-linux.yaml +++ b/.github/workflows/package-and-upload-new-linux.yaml @@ -67,7 +67,7 @@ jobs: python-version: 3.10.10 - name: Download web-build artifact - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: web-build path: ./servers/web-build diff --git a/.github/workflows/package-and-upload-new-mac.yaml b/.github/workflows/package-and-upload-new-mac.yaml index c6aa0565..3bd14c3e 100755 --- a/.github/workflows/package-and-upload-new-mac.yaml +++ b/.github/workflows/package-and-upload-new-mac.yaml @@ -67,7 +67,7 @@ jobs: python-version: 3.10.10 - name: Download web-build artifact - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: web-build path: ./servers/web-build diff --git a/.github/workflows/package-and-upload-new-windows.yaml b/.github/workflows/package-and-upload-new-windows.yaml index db60a60e..6f7c9956 100755 --- a/.github/workflows/package-and-upload-new-windows.yaml +++ b/.github/workflows/package-and-upload-new-windows.yaml @@ -66,7 +66,7 @@ jobs: python-version: 3.10.10 - name: Download web-build artifact - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: web-build path: ./servers/web-build @@ -125,7 +125,7 @@ jobs: overwrite: true - name: Download web-build artifact - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: web-build path: ./web-build diff --git a/.github/workflows/package-and-upload.yaml b/.github/workflows/package-and-upload.yaml index 1a164639..4520d4d9 100755 --- a/.github/workflows/package-and-upload.yaml +++ b/.github/workflows/package-and-upload.yaml @@ -56,7 +56,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Download web-build artifact - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: web-build path: ./web-build @@ -94,7 +94,7 @@ jobs: python-version: 3.10.10 - name: Download web-build artifact - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: web-build path: ./servers/web-build @@ -172,7 +172,7 @@ jobs: python-version: 3.10.10 - name: Download web-build artifact - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: web-build path: ./servers/web-build @@ -222,7 +222,7 @@ jobs: python-version: 3.10.10 - name: Download web-build artifact - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: web-build path: ./servers/web-build diff --git a/.github/workflows/package-test.yaml b/.github/workflows/package-test.yaml index 2f1acd5b..ef42b286 100755 --- a/.github/workflows/package-test.yaml +++ b/.github/workflows/package-test.yaml @@ -57,7 +57,7 @@ jobs: python-version: 3.10.10 - name: Download web-build artifact - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: web-build path: ./servers/web-build @@ -127,7 +127,7 @@ jobs: python-version: 3.10.10 - name: Download web-build artifact - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: web-build path: ./servers/web-build @@ -169,7 +169,7 @@ jobs: python-version: 3.10.10 - name: Download web-build artifact - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: web-build path: ./servers/web-build diff --git a/.gitignore b/.gitignore index 107143e7..fc3df129 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ hf_repo launch.sh .idea/ .DS_Store +custom_widgets/*/ \ No newline at end of file diff --git a/assets/myshell_widget_list.json b/assets/myshell_widget_list.json index ddb86b3a..1bdadfbd 100644 --- a/assets/myshell_widget_list.json +++ b/assets/myshell_widget_list.json @@ -1,4 +1,65 @@ { + "1892877244783509504": { + "id": "1892877244783509504", + "name": "API Widget", + "description": "API Widget", + "usage": "Tools", + "inputs": { + "properties": { + "url": { + "type": "string", + "description": "API endpoint URL", + "default": "" + }, + "method": { + "type": "string", + "description": "HTTP method", + "enum": [ + "GET", + "POST", + "PUT", + "DELETE" + ], + "default": "GET" + }, + "headers": { + "type": "string", + "description": "Request headers (JSON format)", + "default": "{}" + }, + "params": { + "type": "string", + "description": "URL parameters (JSON format)", + "default": "{}" + }, + "data": { + "type": "string", + "description": "Request body (JSON format)", + "default": "{}" + } + }, + "required": [ + "url" + ] + }, + "outputs": { + "properties": { + "status_code": { + "type": "integer", + "description": "HTTP status code" + }, + "headers": { + "type": "object", + "description": "Response headers" + }, + "body": { + "type": "string", + "description": "Response body" + } + } + }, + "widget_id": "1892877244783509504" + }, "1781991963803181056": { "id": "1781991963803181056", "name": "Crawler", diff --git a/custom_widget_info.json b/custom_widget_info.json index dac230f7..5e7a1b03 100644 --- a/custom_widget_info.json +++ b/custom_widget_info.json @@ -1,8 +1,8 @@ { - "proconfig-diffuser-imagen": { - "git": "https://github.com/wl-zhao/proconfig-diffuser-imagen.git", - "commit": "latest", + "custom_widget_demo": { + "git": "https://github.com/Cherwayway/custom_widget_demo.git", + "commit": "651f1c69cad4921d09e705802733aadae1aa9058", "branch": "main", - "description": "Simple image generation widget implemented by diffusers" + "description": "A demo widget for custom widgets" } } \ No newline at end of file diff --git a/custom_widgets/widgets_status.json b/custom_widgets/widgets_status.json deleted file mode 100644 index 9e26dfee..00000000 --- a/custom_widgets/widgets_status.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/proconfig/runners/runner.py b/proconfig/runners/runner.py index 378054f9..e0c30371 100755 --- a/proconfig/runners/runner.py +++ b/proconfig/runners/runner.py @@ -14,6 +14,7 @@ from proconfig.utils.pytree import tree_map import proconfig.utils.pytree as pytree +from proconfig.widgets.base import WIDGETS from proconfig.utils.expressions import calc_expression from proconfig.utils.misc import hash_dict from proconfig.core import Automata, Workflow, State @@ -253,6 +254,14 @@ def process_myshell_extra_inputs(self, task): "inputs": copy.deepcopy(task.inputs) } task.inputs["myshell_actual_inputs"] = myshell_actual_inputs + + def process_custom_widget_extra_inputs(self, task): + custom_widget_actual_inputs = { + "widget_name": task.widget_class_name, + "inputs": copy.deepcopy(task.inputs) + } + task.widget_class_name = "CustomAnyWidgetCallerWidget" + task.inputs["custom_widget_actual_inputs"] = custom_widget_actual_inputs def run_task(self, container, task, environ, local_vars): @@ -262,6 +271,8 @@ def run_task(self, container, task, environ, local_vars): self.process_comfy_extra_inputs(task) elif task.widget_class_name == "MyShellAnyWidgetCallerWidget": self.process_myshell_extra_inputs(task) + elif not WIDGETS.get(task.widget_class_name): + self.process_custom_widget_extra_inputs(task) if task.mode in ["widget", "comfy_workflow"]: return self.run_widget_task(container, task, environ, local_vars) diff --git a/proconfig/widgets/__init__.py b/proconfig/widgets/__init__.py index 2518cedf..f14144bb 100644 --- a/proconfig/widgets/__init__.py +++ b/proconfig/widgets/__init__.py @@ -4,23 +4,51 @@ os.environ[CURRENT_PACKAGE_NAME_KEY] = "myshell" from proconfig.widgets.utils import load_module from proconfig.widgets.base import build_widgets +from proconfig.utils.widget_manager import install_widget import proconfig.widgets.imagen_widgets import proconfig.widgets.language_models import proconfig.widgets.tools import proconfig.widgets.myshell_widgets +import proconfig.widgets.custom_widgets # load custom widgets import os def load_custom_widgets(): - widget_status = json.load(open("custom_widgets/widgets_status.json")) - for custom_widget in os.listdir("custom_widgets"): - if not os.path.isdir(os.path.join("custom_widgets", custom_widget)): - continue - if custom_widget in widget_status: - current_commit = widget_status[custom_widget]["current_commit"] - os.environ[CURRENT_PACKAGE_NAME_KEY] = custom_widget - load_module(os.path.join(custom_widget, current_commit), "custom_widgets", module_name=custom_widget) - + try: + with open("custom_widget_info.json", "r") as f: + widget_info = json.load(f) + # Remove custom_widget_demo if it exists + if "custom_widget_demo" in widget_info: + del widget_info["custom_widget_demo"] + except FileNotFoundError: + widget_info = {} + + # Get list of existing widget directories + existing_widgets = set(d for d in os.listdir("custom_widgets") + if os.path.isdir(os.path.join("custom_widgets", d))) + + # Install widgets from widget_info that don't exist + for widget_name, widget_data in widget_info.items(): + if widget_name not in existing_widgets: + # Extract git URL from widget_data if it's a dictionary + install_widget( + widget_data["git"], + os.path.join("custom_widgets", widget_name), + widget_data.get("commit", None), + widget_data.get("branch", "main") + ) + + # Update existing widget list, filter out non-directory files + existing_widgets = set(d for d in os.listdir("custom_widgets") + if os.path.isdir(os.path.join("custom_widgets", d)) + and not d.startswith('.') + and not d.startswith('__')) + + # Load all widgets from custom_widgets directory + for custom_widget in existing_widgets: + os.environ[CURRENT_PACKAGE_NAME_KEY] = custom_widget + load_module(custom_widget, "custom_widgets", module_name=custom_widget) + load_custom_widgets() \ No newline at end of file diff --git a/proconfig/widgets/custom_widgets/__init__.py b/proconfig/widgets/custom_widgets/__init__.py new file mode 100644 index 00000000..1398717b --- /dev/null +++ b/proconfig/widgets/custom_widgets/__init__.py @@ -0,0 +1 @@ +from proconfig.widgets.custom_widgets.custom_widget_caller import CustomAnyWidgetCallerWidget \ No newline at end of file diff --git a/proconfig/widgets/custom_widgets/custom_widget_caller.py b/proconfig/widgets/custom_widgets/custom_widget_caller.py new file mode 100644 index 00000000..c3521a27 --- /dev/null +++ b/proconfig/widgets/custom_widgets/custom_widget_caller.py @@ -0,0 +1,75 @@ +from typing import Any, Literal, Optional, List +from pydantic import Field, BaseModel + +from proconfig.widgets.base import BaseWidget, WIDGETS +from proconfig.core.exception import ShellException + +# import instructor +import os +from pydantic import BaseModel, Field +import requests +import json + +@WIDGETS.register_module() +class CustomAnyWidgetCallerWidget(BaseWidget): + NAME = "CustomAnyWidgetCallerWidget" + dynamic_schema = True + + class InputsSchema(BaseWidget.InputsSchema): + widget_name: str = Field(..., description="the widget name to call") + inputs: dict = {} + + class OutputsSchema(BaseWidget.OutputsSchema): # useless + data: str | list + + def execute(self, environ, config_ori): + # API endpoint URL + url = "https://openapi.myshell.ai/public/v1/custom_widget/run" + if "MYSHELL_DEPLOY" in os.environ and os.environ["MYSHELL_DEPLOY"] == "TEST": + url = "https://openapi-test.myshell.fun/public/v1/custom_widget/run" + config = config_ori["custom_widget_actual_inputs"] + widget_name = config["widget_name"] + + # Headers for the API request + headers = { + "x-myshell-openapi-key": os.environ["MYSHELL_API_KEY"], + "Content-Type": "application/json", + **environ.get("MYSHELL_HEADERS", {}) + } + + # Request payload + data = { + "widget_name": widget_name, + "input": json.dumps(config["inputs"]) + } + + del config_ori["custom_widget_actual_inputs"] + + # print("widget inputs:", config["inputs"]) + + # Send POST request to the API + response = requests.post(url, headers=headers, json=data) + + # Parse the JSON response + json_response = response.json() + + # Extract the 'result' field and return it as a string + if json_response.get('success') and 'result' in json_response: + result = json.loads(json_response['result']) + # handle the _url + if "_url" in result: + response = requests.get(result["_url"]) + if response.status_code == 200: + return response.json() + else: + return {"error_message": "error when retrieve the result"} + return result + else: + error = { + 'error_code': 'SHELL-1102', + 'error_head': 'Widget Execution Error', + 'msg': f'widget {widget_name} failed to execute', + 'traceback': json.dumps(json_response), + } + exception = ShellException(**error) + raise exception diff --git a/proconfig/widgets/tools/dependency_checker.py b/proconfig/widgets/tools/dependency_checker.py index d20a3140..fd8b9370 100755 --- a/proconfig/widgets/tools/dependency_checker.py +++ b/proconfig/widgets/tools/dependency_checker.py @@ -3,12 +3,15 @@ import copy from proconfig.core import Automata, Workflow from proconfig.widgets import build_widgets +from proconfig.widgets.base import WIDGETS +from proconfig.core.exception import ShellException from proconfig.widgets.imagen_widgets.utils.model_manager import compute_sha256 from proconfig.utils.expressions import calc_expression, tree_map from functools import partial from proconfig.utils.misc import windows_to_linux_path, generate_comfyui_workflow_id import logging from easydict import EasyDict as edict +import subprocess PROCONFIG_PROJECT_ROOT = os.environ.get("PROCONFIG_PROJECT_ROOT", "data") @@ -78,24 +81,98 @@ def handle_model_info(ckpt_path): "image": "" } -widget_status = json.load(open("custom_widgets/widgets_status.json")) all_widget_json = json.load(open("custom_widget_info.json")) def check_missing_widgets(config, missing_widgets): - # very simple - package_name = getattr(config, "package_name", None) or "myshell" + widget_name = config.widget_name + widget_class = WIDGETS.get(widget_name) + try: + package_name = widget_class.__module__.split('.')[0] if widget_class else "myshell" + if package_name == "proconfig": + package_name = "myshell" + except Exception as e: + package_name = "myshell" + if package_name != "myshell": + # custom widget if package_name in missing_widgets: # already added + # check if the widget_name is in the used_func + if widget_name not in missing_widgets[package_name]["used_func"]: + missing_widgets[package_name]["used_func"].append(widget_name) return missing_widgets - if package_name in widget_status: - current_commit = widget_status[package_name]["current_commit"] + + widget_path = os.path.join("custom_widgets", package_name) + if not os.path.exists(widget_path): + error = { + 'error_code': 'SHELL-1113', + 'error_head': 'Widget Not Found Error', + 'msg': f"Widget {package_name} directory not found" + } + raise ShellException(**error) + + try: + result = subprocess.run( + ["git", "status", "--porcelain"], + cwd=widget_path, + capture_output=True, + text=True + ) + if result.stdout.strip(): + error = { + 'error_code': 'SHELL-1114', + 'error_head': 'Local Changes Error', + 'msg': f"Widget {package_name} has uncommitted local changes" + } + raise ShellException(**error) + except subprocess.CalledProcessError: + error = { + 'error_code': 'SHELL-1115', + 'error_head': 'Git Error', + 'msg': f"Failed to check git status for widget {package_name}" + } + raise ShellException(**error) + + # check commit id + try: + result = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=widget_path, + capture_output=True, + text=True + ) + current_commit = result.stdout.strip() + expected_commit = all_widget_json[package_name]["commit"] + + if current_commit != expected_commit: + error = { + 'error_code': 'SHELL-1116', + 'error_head': 'Commit Mismatch Error', + 'msg': f"Widget {package_name} commit mismatch: expected {expected_commit}, got {current_commit}" + } + raise ShellException(**error) + except subprocess.CalledProcessError: + error = { + 'error_code': 'SHELL-1117', + 'error_head': 'Git Error', + 'msg': f"Failed to get current commit for widget {package_name}" + } + raise ShellException(**error) + + if package_name in all_widget_json: + current_commit = all_widget_json[package_name]["commit"] else: - # randomly pick one - current_commit = sorted(os.listdir(os.path.join("custom_widgets", package_name)))[0] + error = { + 'error_code': 'SHELL-1112', + 'error_head': 'Package Not Registered Error', + 'msg': f"{package_name} should be registered to custom_widget_info.json" + } + raise ShellException(**error) + registered = package_name in all_widget_json missing_widgets[package_name] = { "commit": current_commit, - "git": all_widget_json[package_name]["git"] if registered else None + "repo": all_widget_json[package_name]["git"] if registered else None, + "used_func": [widget_name] } return missing_widgets @@ -171,7 +248,23 @@ def check_dependency_recursive(config, non_existed_models: list, missing_models: for block_config in config.blocks: check_dependency_recursive(block_config, non_existed_models, missing_models, undefined_widgets, missing_widgets, local_vars, {}, workflow_ids=workflow_ids, comfyui_workflow_ids=comfyui_workflow_ids, environ=environ) return - + +def transform_widgets_format(original_data): + if not original_data: + return {"custom_widgets": [], "used_func": []} + new_format = { + "custom_widgets": [], + "used_func": [] + } + for widget_name, widget_info in original_data.items(): + new_format["custom_widgets"].append({ + "name": widget_name, + "repo": widget_info["repo"], + "commit": widget_info["commit"] + }) + new_format["used_func"].extend(widget_info["used_func"]) + new_format["used_func"] = list(set(new_format["used_func"])) + return new_format def check_dependency(config, environ={}): # here config is a json @@ -193,6 +286,7 @@ def check_dependency(config, environ={}): check_dependency_recursive(config, non_existed_models=non_existed_models, missing_models=missing_models, undefined_widgets=undefined_widgets, missing_widgets=missing_widgets, workflow_ids=workflow_ids, comfyui_workflow_ids=comfyui_workflow_ids, local_vars={}, payload={}, environ=environ) + missing_widgets = transform_widgets_format(missing_widgets) return { "non_existed_models": non_existed_models, "models": missing_models, diff --git a/servers/automata.py b/servers/automata.py index bef05142..931e2d2b 100755 --- a/servers/automata.py +++ b/servers/automata.py @@ -295,6 +295,11 @@ async def export_app(data: dict, request: Request): for key in sensitive_keys: envs.pop(key, None) + # check the local ShellAgent commit + from pygit2 import Repository + repo = Repository(os.getcwd()) + commit_id = str(repo.head.peel().id) + results = { "data": { **exported_data, @@ -302,9 +307,14 @@ async def export_app(data: dict, request: Request): "comfyui_dependencies": comfyui_dependencies, "metadata": metadata, "reactflow": reactflow, - "dependency": { + "dependencies": { + "shellagent_version": { + "repo": "https://github.com/myshell-ai/ShellAgent.git", + "commit": commit_id + }, "models": dependency_results["models"], - "widgets": dependency_results["widgets"] + "custom_widgets": dependency_results["widgets"]["custom_widgets"], + "used_func": dependency_results["widgets"]["used_func"] }, "envs": export_env_variables(public_key, envs) }, diff --git a/servers/base.py b/servers/base.py index d2fefaa2..1d65adfa 100644 --- a/servers/base.py +++ b/servers/base.py @@ -68,7 +68,9 @@ def compute_root_path(root_type, project_root=None, headers=None): user_id = headers["x-myshell-openapi-user-id"] project_root = os.path.join("data_cloud", f"user_{user_id}") else: - project_root = project_root + if root_type == "file": + return "" + project_root = PROJECT_ROOT # assert root_type in ["workflow", "app", "app_run", "workflow_run", "comfy_workflow", "input"] root_path = None @@ -100,7 +102,6 @@ def compute_root_path(root_type, project_root=None, headers=None): MODEL_DIR = "models" CUSTOM_WIDGETS_DIR = "custom_widgets" -CUSTOM_WIDGETS_STATUS_PATH = os.path.join(CUSTOM_WIDGETS_DIR, "widgets_status.json") MODELS_STATUS_PATH = os.path.join(MODEL_DIR, "model_status.json") os.environ["MODEL_DIR"] = MODEL_DIR @@ -124,8 +125,6 @@ def initialize_envs(): os.makedirs(os.path.join(os.path.dirname(__file__), "web", "extensions"), exist_ok=True) if not os.path.isfile(os.environ["MODELS_STATUS_PATH"]): json.dump({}, open(os.environ["MODELS_STATUS_PATH"], "w")) - if not os.path.isfile(CUSTOM_WIDGETS_STATUS_PATH): - json.dump({}, open(CUSTOM_WIDGETS_STATUS_PATH, "w")) for k, v in env["envs"].items(): if k != "": diff --git a/servers/common.py b/servers/common.py index 4b662fe5..70dfbd61 100755 --- a/servers/common.py +++ b/servers/common.py @@ -84,7 +84,7 @@ async def get_cwd(): BASE_DIR = os.getcwd() @app.get('/api/files/{filename:path}') async def get_file(filename: str, request: Request): - root_path = compute_root_path("", headers=request.headers) + root_path = compute_root_path("file", headers=request.headers) filename = filename.strip() assert "../" not in filename diff --git a/web/apps/web/src/services/app/index.tsx b/web/apps/web/src/services/app/index.tsx index ef53aeae..26994ad9 100755 --- a/web/apps/web/src/services/app/index.tsx +++ b/web/apps/web/src/services/app/index.tsx @@ -241,8 +241,10 @@ export const loadEnvSvc: Fetcher = () => { return APIFetch.get(loadSettingEnvFormUrl); }; -export const saveEnvSvc: Fetcher = () => { - return APIFetch.get(saveSettingEnvFormUrl); +export const saveEnvSvc: Fetcher = (params: any) => { + return APIFetch.post(saveSettingEnvFormUrl, { + body: params, + }); }; // 校验ip export const checkIp = (params: CheckIpRequest) => { diff --git a/web/apps/web/src/services/app/type.ts b/web/apps/web/src/services/app/type.ts index 16e3f07d..defed13a 100644 --- a/web/apps/web/src/services/app/type.ts +++ b/web/apps/web/src/services/app/type.ts @@ -139,6 +139,20 @@ export interface ExportBotResponse { comfyui_workflows: { [key: string]: any; }; + dependencies: { + models: { + [key: string]: { + filename: string; + save_path: string; + urls: Array; + }; + }; + custom_widgets: Array<{ + name: string; + repo: string; + commit: string; + }>; + }; dependency: { models: { [key: string]: { diff --git a/web/apps/web/src/stores/app/utils/automata-export.ts b/web/apps/web/src/stores/app/utils/automata-export.ts index ff863707..5d804004 100644 --- a/web/apps/web/src/stores/app/utils/automata-export.ts +++ b/web/apps/web/src/stores/app/utils/automata-export.ts @@ -30,12 +30,29 @@ export const checkDependency = (data: ExportBotResponse['data']) => { models: {}, widgets: {}, }; - Object.entries(data.dependency.models || {}).forEach(([key, item]) => { + Object.entries( + data.dependency?.models || data.dependencies?.models || {}, + ).forEach(([key, item]) => { if (isEmpty(item.urls)) { set(deps, ['models', key], item.filename); } }); - Object.entries(data.dependency.widgets || {}).forEach(([key, item]) => { + Object.entries( + data.dependency?.widgets || + data.dependencies?.custom_widgets.reduce<{ + [key: string]: { + git: string; + commit: string; + }; + }>((acc, cur) => { + acc[cur.name] = { + git: cur.repo, + commit: cur.commit, + }; + return acc; + }, {}) || + {}, + ).forEach(([key, item]) => { if (isEmpty(item.git) || item.git === 'None') { set(deps, ['widgets', key], key); }