From ed3db06154e80dc5bf4f46f790e52c6f71e4a30e Mon Sep 17 00:00:00 2001 From: JzoNg Date: Tue, 7 Apr 2026 16:12:25 +0800 Subject: [PATCH] feat(web): restrictions of evalution workflow available nodes --- .../app-publisher/__tests__/index.spec.tsx | 20 +++ .../app-publisher/__tests__/sections.spec.tsx | 31 +++++ .../components/app/app-publisher/index.tsx | 18 ++- .../components/app/app-publisher/sections.tsx | 120 +++++++++--------- .../__tests__/index.spec.tsx | 23 ++++ .../start-node-selection-panel.spec.tsx | 23 ++++ .../start-node-selection-panel.tsx | 62 +++++---- .../use-available-nodes-meta-data.spec.ts | 27 ++++ .../hooks/use-available-nodes-meta-data.ts | 39 ++++-- .../__tests__/all-start-blocks.spec.tsx | 35 ++++- .../block-selector/__tests__/blocks.spec.tsx | 51 ++++++++ .../block-selector/all-start-blocks.tsx | 19 ++- .../workflow/block-selector/blocks.tsx | 13 +- .../workflow/utils/evaluation-workflow.ts | 20 +++ web/i18n/en-US/workflow.json | 1 + web/i18n/zh-Hans/workflow.json | 1 + 16 files changed, 394 insertions(+), 109 deletions(-) create mode 100644 web/app/components/workflow/block-selector/__tests__/blocks.spec.tsx create mode 100644 web/app/components/workflow/utils/evaluation-workflow.ts diff --git a/web/app/components/app/app-publisher/__tests__/index.spec.tsx b/web/app/components/app/app-publisher/__tests__/index.spec.tsx index 137d028a3f..1c81d84612 100644 --- a/web/app/components/app/app-publisher/__tests__/index.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/index.spec.tsx @@ -517,4 +517,24 @@ describe('AppPublisher', () => { tipKey: 'common.switchToStandardWorkflowTip', }) }) + + it('should block switching to evaluation workflow when restricted nodes exist', async () => { + render( + , + ) + + fireEvent.click(screen.getByText('common.publish')) + fireEvent.click(screen.getByText('publisher-switch-workflow-type')) + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalledWith('common.switchToEvaluationWorkflowDisabledTip') + }) + + expect(mockConvertWorkflowType).not.toHaveBeenCalled() + expect(sectionProps.summary?.workflowTypeSwitchDisabled).toBe(true) + expect(sectionProps.summary?.workflowTypeSwitchDisabledReason).toBe('common.switchToEvaluationWorkflowDisabledTip') + }) }) diff --git a/web/app/components/app/app-publisher/__tests__/sections.spec.tsx b/web/app/components/app/app-publisher/__tests__/sections.spec.tsx index 87155e6aa6..f242f1d6c2 100644 --- a/web/app/components/app/app-publisher/__tests__/sections.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/sections.spec.tsx @@ -185,6 +185,37 @@ describe('app-publisher sections', () => { expect(onWorkflowTypeSwitch).toHaveBeenCalledTimes(1) }) + it('should disable workflow type switch when a disabled reason is provided', () => { + render( + '1 minute ago'} + handlePublish={vi.fn()} + handleRestore={vi.fn()} + isChatApp={false} + multipleModelConfigs={[]} + onWorkflowTypeSwitch={vi.fn()} + publishDisabled={false} + published={false} + publishedAt={undefined} + publishShortcut={['ctrl', '⇧', 'P']} + startNodeLimitExceeded={false} + upgradeHighlightStyle={{}} + workflowTypeSwitchConfig={{ + targetType: 'evaluation', + publishLabelKey: 'common.publishAsEvaluationWorkflow', + switchLabelKey: 'common.switchToEvaluationWorkflow', + tipKey: 'common.switchToEvaluationWorkflowTip', + }} + workflowTypeSwitchDisabled + workflowTypeSwitchDisabledReason="common.switchToEvaluationWorkflowDisabledTip" + />, + ) + + expect(screen.getByRole('button', { name: /common\.publishAsEvaluationWorkflow/i })).toBeDisabled() + }) + it('should render loading access state and access mode labels when enabled', () => { const { rerender } = render( { + if (workflowTypeSwitchConfig?.targetType !== AppTypeEnum.EVALUATION) + return undefined + + if (!hasHumanInputNode && !hasTriggerNode) + return undefined + + return t('common.switchToEvaluationWorkflowDisabledTip', { ns: 'workflow' }) + }, [hasHumanInputNode, hasTriggerNode, t, workflowTypeSwitchConfig?.targetType]) const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp, refetch } = useGetUserCanAccessApp({ appId: appDetail?.id, enabled: false }) const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = useAppWhiteListSubjects(appDetail?.id, open && systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS) @@ -228,6 +237,10 @@ const AppPublisher = ({ const handleWorkflowTypeSwitch = useCallback(async () => { if (!appDetail?.id || !workflowTypeSwitchConfig) return + if (workflowTypeSwitchDisabledReason) { + toast.error(workflowTypeSwitchDisabledReason) + return + } try { await convertWorkflowType({ @@ -252,7 +265,7 @@ const AppPublisher = ({ setOpen(false) } catch { } - }, [appDetail?.id, convertWorkflowType, handlePublish, publishedAt, setAppDetail, workflowTypeSwitchConfig]) + }, [appDetail?.id, convertWorkflowType, handlePublish, publishedAt, setAppDetail, workflowTypeSwitchConfig, workflowTypeSwitchDisabledReason]) useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => { e.preventDefault() @@ -310,7 +323,8 @@ const AppPublisher = ({ startNodeLimitExceeded={startNodeLimitExceeded} upgradeHighlightStyle={upgradeHighlightStyle} workflowTypeSwitchConfig={workflowTypeSwitchConfig} - workflowTypeSwitchDisabled={publishDisabled || published || isConvertingWorkflowType} + workflowTypeSwitchDisabled={publishDisabled || published || isConvertingWorkflowType || Boolean(workflowTypeSwitchDisabledReason)} + workflowTypeSwitchDisabledReason={workflowTypeSwitchDisabledReason} onWorkflowTypeSwitch={handleWorkflowTypeSwitch} /> {!isEvaluationWorkflowType && ( diff --git a/web/app/components/app/app-publisher/sections.tsx b/web/app/components/app/app-publisher/sections.tsx index 2524653ff7..67be6fd5a9 100644 --- a/web/app/components/app/app-publisher/sections.tsx +++ b/web/app/components/app/app-publisher/sections.tsx @@ -44,6 +44,7 @@ type SummarySectionProps = Pick { + if (!disabled || !tooltip) + return <>{children} + + return ( + + {children}} /> + + {tooltip} + + + ) +} + export const PublisherSummarySection = ({ debugWithMultipleModel = false, draftUpdatedAt, @@ -117,6 +140,7 @@ export const PublisherSummarySection = ({ upgradeHighlightStyle, workflowTypeSwitchConfig, workflowTypeSwitchDisabled, + workflowTypeSwitchDisabledReason, }: SummarySectionProps) => { const { t } = useTranslation() @@ -178,43 +202,45 @@ export const PublisherSummarySection = ({ )} {workflowTypeSwitchConfig && ( - + + + { + e.preventDefault() + e.stopPropagation() + }} + > + + + )} + /> + + {t(workflowTypeSwitchConfig.tipKey, { ns: 'workflow' })} + + + + )} {startNodeLimitExceeded && (
@@ -279,28 +305,6 @@ export const PublisherAccessSection = ({ ) } -const ActionTooltip = ({ - disabled, - tooltip, - children, -}: { - disabled: boolean - tooltip?: ReactNode - children: ReactNode -}) => { - if (!disabled || !tooltip) - return <>{children} - - return ( - - {children}
} /> - - {tooltip} - - - ) -} - export const PublisherActionsSection = ({ appDetail, appURL, diff --git a/web/app/components/workflow-app/components/workflow-onboarding-modal/__tests__/index.spec.tsx b/web/app/components/workflow-app/components/workflow-onboarding-modal/__tests__/index.spec.tsx index af38ca113f..479b95dcb6 100644 --- a/web/app/components/workflow-app/components/workflow-onboarding-modal/__tests__/index.spec.tsx +++ b/web/app/components/workflow-app/components/workflow-onboarding-modal/__tests__/index.spec.tsx @@ -2,8 +2,21 @@ import type { ReactNode } from 'react' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { BlockEnum } from '@/app/components/workflow/types' +import { AppTypeEnum } from '@/types/app' import WorkflowOnboardingModal from '../index' +const mockAppType = vi.hoisted<{ current?: string }>(() => ({ + current: 'workflow', +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: { appDetail: { type?: string } }) => unknown) => selector({ + appDetail: { + type: mockAppType.current, + }, + }), +})) + vi.mock('@/app/components/workflow/block-selector', () => ({ default: function MockNodeSelector({ open, @@ -44,6 +57,7 @@ describe('WorkflowOnboardingModal', () => { beforeEach(() => { vi.clearAllMocks() + mockAppType.current = AppTypeEnum.WORKFLOW }) const renderComponent = (props = {}) => { @@ -91,6 +105,15 @@ describe('WorkflowOnboardingModal', () => { expect(getTriggerHeading()).toBeInTheDocument() }) + it('should hide the trigger starter in evaluation workflows', () => { + mockAppType.current = AppTypeEnum.EVALUATION + + renderComponent() + + expect(getUserInputHeading()).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'workflow.onboarding.trigger' })).not.toBeInTheDocument() + }) + it('should render ESC tip when shown', () => { renderComponent({ isShow: true }) diff --git a/web/app/components/workflow-app/components/workflow-onboarding-modal/__tests__/start-node-selection-panel.spec.tsx b/web/app/components/workflow-app/components/workflow-onboarding-modal/__tests__/start-node-selection-panel.spec.tsx index b2496f8714..36544b568c 100644 --- a/web/app/components/workflow-app/components/workflow-onboarding-modal/__tests__/start-node-selection-panel.spec.tsx +++ b/web/app/components/workflow-app/components/workflow-onboarding-modal/__tests__/start-node-selection-panel.spec.tsx @@ -2,8 +2,21 @@ import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' import { BlockEnum } from '@/app/components/workflow/types' +import { AppTypeEnum } from '@/types/app' import StartNodeSelectionPanel from '../start-node-selection-panel' +const mockAppType = vi.hoisted<{ current?: string }>(() => ({ + current: 'workflow', +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: { appDetail: { type?: string } }) => unknown) => selector({ + appDetail: { + type: mockAppType.current, + }, + }), +})) + // Mock NodeSelector component vi.mock('@/app/components/workflow/block-selector', () => ({ default: function MockNodeSelector({ @@ -61,6 +74,7 @@ describe('StartNodeSelectionPanel', () => { beforeEach(() => { vi.clearAllMocks() + mockAppType.current = AppTypeEnum.WORKFLOW }) // Helper function to render component @@ -96,6 +110,15 @@ describe('StartNodeSelectionPanel', () => { expect(screen.getByText('workflow.onboarding.triggerDescription')).toBeInTheDocument() }) + it('should hide the trigger option in evaluation workflows', () => { + mockAppType.current = AppTypeEnum.EVALUATION + + renderComponent() + + expect(screen.queryByText('workflow.onboarding.trigger')).not.toBeInTheDocument() + expect(screen.queryByTestId('node-selector')).not.toBeInTheDocument() + }) + it('should render node selector component', () => { // Arrange & Act renderComponent() diff --git a/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-selection-panel.tsx b/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-selection-panel.tsx index 883212cd2d..6c45ae6315 100644 --- a/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-selection-panel.tsx +++ b/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-selection-panel.tsx @@ -3,10 +3,12 @@ import type { FC } from 'react' import type { PluginDefaultValue } from '@/app/components/workflow/block-selector/types' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' +import { useStore as useAppStore } from '@/app/components/app/store' import { Home, TriggerAll } from '@/app/components/base/icons/src/vender/workflow' import NodeSelector from '@/app/components/workflow/block-selector' import { TabsEnum } from '@/app/components/workflow/block-selector/types' import { BlockEnum } from '@/app/components/workflow/types' +import { isEvaluationWorkflow } from '@/app/components/workflow/utils/evaluation-workflow' import StartNodeOption from './start-node-option' type StartNodeSelectionPanelProps = { @@ -19,7 +21,9 @@ const StartNodeSelectionPanel: FC = ({ onSelectTrigger, }) => { const { t } = useTranslation() + const appType = useAppStore(s => s.appDetail?.type) const [showTriggerSelector, setShowTriggerSelector] = useState(false) + const isEvaluationWorkflowType = isEvaluationWorkflow(appType) const handleTriggerSelect = useCallback((nodeType: BlockEnum, toolConfig?: PluginDefaultValue) => { setShowTriggerSelector(false) @@ -39,34 +43,36 @@ const StartNodeSelectionPanel: FC = ({ onClick={onSelectUserInput} /> - ( - - - - )} - title={t('onboarding.trigger', { ns: 'workflow' })} - description={t('onboarding.triggerDescription', { ns: 'workflow' })} - onClick={() => setShowTriggerSelector(true)} - /> - )} - /> + {!isEvaluationWorkflowType && ( + ( + + + + )} + title={t('onboarding.trigger', { ns: 'workflow' })} + description={t('onboarding.triggerDescription', { ns: 'workflow' })} + onClick={() => setShowTriggerSelector(true)} + /> + )} + /> + )} ) } diff --git a/web/app/components/workflow-app/hooks/__tests__/use-available-nodes-meta-data.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-available-nodes-meta-data.spec.ts index c92e438cb3..8a645d1ff9 100644 --- a/web/app/components/workflow-app/hooks/__tests__/use-available-nodes-meta-data.spec.ts +++ b/web/app/components/workflow-app/hooks/__tests__/use-available-nodes-meta-data.spec.ts @@ -1,13 +1,25 @@ import { renderHook } from '@testing-library/react' import { BlockEnum } from '@/app/components/workflow/types' +import { AppTypeEnum } from '@/types/app' import { useAvailableNodesMetaData } from '../use-available-nodes-meta-data' const mockUseIsChatMode = vi.fn() +const mockAppType = vi.hoisted<{ current?: string }>(() => ({ + current: 'workflow', +})) vi.mock('@/app/components/workflow-app/hooks/use-is-chat-mode', () => ({ useIsChatMode: () => mockUseIsChatMode(), })) +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: { appDetail: { type?: string } }) => unknown) => selector({ + appDetail: { + type: mockAppType.current, + }, + }), +})) + vi.mock('@/context/i18n', () => ({ useDocLink: () => (path: string) => `/docs${path}`, })) @@ -15,6 +27,7 @@ vi.mock('@/context/i18n', () => ({ describe('useAvailableNodesMetaData', () => { beforeEach(() => { vi.clearAllMocks() + mockAppType.current = AppTypeEnum.WORKFLOW }) it('should include chat-specific nodes and make the start node undeletable in chat mode', () => { @@ -46,4 +59,18 @@ describe('useAvailableNodesMetaData', () => { title: 'workflow.blocks.start', }) }) + + it('should exclude human input and trigger nodes in evaluation workflows', () => { + mockUseIsChatMode.mockReturnValue(false) + mockAppType.current = AppTypeEnum.EVALUATION + + const { result } = renderHook(() => useAvailableNodesMetaData()) + + expect(result.current.nodesMap?.[BlockEnum.Start]).toBeDefined() + expect(result.current.nodesMap?.[BlockEnum.End]).toBeDefined() + expect(result.current.nodesMap?.[BlockEnum.HumanInput]).toBeUndefined() + expect(result.current.nodesMap?.[BlockEnum.TriggerWebhook]).toBeUndefined() + expect(result.current.nodesMap?.[BlockEnum.TriggerSchedule]).toBeUndefined() + expect(result.current.nodesMap?.[BlockEnum.TriggerPlugin]).toBeUndefined() + }) }) diff --git a/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts b/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts index 0c5c1e4a40..33edaa2ed5 100644 --- a/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts +++ b/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts @@ -2,6 +2,7 @@ import type { AvailableNodesMetaData } from '@/app/components/workflow/hooks-sto import type { DocPathWithoutLang } from '@/types/doc-paths' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' +import { useStore as useAppStore } from '@/app/components/app/store' import { WORKFLOW_COMMON_NODES } from '@/app/components/workflow/constants/node' import AnswerDefault from '@/app/components/workflow/nodes/answer/default' import EndDefault from '@/app/components/workflow/nodes/end/default' @@ -10,13 +11,16 @@ import TriggerPluginDefault from '@/app/components/workflow/nodes/trigger-plugin import TriggerScheduleDefault from '@/app/components/workflow/nodes/trigger-schedule/default' import TriggerWebhookDefault from '@/app/components/workflow/nodes/trigger-webhook/default' import { BlockEnum } from '@/app/components/workflow/types' +import { isEvaluationWorkflow, isEvaluationWorkflowRestrictedNodeType } from '@/app/components/workflow/utils/evaluation-workflow' import { useDocLink } from '@/context/i18n' import { useIsChatMode } from './use-is-chat-mode' export const useAvailableNodesMetaData = () => { const { t } = useTranslation() const isChatMode = useIsChatMode() + const appType = useAppStore(s => s.appDetail?.type) const docLink = useDocLink() + const isEvaluationWorkflowType = isEvaluationWorkflow(appType) const startNodeMetaData = useMemo(() => ({ ...StartDefault, @@ -26,20 +30,27 @@ export const useAvailableNodesMetaData = () => { }, }), [isChatMode]) - const mergedNodesMetaData = useMemo(() => [ - ...WORKFLOW_COMMON_NODES, - startNodeMetaData, - ...( - isChatMode - ? [AnswerDefault] - : [ - EndDefault, - TriggerWebhookDefault, - TriggerScheduleDefault, - TriggerPluginDefault, - ] - ), - ], [isChatMode, startNodeMetaData]) + const mergedNodesMetaData = useMemo(() => { + const nodes = [ + ...WORKFLOW_COMMON_NODES, + startNodeMetaData, + ...( + isChatMode + ? [AnswerDefault] + : [ + EndDefault, + TriggerWebhookDefault, + TriggerScheduleDefault, + TriggerPluginDefault, + ] + ), + ] + + if (!isEvaluationWorkflowType) + return nodes + + return nodes.filter(node => !isEvaluationWorkflowRestrictedNodeType(node.metaData.type)) + }, [isChatMode, isEvaluationWorkflowType, startNodeMetaData]) const availableNodesMetaData = useMemo(() => mergedNodesMetaData.map((node) => { const { metaData } = node diff --git a/web/app/components/workflow/block-selector/__tests__/all-start-blocks.spec.tsx b/web/app/components/workflow/block-selector/__tests__/all-start-blocks.spec.tsx index 2b28662b45..0ffe583bb1 100644 --- a/web/app/components/workflow/block-selector/__tests__/all-start-blocks.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/all-start-blocks.spec.tsx @@ -8,7 +8,7 @@ import { useGetLanguage, useLocale } from '@/context/i18n' import useTheme from '@/hooks/use-theme' import { useFeaturedTriggersRecommendations } from '@/service/use-plugins' import { useAllTriggerPlugins, useInvalidateAllTriggerPlugins } from '@/service/use-triggers' -import { Theme } from '@/types/app' +import { AppTypeEnum, Theme } from '@/types/app' import { defaultSystemFeatures } from '@/types/feature' import { useAvailableNodesMetaData } from '../../../workflow-app/hooks' import useNodes from '../../store/workflow/use-nodes' @@ -19,6 +19,18 @@ vi.mock('@/context/global-public-context', () => ({ useGlobalPublicStore: vi.fn(), })) +const mockAppType = vi.hoisted<{ current?: string }>(() => ({ + current: 'workflow', +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: { appDetail: { type?: string } }) => unknown) => selector({ + appDetail: { + type: mockAppType.current, + }, + }), +})) + vi.mock('@/context/i18n', () => ({ useGetLanguage: vi.fn(), useLocale: vi.fn(), @@ -179,6 +191,7 @@ const createAvailableNodesMetaData = (): ReturnType { beforeEach(() => { vi.clearAllMocks() + mockAppType.current = AppTypeEnum.WORKFLOW mockUseGlobalPublicStore.mockImplementation(selector => selector(createGlobalPublicStoreState(false))) mockUseGetLanguage.mockReturnValue('en_US') mockUseLocale.mockReturnValue('en_US') @@ -238,6 +251,26 @@ describe('AllStartBlocks', () => { expect(await screen.findByRole('link', { name: /plugin\.findMoreInMarketplace/ })).toHaveAttribute('href', 'https://marketplace.test/start') }) + + it('should hide trigger options in evaluation workflows', async () => { + mockAppType.current = AppTypeEnum.EVALUATION + + render( + , + ) + + await waitFor(() => { + expect(screen.getByText('workflow.blocks.start')).toBeInTheDocument() + }) + + expect(screen.queryByText('Provider One')).not.toBeInTheDocument() + expect(screen.queryByText('workflow.tabs.allTriggers')).not.toBeInTheDocument() + }) }) // Empty filter states should surface the request-to-community fallback. diff --git a/web/app/components/workflow/block-selector/__tests__/blocks.spec.tsx b/web/app/components/workflow/block-selector/__tests__/blocks.spec.tsx new file mode 100644 index 0000000000..27b8e68c7f --- /dev/null +++ b/web/app/components/workflow/block-selector/__tests__/blocks.spec.tsx @@ -0,0 +1,51 @@ +import { render, screen } from '@testing-library/react' +import { useStoreApi } from 'reactflow' +import { AppTypeEnum } from '@/types/app' +import { BlockEnum } from '../../types' +import Blocks from '../blocks' + +const mockGetNodes = vi.fn(() => []) +const mockAppType = vi.hoisted<{ current?: string }>(() => ({ + current: 'workflow', +})) + +vi.mock('reactflow', () => ({ + useStoreApi: vi.fn(), +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: { appDetail: { type?: string } }) => unknown) => selector({ + appDetail: { + type: mockAppType.current, + }, + }), +})) + +const mockUseStoreApi = vi.mocked(useStoreApi) + +describe('Blocks', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAppType.current = AppTypeEnum.WORKFLOW + mockUseStoreApi.mockReturnValue({ + getState: () => ({ + getNodes: mockGetNodes, + }), + } as unknown as ReturnType) + }) + + it('should hide human input in evaluation workflows', () => { + mockAppType.current = AppTypeEnum.EVALUATION + + render( + , + ) + + expect(screen.queryByText('workflow.blocks.human-input')).not.toBeInTheDocument() + expect(screen.getByText('workflow.blocks.llm')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/workflow/block-selector/all-start-blocks.tsx b/web/app/components/workflow/block-selector/all-start-blocks.tsx index f59edfbcbf..80ce02cc47 100644 --- a/web/app/components/workflow/block-selector/all-start-blocks.tsx +++ b/web/app/components/workflow/block-selector/all-start-blocks.tsx @@ -14,9 +14,11 @@ import { useState, } from 'react' import { useTranslation } from 'react-i18next' +import { useStore as useAppStore } from '@/app/components/app/store' import Button from '@/app/components/base/button' import Divider from '@/app/components/base/divider' import { SearchMenu } from '@/app/components/base/icons/src/vender/line/general' +import { filterEvaluationWorkflowRestrictedBlockTypes, isEvaluationWorkflow } from '@/app/components/workflow/utils/evaluation-workflow' import { useGlobalPublicStore } from '@/context/global-public-context' import Link from '@/next/link' import { useFeaturedTriggersRecommendations } from '@/service/use-plugins' @@ -54,13 +56,21 @@ const AllStartBlocks = ({ const { t } = useTranslation() const [hasStartBlocksContent, setHasStartBlocksContent] = useState(false) const [hasPluginContent, setHasPluginContent] = useState(false) + const appType = useAppStore(s => s.appDetail?.type) const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures) const pluginRef = useRef(null) const wrapElemRef = useRef(null) - const entryNodeTypes = availableBlocksTypes?.length - ? availableBlocksTypes - : ENTRY_NODE_TYPES + const entryNodeTypes = useMemo(() => { + const blockTypes = availableBlocksTypes?.length + ? availableBlocksTypes + : [...ENTRY_NODE_TYPES] + + if (!isEvaluationWorkflow(appType)) + return blockTypes + + return filterEvaluationWorkflowRestrictedBlockTypes(blockTypes) + }, [appType, availableBlocksTypes]) const enableTriggerPlugin = entryNodeTypes.includes(BlockEnumValue.TriggerPlugin) const { data: triggerProviders = [] } = useAllTriggerPlugins(enableTriggerPlugin) const providerMap = useMemo(() => { @@ -94,7 +104,8 @@ const AllStartBlocks = ({ const shouldShowFeatured = enableTriggerPlugin && enable_marketplace && !hasFilter - const shouldShowTriggerListTitle = hasStartBlocksContent || hasPluginContent + const hasTriggerOptions = entryNodeTypes.some(type => type !== BlockEnumValue.Start) + const shouldShowTriggerListTitle = hasTriggerOptions && (hasStartBlocksContent || hasPluginContent) const shouldShowMarketplaceFooter = enable_marketplace && !hasFilter const handleStartBlocksContentChange = useCallback((hasContent: boolean) => { diff --git a/web/app/components/workflow/block-selector/blocks.tsx b/web/app/components/workflow/block-selector/blocks.tsx index 2425112335..429c25b21b 100644 --- a/web/app/components/workflow/block-selector/blocks.tsx +++ b/web/app/components/workflow/block-selector/blocks.tsx @@ -8,8 +8,10 @@ import { } from 'react' import { useTranslation } from 'react-i18next' import { useStoreApi } from 'reactflow' +import { useStore as useAppStore } from '@/app/components/app/store' import Badge from '@/app/components/base/badge' import Tooltip from '@/app/components/base/tooltip' +import { filterEvaluationWorkflowRestrictedBlockTypes, isEvaluationWorkflow } from '@/app/components/workflow/utils/evaluation-workflow' import BlockIcon from '../block-icon' import { BlockEnum } from '../types' import { BLOCK_CLASSIFICATIONS } from './constants' @@ -29,7 +31,14 @@ const Blocks = ({ }: BlocksProps) => { const { t } = useTranslation() const store = useStoreApi() + const appType = useAppStore(s => s.appDetail?.type) const blocksFromHooks = useBlocks() + const filteredAvailableBlocksTypes = useMemo(() => { + if (!isEvaluationWorkflow(appType)) + return availableBlocksTypes + + return filterEvaluationWorkflowRestrictedBlockTypes(availableBlocksTypes) + }, [appType, availableBlocksTypes]) // Use external blocks if provided, otherwise fallback to hook-based blocks const blocks = blocksFromProps || blocksFromHooks.map(block => ({ @@ -57,7 +66,7 @@ const Blocks = ({ return false } - return block.metaData.title.toLowerCase().includes(searchText.toLowerCase()) && availableBlocksTypes.includes(block.metaData.type) + return block.metaData.title.toLowerCase().includes(searchText.toLowerCase()) && filteredAvailableBlocksTypes.includes(block.metaData.type) }) return { @@ -65,7 +74,7 @@ const Blocks = ({ [classification]: list, } }, {} as Record) - }, [blocks, searchText, availableBlocksTypes]) + }, [blocks, filteredAvailableBlocksTypes, searchText]) const isEmpty = Object.values(groups).every(list => !list.length) const renderGroup = useCallback((classification: BlockClassificationEnum) => { diff --git a/web/app/components/workflow/utils/evaluation-workflow.ts b/web/app/components/workflow/utils/evaluation-workflow.ts new file mode 100644 index 0000000000..40d4b2fb65 --- /dev/null +++ b/web/app/components/workflow/utils/evaluation-workflow.ts @@ -0,0 +1,20 @@ +import { AppTypeEnum } from '@/types/app' +import { BlockEnum, TRIGGER_NODE_TYPES } from '../types' + +const EVALUATION_WORKFLOW_RESTRICTED_NODE_TYPES = new Set([ + BlockEnum.HumanInput, + ...TRIGGER_NODE_TYPES, +]) + +export const isEvaluationWorkflow = (appType?: string) => appType === AppTypeEnum.EVALUATION + +export const isEvaluationWorkflowRestrictedNodeType = (nodeType?: string) => { + if (!nodeType) + return false + + return EVALUATION_WORKFLOW_RESTRICTED_NODE_TYPES.has(nodeType) +} + +export const filterEvaluationWorkflowRestrictedBlockTypes = (blockTypes: BlockEnum[]) => { + return blockTypes.filter(blockType => !isEvaluationWorkflowRestrictedNodeType(blockType)) +} diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index 91161bbc04..944601ad33 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -216,6 +216,7 @@ "common.setVarValuePlaceholder": "Set variable", "common.showRunHistory": "Show Run History", "common.switchToEvaluationWorkflow": "Switch to Evaluation Workflow", + "common.switchToEvaluationWorkflowDisabledTip": "Evaluation workflows do not support Human Input nodes or Trigger start nodes.", "common.switchToEvaluationWorkflowTip": "Turns this workflow into a custom evaluator for batch testing. Disables public Web App access.", "common.switchToStandardWorkflow": "Switch to Standard Workflow", "common.switchToStandardWorkflowTip": "Turns this evaluator back into a standard workflow and restores public Web App access.", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index a82a89c581..1688dba6a7 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -216,6 +216,7 @@ "common.setVarValuePlaceholder": "设置变量值", "common.showRunHistory": "显示运行历史", "common.switchToEvaluationWorkflow": "切换为评测工作流", + "common.switchToEvaluationWorkflowDisabledTip": "评测工作流不支持 Human Input 节点或 Trigger 开始节点。", "common.switchToEvaluationWorkflowTip": "将当前工作流转换为批量测试用的自定义评测器,并禁用公开 Web App 访问。", "common.switchToStandardWorkflow": "切换为标准工作流", "common.switchToStandardWorkflowTip": "将当前评测器转换回标准工作流,并恢复公开 Web App 访问。",