add ENABLE_COLLABORATION_MODE

This commit is contained in:
hjlarry 2025-10-21 11:46:28 +08:00
parent a23bf53d2b
commit 753234fdfe
11 changed files with 80 additions and 25 deletions

View File

@ -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

View File

@ -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,

View File

@ -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")

View File

@ -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 != ""

View File

@ -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

View File

@ -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.

View File

@ -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<string | null>(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<HTMLElement>, 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,

View File

@ -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,

View File

@ -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<Partial<CollaborationState & { isLeader: boolean }>>({
@ -14,9 +15,19 @@ export function useCollaboration(appId: string, reactFlowStore?: any) {
})
const cursorServiceRef = useRef<CursorService | null>(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<HTMLElement>, 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,
}

View File

@ -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:

View File

@ -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,