Przeglądaj źródła

feat: support install plugin

Yeuoly 6 miesięcy temu
rodzic
commit
e27a03ae15

+ 2 - 0
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

+ 10 - 0
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):
     """

+ 101 - 3
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")

+ 67 - 0
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

+ 12 - 6
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:

+ 17 - 7
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"},
         )

+ 0 - 8
api/services/plugin/plugin_debugging_service.py

@@ -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)

+ 45 - 0
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))