feat: add skill enable feature switch (#41246)

This commit is contained in:
wangxiaolei
2026-08-25 08:42:15 +00:00
committed by GitHub
parent 18d3990ae6
commit ddcdbb2d14
18 changed files with 72 additions and 4 deletions
+3
View File
@@ -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
+8
View File
@@ -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,
+1
View File
@@ -18209,6 +18209,7 @@ Flask blueprint initialization.
| docs_processing | string, <br>**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, <br>**Default:** true | | Yes |
| knowledge_pipeline | [KnowledgePipeline](#knowledgepipeline) | | Yes |
@@ -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)
+1
View File
@@ -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):
@@ -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
@@ -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
@@ -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
@@ -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 }),
+1
View File
@@ -19,6 +19,7 @@ export const baseProviderContextValue: ProviderContextState = {
isFetchedPlan: false,
isFetchedPlanInfo: false,
enableBilling: false,
enableSkill: false,
onPlanInfoChanged: noop,
enableReplaceWebAppLogo: false,
modelLoadBalancingEnabled: false,
@@ -50,6 +50,7 @@ const defaultProviderContext = {
isFetchedPlan: false,
isFetchedPlanInfo: false,
enableBilling: false,
enableSkill: false,
onPlanInfoChanged: noop,
enableReplaceWebAppLogo: false,
modelLoadBalancingEnabled: false,
@@ -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: {
@@ -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<ProviderContextState>,
}))
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<ProviderContextState>) => 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')
+4
View File
@@ -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<HTMLButtonElement>(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,
+4 -3
View File
@@ -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',
@@ -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,
+2
View File
@@ -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,
@@ -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<AgentAppDetailWithSite | undefined>(
agentDetailQueryKey,
(agentDetail) =>
@@ -82,6 +82,7 @@ export function WebAppAccessCard({
}
: agentDetail,
)
await queryClient.invalidateQueries({ queryKey: agentDetailQueryKey })
toast.success(tCommon(($) => $['actionMsg.modifiedSuccessfully']))
},
onError: () => {