From ddcdbb2d14126662ed8d144ea666ff4ab91819a0 Mon Sep 17 00:00:00 2001 From: wangxiaolei Date: Tue, 25 Aug 2026 08:42:15 +0000 Subject: [PATCH] feat: add skill enable feature switch (#41246) --- api/.env.example | 3 +++ api/configs/feature/__init__.py | 8 +++++++ api/openapi/markdown/console-openapi.md | 1 + api/services/entities/feature_entities.py | 1 + api/services/feature_service.py | 1 + .../services/test_feature_service_skill.py | 17 +++++++++++++++ docker/envs/core-services/shared.env.example | 1 + .../api/console/features/types.gen.ts | 1 + .../generated/api/console/features/zod.gen.ts | 1 + web/__mocks__/provider-context.ts | 1 + .../__tests__/test-utils.tsx | 1 + .../billing/utils/__tests__/index.spec.ts | 1 + .../main-nav/__tests__/index.spec.tsx | 21 +++++++++++++++++++ web/app/components/main-nav/index.tsx | 4 ++++ web/app/components/main-nav/routes.ts | 7 ++++--- web/context/provider-context-provider.tsx | 2 ++ web/context/provider-context.ts | 2 ++ .../access/components/web-app-access-card.tsx | 3 ++- 18 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 api/tests/unit_tests/services/test_feature_service_skill.py diff --git a/api/.env.example b/api/.env.example index fbbbc59a305..b9a6fbe1cbf 100644 --- a/api/.env.example +++ b/api/.env.example @@ -850,3 +850,6 @@ CONVERSATION_CLEANUP_BATCH_SIZE=100 DIFY_ENV_NACOS_REQUEST_TIMEOUT=10.0 # Connect timeout for Nacos requests (default: 3.0) DIFY_ENV_NACOS_CONNECT_TIMEOUT=3.0 + +# skill entry +ENABLE_SKILL=false diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index 88ed67acce9..e498e1428a9 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -1291,6 +1291,13 @@ class DataSetConfig(BaseSettings): ) +class SkillConfig(BaseSettings): + ENABLE_SKILL: bool = Field( + description="Enable or disable Skill feature entry points", + default=False, + ) + + class WorkspaceConfig(BaseSettings): """ Configuration for workspace management @@ -1685,6 +1692,7 @@ class FeatureConfig( RepositoryConfig, SandboxExpiredRecordsCleanConfig, SecurityConfig, + SkillConfig, TenantIsolatedTaskQueueConfig, ToolConfig, UpdateConfig, diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 4f092258d06..034a0476682 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -18209,6 +18209,7 @@ Flask blueprint initialization. | docs_processing | string,
**Default:** standard | | Yes | | documents_upload_quota | [LimitationModel](#limitationmodel) | | Yes | | education | [EducationModel](#educationmodel) | | Yes | +| enable_skill | boolean | | Yes | | human_input_email_delivery_enabled | boolean | | Yes | | is_allow_transfer_workspace | boolean,
**Default:** true | | Yes | | knowledge_pipeline | [KnowledgePipeline](#knowledgepipeline) | | Yes | diff --git a/api/services/entities/feature_entities.py b/api/services/entities/feature_entities.py index 666943a5276..e8b959e9eb3 100644 --- a/api/services/entities/feature_entities.py +++ b/api/services/entities/feature_entities.py @@ -151,6 +151,7 @@ class PluginInstallationPermissionModel(FeatureResponseModel): class FeatureModel(FeatureResponseModel): billing: BillingModel = BillingModel() education: EducationModel = EducationModel() + enable_skill: bool = False members: LimitationModel = LimitationModel(size=0, limit=1) apps: LimitationModel = LimitationModel(size=0, limit=10) vector_space: LimitationModel | None = LimitationModel(size=0, limit=5) diff --git a/api/services/feature_service.py b/api/services/feature_service.py index ad716a71f6e..579316e8fe8 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -214,6 +214,7 @@ class FeatureService: features.model_load_balancing_enabled = dify_config.MODEL_LB_ENABLED features.dataset_operator_enabled = dify_config.DATASET_OPERATOR_ENABLED features.education.enabled = dify_config.EDUCATION_ENABLED + features.enable_skill = dify_config.ENABLE_SKILL @classmethod def _fulfill_params_from_workspace_info(cls, features: feature_entities.FeatureModel, tenant_id: str): diff --git a/api/tests/unit_tests/services/test_feature_service_skill.py b/api/tests/unit_tests/services/test_feature_service_skill.py new file mode 100644 index 00000000000..c0d7530c03b --- /dev/null +++ b/api/tests/unit_tests/services/test_feature_service_skill.py @@ -0,0 +1,17 @@ +from collections.abc import Callable + +from enums import DeploymentEdition +from services.entities.feature_entities import FeatureModel +from services.feature_service import FeatureService + + +def test_skill_feature_is_disabled_by_default() -> None: + assert FeatureModel().enable_skill is False + + +def test_skill_feature_follows_env_config(config_overrides: Callable[..., None]) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY, ENABLE_SKILL=True) + + features = FeatureService.get_features("") + + assert features.enable_skill is True diff --git a/docker/envs/core-services/shared.env.example b/docker/envs/core-services/shared.env.example index d2322a4e94e..2f9dc30aae7 100644 --- a/docker/envs/core-services/shared.env.example +++ b/docker/envs/core-services/shared.env.example @@ -32,6 +32,7 @@ ENABLE_TRIAL_APP=false ENABLE_EXPLORE_BANNER=false ENABLE_LEARN_APP=true ENABLE_STEP_BY_STEP_TOUR=false +ENABLE_SKILL=false RBAC_ENABLED=false ENABLE_LICENSE_EXPIRY_NOTICE=true CELERY_BROKER_URL=redis://:difyai123456@redis:6379/1 diff --git a/packages/contracts/generated/api/console/features/types.gen.ts b/packages/contracts/generated/api/console/features/types.gen.ts index bcc666d3f40..02c727aa04d 100644 --- a/packages/contracts/generated/api/console/features/types.gen.ts +++ b/packages/contracts/generated/api/console/features/types.gen.ts @@ -14,6 +14,7 @@ export type FeatureModel = { docs_processing: string documents_upload_quota: LimitationModel education: EducationModel + enable_skill: boolean human_input_email_delivery_enabled: boolean is_allow_transfer_workspace: boolean knowledge_pipeline: KnowledgePipeline diff --git a/packages/contracts/generated/api/console/features/zod.gen.ts b/packages/contracts/generated/api/console/features/zod.gen.ts index 0e248a8dee3..aeaea985907 100644 --- a/packages/contracts/generated/api/console/features/zod.gen.ts +++ b/packages/contracts/generated/api/console/features/zod.gen.ts @@ -103,6 +103,7 @@ export const zFeatureModel = z.object({ docs_processing: z.string().default('standard'), documents_upload_quota: zLimitationModel.default({ limit: 50, size: 0 }), education: zEducationModel.default({ activated: false, enabled: false }), + enable_skill: z.boolean().default(false), human_input_email_delivery_enabled: z.boolean().default(false), is_allow_transfer_workspace: z.boolean().default(true), knowledge_pipeline: zKnowledgePipeline.default({ publish_enabled: false }), diff --git a/web/__mocks__/provider-context.ts b/web/__mocks__/provider-context.ts index 248d18ddaf3..f41aab5c24e 100644 --- a/web/__mocks__/provider-context.ts +++ b/web/__mocks__/provider-context.ts @@ -19,6 +19,7 @@ export const baseProviderContextValue: ProviderContextState = { isFetchedPlan: false, isFetchedPlanInfo: false, enableBilling: false, + enableSkill: false, onPlanInfoChanged: noop, enableReplaceWebAppLogo: false, modelLoadBalancingEnabled: false, diff --git a/web/app/components/app/overview/apikey-info-panel/__tests__/test-utils.tsx b/web/app/components/app/overview/apikey-info-panel/__tests__/test-utils.tsx index 04b2cb7b1eb..23177654223 100644 --- a/web/app/components/app/overview/apikey-info-panel/__tests__/test-utils.tsx +++ b/web/app/components/app/overview/apikey-info-panel/__tests__/test-utils.tsx @@ -50,6 +50,7 @@ const defaultProviderContext = { isFetchedPlan: false, isFetchedPlanInfo: false, enableBilling: false, + enableSkill: false, onPlanInfoChanged: noop, enableReplaceWebAppLogo: false, modelLoadBalancingEnabled: false, diff --git a/web/app/components/billing/utils/__tests__/index.spec.ts b/web/app/components/billing/utils/__tests__/index.spec.ts index 0392d2a3da8..c33312a9209 100644 --- a/web/app/components/billing/utils/__tests__/index.spec.ts +++ b/web/app/components/billing/utils/__tests__/index.spec.ts @@ -78,6 +78,7 @@ describe('billing utils', () => { activated: false, enabled: false, }, + enable_skill: false, human_input_email_delivery_enabled: false, is_allow_transfer_workspace: false, knowledge_pipeline: { diff --git a/web/app/components/main-nav/__tests__/index.spec.tsx b/web/app/components/main-nav/__tests__/index.spec.tsx index 5dcb614696b..81b11dbd0c2 100644 --- a/web/app/components/main-nav/__tests__/index.spec.tsx +++ b/web/app/components/main-nav/__tests__/index.spec.tsx @@ -168,6 +168,11 @@ type MainNavConsoleState = ConsoleStateFixture & { const mockConsoleState = vi.hoisted(() => ({ current: undefined as MainNavConsoleState | undefined, })) +const mockProviderContextState = vi.hoisted(() => ({ + current: { + enableSkill: true, + } as Partial, +})) vi.mock('@/features/agent-v2/feature-flag', () => ({ isAgentV2Enabled: () => mockIsAgentV2Enabled(), @@ -187,6 +192,9 @@ vi.mock('@/context/permission-state', async () => { }) vi.mock('@/context/provider-context', () => ({ useProviderContext: vi.fn(), + useProviderContextSelector: vi.fn((selector: (state: Partial) => unknown) => + selector(mockProviderContextState.current), + ), })) vi.mock('@/context/modal-context', () => ({ @@ -638,6 +646,9 @@ describe('MainNav', () => { refresh: vi.fn(), }) mockConsoleState.current = consoleState + mockProviderContextState.current = { + enableSkill: true, + } ;(useProviderContext as Mock).mockReturnValue({ enableBilling: true, enableEducationPlan: false, @@ -759,6 +770,16 @@ describe('MainNav', () => { ).not.toBeInTheDocument() }) + it('hides the skills entry when skill is disabled', () => { + mockProviderContextState.current = { + enableSkill: false, + } + + renderMainNav() + + expect(screen.queryByRole('link', { name: /common.mainNav.skills/ })).not.toBeInTheDocument() + }) + it('orders the Step-by-step Tour before the account and help actions', async () => { localStorage.setItem(STEP_BY_STEP_TOUR_SHELL_MODE_STORAGE_KEY, 'collapsed') diff --git a/web/app/components/main-nav/index.tsx b/web/app/components/main-nav/index.tsx index aecd6cade81..15345bf311b 100644 --- a/web/app/components/main-nav/index.tsx +++ b/web/app/components/main-nav/index.tsx @@ -10,6 +10,7 @@ import Badge from '@/app/components/base/badge' import { DifyLogo } from '@/app/components/base/logo/dify-logo' import EnvNav from '@/app/components/header/env-nav' import StepByStepTourMount from '@/app/components/step-by-step-tour/mount' +import { useProviderContextSelector } from '@/context/provider-context' import { isCurrentWorkspaceDatasetOperatorAtom } from '@/context/workspace-state' import { userProfileQueryOptions } from '@/features/account-profile/client' import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag' @@ -38,6 +39,7 @@ export function MainNav({ className }: MainNavProps) { }) const agentV2Enabled = isAgentV2Enabled() const canManageAgents = useCanManageAgents() + const enableSkill = useProviderContextSelector((state) => state.enableSkill) const showEnvTag = currentEnv === 'TESTING' || currentEnv === 'DEVELOPMENT' const helpMenuTriggerRef = useRef(null) @@ -49,6 +51,7 @@ export function MainNav({ className }: MainNavProps) { canManageAgents, isCurrentWorkspaceDatasetOperator, marketplaceEnabled: systemFeatures.enable_marketplace, + skillEnabled: enableSkill, }), ).map((route) => ({ href: route.href, @@ -60,6 +63,7 @@ export function MainNav({ className }: MainNavProps) { [ agentV2Enabled, canManageAgents, + enableSkill, isCurrentWorkspaceDatasetOperator, systemFeatures.enable_marketplace, t, diff --git a/web/app/components/main-nav/routes.ts b/web/app/components/main-nav/routes.ts index e68e46bd4b9..a38d2f431ec 100644 --- a/web/app/components/main-nav/routes.ts +++ b/web/app/components/main-nav/routes.ts @@ -20,6 +20,7 @@ export type MainNavRouteVisibilityOptions = { canManageAgents: boolean isCurrentWorkspaceDatasetOperator: boolean marketplaceEnabled: boolean + skillEnabled: boolean } export type DetailSidebarVisibilityOptions = Pick< @@ -29,8 +30,8 @@ export type DetailSidebarVisibilityOptions = Pick< const VISIBLE_TO_ALL: MainNavRouteVisibility = () => true const CAN_MANAGE_AGENTS: MainNavRouteVisibility = (options) => options.canManageAgents -const NOT_DATASET_OPERATOR: MainNavRouteVisibility = (options) => - !options.isCurrentWorkspaceDatasetOperator +const SKILL_ENABLED_FOR_WORKSPACE: MainNavRouteVisibility = (options) => + options.skillEnabled && !options.isCurrentWorkspaceDatasetOperator function isPathUnderRoute(pathname: string, route: string) { return pathname === route || pathname.startsWith(`${route}/`) @@ -84,7 +85,7 @@ export const MAIN_NAV_ROUTES = [ active: (path: string) => isPathUnderRoute(path, '/skills'), icon: 'i-custom-vender-main-nav-skill', activeIcon: 'i-custom-vender-main-nav-skill-active', - visibility: NOT_DATASET_OPERATOR, + visibility: SKILL_ENABLED_FOR_WORKSPACE, }, { key: 'integrations', diff --git a/web/context/provider-context-provider.tsx b/web/context/provider-context-provider.tsx index 67b60e7f461..3320c10d935 100644 --- a/web/context/provider-context-provider.tsx +++ b/web/context/provider-context-provider.tsx @@ -43,6 +43,7 @@ export const ProviderContextProvider = ({ children }: ProviderContextProviderPro const isFetchedPlan = featuresQuery.isSuccess && enableBilling const isFetchedPlanInfo = featuresQuery.isFetched const enableEducationPlan = features?.education.enabled ?? false + const enableSkill = features?.enable_skill ?? false const enableReplaceWebAppLogo = features?.can_replace_logo ?? false const modelLoadBalancingEnabled = features?.model_load_balancing_enabled ?? false const webappCopyrightEnabled = features?.webapp_copyright_enabled ?? false @@ -97,6 +98,7 @@ export const ProviderContextProvider = ({ children }: ProviderContextProviderPro isFetchedPlan, isFetchedPlanInfo, enableBilling, + enableSkill, onPlanInfoChanged: refreshFeatures, enableReplaceWebAppLogo, modelLoadBalancingEnabled, diff --git a/web/context/provider-context.ts b/web/context/provider-context.ts index 3ed84b46e93..54f0fc22e8f 100644 --- a/web/context/provider-context.ts +++ b/web/context/provider-context.ts @@ -30,6 +30,7 @@ export type ProviderContextState = { isFetchedPlan: boolean isFetchedPlanInfo: boolean enableBilling: boolean + enableSkill: boolean onPlanInfoChanged: () => void enableReplaceWebAppLogo: boolean modelLoadBalancingEnabled: boolean @@ -53,6 +54,7 @@ export const baseProviderContextValue: ProviderContextState = { isFetchedPlan: false, isFetchedPlanInfo: false, enableBilling: false, + enableSkill: false, onPlanInfoChanged: noop, enableReplaceWebAppLogo: false, modelLoadBalancingEnabled: false, diff --git a/web/features/agent-v2/agent-detail/access/components/web-app-access-card.tsx b/web/features/agent-v2/agent-detail/access/components/web-app-access-card.tsx index cf5520cfa66..88b66d463c8 100644 --- a/web/features/agent-v2/agent-detail/access/components/web-app-access-card.tsx +++ b/web/features/agent-v2/agent-detail/access/components/web-app-access-card.tsx @@ -71,7 +71,7 @@ export function WebAppAccessCard({ }) const toggleSiteMutation = useMutation( consoleQuery.apps.byAppId.siteEnable.post.mutationOptions({ - onSuccess: (_updatedApp, variables) => { + onSuccess: async (_updatedApp, variables) => { queryClient.setQueryData( agentDetailQueryKey, (agentDetail) => @@ -82,6 +82,7 @@ export function WebAppAccessCard({ } : agentDetail, ) + await queryClient.invalidateQueries({ queryKey: agentDetailQueryKey }) toast.success(tCommon(($) => $['actionMsg.modifiedSuccessfully'])) }, onError: () => {