From 753234fdfed460255d4c95400e7106245f38cc60 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Tue, 21 Oct 2025 11:46:28 +0800 Subject: [PATCH] add ENABLE_COLLABORATION_MODE --- api/.env.example | 3 ++ api/configs/feature/__init__.py | 8 +++++ api/controllers/console/app/online_user.py | 5 +-- api/services/feature_service.py | 2 ++ .../services/test_feature_service.py | 4 +++ docker/.env.example | 5 +++ .../workflow-app/components/workflow-main.tsx | 32 +++++++++++++------ .../hooks/use-nodes-sync-draft.ts | 21 ++++++------ .../collaboration/hooks/use-collaboration.ts | 19 +++++++++-- .../workflow/header/online-users.tsx | 4 +-- web/types/feature.ts | 2 ++ 11 files changed, 80 insertions(+), 25 deletions(-) diff --git a/api/.env.example b/api/.env.example index 1d8190ce5f..c5cdccc0cf 100644 --- a/api/.env.example +++ b/api/.env.example @@ -30,6 +30,9 @@ INTERNAL_FILES_URL=http://127.0.0.1:5001 # The time in seconds after the signature is rejected FILES_ACCESS_TIMEOUT=300 +# Collaboration mode toggle +ENABLE_COLLABORATION_MODE=false + # Access token expiration time in minutes ACCESS_TOKEN_EXPIRE_MINUTES=60 diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index d0438bdac1..00b6b16577 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -1058,6 +1058,13 @@ class PositionConfig(BaseSettings): return {item.strip() for item in self.POSITION_TOOL_EXCLUDES.split(",") if item.strip() != ""} +class CollaborationConfig(BaseSettings): + ENABLE_COLLABORATION_MODE: bool = Field( + description="Whether to enable collaboration mode features across the workspace", + default=False, + ) + + class LoginConfig(BaseSettings): ENABLE_EMAIL_CODE_LOGIN: bool = Field( description="whether to enable email code login", @@ -1146,6 +1153,7 @@ class FeatureConfig( WorkflowConfig, WorkflowNodeExecutionConfig, WorkspaceConfig, + CollaborationConfig, LoginConfig, AccountConfig, SwaggerUIConfig, diff --git a/api/controllers/console/app/online_user.py b/api/controllers/console/app/online_user.py index 74eb8891a2..8e2f6f2a3e 100644 --- a/api/controllers/console/app/online_user.py +++ b/api/controllers/console/app/online_user.py @@ -1,12 +1,13 @@ import json import time +from werkzeug.wrappers import Request as WerkzeugRequest + from extensions.ext_redis import redis_client from extensions.ext_socketio import sio -from libs.token import extract_access_token from libs.passport import PassportService +from libs.token import extract_access_token from services.account_service import AccountService -from werkzeug.wrappers import Request as WerkzeugRequest @sio.on("connect") diff --git a/api/services/feature_service.py b/api/services/feature_service.py index cbba537333..1b5805b220 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -152,6 +152,7 @@ class SystemFeatureModel(BaseModel): enable_email_code_login: bool = False enable_email_password_login: bool = True enable_social_oauth_login: bool = False + enable_collaboration_mode: bool = False is_allow_register: bool = False is_allow_create_workspace: bool = False is_email_setup: bool = False @@ -214,6 +215,7 @@ class FeatureService: system_features.enable_email_code_login = dify_config.ENABLE_EMAIL_CODE_LOGIN system_features.enable_email_password_login = dify_config.ENABLE_EMAIL_PASSWORD_LOGIN system_features.enable_social_oauth_login = dify_config.ENABLE_SOCIAL_OAUTH_LOGIN + system_features.enable_collaboration_mode = dify_config.ENABLE_COLLABORATION_MODE system_features.is_allow_register = dify_config.ALLOW_REGISTER system_features.is_allow_create_workspace = dify_config.ALLOW_CREATE_WORKSPACE system_features.is_email_setup = dify_config.MAIL_TYPE is not None and dify_config.MAIL_TYPE != "" diff --git a/api/tests/test_containers_integration_tests/services/test_feature_service.py b/api/tests/test_containers_integration_tests/services/test_feature_service.py index 8bd5440411..26428bb732 100644 --- a/api/tests/test_containers_integration_tests/services/test_feature_service.py +++ b/api/tests/test_containers_integration_tests/services/test_feature_service.py @@ -267,6 +267,7 @@ class TestFeatureService: mock_config.ENABLE_EMAIL_CODE_LOGIN = True mock_config.ENABLE_EMAIL_PASSWORD_LOGIN = True mock_config.ENABLE_SOCIAL_OAUTH_LOGIN = False + mock_config.ENABLE_COLLABORATION_MODE = True mock_config.ALLOW_REGISTER = False mock_config.ALLOW_CREATE_WORKSPACE = False mock_config.MAIL_TYPE = "smtp" @@ -291,6 +292,7 @@ class TestFeatureService: # Verify authentication settings assert result.enable_email_code_login is True assert result.enable_email_password_login is False + assert result.enable_collaboration_mode is True assert result.is_allow_register is False assert result.is_allow_create_workspace is False @@ -340,6 +342,7 @@ class TestFeatureService: mock_config.ENABLE_EMAIL_CODE_LOGIN = True mock_config.ENABLE_EMAIL_PASSWORD_LOGIN = True mock_config.ENABLE_SOCIAL_OAUTH_LOGIN = False + mock_config.ENABLE_COLLABORATION_MODE = False mock_config.ALLOW_REGISTER = True mock_config.ALLOW_CREATE_WORKSPACE = True mock_config.MAIL_TYPE = "smtp" @@ -361,6 +364,7 @@ class TestFeatureService: assert result.enable_email_code_login is True assert result.enable_email_password_login is True assert result.enable_social_oauth_login is False + assert result.enable_collaboration_mode is False assert result.is_allow_register is True assert result.is_allow_create_workspace is True assert result.is_email_setup is True diff --git a/docker/.env.example b/docker/.env.example index b0e8d020ba..a150730de8 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -122,6 +122,10 @@ MIGRATION_ENABLED=true # The default value is 300 seconds. FILES_ACCESS_TIMEOUT=300 +# Collaboration mode toggle +# To open collaboration features, you also need to set SERVER_WORKER_CLASS=geventwebsocket.gunicorn.workers.GeventWebSocketWorker +ENABLE_COLLABORATION_MODE=false + # Access token expiration time in minutes ACCESS_TOKEN_EXPIRE_MINUTES=60 @@ -149,6 +153,7 @@ DIFY_PORT=5001 SERVER_WORKER_AMOUNT=1 # Defaults to gevent. If using windows, it can be switched to sync or solo. +# If enable collaboration mode, it must be set to geventwebsocket.gunicorn.workers.GeventWebSocketWorker SERVER_WORKER_CLASS=gevent # Default number of worker connections, the default is 10. diff --git a/web/app/components/workflow-app/components/workflow-main.tsx b/web/app/components/workflow-app/components/workflow-main.tsx index a0d93bff87..2f740de9f2 100644 --- a/web/app/components/workflow-app/components/workflow-main.tsx +++ b/web/app/components/workflow-app/components/workflow-main.tsx @@ -45,26 +45,38 @@ const WorkflowMain = ({ const reactFlow = useReactFlow() const store = useStoreApi() - const { startCursorTracking, stopCursorTracking, onlineUsers, cursors, isConnected } = useCollaboration(appId || '', store) + const { + startCursorTracking, + stopCursorTracking, + onlineUsers, + cursors, + isConnected, + isEnabled: isCollaborationEnabled, + } = useCollaboration(appId || '', store) const [myUserId, setMyUserId] = useState(null) useEffect(() => { - if (isConnected) + if (isCollaborationEnabled && isConnected) setMyUserId('current-user') - }, [isConnected]) + else + setMyUserId(null) + }, [isCollaborationEnabled, isConnected]) const filteredCursors = Object.fromEntries( Object.entries(cursors).filter(([userId]) => userId !== myUserId), ) useEffect(() => { + if (!isCollaborationEnabled) + return + if (containerRef.current) startCursorTracking(containerRef as React.RefObject, reactFlow) return () => { stopCursorTracking() } - }, [startCursorTracking, stopCursorTracking, reactFlow]) + }, [startCursorTracking, stopCursorTracking, reactFlow, isCollaborationEnabled]) const handleWorkflowDataUpdate = useCallback((payload: any) => { const { @@ -128,7 +140,7 @@ const WorkflowMain = ({ } = useWorkflowRun() useEffect(() => { - if (!appId) return + if (!appId || !isCollaborationEnabled) return const unsubscribe = collaborationManager.onVarsAndFeaturesUpdate(async (update: any) => { try { @@ -141,11 +153,11 @@ const WorkflowMain = ({ }) return unsubscribe - }, [appId, handleWorkflowDataUpdate]) + }, [appId, handleWorkflowDataUpdate, isCollaborationEnabled]) // Listen for workflow updates from other users useEffect(() => { - if (!appId) return + if (!appId || !isCollaborationEnabled) return const unsubscribe = collaborationManager.onWorkflowUpdate(async () => { console.log('Received workflow update from collaborator, fetching latest workflow data') @@ -170,11 +182,11 @@ const WorkflowMain = ({ }) return unsubscribe - }, [appId, handleWorkflowDataUpdate, handleUpdateWorkflowCanvas]) + }, [appId, handleWorkflowDataUpdate, handleUpdateWorkflowCanvas, isCollaborationEnabled]) // Listen for sync requests from other users (only processed by leader) useEffect(() => { - if (!appId) return + if (!appId || !isCollaborationEnabled) return const unsubscribe = collaborationManager.onSyncRequest(() => { console.log('Leader received sync request, performing sync') @@ -182,7 +194,7 @@ const WorkflowMain = ({ }) return unsubscribe - }, [appId, doSyncWorkflowDraft]) + }, [appId, doSyncWorkflowDraft, isCollaborationEnabled]) const { handleStartWorkflowRun, handleWorkflowStartRunInChatflow, diff --git a/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts b/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts index 9665da556e..81fcead08e 100644 --- a/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts +++ b/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts @@ -14,6 +14,7 @@ import { useFeaturesStore } from '@/app/components/base/features/hooks' import { API_PREFIX } from '@/config' import { useWorkflowRefreshDraft } from '.' import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager' +import { useGlobalPublicStore } from '@/context/global-public-context' export const useNodesSyncDraft = () => { const store = useStoreApi() @@ -22,6 +23,7 @@ export const useNodesSyncDraft = () => { const { getNodesReadOnly } = useNodesReadOnly() const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft() const params = useParams() + const isCollaborationEnabled = useGlobalPublicStore(s => s.systemFeatures.enable_collaboration_mode) const getPostParams = useCallback(() => { const { @@ -86,21 +88,21 @@ export const useNodesSyncDraft = () => { environment_variables: environmentVariables, conversation_variables: conversationVariables, hash: syncWorkflowDraftHash, - _is_collaborative: true, + _is_collaborative: isCollaborationEnabled, }, } } - }, [store, featuresStore, workflowStore]) + }, [store, featuresStore, workflowStore, isCollaborationEnabled]) const syncWorkflowDraftWhenPageClose = useCallback(() => { if (getNodesReadOnly()) return // Check leader status at sync time - const currentIsLeader = collaborationManager.getIsLeader() + const currentIsLeader = isCollaborationEnabled ? collaborationManager.getIsLeader() : true // Only allow leader to sync data - if (!currentIsLeader) { + if (isCollaborationEnabled && !currentIsLeader) { console.log('Not leader, skipping sync on page close') return } @@ -114,7 +116,7 @@ export const useNodesSyncDraft = () => { JSON.stringify(postParams.params), ) } - }, [getPostParams, params.appId, getNodesReadOnly]) + }, [getPostParams, params.appId, getNodesReadOnly, isCollaborationEnabled]) const doSyncWorkflowDraft = useCallback(async ( notRefreshWhenSyncError?: boolean, @@ -129,12 +131,13 @@ export const useNodesSyncDraft = () => { return // Check leader status at sync time - const currentIsLeader = collaborationManager.getIsLeader() + const currentIsLeader = isCollaborationEnabled ? collaborationManager.getIsLeader() : true // If not leader and not forcing upload, request the leader to sync - if (!currentIsLeader && !forceUpload) { + if (isCollaborationEnabled && !currentIsLeader && !forceUpload) { console.log('Not leader, requesting leader to sync workflow draft') - collaborationManager.emitSyncRequest() + if (isCollaborationEnabled) + collaborationManager.emitSyncRequest() callback?.onSettled?.() return } @@ -181,7 +184,7 @@ export const useNodesSyncDraft = () => { callback?.onSettled?.() } } - }, [workflowStore, getPostParams, getNodesReadOnly, handleRefreshWorkflowDraft]) + }, [workflowStore, getPostParams, getNodesReadOnly, handleRefreshWorkflowDraft, isCollaborationEnabled]) return { doSyncWorkflowDraft, diff --git a/web/app/components/workflow/collaboration/hooks/use-collaboration.ts b/web/app/components/workflow/collaboration/hooks/use-collaboration.ts index 5d549e2baa..3aec92a2e6 100644 --- a/web/app/components/workflow/collaboration/hooks/use-collaboration.ts +++ b/web/app/components/workflow/collaboration/hooks/use-collaboration.ts @@ -3,6 +3,7 @@ import type { ReactFlowInstance } from 'reactflow' import { collaborationManager } from '../core/collaboration-manager' import { CursorService } from '../services/cursor-service' import type { CollaborationState } from '../types/collaboration' +import { useGlobalPublicStore } from '@/context/global-public-context' export function useCollaboration(appId: string, reactFlowStore?: any) { const [state, setState] = useState>({ @@ -14,9 +15,19 @@ export function useCollaboration(appId: string, reactFlowStore?: any) { }) const cursorServiceRef = useRef(null) + const isCollaborationEnabled = useGlobalPublicStore(s => s.systemFeatures.enable_collaboration_mode) useEffect(() => { - if (!appId) return + if (!appId || !isCollaborationEnabled) { + setState({ + isConnected: false, + onlineUsers: [], + cursors: {}, + nodePanelPresence: {}, + isLeader: false, + }) + return + } let connectionId: string | null = null let isUnmounted = false @@ -75,9 +86,12 @@ export function useCollaboration(appId: string, reactFlowStore?: any) { if (connectionId) collaborationManager.disconnect(connectionId) } - }, [appId, reactFlowStore]) + }, [appId, reactFlowStore, isCollaborationEnabled]) const startCursorTracking = (containerRef: React.RefObject, reactFlowInstance?: ReactFlowInstance) => { + if (!isCollaborationEnabled || !cursorServiceRef.current) + return + if (cursorServiceRef.current) { cursorServiceRef.current.startTracking(containerRef, (position) => { collaborationManager.emitCursorMove(position) @@ -96,6 +110,7 @@ export function useCollaboration(appId: string, reactFlowStore?: any) { nodePanelPresence: state.nodePanelPresence || {}, isLeader: state.isLeader || false, leaderId: collaborationManager.getLeaderId(), + isEnabled: isCollaborationEnabled, startCursorTracking, stopCursorTracking, } diff --git a/web/app/components/workflow/header/online-users.tsx b/web/app/components/workflow/header/online-users.tsx index 5d87552dce..72356ce7d5 100644 --- a/web/app/components/workflow/header/online-users.tsx +++ b/web/app/components/workflow/header/online-users.tsx @@ -50,7 +50,7 @@ const useAvatarUrls = (users: any[]) => { const OnlineUsers = () => { const appId = useStore(s => s.appId) - const { onlineUsers, cursors } = useCollaboration(appId as string) + const { onlineUsers, cursors, isEnabled: isCollaborationEnabled } = useCollaboration(appId as string) const { userProfile } = useAppContext() const reactFlow = useReactFlow() const [dropdownOpen, setDropdownOpen] = useState(false) @@ -67,7 +67,7 @@ const OnlineUsers = () => { reactFlow.setCenter(cursor.x, cursor.y, { zoom: 1, duration: 800 }) } - if (!onlineUsers || onlineUsers.length === 0) + if (!isCollaborationEnabled || !onlineUsers || onlineUsers.length === 0) return null // Display logic: diff --git a/web/types/feature.ts b/web/types/feature.ts index 56fe0c0484..611f3a173f 100644 --- a/web/types/feature.ts +++ b/web/types/feature.ts @@ -39,6 +39,7 @@ export type SystemFeatures = { enable_email_code_login: boolean enable_email_password_login: boolean enable_social_oauth_login: boolean + enable_collaboration_mode: boolean is_allow_create_workspace: boolean is_allow_register: boolean is_email_setup: boolean @@ -75,6 +76,7 @@ export const defaultSystemFeatures: SystemFeatures = { enable_email_code_login: false, enable_email_password_login: false, enable_social_oauth_login: false, + enable_collaboration_mode: false, is_allow_create_workspace: false, is_allow_register: false, is_email_setup: false,