diff --git a/api/.env.example b/api/.env.example index 084ef63c60..f1ab74b433 100644 --- a/api/.env.example +++ b/api/.env.example @@ -295,4 +295,6 @@ POSITION_PROVIDER_EXCLUDES= # Plugin configuration PLUGIN_API_KEY=lYkiYYT6owG+71oLerGzA7GXCgOT++6ovaezWAjpCjf+Sjc3ZtU+qUEi+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1 PLUGIN_API_URL=http://127.0.0.1:5002 +PLUGIN_REMOTE_INSTALL_PORT=5003 +PLUGIN_REMOTE_INSTALL_HOST=localhost INNER_API_KEY=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1 \ No newline at end of file diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index 8e03415ae0..a846c0446c 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -127,6 +127,16 @@ class PluginConfig(BaseSettings): INNER_API_KEY_FOR_PLUGIN: str = Field(description="Inner api key for plugin", default="inner-api-key") + PLUGIN_REMOTE_INSTALL_HOST: str = Field( + description="Plugin Remote Install Host", + default="localhost", + ) + + PLUGIN_REMOTE_INSTALL_PORT: PositiveInt = Field( + description="Plugin Remote Install Port", + default=5003, + ) + class EndpointConfig(BaseSettings): """ diff --git a/api/controllers/console/workspace/plugin.py b/api/controllers/console/workspace/plugin.py index 4001b69a59..fcc42c9894 100644 --- a/api/controllers/console/workspace/plugin.py +++ b/api/controllers/console/workspace/plugin.py @@ -1,12 +1,18 @@ +import io +import json + +from flask import Response, request, send_file from flask_login import current_user -from flask_restful import Resource +from flask_restful import Resource, reqparse from werkzeug.exceptions import Forbidden +from configs import dify_config from controllers.console import api from controllers.console.setup import setup_required from controllers.console.wraps import account_initialization_required +from core.model_runtime.utils.encoders import jsonable_encoder from libs.login import login_required -from services.plugin.plugin_debugging_service import PluginDebuggingService +from services.plugin.plugin_service import PluginService class PluginDebuggingKeyApi(Resource): @@ -19,7 +25,99 @@ class PluginDebuggingKeyApi(Resource): raise Forbidden() tenant_id = user.current_tenant_id - return {"key": PluginDebuggingService.get_plugin_debugging_key(tenant_id)} + + return { + "key": PluginService.get_plugin_debugging_key(tenant_id), + "host": dify_config.PLUGIN_REMOTE_INSTALL_HOST, + "port": dify_config.PLUGIN_REMOTE_INSTALL_PORT, + } + + +class PluginListApi(Resource): + @setup_required + @login_required + @account_initialization_required + def get(self): + user = current_user + tenant_id = user.current_tenant_id + plugins = PluginService.list_plugins(tenant_id) + return jsonable_encoder({"plugins": plugins}) + + +class PluginIconApi(Resource): + @setup_required + def get(self): + req = reqparse.RequestParser() + req.add_argument("tenant_id", type=str, required=True, location="args") + req.add_argument("filename", type=str, required=True, location="args") + args = req.parse_args() + + icon_bytes, mimetype = PluginService.get_asset(args["tenant_id"], args["filename"]) + + icon_cache_max_age = dify_config.TOOL_ICON_CACHE_MAX_AGE + return send_file(io.BytesIO(icon_bytes), mimetype=mimetype, max_age=icon_cache_max_age) + + +class PluginInstallCheckUniqueIdentifierApi(Resource): + @setup_required + @login_required + @account_initialization_required + def get(self): + req = reqparse.RequestParser() + req.add_argument("plugin_unique_identifier", type=str, required=True, location="args") + args = req.parse_args() + + user = current_user + tenant_id = user.current_tenant_id + + return {"installed": PluginService.check_plugin_unique_identifier(tenant_id, args["plugin_unique_identifier"])} + + +class PluginInstallFromUniqueIdentifierApi(Resource): + @setup_required + @login_required + @account_initialization_required + def post(self): + req = reqparse.RequestParser() + req.add_argument("plugin_unique_identifier", type=str, required=True, location="json") + args = req.parse_args() + + user = current_user + if not user.is_admin_or_owner: + raise Forbidden() + + tenant_id = user.current_tenant_id + + return { + "success": PluginService.install_plugin_from_unique_identifier(tenant_id, args["plugin_unique_identifier"]) + } + + +class PluginInstallFromPkgApi(Resource): + @setup_required + @login_required + @account_initialization_required + def post(self): + user = current_user + if not user.is_admin_or_owner: + raise Forbidden() + + tenant_id = user.current_tenant_id + + file = request.files["pkg"] + content = file.read() + + def generator(): + response = PluginService.install_plugin_from_pkg(tenant_id, content) + for message in response: + yield f"data: {json.dumps(jsonable_encoder(message))}\n\n" + + return Response(generator(), mimetype="text/event-stream") api.add_resource(PluginDebuggingKeyApi, "/workspaces/current/plugin/debugging-key") +api.add_resource(PluginListApi, "/workspaces/current/plugin/list") +api.add_resource(PluginIconApi, "/workspaces/current/plugin/icon") +api.add_resource(PluginInstallCheckUniqueIdentifierApi, "/workspaces/current/plugin/install/check_unique_identifier") +api.add_resource(PluginInstallFromUniqueIdentifierApi, "/workspaces/current/plugin/install/from_unique_identifier") +api.add_resource(PluginInstallFromPkgApi, "/workspaces/current/plugin/install/from_pkg") diff --git a/api/core/plugin/entities/plugin.py b/api/core/plugin/entities/plugin.py index 8098ade025..7ad7750036 100644 --- a/api/core/plugin/entities/plugin.py +++ b/api/core/plugin/entities/plugin.py @@ -1,10 +1,77 @@ +import datetime +from typing import Optional + +from pydantic import BaseModel, Field + +from core.model_runtime.entities.provider_entities import ProviderEntity from core.plugin.entities.base import BasePluginEntity +from core.plugin.entities.endpoint import EndpointEntity +from core.tools.entities.common_entities import I18nObject +from core.tools.entities.tool_entities import ToolProviderEntity + + +class PluginResourceRequirements(BaseModel): + memory: int + + class Permission(BaseModel): + class Tool(BaseModel): + enabled: Optional[bool] = Field(default=False) + + class Model(BaseModel): + enabled: Optional[bool] = Field(default=False) + llm: Optional[bool] = Field(default=False) + text_embedding: Optional[bool] = Field(default=False) + rerank: Optional[bool] = Field(default=False) + tts: Optional[bool] = Field(default=False) + speech2text: Optional[bool] = Field(default=False) + moderation: Optional[bool] = Field(default=False) + + class Node(BaseModel): + enabled: Optional[bool] = Field(default=False) + + class Endpoint(BaseModel): + enabled: Optional[bool] = Field(default=False) + + class Storage(BaseModel): + enabled: Optional[bool] = Field(default=False) + size: int = Field(ge=1024, le=1073741824, default=1048576) + + tool: Optional[Tool] = Field(default=None) + model: Optional[Model] = Field(default=None) + node: Optional[Node] = Field(default=None) + endpoint: Optional[Endpoint] = Field(default=None) + storage: Storage = Field(default=None) + + permission: Optional[Permission] + + +class PluginDeclaration(BaseModel): + class Plugins(BaseModel): + tools: list[str] = Field(default_factory=list) + models: list[str] = Field(default_factory=list) + endpoints: list[str] = Field(default_factory=list) + + version: str = Field(..., pattern=r"^\d{1,4}(\.\d{1,4}){1,3}(-\w{1,16})?$") + author: Optional[str] = Field(..., pattern=r"^[a-zA-Z0-9_-]{1,64}$") + name: str = Field(..., pattern=r"^[a-z0-9_-]{1,128}$") + icon: str + label: I18nObject + created_at: datetime.datetime + resource: PluginResourceRequirements + plugins: Plugins + tool: Optional[ToolProviderEntity] = None + model: Optional[ProviderEntity] = None + endpoint: Optional[EndpointEntity] = None class PluginEntity(BasePluginEntity): name: str plugin_id: str plugin_unique_identifier: str + declaration: PluginDeclaration + installation_id: str tenant_id: str endpoints_setups: int endpoints_active: int + runtime_type: str + version: str diff --git a/api/core/plugin/manager/base.py b/api/core/plugin/manager/base.py index ebfa68b914..3c46e8a3c3 100644 --- a/api/core/plugin/manager/base.py +++ b/api/core/plugin/manager/base.py @@ -30,6 +30,7 @@ class BasePluginManager: headers: dict | None = None, data: bytes | dict | str | None = None, params: dict | None = None, + files: dict | None = None, stream: bool = False, ) -> requests.Response: """ @@ -44,7 +45,7 @@ class BasePluginManager: data = json.dumps(data) response = requests.request( - method=method, url=str(url), headers=headers, data=data, params=params, stream=stream + method=method, url=str(url), headers=headers, data=data, params=params, stream=stream, files=files ) return response @@ -55,11 +56,12 @@ class BasePluginManager: params: dict | None = None, headers: dict | None = None, data: bytes | dict | None = None, + files: dict | None = None, ) -> Generator[bytes, None, None]: """ Make a stream request to the plugin daemon inner API """ - response = self._request(method, path, headers, data, params, stream=True) + response = self._request(method, path, headers, data, params, files, stream=True) for line in response.iter_lines(): line = line.decode("utf-8").strip() if line.startswith("data:"): @@ -75,11 +77,12 @@ class BasePluginManager: headers: dict | None = None, data: bytes | dict | None = None, params: dict | None = None, + files: dict | None = None, ) -> Generator[T, None, None]: """ Make a stream request to the plugin daemon inner API and yield the response as a model. """ - for line in self._stream_request(method, path, params, headers, data): + for line in self._stream_request(method, path, params, headers, data, files): yield type(**json.loads(line)) def _request_with_model( @@ -90,11 +93,12 @@ class BasePluginManager: headers: dict | None = None, data: bytes | None = None, params: dict | None = None, + files: dict | None = None, ) -> T: """ Make a request to the plugin daemon inner API and return the response as a model. """ - response = self._request(method, path, headers, data, params) + response = self._request(method, path, headers, data, params, files) return type(**response.json()) def _request_with_plugin_daemon_response( @@ -105,12 +109,13 @@ class BasePluginManager: headers: dict | None = None, data: bytes | dict | None = None, params: dict | None = None, + files: dict | None = None, transformer: Callable[[dict], dict] | None = None, ) -> T: """ Make a request to the plugin daemon inner API and return the response as a model. """ - response = self._request(method, path, headers, data, params) + response = self._request(method, path, headers, data, params, files) json_response = response.json() if transformer: json_response = transformer(json_response) @@ -138,11 +143,12 @@ class BasePluginManager: headers: dict | None = None, data: bytes | dict | None = None, params: dict | None = None, + files: dict | None = None, ) -> Generator[T, None, None]: """ Make a stream request to the plugin daemon inner API and yield the response as a model. """ - for line in self._stream_request(method, path, params, headers, data): + for line in self._stream_request(method, path, params, headers, data, files): line_data = json.loads(line) rep = PluginDaemonBasicResponse[type](**line_data) if rep.code != 0: diff --git a/api/core/plugin/manager/plugin.py b/api/core/plugin/manager/plugin.py index 5a9eb336c5..fa8e5a464b 100644 --- a/api/core/plugin/manager/plugin.py +++ b/api/core/plugin/manager/plugin.py @@ -10,7 +10,10 @@ class PluginInstallationManager(BasePluginManager): # urlencode the identifier return self._request_with_plugin_daemon_response( - "GET", f"plugin/{tenant_id}/fetch/identifier", bool, params={"plugin_unique_identifier": identifier} + "GET", + f"plugin/{tenant_id}/management/fetch/identifier", + bool, + params={"plugin_unique_identifier": identifier}, ) def list_plugins(self, tenant_id: str) -> list[PluginEntity]: @@ -29,7 +32,10 @@ class PluginInstallationManager(BasePluginManager): body = {"dify_pkg": ("dify_pkg", pkg, "application/octet-stream")} return self._request_with_plugin_daemon_response_stream( - "POST", f"plugin/{tenant_id}/install/pkg", InstallPluginMessage, data=body + "POST", + f"plugin/{tenant_id}/management/install/pkg", + InstallPluginMessage, + files=body, ) def install_from_identifier(self, tenant_id: str, identifier: str) -> bool: @@ -39,14 +45,12 @@ class PluginInstallationManager(BasePluginManager): # exception will be raised if the request failed return self._request_with_plugin_daemon_response( "POST", - f"plugin/{tenant_id}/install/identifier", + f"plugin/{tenant_id}/management/install/identifier", bool, - params={ - "plugin_unique_identifier": identifier, - }, data={ "plugin_unique_identifier": identifier, }, + headers={"Content-Type": "application/json"}, ) def uninstall(self, tenant_id: str, identifier: str) -> bool: @@ -54,5 +58,11 @@ class PluginInstallationManager(BasePluginManager): Uninstall a plugin. """ return self._request_with_plugin_daemon_response( - "DELETE", f"plugin/{tenant_id}/uninstall", bool, params={"plugin_unique_identifier": identifier} + "DELETE", + f"plugin/{tenant_id}/management/uninstall", + bool, + data={ + "plugin_unique_identifier": identifier, + }, + headers={"Content-Type": "application/json"}, ) diff --git a/api/services/plugin/plugin_debugging_service.py b/api/services/plugin/plugin_debugging_service.py deleted file mode 100644 index 2bf24e6de8..0000000000 --- a/api/services/plugin/plugin_debugging_service.py +++ /dev/null @@ -1,8 +0,0 @@ -from core.plugin.manager.debugging import PluginDebuggingManager - - -class PluginDebuggingService: - @staticmethod - def get_plugin_debugging_key(tenant_id: str) -> str: - manager = PluginDebuggingManager() - return manager.get_debugging_key(tenant_id) diff --git a/api/services/plugin/plugin_service.py b/api/services/plugin/plugin_service.py new file mode 100644 index 0000000000..b8d70be106 --- /dev/null +++ b/api/services/plugin/plugin_service.py @@ -0,0 +1,45 @@ +from collections.abc import Generator +from mimetypes import guess_type + +from core.plugin.entities.plugin import PluginEntity +from core.plugin.entities.plugin_daemon import InstallPluginMessage, PluginDaemonInnerError +from core.plugin.manager.asset import PluginAssetManager +from core.plugin.manager.debugging import PluginDebuggingManager +from core.plugin.manager.plugin import PluginInstallationManager + + +class PluginService: + @staticmethod + def get_plugin_debugging_key(tenant_id: str) -> str: + manager = PluginDebuggingManager() + return manager.get_debugging_key(tenant_id) + + @staticmethod + def list_plugins(tenant_id: str) -> list[PluginEntity]: + manager = PluginInstallationManager() + return manager.list_plugins(tenant_id) + + @staticmethod + def get_asset(tenant_id: str, asset_file: str) -> tuple[bytes, str]: + manager = PluginAssetManager() + # guess mime type + mime_type, _ = guess_type(asset_file) + return manager.fetch_asset(tenant_id, asset_file), mime_type or "application/octet-stream" + + @staticmethod + def check_plugin_unique_identifier(tenant_id: str, plugin_unique_identifier: str) -> bool: + manager = PluginInstallationManager() + return manager.fetch_plugin_by_identifier(tenant_id, plugin_unique_identifier) + + @staticmethod + def install_plugin_from_unique_identifier(tenant_id: str, plugin_unique_identifier: str) -> bool: + manager = PluginInstallationManager() + return manager.install_from_identifier(tenant_id, plugin_unique_identifier) + + @staticmethod + def install_plugin_from_pkg(tenant_id: str, pkg: bytes) -> Generator[InstallPluginMessage, None, None]: + manager = PluginInstallationManager() + try: + yield from manager.install_from_pkg(tenant_id, pkg) + except PluginDaemonInnerError as e: + yield InstallPluginMessage(event=InstallPluginMessage.Event.Error, data=str(e.message))