diff --git a/api/core/tools/entities/api_entities.py b/api/core/tools/entities/api_entities.py index 807d0245d1..218ffafd55 100644 --- a/api/core/tools/entities/api_entities.py +++ b/api/core/tools/entities/api_entities.py @@ -54,6 +54,8 @@ class ToolProviderApiEntity(BaseModel): configuration: MCPConfiguration | None = Field( default=None, description="The timeout and sse_read_timeout of the MCP tool" ) + # Workflow + workflow_app_id: str | None = Field(default=None, description="The app id of the workflow tool") @field_validator("tools", mode="before") @classmethod @@ -87,6 +89,8 @@ class ToolProviderApiEntity(BaseModel): optional_fields.update(self.optional_field("is_dynamic_registration", self.is_dynamic_registration)) optional_fields.update(self.optional_field("masked_headers", self.masked_headers)) optional_fields.update(self.optional_field("original_headers", self.original_headers)) + elif self.type == ToolProviderType.WORKFLOW: + optional_fields.update(self.optional_field("workflow_app_id", self.workflow_app_id)) return { "id": self.id, "author": self.author, diff --git a/api/services/tools/tools_transform_service.py b/api/services/tools/tools_transform_service.py index 81872e3ebc..e323b3cda9 100644 --- a/api/services/tools/tools_transform_service.py +++ b/api/services/tools/tools_transform_service.py @@ -201,7 +201,9 @@ class ToolTransformService: @staticmethod def workflow_provider_to_user_provider( - provider_controller: WorkflowToolProviderController, labels: list[str] | None = None + provider_controller: WorkflowToolProviderController, + labels: list[str] | None = None, + workflow_app_id: str | None = None, ): """ convert provider controller to user provider @@ -221,6 +223,7 @@ class ToolTransformService: plugin_unique_identifier=None, tools=[], labels=labels or [], + workflow_app_id=workflow_app_id, ) @staticmethod diff --git a/api/services/tools/workflow_tools_manage_service.py b/api/services/tools/workflow_tools_manage_service.py index b743cc1105..c2bfb4dde6 100644 --- a/api/services/tools/workflow_tools_manage_service.py +++ b/api/services/tools/workflow_tools_manage_service.py @@ -189,6 +189,9 @@ class WorkflowToolManageService: select(WorkflowToolProvider).where(WorkflowToolProvider.tenant_id == tenant_id) ).all() + # Create a mapping from provider_id to app_id + provider_id_to_app_id = {provider.id: provider.app_id for provider in db_tools} + tools: list[WorkflowToolProviderController] = [] for provider in db_tools: try: @@ -202,8 +205,11 @@ class WorkflowToolManageService: result = [] for tool in tools: + workflow_app_id = provider_id_to_app_id.get(tool.provider_id) user_tool_provider = ToolTransformService.workflow_provider_to_user_provider( - provider_controller=tool, labels=labels.get(tool.provider_id, []) + provider_controller=tool, + labels=labels.get(tool.provider_id, []), + workflow_app_id=workflow_app_id, ) ToolTransformService.repack_provider(tenant_id=tenant_id, provider=user_tool_provider) user_tool_provider.tools = [ diff --git a/api/tests/unit_tests/core/tools/entities/__init__.py b/api/tests/unit_tests/core/tools/entities/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/tests/unit_tests/core/tools/entities/test_api_entities.py b/api/tests/unit_tests/core/tools/entities/test_api_entities.py new file mode 100644 index 0000000000..34f87ca6fa --- /dev/null +++ b/api/tests/unit_tests/core/tools/entities/test_api_entities.py @@ -0,0 +1,100 @@ +""" +Unit tests for ToolProviderApiEntity workflow_app_id field. + +This test suite covers: +- ToolProviderApiEntity workflow_app_id field creation and default value +- ToolProviderApiEntity.to_dict() method behavior with workflow_app_id +""" + +from core.tools.entities.api_entities import ToolProviderApiEntity +from core.tools.entities.common_entities import I18nObject +from core.tools.entities.tool_entities import ToolProviderType + + +class TestToolProviderApiEntityWorkflowAppId: + """Test suite for ToolProviderApiEntity workflow_app_id field.""" + + def test_workflow_app_id_field_default_none(self): + """Test that workflow_app_id defaults to None when not provided.""" + entity = ToolProviderApiEntity( + id="test_id", + author="test_author", + name="test_name", + description=I18nObject(en_US="Test description"), + icon="test_icon", + label=I18nObject(en_US="Test label"), + type=ToolProviderType.WORKFLOW, + ) + + assert entity.workflow_app_id is None + + def test_to_dict_includes_workflow_app_id_when_workflow_type_and_has_value(self): + """Test that to_dict() includes workflow_app_id when type is WORKFLOW and value is set.""" + workflow_app_id = "app_123" + entity = ToolProviderApiEntity( + id="test_id", + author="test_author", + name="test_name", + description=I18nObject(en_US="Test description"), + icon="test_icon", + label=I18nObject(en_US="Test label"), + type=ToolProviderType.WORKFLOW, + workflow_app_id=workflow_app_id, + ) + + result = entity.to_dict() + + assert "workflow_app_id" in result + assert result["workflow_app_id"] == workflow_app_id + + def test_to_dict_excludes_workflow_app_id_when_workflow_type_and_none(self): + """Test that to_dict() excludes workflow_app_id when type is WORKFLOW but value is None.""" + entity = ToolProviderApiEntity( + id="test_id", + author="test_author", + name="test_name", + description=I18nObject(en_US="Test description"), + icon="test_icon", + label=I18nObject(en_US="Test label"), + type=ToolProviderType.WORKFLOW, + workflow_app_id=None, + ) + + result = entity.to_dict() + + assert "workflow_app_id" not in result + + def test_to_dict_excludes_workflow_app_id_when_not_workflow_type(self): + """Test that to_dict() excludes workflow_app_id when type is not WORKFLOW.""" + workflow_app_id = "app_123" + entity = ToolProviderApiEntity( + id="test_id", + author="test_author", + name="test_name", + description=I18nObject(en_US="Test description"), + icon="test_icon", + label=I18nObject(en_US="Test label"), + type=ToolProviderType.BUILT_IN, + workflow_app_id=workflow_app_id, + ) + + result = entity.to_dict() + + assert "workflow_app_id" not in result + + def test_to_dict_includes_workflow_app_id_for_workflow_type_with_empty_string(self): + """Test that to_dict() excludes workflow_app_id when value is empty string (falsy).""" + entity = ToolProviderApiEntity( + id="test_id", + author="test_author", + name="test_name", + description=I18nObject(en_US="Test description"), + icon="test_icon", + label=I18nObject(en_US="Test label"), + type=ToolProviderType.WORKFLOW, + workflow_app_id="", + ) + + result = entity.to_dict() + + assert "workflow_app_id" not in result diff --git a/api/tests/unit_tests/services/tools/test_tools_transform_service.py b/api/tests/unit_tests/services/tools/test_tools_transform_service.py index 549ad018e8..9616d2f102 100644 --- a/api/tests/unit_tests/services/tools/test_tools_transform_service.py +++ b/api/tests/unit_tests/services/tools/test_tools_transform_service.py @@ -1,9 +1,9 @@ from unittest.mock import Mock from core.tools.__base.tool import Tool -from core.tools.entities.api_entities import ToolApiEntity +from core.tools.entities.api_entities import ToolApiEntity, ToolProviderApiEntity from core.tools.entities.common_entities import I18nObject -from core.tools.entities.tool_entities import ToolParameter +from core.tools.entities.tool_entities import ToolParameter, ToolProviderType from services.tools.tools_transform_service import ToolTransformService @@ -299,3 +299,154 @@ class TestToolTransformService: param2 = result.parameters[1] assert param2.name == "param2" assert param2.label == "Runtime Param 2" + + +class TestWorkflowProviderToUserProvider: + """Test cases for ToolTransformService.workflow_provider_to_user_provider method""" + + def test_workflow_provider_to_user_provider_with_workflow_app_id(self): + """Test that workflow_provider_to_user_provider correctly sets workflow_app_id.""" + from core.tools.workflow_as_tool.provider import WorkflowToolProviderController + + # Create mock workflow tool provider controller + workflow_app_id = "app_123" + provider_id = "provider_123" + mock_controller = Mock(spec=WorkflowToolProviderController) + mock_controller.provider_id = provider_id + mock_controller.entity = Mock() + mock_controller.entity.identity = Mock() + mock_controller.entity.identity.author = "test_author" + mock_controller.entity.identity.name = "test_workflow_tool" + mock_controller.entity.identity.description = I18nObject(en_US="Test description") + mock_controller.entity.identity.icon = {"type": "emoji", "content": "🔧"} + mock_controller.entity.identity.icon_dark = None + mock_controller.entity.identity.label = I18nObject(en_US="Test Workflow Tool") + + # Call the method + result = ToolTransformService.workflow_provider_to_user_provider( + provider_controller=mock_controller, + labels=["label1", "label2"], + workflow_app_id=workflow_app_id, + ) + + # Verify the result + assert isinstance(result, ToolProviderApiEntity) + assert result.id == provider_id + assert result.author == "test_author" + assert result.name == "test_workflow_tool" + assert result.type == ToolProviderType.WORKFLOW + assert result.workflow_app_id == workflow_app_id + assert result.labels == ["label1", "label2"] + assert result.is_team_authorization is True + assert result.plugin_id is None + assert result.plugin_unique_identifier is None + assert result.tools == [] + + def test_workflow_provider_to_user_provider_without_workflow_app_id(self): + """Test that workflow_provider_to_user_provider works when workflow_app_id is not provided.""" + from core.tools.workflow_as_tool.provider import WorkflowToolProviderController + + # Create mock workflow tool provider controller + provider_id = "provider_123" + mock_controller = Mock(spec=WorkflowToolProviderController) + mock_controller.provider_id = provider_id + mock_controller.entity = Mock() + mock_controller.entity.identity = Mock() + mock_controller.entity.identity.author = "test_author" + mock_controller.entity.identity.name = "test_workflow_tool" + mock_controller.entity.identity.description = I18nObject(en_US="Test description") + mock_controller.entity.identity.icon = {"type": "emoji", "content": "🔧"} + mock_controller.entity.identity.icon_dark = None + mock_controller.entity.identity.label = I18nObject(en_US="Test Workflow Tool") + + # Call the method without workflow_app_id + result = ToolTransformService.workflow_provider_to_user_provider( + provider_controller=mock_controller, + labels=["label1"], + ) + + # Verify the result + assert isinstance(result, ToolProviderApiEntity) + assert result.id == provider_id + assert result.workflow_app_id is None + assert result.labels == ["label1"] + + def test_workflow_provider_to_user_provider_workflow_app_id_none(self): + """Test that workflow_provider_to_user_provider handles None workflow_app_id explicitly.""" + from core.tools.workflow_as_tool.provider import WorkflowToolProviderController + + # Create mock workflow tool provider controller + provider_id = "provider_123" + mock_controller = Mock(spec=WorkflowToolProviderController) + mock_controller.provider_id = provider_id + mock_controller.entity = Mock() + mock_controller.entity.identity = Mock() + mock_controller.entity.identity.author = "test_author" + mock_controller.entity.identity.name = "test_workflow_tool" + mock_controller.entity.identity.description = I18nObject(en_US="Test description") + mock_controller.entity.identity.icon = {"type": "emoji", "content": "🔧"} + mock_controller.entity.identity.icon_dark = None + mock_controller.entity.identity.label = I18nObject(en_US="Test Workflow Tool") + + # Call the method with explicit None values + result = ToolTransformService.workflow_provider_to_user_provider( + provider_controller=mock_controller, + labels=None, + workflow_app_id=None, + ) + + # Verify the result + assert isinstance(result, ToolProviderApiEntity) + assert result.id == provider_id + assert result.workflow_app_id is None + assert result.labels == [] + + def test_workflow_provider_to_user_provider_preserves_other_fields(self): + """Test that workflow_provider_to_user_provider preserves all other entity fields.""" + from core.tools.workflow_as_tool.provider import WorkflowToolProviderController + + # Create mock workflow tool provider controller with various fields + workflow_app_id = "app_456" + provider_id = "provider_456" + mock_controller = Mock(spec=WorkflowToolProviderController) + mock_controller.provider_id = provider_id + mock_controller.entity = Mock() + mock_controller.entity.identity = Mock() + mock_controller.entity.identity.author = "another_author" + mock_controller.entity.identity.name = "another_workflow_tool" + mock_controller.entity.identity.description = I18nObject( + en_US="Another description", zh_Hans="Another description" + ) + mock_controller.entity.identity.icon = {"type": "emoji", "content": "⚙️"} + mock_controller.entity.identity.icon_dark = {"type": "emoji", "content": "🔧"} + mock_controller.entity.identity.label = I18nObject( + en_US="Another Workflow Tool", zh_Hans="Another Workflow Tool" + ) + + # Call the method + result = ToolTransformService.workflow_provider_to_user_provider( + provider_controller=mock_controller, + labels=["automation", "workflow"], + workflow_app_id=workflow_app_id, + ) + + # Verify all fields are preserved correctly + assert isinstance(result, ToolProviderApiEntity) + assert result.id == provider_id + assert result.author == "another_author" + assert result.name == "another_workflow_tool" + assert result.description.en_US == "Another description" + assert result.description.zh_Hans == "Another description" + assert result.icon == {"type": "emoji", "content": "⚙️"} + assert result.icon_dark == {"type": "emoji", "content": "🔧"} + assert result.label.en_US == "Another Workflow Tool" + assert result.label.zh_Hans == "Another Workflow Tool" + assert result.type == ToolProviderType.WORKFLOW + assert result.workflow_app_id == workflow_app_id + assert result.labels == ["automation", "workflow"] + assert result.masked_credentials == {} + assert result.is_team_authorization is True + assert result.allow_delete is True + assert result.plugin_id is None + assert result.plugin_unique_identifier is None + assert result.tools == [] diff --git a/web/app/components/tools/types.ts b/web/app/components/tools/types.ts index 652d6ac676..499a07342d 100644 --- a/web/app/components/tools/types.ts +++ b/web/app/components/tools/types.ts @@ -77,6 +77,8 @@ export type Collection = { timeout?: number sse_read_timeout?: number } + // Workflow + workflow_app_id?: string } export type ToolParameter = { diff --git a/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx b/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx index a871e60e3a..613744a50e 100644 --- a/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx +++ b/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx @@ -1,5 +1,6 @@ import { memo, + useMemo, } from 'react' import { useTranslation } from 'react-i18next' import { useEdges } from 'reactflow' @@ -16,6 +17,10 @@ import { } from '@/app/components/workflow/hooks' import ShortcutsName from '@/app/components/workflow/shortcuts-name' import type { Node } from '@/app/components/workflow/types' +import { BlockEnum } from '@/app/components/workflow/types' +import { CollectionType } from '@/app/components/tools/types' +import { useAllWorkflowTools } from '@/service/use-tools' +import { canFindTool } from '@/utils' type PanelOperatorPopupProps = { id: string @@ -45,6 +50,14 @@ const PanelOperatorPopup = ({ const showChangeBlock = !nodeMetaData.isTypeFixed && !nodesReadOnly const isChildNode = !!(data.isInIteration || data.isInLoop) + const { data: workflowTools } = useAllWorkflowTools() + const isWorkflowTool = data.type === BlockEnum.Tool && data.provider_type === CollectionType.workflow + const workflowAppId = useMemo(() => { + if (!isWorkflowTool || !workflowTools || !data.provider_id) return undefined + const workflowTool = workflowTools.find(item => canFindTool(item.id, data.provider_id)) + return workflowTool?.workflow_app_id + }, [isWorkflowTool, workflowTools, data.provider_id]) + return (