Bladeren bron

feat: knowledge admin role (#5965)

Co-authored-by: JzoNg <jzongcode@gmail.com>
Co-authored-by: jyong <718720800@qq.com>
Joe 11 maanden geleden
bovenliggende
commit
5d9ad430af
46 gewijzigde bestanden met toevoegingen van 1028 en 350 verwijderingen
  1. 5 0
      api/configs/feature/__init__.py
  2. 59 8
      api/controllers/console/datasets/datasets.py
  3. 18 8
      api/controllers/console/datasets/datasets_document.py
  4. 6 6
      api/controllers/console/tag/tags.py
  5. 13 0
      api/controllers/console/workspace/members.py
  6. 42 0
      api/migrations/versions/7e6a8693e07a_add_table_dataset_permissions.py
  7. 22 2
      api/models/account.py
  8. 15 0
      api/models/dataset.py
  9. 22 0
      api/services/account_service.py
  10. 236 75
      api/services/dataset_service.py
  11. 2 0
      api/services/feature_service.py
  12. 12 1
      web/app/(commonLayout)/app/(appDetailLayout)/layout.tsx
  13. 8 1
      web/app/(commonLayout)/apps/Apps.tsx
  14. 3 1
      web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout.tsx
  15. 10 1
      web/app/(commonLayout)/datasets/Container.tsx
  16. 17 11
      web/app/(commonLayout)/datasets/DatasetCard.tsx
  17. 11 0
      web/app/(commonLayout)/tools/page.tsx
  18. 38 7
      web/app/components/app/configuration/dataset-config/settings-modal/index.tsx
  19. 10 0
      web/app/components/base/icons/assets/vender/solid/users/users-plus.svg
  20. 77 0
      web/app/components/base/icons/src/vender/solid/users/UsersPlus.json
  21. 16 0
      web/app/components/base/icons/src/vender/solid/users/UsersPlus.tsx
  22. 1 0
      web/app/components/base/icons/src/vender/solid/users/index.ts
  23. 1 1
      web/app/components/base/search-input/index.tsx
  24. 1 0
      web/app/components/billing/type.ts
  25. 40 22
      web/app/components/datasets/settings/form/index.tsx
  26. 174 0
      web/app/components/datasets/settings/permission-selector/index.tsx
  27. 0 7
      web/app/components/datasets/settings/permissions-radio/assets/user.svg
  28. 0 46
      web/app/components/datasets/settings/permissions-radio/index.module.css
  29. 0 66
      web/app/components/datasets/settings/permissions-radio/index.tsx
  30. 8 1
      web/app/components/explore/index.tsx
  31. 7 1
      web/app/components/header/account-setting/index.tsx
  32. 1 0
      web/app/components/header/account-setting/members-page/index.tsx
  33. 7 67
      web/app/components/header/account-setting/members-page/invite-modal/index.tsx
  34. 95 0
      web/app/components/header/account-setting/members-page/invite-modal/role-selector.tsx
  35. 14 4
      web/app/components/header/account-setting/members-page/operation/index.tsx
  36. 9 9
      web/app/components/header/index.tsx
  37. 1 1
      web/app/components/header/nav/nav-selector/index.tsx
  38. 4 0
      web/context/app-context.tsx
  39. 8 0
      web/context/provider-context.tsx
  40. 2 0
      web/i18n/en-US/common.ts
  41. 2 0
      web/i18n/en-US/dataset-settings.ts
  42. 2 0
      web/i18n/zh-Hans/common.ts
  43. 2 0
      web/i18n/zh-Hans/dataset-settings.ts
  44. 2 2
      web/models/common.ts
  45. 4 1
      web/models/datasets.ts
  46. 1 1
      web/service/datasets.ts

+ 5 - 0
api/configs/feature/__init__.py

@@ -391,6 +391,11 @@ class DataSetConfig(BaseModel):
         default=30,
     )
 
+    DATASET_OPERATOR_ENABLED: bool = Field(
+        description='whether to enable dataset operator',
+        default=False,
+    )
+
 
 class WorkspaceConfig(BaseModel):
     """

+ 59 - 8
api/controllers/console/datasets/datasets.py

@@ -25,7 +25,7 @@ from fields.document_fields import document_status_fields
 from libs.login import login_required
 from models.dataset import Dataset, Document, DocumentSegment
 from models.model import ApiToken, UploadFile
-from services.dataset_service import DatasetService, DocumentService
+from services.dataset_service import DatasetPermissionService, DatasetService, DocumentService
 
 
 def _validate_name(name):
@@ -85,6 +85,12 @@ class DatasetListApi(Resource):
             else:
                 item['embedding_available'] = True
 
+            if item.get('permission') == 'partial_members':
+                part_users_list = DatasetPermissionService.get_dataset_partial_member_list(item['id'])
+                item.update({'partial_member_list': part_users_list})
+            else:
+                item.update({'partial_member_list': []})
+
         response = {
             'data': data,
             'has_more': len(datasets) == limit,
@@ -108,7 +114,7 @@ class DatasetListApi(Resource):
                             help='Invalid indexing technique.')
         args = parser.parse_args()
 
-        # The role of the current user in the ta table must be admin, owner, or editor
+        # The role of the current user in the ta table must be admin, owner, or editor, or dataset_operator
         if not current_user.is_editor:
             raise Forbidden()
 
@@ -140,6 +146,10 @@ class DatasetApi(Resource):
         except services.errors.account.NoPermissionError as e:
             raise Forbidden(str(e))
         data = marshal(dataset, dataset_detail_fields)
+        if data.get('permission') == 'partial_members':
+            part_users_list = DatasetPermissionService.get_dataset_partial_member_list(dataset_id_str)
+            data.update({'partial_member_list': part_users_list})
+
         # check embedding setting
         provider_manager = ProviderManager()
         configurations = provider_manager.get_configurations(
@@ -163,6 +173,11 @@ class DatasetApi(Resource):
                 data['embedding_available'] = False
         else:
             data['embedding_available'] = True
+
+        if data.get('permission') == 'partial_members':
+            part_users_list = DatasetPermissionService.get_dataset_partial_member_list(dataset_id_str)
+            data.update({'partial_member_list': part_users_list})
+
         return data, 200
 
     @setup_required
@@ -188,17 +203,21 @@ class DatasetApi(Resource):
                             nullable=True,
                             help='Invalid indexing technique.')
         parser.add_argument('permission', type=str, location='json', choices=(
-            'only_me', 'all_team_members'), help='Invalid permission.')
+            'only_me', 'all_team_members', 'partial_members'), help='Invalid permission.'
+                            )
         parser.add_argument('embedding_model', type=str,
                             location='json', help='Invalid embedding model.')
         parser.add_argument('embedding_model_provider', type=str,
                             location='json', help='Invalid embedding model provider.')
         parser.add_argument('retrieval_model', type=dict, location='json', help='Invalid retrieval model.')
+        parser.add_argument('partial_member_list', type=list, location='json', help='Invalid parent user list.')
         args = parser.parse_args()
+        data = request.get_json()
 
-        # The role of the current user in the ta table must be admin, owner, or editor
-        if not current_user.is_editor:
-            raise Forbidden()
+        # The role of the current user in the ta table must be admin, owner, editor, or dataset_operator
+        DatasetPermissionService.check_permission(
+            current_user, dataset, data.get('permission'), data.get('partial_member_list')
+        )
 
         dataset = DatasetService.update_dataset(
             dataset_id_str, args, current_user)
@@ -206,7 +225,17 @@ class DatasetApi(Resource):
         if dataset is None:
             raise NotFound("Dataset not found.")
 
-        return marshal(dataset, dataset_detail_fields), 200
+        result_data = marshal(dataset, dataset_detail_fields)
+
+        if data.get('partial_member_list') and data.get('permission') == 'partial_members':
+            DatasetPermissionService.update_partial_member_list(dataset_id_str, data.get('partial_member_list'))
+        else:
+            DatasetPermissionService.clear_partial_member_list(dataset_id_str)
+
+        partial_member_list = DatasetPermissionService.get_dataset_partial_member_list(dataset_id_str)
+        result_data.update({'partial_member_list': partial_member_list})
+
+        return result_data, 200
 
     @setup_required
     @login_required
@@ -215,7 +244,7 @@ class DatasetApi(Resource):
         dataset_id_str = str(dataset_id)
 
         # The role of the current user in the ta table must be admin, owner, or editor
-        if not current_user.is_editor:
+        if not current_user.is_editor or current_user.is_dataset_operator:
             raise Forbidden()
 
         try:
@@ -569,6 +598,27 @@ class DatasetErrorDocs(Resource):
         }, 200
 
 
+class DatasetPermissionUserListApi(Resource):
+    @setup_required
+    @login_required
+    @account_initialization_required
+    def get(self, dataset_id):
+        dataset_id_str = str(dataset_id)
+        dataset = DatasetService.get_dataset(dataset_id_str)
+        if dataset is None:
+            raise NotFound("Dataset not found.")
+        try:
+            DatasetService.check_dataset_permission(dataset, current_user)
+        except services.errors.account.NoPermissionError as e:
+            raise Forbidden(str(e))
+
+        partial_members_list = DatasetPermissionService.get_dataset_partial_member_list(dataset_id_str)
+
+        return {
+            'data': partial_members_list,
+        }, 200
+
+
 api.add_resource(DatasetListApi, '/datasets')
 api.add_resource(DatasetApi, '/datasets/<uuid:dataset_id>')
 api.add_resource(DatasetUseCheckApi, '/datasets/<uuid:dataset_id>/use-check')
@@ -582,3 +632,4 @@ api.add_resource(DatasetApiDeleteApi, '/datasets/api-keys/<uuid:api_key_id>')
 api.add_resource(DatasetApiBaseUrlApi, '/datasets/api-base-info')
 api.add_resource(DatasetRetrievalSettingApi, '/datasets/retrieval-setting')
 api.add_resource(DatasetRetrievalSettingMockApi, '/datasets/retrieval-setting/<string:vector_type>')
+api.add_resource(DatasetPermissionUserListApi, '/datasets/<uuid:dataset_id>/permission-part-users')

+ 18 - 8
api/controllers/console/datasets/datasets_document.py

@@ -228,7 +228,7 @@ class DatasetDocumentListApi(Resource):
             raise NotFound('Dataset not found.')
 
         # The role of the current user in the ta table must be admin, owner, or editor
-        if not current_user.is_editor:
+        if not current_user.is_dataset_editor:
             raise Forbidden()
 
         try:
@@ -294,6 +294,11 @@ class DatasetInitApi(Resource):
         parser.add_argument('retrieval_model', type=dict, required=False, nullable=False,
                             location='json')
         args = parser.parse_args()
+
+        # The role of the current user in the ta table must be admin, owner, or editor, or dataset_operator
+        if not current_user.is_dataset_editor:
+            raise Forbidden()
+
         if args['indexing_technique'] == 'high_quality':
             try:
                 model_manager = ModelManager()
@@ -757,14 +762,18 @@ class DocumentStatusApi(DocumentResource):
         dataset = DatasetService.get_dataset(dataset_id)
         if dataset is None:
             raise NotFound("Dataset not found.")
+
+        # The role of the current user in the ta table must be admin, owner, or editor
+        if not current_user.is_dataset_editor:
+            raise Forbidden()
+
         # check user's model setting
         DatasetService.check_dataset_model_setting(dataset)
 
-        document = self.get_document(dataset_id, document_id)
+        # check user's permission
+        DatasetService.check_dataset_permission(dataset, current_user)
 
-        # The role of the current user in the ta table must be admin, owner, or editor
-        if not current_user.is_editor:
-            raise Forbidden()
+        document = self.get_document(dataset_id, document_id)
 
         indexing_cache_key = 'document_{}_indexing'.format(document.id)
         cache_result = redis_client.get(indexing_cache_key)
@@ -955,10 +964,11 @@ class DocumentRenameApi(DocumentResource):
     @account_initialization_required
     @marshal_with(document_fields)
     def post(self, dataset_id, document_id):
-        # The role of the current user in the ta table must be admin or owner
-        if not current_user.is_admin_or_owner:
+        # The role of the current user in the ta table must be admin, owner, editor, or dataset_operator
+        if not current_user.is_dataset_editor:
             raise Forbidden()
-
+        dataset = DatasetService.get_dataset(dataset_id)
+        DatasetService.check_dataset_operator_permission(current_user, dataset)
         parser = reqparse.RequestParser()
         parser.add_argument('name', type=str, required=True, nullable=False, location='json')
         args = parser.parse_args()

+ 6 - 6
api/controllers/console/tag/tags.py

@@ -36,7 +36,7 @@ class TagListApi(Resource):
     @account_initialization_required
     def post(self):
         # The role of the current user in the ta table must be admin, owner, or editor
-        if not current_user.is_editor:
+        if not (current_user.is_editor or current_user.is_dataset_editor):
             raise Forbidden()
 
         parser = reqparse.RequestParser()
@@ -68,7 +68,7 @@ class TagUpdateDeleteApi(Resource):
     def patch(self, tag_id):
         tag_id = str(tag_id)
         # The role of the current user in the ta table must be admin, owner, or editor
-        if not current_user.is_editor:
+        if not (current_user.is_editor or current_user.is_dataset_editor):
             raise Forbidden()
 
         parser = reqparse.RequestParser()
@@ -109,8 +109,8 @@ class TagBindingCreateApi(Resource):
     @login_required
     @account_initialization_required
     def post(self):
-        # The role of the current user in the ta table must be admin, owner, or editor
-        if not current_user.is_editor:
+        # The role of the current user in the ta table must be admin, owner, editor, or dataset_operator
+        if not (current_user.is_editor or current_user.is_dataset_editor):
             raise Forbidden()
 
         parser = reqparse.RequestParser()
@@ -134,8 +134,8 @@ class TagBindingDeleteApi(Resource):
     @login_required
     @account_initialization_required
     def post(self):
-        # The role of the current user in the ta table must be admin, owner, or editor
-        if not current_user.is_editor:
+        # The role of the current user in the ta table must be admin, owner, editor, or dataset_operator
+        if not (current_user.is_editor or current_user.is_dataset_editor):
             raise Forbidden()
 
         parser = reqparse.RequestParser()

+ 13 - 0
api/controllers/console/workspace/members.py

@@ -131,7 +131,20 @@ class MemberUpdateRoleApi(Resource):
         return {'result': 'success'}
 
 
+class DatasetOperatorMemberListApi(Resource):
+    """List all members of current tenant."""
+
+    @setup_required
+    @login_required
+    @account_initialization_required
+    @marshal_with(account_with_role_list_fields)
+    def get(self):
+        members = TenantService.get_dataset_operator_members(current_user.current_tenant)
+        return {'result': 'success', 'accounts': members}, 200
+
+
 api.add_resource(MemberListApi, '/workspaces/current/members')
 api.add_resource(MemberInviteEmailApi, '/workspaces/current/members/invite-email')
 api.add_resource(MemberCancelInviteApi, '/workspaces/current/members/<uuid:member_id>')
 api.add_resource(MemberUpdateRoleApi, '/workspaces/current/members/<uuid:member_id>/update-role')
+api.add_resource(DatasetOperatorMemberListApi, '/workspaces/current/dataset-operators')

+ 42 - 0
api/migrations/versions/7e6a8693e07a_add_table_dataset_permissions.py

@@ -0,0 +1,42 @@
+"""add table dataset_permissions
+
+Revision ID: 7e6a8693e07a
+Revises: 4ff534e1eb11
+Create Date: 2024-06-25 03:20:46.012193
+
+"""
+import sqlalchemy as sa
+from alembic import op
+
+import models as models
+
+# revision identifiers, used by Alembic.
+revision = '7e6a8693e07a'
+down_revision = 'b2602e131636'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.create_table('dataset_permissions',
+    sa.Column('id', models.StringUUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False),
+    sa.Column('dataset_id', models.StringUUID(), nullable=False),
+    sa.Column('account_id', models.StringUUID(), nullable=False),
+    sa.Column('has_permission', sa.Boolean(), server_default=sa.text('true'), nullable=False),
+    sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False),
+    sa.PrimaryKeyConstraint('id', name='dataset_permission_pkey')
+    )
+    with op.batch_alter_table('dataset_permissions', schema=None) as batch_op:
+        batch_op.create_index('idx_dataset_permissions_account_id', ['account_id'], unique=False)
+        batch_op.create_index('idx_dataset_permissions_dataset_id', ['dataset_id'], unique=False)
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    with op.batch_alter_table('dataset_permissions', schema=None) as batch_op:
+        batch_op.drop_index('idx_dataset_permissions_dataset_id')
+        batch_op.drop_index('idx_dataset_permissions_account_id')
+    op.drop_table('dataset_permissions')
+    # ### end Alembic commands ###

+ 22 - 2
api/models/account.py

@@ -80,6 +80,10 @@ class Account(UserMixin, db.Model):
 
         self._current_tenant = tenant
 
+    @property
+    def current_role(self):
+        return self._current_tenant.current_role
+
     def get_status(self) -> AccountStatus:
         status_str = self.status
         return AccountStatus(status_str)
@@ -110,6 +114,14 @@ class Account(UserMixin, db.Model):
     def is_editor(self):
         return TenantAccountRole.is_editing_role(self._current_tenant.current_role)
 
+    @property
+    def is_dataset_editor(self):
+        return TenantAccountRole.is_dataset_edit_role(self._current_tenant.current_role)
+
+    @property
+    def is_dataset_operator(self):
+        return self._current_tenant.current_role == TenantAccountRole.DATASET_OPERATOR
+
 class TenantStatus(str, enum.Enum):
     NORMAL = 'normal'
     ARCHIVE = 'archive'
@@ -120,10 +132,12 @@ class TenantAccountRole(str, enum.Enum):
     ADMIN = 'admin'
     EDITOR = 'editor'
     NORMAL = 'normal'
+    DATASET_OPERATOR = 'dataset_operator'
 
     @staticmethod
     def is_valid_role(role: str) -> bool:
-        return role and role in {TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR, TenantAccountRole.NORMAL}
+        return role and role in {TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR,
+                                 TenantAccountRole.NORMAL, TenantAccountRole.DATASET_OPERATOR}
 
     @staticmethod
     def is_privileged_role(role: str) -> bool:
@@ -131,12 +145,17 @@ class TenantAccountRole(str, enum.Enum):
     
     @staticmethod
     def is_non_owner_role(role: str) -> bool:
-        return role and role in {TenantAccountRole.ADMIN, TenantAccountRole.EDITOR, TenantAccountRole.NORMAL}
+        return role and role in {TenantAccountRole.ADMIN, TenantAccountRole.EDITOR, TenantAccountRole.NORMAL,
+                                 TenantAccountRole.DATASET_OPERATOR}
     
     @staticmethod
     def is_editing_role(role: str) -> bool:
         return role and role in {TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR}
 
+    @staticmethod
+    def is_dataset_edit_role(role: str) -> bool:
+        return role and role in {TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR,
+                                 TenantAccountRole.DATASET_OPERATOR}
 
 class Tenant(db.Model):
     __tablename__ = 'tenants'
@@ -172,6 +191,7 @@ class TenantAccountJoinRole(enum.Enum):
     OWNER = 'owner'
     ADMIN = 'admin'
     NORMAL = 'normal'
+    DATASET_OPERATOR = 'dataset_operator'
 
 
 class TenantAccountJoin(db.Model):

+ 15 - 0
api/models/dataset.py

@@ -663,3 +663,18 @@ class DatasetCollectionBinding(db.Model):
     type = db.Column(db.String(40), server_default=db.text("'dataset'::character varying"), nullable=False)
     collection_name = db.Column(db.String(64), nullable=False)
     created_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)'))
+
+
+class DatasetPermission(db.Model):
+    __tablename__ = 'dataset_permissions'
+    __table_args__ = (
+        db.PrimaryKeyConstraint('id', name='dataset_permission_pkey'),
+        db.Index('idx_dataset_permissions_dataset_id', 'dataset_id'),
+        db.Index('idx_dataset_permissions_account_id', 'account_id')
+    )
+
+    id = db.Column(StringUUID, server_default=db.text('uuid_generate_v4()'), primary_key=True)
+    dataset_id = db.Column(StringUUID, nullable=False)
+    account_id = db.Column(StringUUID, nullable=False)
+    has_permission = db.Column(db.Boolean, nullable=False, server_default=db.text('true'))
+    created_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)'))

+ 22 - 0
api/services/account_service.py

@@ -337,6 +337,28 @@ class TenantService:
         return updated_accounts
 
     @staticmethod
+    def get_dataset_operator_members(tenant: Tenant) -> list[Account]:
+        """Get dataset admin members"""
+        query = (
+            db.session.query(Account, TenantAccountJoin.role)
+            .select_from(Account)
+            .join(
+                TenantAccountJoin, Account.id == TenantAccountJoin.account_id
+            )
+            .filter(TenantAccountJoin.tenant_id == tenant.id)
+            .filter(TenantAccountJoin.role == 'dataset_operator')
+        )
+
+        # Initialize an empty list to store the updated accounts
+        updated_accounts = []
+
+        for account, role in query:
+            account.role = role
+            updated_accounts.append(account)
+
+        return updated_accounts
+
+    @staticmethod
     def has_roles(tenant: Tenant, roles: list[TenantAccountJoinRole]) -> bool:
         """Check if user has any of the given roles for a tenant"""
         if not all(isinstance(role, TenantAccountJoinRole) for role in roles):

+ 236 - 75
api/services/dataset_service.py

@@ -21,11 +21,12 @@ from events.document_event import document_was_deleted
 from extensions.ext_database import db
 from extensions.ext_redis import redis_client
 from libs import helper
-from models.account import Account
+from models.account import Account, TenantAccountRole
 from models.dataset import (
     AppDatasetJoin,
     Dataset,
     DatasetCollectionBinding,
+    DatasetPermission,
     DatasetProcessRule,
     DatasetQuery,
     Document,
@@ -56,22 +57,38 @@ class DatasetService:
 
     @staticmethod
     def get_datasets(page, per_page, provider="vendor", tenant_id=None, user=None, search=None, tag_ids=None):
+        query = Dataset.query.filter(Dataset.provider == provider, Dataset.tenant_id == tenant_id)
+
         if user:
-            permission_filter = db.or_(Dataset.created_by == user.id,
-                                       Dataset.permission == 'all_team_members')
+            if user.current_role == TenantAccountRole.DATASET_OPERATOR:
+                dataset_permission = DatasetPermission.query.filter_by(account_id=user.id).all()
+                if dataset_permission:
+                    dataset_ids = [dp.dataset_id for dp in dataset_permission]
+                    query = query.filter(Dataset.id.in_(dataset_ids))
+                else:
+                    query = query.filter(db.false())
+            else:
+                permission_filter = db.or_(
+                    Dataset.created_by == user.id,
+                    Dataset.permission == 'all_team_members',
+                    Dataset.permission == 'partial_members',
+                    Dataset.permission == 'only_me'
+                )
+                query = query.filter(permission_filter)
         else:
             permission_filter = Dataset.permission == 'all_team_members'
-        query = Dataset.query.filter(
-            db.and_(Dataset.provider == provider, Dataset.tenant_id == tenant_id, permission_filter)) \
-            .order_by(Dataset.created_at.desc())
+            query = query.filter(permission_filter)
+
         if search:
-            query = query.filter(db.and_(Dataset.name.ilike(f'%{search}%')))
+            query = query.filter(Dataset.name.ilike(f'%{search}%'))
+
         if tag_ids:
             target_ids = TagService.get_target_ids_by_tag_ids('knowledge', tenant_id, tag_ids)
             if target_ids:
-                query = query.filter(db.and_(Dataset.id.in_(target_ids)))
+                query = query.filter(Dataset.id.in_(target_ids))
             else:
                 return [], 0
+
         datasets = query.paginate(
             page=page,
             per_page=per_page,
@@ -79,6 +96,12 @@ class DatasetService:
             error_out=False
         )
 
+        # check datasets permission,
+        if user and user.current_role != TenantAccountRole.DATASET_OPERATOR:
+            datasets.items, datasets.total = DatasetService.filter_datasets_by_permission(
+                user, datasets
+            )
+
         return datasets.items, datasets.total
 
     @staticmethod
@@ -102,9 +125,12 @@ class DatasetService:
 
     @staticmethod
     def get_datasets_by_ids(ids, tenant_id):
-        datasets = Dataset.query.filter(Dataset.id.in_(ids),
-                                        Dataset.tenant_id == tenant_id).paginate(
-            page=1, per_page=len(ids), max_per_page=len(ids), error_out=False)
+        datasets = Dataset.query.filter(
+            Dataset.id.in_(ids),
+            Dataset.tenant_id == tenant_id
+        ).paginate(
+            page=1, per_page=len(ids), max_per_page=len(ids), error_out=False
+        )
         return datasets.items, datasets.total
 
     @staticmethod
@@ -112,7 +138,8 @@ class DatasetService:
         # check if dataset name already exists
         if Dataset.query.filter_by(name=name, tenant_id=tenant_id).first():
             raise DatasetNameDuplicateError(
-                f'Dataset with name {name} already exists.')
+                f'Dataset with name {name} already exists.'
+            )
         embedding_model = None
         if indexing_technique == 'high_quality':
             model_manager = ModelManager()
@@ -151,13 +178,17 @@ class DatasetService:
             except LLMBadRequestError:
                 raise ValueError(
                     "No Embedding Model available. Please configure a valid provider "
-                    "in the Settings -> Model Provider.")
+                    "in the Settings -> Model Provider."
+                )
             except ProviderTokenNotInitError as ex:
-                raise ValueError(f"The dataset in unavailable, due to: "
-                                 f"{ex.description}")
+                raise ValueError(
+                    f"The dataset in unavailable, due to: "
+                    f"{ex.description}"
+                )
 
     @staticmethod
     def update_dataset(dataset_id, data, user):
+        data.pop('partial_member_list', None)
         filtered_data = {k: v for k, v in data.items() if v is not None or k == 'description'}
         dataset = DatasetService.get_dataset(dataset_id)
         DatasetService.check_dataset_permission(dataset, user)
@@ -190,12 +221,13 @@ class DatasetService:
                 except LLMBadRequestError:
                     raise ValueError(
                         "No Embedding Model available. Please configure a valid provider "
-                        "in the Settings -> Model Provider.")
+                        "in the Settings -> Model Provider."
+                    )
                 except ProviderTokenNotInitError as ex:
                     raise ValueError(ex.description)
         else:
             if data['embedding_model_provider'] != dataset.embedding_model_provider or \
-                    data['embedding_model'] != dataset.embedding_model:
+                data['embedding_model'] != dataset.embedding_model:
                 action = 'update'
                 try:
                     model_manager = ModelManager()
@@ -215,7 +247,8 @@ class DatasetService:
                 except LLMBadRequestError:
                     raise ValueError(
                         "No Embedding Model available. Please configure a valid provider "
-                        "in the Settings -> Model Provider.")
+                        "in the Settings -> Model Provider."
+                    )
                 except ProviderTokenNotInitError as ex:
                     raise ValueError(ex.description)
 
@@ -259,14 +292,41 @@ class DatasetService:
     def check_dataset_permission(dataset, user):
         if dataset.tenant_id != user.current_tenant_id:
             logging.debug(
-                f'User {user.id} does not have permission to access dataset {dataset.id}')
+                f'User {user.id} does not have permission to access dataset {dataset.id}'
+            )
             raise NoPermissionError(
-                'You do not have permission to access this dataset.')
+                'You do not have permission to access this dataset.'
+            )
         if dataset.permission == 'only_me' and dataset.created_by != user.id:
             logging.debug(
-                f'User {user.id} does not have permission to access dataset {dataset.id}')
+                f'User {user.id} does not have permission to access dataset {dataset.id}'
+            )
             raise NoPermissionError(
-                'You do not have permission to access this dataset.')
+                'You do not have permission to access this dataset.'
+            )
+        if dataset.permission == 'partial_members':
+            user_permission = DatasetPermission.query.filter_by(
+                dataset_id=dataset.id, account_id=user.id
+            ).first()
+            if not user_permission and dataset.tenant_id != user.current_tenant_id and dataset.created_by != user.id:
+                logging.debug(
+                    f'User {user.id} does not have permission to access dataset {dataset.id}'
+                )
+                raise NoPermissionError(
+                    'You do not have permission to access this dataset.'
+                )
+
+    @staticmethod
+    def check_dataset_operator_permission(user: Account = None, dataset: Dataset = None):
+        if dataset.permission == 'only_me':
+            if dataset.created_by != user.id:
+                raise NoPermissionError('You do not have permission to access this dataset.')
+
+        elif dataset.permission == 'partial_members':
+            if not any(
+                dp.dataset_id == dataset.id for dp in DatasetPermission.query.filter_by(account_id=user.id).all()
+            ):
+                raise NoPermissionError('You do not have permission to access this dataset.')
 
     @staticmethod
     def get_dataset_queries(dataset_id: str, page: int, per_page: int):
@@ -282,6 +342,22 @@ class DatasetService:
         return AppDatasetJoin.query.filter(AppDatasetJoin.dataset_id == dataset_id) \
             .order_by(db.desc(AppDatasetJoin.created_at)).all()
 
+    @staticmethod
+    def filter_datasets_by_permission(user, datasets):
+        dataset_permission = DatasetPermission.query.filter_by(account_id=user.id).all()
+        permitted_dataset_ids = {dp.dataset_id for dp in dataset_permission} if dataset_permission else set()
+
+        filtered_datasets = [
+            dataset for dataset in datasets if
+            (dataset.permission == 'all_team_members') or
+            (dataset.permission == 'only_me' and dataset.created_by == user.id) or
+            (dataset.id in permitted_dataset_ids)
+        ]
+
+        filtered_count = len(filtered_datasets)
+
+        return filtered_datasets, filtered_count
+
 
 class DocumentService:
     DEFAULT_RULES = {
@@ -547,6 +623,7 @@ class DocumentService:
         redis_client.setex(sync_indexing_cache_key, 600, 1)
 
         sync_website_document_indexing_task.delay(dataset_id, document.id)
+
     @staticmethod
     def get_documents_position(dataset_id):
         document = Document.query.filter_by(dataset_id=dataset_id).order_by(Document.position.desc()).first()
@@ -556,9 +633,11 @@ class DocumentService:
             return 1
 
     @staticmethod
-    def save_document_with_dataset_id(dataset: Dataset, document_data: dict,
-                                      account: Account, dataset_process_rule: Optional[DatasetProcessRule] = None,
-                                      created_from: str = 'web'):
+    def save_document_with_dataset_id(
+        dataset: Dataset, document_data: dict,
+        account: Account, dataset_process_rule: Optional[DatasetProcessRule] = None,
+        created_from: str = 'web'
+    ):
 
         # check document limit
         features = FeatureService.get_features(current_user.current_tenant_id)
@@ -588,7 +667,7 @@ class DocumentService:
 
         if not dataset.indexing_technique:
             if 'indexing_technique' not in document_data \
-                    or document_data['indexing_technique'] not in Dataset.INDEXING_TECHNIQUE_LIST:
+                or document_data['indexing_technique'] not in Dataset.INDEXING_TECHNIQUE_LIST:
                 raise ValueError("Indexing technique is required")
 
             dataset.indexing_technique = document_data["indexing_technique"]
@@ -618,7 +697,8 @@ class DocumentService:
                     }
 
                     dataset.retrieval_model = document_data.get('retrieval_model') if document_data.get(
-                        'retrieval_model') else default_retrieval_model
+                        'retrieval_model'
+                    ) else default_retrieval_model
 
         documents = []
         batch = time.strftime('%Y%m%d%H%M%S') + str(random.randint(100000, 999999))
@@ -686,12 +766,14 @@ class DocumentService:
                             documents.append(document)
                             duplicate_document_ids.append(document.id)
                             continue
-                    document = DocumentService.build_document(dataset, dataset_process_rule.id,
-                                                              document_data["data_source"]["type"],
-                                                              document_data["doc_form"],
-                                                              document_data["doc_language"],
-                                                              data_source_info, created_from, position,
-                                                              account, file_name, batch)
+                    document = DocumentService.build_document(
+                        dataset, dataset_process_rule.id,
+                        document_data["data_source"]["type"],
+                        document_data["doc_form"],
+                        document_data["doc_language"],
+                        data_source_info, created_from, position,
+                        account, file_name, batch
+                    )
                     db.session.add(document)
                     db.session.flush()
                     document_ids.append(document.id)
@@ -732,12 +814,14 @@ class DocumentService:
                                 "notion_page_icon": page['page_icon'],
                                 "type": page['type']
                             }
-                            document = DocumentService.build_document(dataset, dataset_process_rule.id,
-                                                                      document_data["data_source"]["type"],
-                                                                      document_data["doc_form"],
-                                                                      document_data["doc_language"],
-                                                                      data_source_info, created_from, position,
-                                                                      account, page['page_name'], batch)
+                            document = DocumentService.build_document(
+                                dataset, dataset_process_rule.id,
+                                document_data["data_source"]["type"],
+                                document_data["doc_form"],
+                                document_data["doc_language"],
+                                data_source_info, created_from, position,
+                                account, page['page_name'], batch
+                            )
                             db.session.add(document)
                             db.session.flush()
                             document_ids.append(document.id)
@@ -759,12 +843,14 @@ class DocumentService:
                         'only_main_content': website_info.get('only_main_content', False),
                         'mode': 'crawl',
                     }
-                    document = DocumentService.build_document(dataset, dataset_process_rule.id,
-                                                              document_data["data_source"]["type"],
-                                                              document_data["doc_form"],
-                                                              document_data["doc_language"],
-                                                              data_source_info, created_from, position,
-                                                              account, url, batch)
+                    document = DocumentService.build_document(
+                        dataset, dataset_process_rule.id,
+                        document_data["data_source"]["type"],
+                        document_data["doc_form"],
+                        document_data["doc_language"],
+                        data_source_info, created_from, position,
+                        account, url, batch
+                    )
                     db.session.add(document)
                     db.session.flush()
                     document_ids.append(document.id)
@@ -785,13 +871,16 @@ class DocumentService:
         can_upload_size = features.documents_upload_quota.limit - features.documents_upload_quota.size
         if count > can_upload_size:
             raise ValueError(
-                f'You have reached the limit of your subscription. Only {can_upload_size} documents can be uploaded.')
+                f'You have reached the limit of your subscription. Only {can_upload_size} documents can be uploaded.'
+            )
 
     @staticmethod
-    def build_document(dataset: Dataset, process_rule_id: str, data_source_type: str, document_form: str,
-                       document_language: str, data_source_info: dict, created_from: str, position: int,
-                       account: Account,
-                       name: str, batch: str):
+    def build_document(
+        dataset: Dataset, process_rule_id: str, data_source_type: str, document_form: str,
+        document_language: str, data_source_info: dict, created_from: str, position: int,
+        account: Account,
+        name: str, batch: str
+    ):
         document = Document(
             tenant_id=dataset.tenant_id,
             dataset_id=dataset.id,
@@ -810,16 +899,20 @@ class DocumentService:
 
     @staticmethod
     def get_tenant_documents_count():
-        documents_count = Document.query.filter(Document.completed_at.isnot(None),
-                                                Document.enabled == True,
-                                                Document.archived == False,
-                                                Document.tenant_id == current_user.current_tenant_id).count()
+        documents_count = Document.query.filter(
+            Document.completed_at.isnot(None),
+            Document.enabled == True,
+            Document.archived == False,
+            Document.tenant_id == current_user.current_tenant_id
+        ).count()
         return documents_count
 
     @staticmethod
-    def update_document_with_dataset_id(dataset: Dataset, document_data: dict,
-                                        account: Account, dataset_process_rule: Optional[DatasetProcessRule] = None,
-                                        created_from: str = 'web'):
+    def update_document_with_dataset_id(
+        dataset: Dataset, document_data: dict,
+        account: Account, dataset_process_rule: Optional[DatasetProcessRule] = None,
+        created_from: str = 'web'
+    ):
         DatasetService.check_dataset_model_setting(dataset)
         document = DocumentService.get_document(dataset.id, document_data["original_document_id"])
         if document.display_status != 'available':
@@ -1007,7 +1100,7 @@ class DocumentService:
             DocumentService.process_rule_args_validate(args)
         else:
             if ('data_source' not in args and not args['data_source']) \
-                    and ('process_rule' not in args and not args['process_rule']):
+                and ('process_rule' not in args and not args['process_rule']):
                 raise ValueError("Data source or Process rule is required")
             else:
                 if args.get('data_source'):
@@ -1069,7 +1162,7 @@ class DocumentService:
                 raise ValueError("Process rule rules is invalid")
 
             if 'pre_processing_rules' not in args['process_rule']['rules'] \
-                    or args['process_rule']['rules']['pre_processing_rules'] is None:
+                or args['process_rule']['rules']['pre_processing_rules'] is None:
                 raise ValueError("Process rule pre_processing_rules is required")
 
             if not isinstance(args['process_rule']['rules']['pre_processing_rules'], list):
@@ -1094,21 +1187,21 @@ class DocumentService:
             args['process_rule']['rules']['pre_processing_rules'] = list(unique_pre_processing_rule_dicts.values())
 
             if 'segmentation' not in args['process_rule']['rules'] \
-                    or args['process_rule']['rules']['segmentation'] is None:
+                or args['process_rule']['rules']['segmentation'] is None:
                 raise ValueError("Process rule segmentation is required")
 
             if not isinstance(args['process_rule']['rules']['segmentation'], dict):
                 raise ValueError("Process rule segmentation is invalid")
 
             if 'separator' not in args['process_rule']['rules']['segmentation'] \
-                    or not args['process_rule']['rules']['segmentation']['separator']:
+                or not args['process_rule']['rules']['segmentation']['separator']:
                 raise ValueError("Process rule segmentation separator is required")
 
             if not isinstance(args['process_rule']['rules']['segmentation']['separator'], str):
                 raise ValueError("Process rule segmentation separator is invalid")
 
             if 'max_tokens' not in args['process_rule']['rules']['segmentation'] \
-                    or not args['process_rule']['rules']['segmentation']['max_tokens']:
+                or not args['process_rule']['rules']['segmentation']['max_tokens']:
                 raise ValueError("Process rule segmentation max_tokens is required")
 
             if not isinstance(args['process_rule']['rules']['segmentation']['max_tokens'], int):
@@ -1144,7 +1237,7 @@ class DocumentService:
                 raise ValueError("Process rule rules is invalid")
 
             if 'pre_processing_rules' not in args['process_rule']['rules'] \
-                    or args['process_rule']['rules']['pre_processing_rules'] is None:
+                or args['process_rule']['rules']['pre_processing_rules'] is None:
                 raise ValueError("Process rule pre_processing_rules is required")
 
             if not isinstance(args['process_rule']['rules']['pre_processing_rules'], list):
@@ -1169,21 +1262,21 @@ class DocumentService:
             args['process_rule']['rules']['pre_processing_rules'] = list(unique_pre_processing_rule_dicts.values())
 
             if 'segmentation' not in args['process_rule']['rules'] \
-                    or args['process_rule']['rules']['segmentation'] is None:
+                or args['process_rule']['rules']['segmentation'] is None:
                 raise ValueError("Process rule segmentation is required")
 
             if not isinstance(args['process_rule']['rules']['segmentation'], dict):
                 raise ValueError("Process rule segmentation is invalid")
 
             if 'separator' not in args['process_rule']['rules']['segmentation'] \
-                    or not args['process_rule']['rules']['segmentation']['separator']:
+                or not args['process_rule']['rules']['segmentation']['separator']:
                 raise ValueError("Process rule segmentation separator is required")
 
             if not isinstance(args['process_rule']['rules']['segmentation']['separator'], str):
                 raise ValueError("Process rule segmentation separator is invalid")
 
             if 'max_tokens' not in args['process_rule']['rules']['segmentation'] \
-                    or not args['process_rule']['rules']['segmentation']['max_tokens']:
+                or not args['process_rule']['rules']['segmentation']['max_tokens']:
                 raise ValueError("Process rule segmentation max_tokens is required")
 
             if not isinstance(args['process_rule']['rules']['segmentation']['max_tokens'], int):
@@ -1437,12 +1530,16 @@ class SegmentService:
 
 class DatasetCollectionBindingService:
     @classmethod
-    def get_dataset_collection_binding(cls, provider_name: str, model_name: str,
-                                       collection_type: str = 'dataset') -> DatasetCollectionBinding:
+    def get_dataset_collection_binding(
+        cls, provider_name: str, model_name: str,
+        collection_type: str = 'dataset'
+    ) -> DatasetCollectionBinding:
         dataset_collection_binding = db.session.query(DatasetCollectionBinding). \
-            filter(DatasetCollectionBinding.provider_name == provider_name,
-                   DatasetCollectionBinding.model_name == model_name,
-                   DatasetCollectionBinding.type == collection_type). \
+            filter(
+            DatasetCollectionBinding.provider_name == provider_name,
+            DatasetCollectionBinding.model_name == model_name,
+            DatasetCollectionBinding.type == collection_type
+        ). \
             order_by(DatasetCollectionBinding.created_at). \
             first()
 
@@ -1458,12 +1555,76 @@ class DatasetCollectionBindingService:
         return dataset_collection_binding
 
     @classmethod
-    def get_dataset_collection_binding_by_id_and_type(cls, collection_binding_id: str,
-                                                      collection_type: str = 'dataset') -> DatasetCollectionBinding:
+    def get_dataset_collection_binding_by_id_and_type(
+        cls, collection_binding_id: str,
+        collection_type: str = 'dataset'
+    ) -> DatasetCollectionBinding:
         dataset_collection_binding = db.session.query(DatasetCollectionBinding). \
-            filter(DatasetCollectionBinding.id == collection_binding_id,
-                   DatasetCollectionBinding.type == collection_type). \
+            filter(
+            DatasetCollectionBinding.id == collection_binding_id,
+            DatasetCollectionBinding.type == collection_type
+        ). \
             order_by(DatasetCollectionBinding.created_at). \
             first()
 
         return dataset_collection_binding
+
+
+class DatasetPermissionService:
+    @classmethod
+    def get_dataset_partial_member_list(cls, dataset_id):
+        user_list_query = db.session.query(
+            DatasetPermission.account_id,
+        ).filter(
+            DatasetPermission.dataset_id == dataset_id
+        ).all()
+
+        user_list = []
+        for user in user_list_query:
+            user_list.append(user.account_id)
+
+        return user_list
+
+    @classmethod
+    def update_partial_member_list(cls, dataset_id, user_list):
+        try:
+            db.session.query(DatasetPermission).filter(DatasetPermission.dataset_id == dataset_id).delete()
+            permissions = []
+            for user in user_list:
+                permission = DatasetPermission(
+                    dataset_id=dataset_id,
+                    account_id=user['user_id'],
+                )
+                permissions.append(permission)
+
+            db.session.add_all(permissions)
+            db.session.commit()
+        except Exception as e:
+            db.session.rollback()
+            raise e
+
+    @classmethod
+    def check_permission(cls, user, dataset, requested_permission, requested_partial_member_list):
+        if not user.is_dataset_editor:
+            raise NoPermissionError('User does not have permission to edit this dataset.')
+
+        if user.is_dataset_operator and dataset.permission != requested_permission:
+            raise NoPermissionError('Dataset operators cannot change the dataset permissions.')
+
+        if user.is_dataset_operator and requested_permission == 'partial_members':
+            if not requested_partial_member_list:
+                raise ValueError('Partial member list is required when setting to partial members.')
+
+            local_member_list = cls.get_dataset_partial_member_list(dataset.id)
+            request_member_list = [user['user_id'] for user in requested_partial_member_list]
+            if set(local_member_list) != set(request_member_list):
+                raise ValueError('Dataset operators cannot change the dataset permissions.')
+
+    @classmethod
+    def clear_partial_member_list(cls, dataset_id):
+        try:
+            db.session.query(DatasetPermission).filter(DatasetPermission.dataset_id == dataset_id).delete()
+            db.session.commit()
+        except Exception as e:
+            db.session.rollback()
+            raise e

+ 2 - 0
api/services/feature_service.py

@@ -30,6 +30,7 @@ class FeatureModel(BaseModel):
     docs_processing: str = 'standard'
     can_replace_logo: bool = False
     model_load_balancing_enabled: bool = False
+    dataset_operator_enabled: bool = False
 
     # pydantic configs
     model_config = ConfigDict(protected_namespaces=())
@@ -68,6 +69,7 @@ class FeatureService:
     def _fulfill_params_from_env(cls, features: FeatureModel):
         features.can_replace_logo = current_app.config['CAN_REPLACE_LOGO']
         features.model_load_balancing_enabled = current_app.config['MODEL_LB_ENABLED']
+        features.dataset_operator_enabled = current_app.config['DATASET_OPERATOR_ENABLED']
 
     @classmethod
     def _fulfill_params_from_billing_api(cls, features: FeatureModel, tenant_id: str):

+ 12 - 1
web/app/(commonLayout)/app/(appDetailLayout)/layout.tsx

@@ -1,11 +1,22 @@
+'use client'
 import type { FC } from 'react'
-import React from 'react'
+import React, { useEffect } from 'react'
+import { useRouter } from 'next/navigation'
+import { useAppContext } from '@/context/app-context'
 
 export type IAppDetail = {
   children: React.ReactNode
 }
 
 const AppDetail: FC<IAppDetail> = ({ children }) => {
+  const router = useRouter()
+  const { isCurrentWorkspaceDatasetOperator } = useAppContext()
+
+  useEffect(() => {
+    if (isCurrentWorkspaceDatasetOperator)
+      return router.replace('/datasets')
+  }, [isCurrentWorkspaceDatasetOperator])
+
   return (
     <>
       {children}

+ 8 - 1
web/app/(commonLayout)/apps/Apps.tsx

@@ -1,6 +1,7 @@
 'use client'
 
 import { useCallback, useEffect, useRef, useState } from 'react'
+import { useRouter } from 'next/navigation'
 import useSWRInfinite from 'swr/infinite'
 import { useTranslation } from 'react-i18next'
 import { useDebounceFn } from 'ahooks'
@@ -50,7 +51,8 @@ const getKey = (
 
 const Apps = () => {
   const { t } = useTranslation()
-  const { isCurrentWorkspaceEditor } = useAppContext()
+  const router = useRouter()
+  const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator } = useAppContext()
   const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
   const [activeTab, setActiveTab] = useTabSearchParams({
     defaultTab: 'all',
@@ -87,6 +89,11 @@ const Apps = () => {
     }
   }, [])
 
+  useEffect(() => {
+    if (isCurrentWorkspaceDatasetOperator)
+      return router.replace('/datasets')
+  }, [isCurrentWorkspaceDatasetOperator])
+
   const hasMore = data?.at(-1)?.has_more ?? true
   useEffect(() => {
     let observer: IntersectionObserver | undefined

+ 3 - 1
web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout.tsx

@@ -38,6 +38,7 @@ import { useStore } from '@/app/components/app/store'
 import { AiText, ChatBot, CuteRobote } from '@/app/components/base/icons/src/vender/solid/communication'
 import { Route } from '@/app/components/base/icons/src/vender/solid/mapsAndTravel'
 import { getLocaleOnClient } from '@/i18n'
+import { useAppContext } from '@/context/app-context'
 
 export type IAppDetailLayoutProps = {
   children: React.ReactNode
@@ -187,6 +188,7 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
   const pathname = usePathname()
   const hideSideBar = /documents\/create$/.test(pathname)
   const { t } = useTranslation()
+  const { isCurrentWorkspaceDatasetOperator } = useAppContext()
 
   const media = useBreakpoints()
   const isMobile = media === MediaType.mobile
@@ -232,7 +234,7 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
         icon_background={datasetRes?.icon_background || '#F5F5F5'}
         desc={datasetRes?.description || '--'}
         navigation={navigation}
-        extraInfo={mode => <ExtraInfo isMobile={mode === 'collapse'} relatedApps={relatedApps} />}
+        extraInfo={!isCurrentWorkspaceDatasetOperator ? mode => <ExtraInfo isMobile={mode === 'collapse'} relatedApps={relatedApps} /> : undefined}
         iconType={datasetRes?.data_source_type === DataSourceType.NOTION ? 'notion' : 'dataset'}
       />}
       <DatasetDetailContext.Provider value={{

+ 10 - 1
web/app/(commonLayout)/datasets/Container.tsx

@@ -1,7 +1,8 @@
 'use client'
 
 // Libraries
-import { useRef, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
+import { useRouter } from 'next/navigation'
 import { useTranslation } from 'react-i18next'
 import { useDebounceFn } from 'ahooks'
 import useSWR from 'swr'
@@ -22,9 +23,12 @@ import { fetchDatasetApiBaseUrl } from '@/service/datasets'
 // Hooks
 import { useTabSearchParams } from '@/hooks/use-tab-searchparams'
 import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
+import { useAppContext } from '@/context/app-context'
 
 const Container = () => {
   const { t } = useTranslation()
+  const router = useRouter()
+  const { currentWorkspace } = useAppContext()
   const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
 
   const options = [
@@ -57,6 +61,11 @@ const Container = () => {
     handleTagsUpdate()
   }
 
+  useEffect(() => {
+    if (currentWorkspace.role === 'normal')
+      return router.replace('/apps')
+  }, [currentWorkspace])
+
   return (
     <div ref={containerRef} className='grow relative flex flex-col bg-gray-100 overflow-y-auto'>
       <div className='sticky top-0 flex justify-between pt-4 px-12 pb-2 leading-[56px] bg-gray-100 z-10 flex-wrap gap-y-2'>

+ 17 - 11
web/app/(commonLayout)/datasets/DatasetCard.tsx

@@ -20,6 +20,7 @@ import Divider from '@/app/components/base/divider'
 import RenameDatasetModal from '@/app/components/datasets/rename-modal'
 import type { Tag } from '@/app/components/base/tag-management/constant'
 import TagSelector from '@/app/components/base/tag-management/selector'
+import { useAppContext } from '@/context/app-context'
 
 export type DatasetCardProps = {
   dataset: DataSet
@@ -32,6 +33,7 @@ const DatasetCard = ({
 }: DatasetCardProps) => {
   const { t } = useTranslation()
   const { notify } = useContext(ToastContext)
+  const { isCurrentWorkspaceDatasetOperator } = useAppContext()
   const [tags, setTags] = useState<Tag[]>(dataset.tags)
 
   const [showRenameModal, setShowRenameModal] = useState(false)
@@ -61,7 +63,7 @@ const DatasetCard = ({
     setShowConfirmDelete(false)
   }, [dataset.id, notify, onSuccess, t])
 
-  const Operations = (props: HtmlContentProps) => {
+  const Operations = (props: HtmlContentProps & { showDelete: boolean }) => {
     const onMouseLeave = async () => {
       props.onClose?.()
     }
@@ -82,15 +84,19 @@ const DatasetCard = ({
         <div className='h-8 py-[6px] px-3 mx-1 flex items-center gap-2 hover:bg-gray-100 rounded-lg cursor-pointer' onClick={onClickRename}>
           <span className='text-gray-700 text-sm'>{t('common.operation.settings')}</span>
         </div>
-        <Divider className="!my-1" />
-        <div
-          className='group h-8 py-[6px] px-3 mx-1 flex items-center gap-2 hover:bg-red-50 rounded-lg cursor-pointer'
-          onClick={onClickDelete}
-        >
-          <span className={cn('text-gray-700 text-sm', 'group-hover:text-red-500')}>
-            {t('common.operation.delete')}
-          </span>
-        </div>
+        {props.showDelete && (
+          <>
+            <Divider className="!my-1" />
+            <div
+              className='group h-8 py-[6px] px-3 mx-1 flex items-center gap-2 hover:bg-red-50 rounded-lg cursor-pointer'
+              onClick={onClickDelete}
+            >
+              <span className={cn('text-gray-700 text-sm', 'group-hover:text-red-500')}>
+                {t('common.operation.delete')}
+              </span>
+            </div>
+          </>
+        )}
       </div>
     )
   }
@@ -174,7 +180,7 @@ const DatasetCard = ({
           <div className='!hidden group-hover:!flex shrink-0 mx-1 w-[1px] h-[14px] bg-gray-200' />
           <div className='!hidden group-hover:!flex shrink-0'>
             <CustomPopover
-              htmlContent={<Operations />}
+              htmlContent={<Operations showDelete={!isCurrentWorkspaceDatasetOperator} />}
               position="br"
               trigger="click"
               btnElement={

+ 11 - 0
web/app/(commonLayout)/tools/page.tsx

@@ -1,16 +1,27 @@
 'use client'
 import type { FC } from 'react'
+import { useRouter } from 'next/navigation'
 import { useTranslation } from 'react-i18next'
 import React, { useEffect } from 'react'
 import ToolProviderList from '@/app/components/tools/provider-list'
+import { useAppContext } from '@/context/app-context'
 
 const Layout: FC = () => {
   const { t } = useTranslation()
+  const router = useRouter()
+  const { isCurrentWorkspaceDatasetOperator } = useAppContext()
 
   useEffect(() => {
     document.title = `${t('tools.title')} - Dify`
+    if (isCurrentWorkspaceDatasetOperator)
+      return router.replace('/datasets')
   }, [])
 
+  useEffect(() => {
+    if (isCurrentWorkspaceDatasetOperator)
+      return router.replace('/datasets')
+  }, [isCurrentWorkspaceDatasetOperator])
+
   return <ToolProviderList />
 }
 export default React.memo(Layout)

+ 38 - 7
web/app/components/app/configuration/dataset-config/settings-modal/index.tsx

@@ -1,5 +1,6 @@
 import type { FC } from 'react'
 import { useRef, useState } from 'react'
+import { useMount } from 'ahooks'
 import { useTranslation } from 'react-i18next'
 import { isEqual } from 'lodash-es'
 import cn from 'classnames'
@@ -10,19 +11,22 @@ import Button from '@/app/components/base/button'
 import type { DataSet } from '@/models/datasets'
 import { useToastContext } from '@/app/components/base/toast'
 import { updateDatasetSetting } from '@/service/datasets'
+import { useAppContext } from '@/context/app-context'
 import { useModalContext } from '@/context/modal-context'
 import type { RetrievalConfig } from '@/types/app'
 import RetrievalMethodConfig from '@/app/components/datasets/common/retrieval-method-config'
 import EconomicalRetrievalMethodConfig from '@/app/components/datasets/common/economical-retrieval-method-config'
 import { ensureRerankModelSelected, isReRankModelSelected } from '@/app/components/datasets/common/check-rerank-model'
 import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
-import PermissionsRadio from '@/app/components/datasets/settings/permissions-radio'
+import PermissionSelector from '@/app/components/datasets/settings/permission-selector'
 import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
 import {
   useModelList,
   useModelListAndDefaultModelAndCurrentProviderAndModel,
 } from '@/app/components/header/account-setting/model-provider-page/hooks'
 import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
+import { fetchMembers } from '@/service/common'
+import type { Member } from '@/models/common'
 
 type SettingsModalProps = {
   currentDataset: DataSet
@@ -55,7 +59,11 @@ const SettingsModal: FC<SettingsModalProps> = ({
 
   const { setShowAccountSettingModal } = useModalContext()
   const [loading, setLoading] = useState(false)
+  const { isCurrentWorkspaceDatasetOperator } = useAppContext()
   const [localeCurrentDataset, setLocaleCurrentDataset] = useState({ ...currentDataset })
+  const [selectedMemberIDs, setSelectedMemberIDs] = useState<string[]>(currentDataset.partial_member_list || [])
+  const [memberList, setMemberList] = useState<Member[]>([])
+
   const [indexMethod, setIndexMethod] = useState(currentDataset.indexing_technique)
   const [retrievalConfig, setRetrievalConfig] = useState(localeCurrentDataset?.retrieval_model_dict as RetrievalConfig)
 
@@ -92,7 +100,7 @@ const SettingsModal: FC<SettingsModalProps> = ({
     try {
       setLoading(true)
       const { id, name, description, permission } = localeCurrentDataset
-      await updateDatasetSetting({
+      const requestParams = {
         datasetId: id,
         body: {
           name,
@@ -106,7 +114,16 @@ const SettingsModal: FC<SettingsModalProps> = ({
           embedding_model: localeCurrentDataset.embedding_model,
           embedding_model_provider: localeCurrentDataset.embedding_model_provider,
         },
-      })
+      } as any
+      if (permission === 'partial_members') {
+        requestParams.body.partial_member_list = selectedMemberIDs.map((id) => {
+          return {
+            user_id: id,
+            role: memberList.find(member => member.id === id)?.role,
+          }
+        })
+      }
+      await updateDatasetSetting(requestParams)
       notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
       onSave({
         ...localeCurrentDataset,
@@ -122,6 +139,18 @@ const SettingsModal: FC<SettingsModalProps> = ({
     }
   }
 
+  const getMembers = async () => {
+    const { accounts } = await fetchMembers({ url: '/workspaces/current/members', params: {} })
+    if (!accounts)
+      setMemberList([])
+    else
+      setMemberList(accounts)
+  }
+
+  useMount(() => {
+    getMembers()
+  })
+
   return (
     <div
       className='overflow-hidden w-full flex flex-col bg-white border-[0.5px] border-gray-200 rounded-xl shadow-xl'
@@ -180,11 +209,13 @@ const SettingsModal: FC<SettingsModalProps> = ({
             <div>{t('datasetSettings.form.permissions')}</div>
           </div>
           <div className='w-full'>
-            <PermissionsRadio
-              disable={!localeCurrentDataset?.embedding_available}
-              value={localeCurrentDataset.permission}
+            <PermissionSelector
+              disabled={!localeCurrentDataset?.embedding_available || isCurrentWorkspaceDatasetOperator}
+              permission={localeCurrentDataset.permission}
+              value={selectedMemberIDs}
               onChange={v => handleValueChange('permission', v!)}
-              itemClassName='sm:!w-[280px]'
+              onMemberSelect={setSelectedMemberIDs}
+              memberList={memberList}
             />
           </div>
         </div>

File diff suppressed because it is too large
+ 10 - 0
web/app/components/base/icons/assets/vender/solid/users/users-plus.svg


File diff suppressed because it is too large
+ 77 - 0
web/app/components/base/icons/src/vender/solid/users/UsersPlus.json


+ 16 - 0
web/app/components/base/icons/src/vender/solid/users/UsersPlus.tsx

@@ -0,0 +1,16 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './UsersPlus.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+Icon.displayName = 'UsersPlus'
+
+export default Icon

+ 1 - 0
web/app/components/base/icons/src/vender/solid/users/index.ts

@@ -1,3 +1,4 @@
 export { default as User01 } from './User01'
 export { default as UserEdit02 } from './UserEdit02'
 export { default as Users01 } from './Users01'
+export { default as UsersPlus } from './UsersPlus'

+ 1 - 1
web/app/components/base/search-input/index.tsx

@@ -37,7 +37,7 @@ const SearchInput: FC<SearchInputProps> = ({
         type="text"
         name="query"
         className={cn(
-          'grow block h-[18px] bg-gray-200 rounded-md border-0 text-gray-700 text-[13px] placeholder:text-gray-500 appearance-none outline-none group-hover:bg-gray-300 caret-blue-600',
+          'grow block h-[18px] bg-gray-200 border-0 text-gray-700 text-[13px] placeholder:text-gray-500 appearance-none outline-none group-hover:bg-gray-300 caret-blue-600',
           focus && '!bg-white hover:bg-white group-hover:bg-white placeholder:!text-gray-400',
           !focus && value && 'hover:!bg-gray-200 group-hover:!bg-gray-200',
           white && '!bg-white hover:!bg-white group-hover:!bg-white placeholder:!text-gray-400',

+ 1 - 0
web/app/components/billing/type.ts

@@ -66,6 +66,7 @@ export type CurrentPlanInfoBackend = {
   docs_processing: DocumentProcessingPriority
   can_replace_logo: boolean
   model_load_balancing_enabled: boolean
+  dataset_operator_enabled: boolean
 }
 
 export type SubscriptionItem = {

+ 40 - 22
web/app/components/datasets/settings/form/index.tsx

@@ -1,31 +1,33 @@
 'use client'
-import { useEffect, useState } from 'react'
-import type { Dispatch } from 'react'
+import { useState } from 'react'
+import { useMount } from 'ahooks'
 import { useContext } from 'use-context-selector'
 import { BookOpenIcon } from '@heroicons/react/24/outline'
 import { useTranslation } from 'react-i18next'
 import cn from 'classnames'
 import { useSWRConfig } from 'swr'
 import { unstable_serialize } from 'swr/infinite'
-import PermissionsRadio from '../permissions-radio'
+import PermissionSelector from '../permission-selector'
 import IndexMethodRadio from '../index-method-radio'
 import RetrievalMethodConfig from '@/app/components/datasets/common/retrieval-method-config'
 import EconomicalRetrievalMethodConfig from '@/app/components/datasets/common/economical-retrieval-method-config'
 import { ToastContext } from '@/app/components/base/toast'
 import Button from '@/app/components/base/button'
 import { updateDatasetSetting } from '@/service/datasets'
-import type { DataSet, DataSetListResponse } from '@/models/datasets'
+import type { DataSetListResponse } from '@/models/datasets'
 import DatasetDetailContext from '@/context/dataset-detail'
 import { type RetrievalConfig } from '@/types/app'
-import { useModalContext } from '@/context/modal-context'
+import { useAppContext } from '@/context/app-context'
 import { ensureRerankModelSelected, isReRankModelSelected } from '@/app/components/datasets/common/check-rerank-model'
 import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
 import {
   useModelList,
   useModelListAndDefaultModelAndCurrentProviderAndModel,
 } from '@/app/components/header/account-setting/model-provider-page/hooks'
-import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
 import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations'
+import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
+import { fetchMembers } from '@/service/common'
+import type { Member } from '@/models/common'
 
 const rowClass = `
   flex justify-between py-4 flex-wrap gap-y-2
@@ -36,11 +38,6 @@ const labelClass = `
 const inputClass = `
   w-full max-w-[480px] px-3 bg-gray-100 text-sm text-gray-800 rounded-lg outline-none appearance-none
 `
-const useInitialValue: <T>(depend: T, dispatch: Dispatch<T>) => void = (depend, dispatch) => {
-  useEffect(() => {
-    dispatch(depend)
-  }, [depend])
-}
 
 const getKey = (pageIndex: number, previousPageData: DataSetListResponse) => {
   if (!pageIndex || previousPageData.has_more)
@@ -52,12 +49,14 @@ const Form = () => {
   const { t } = useTranslation()
   const { notify } = useContext(ToastContext)
   const { mutate } = useSWRConfig()
+  const { isCurrentWorkspaceDatasetOperator } = useAppContext()
   const { dataset: currentDataset, mutateDatasetRes: mutateDatasets } = useContext(DatasetDetailContext)
-  const { setShowAccountSettingModal } = useModalContext()
   const [loading, setLoading] = useState(false)
   const [name, setName] = useState(currentDataset?.name ?? '')
   const [description, setDescription] = useState(currentDataset?.description ?? '')
   const [permission, setPermission] = useState(currentDataset?.permission)
+  const [selectedMemberIDs, setSelectedMemberIDs] = useState<string[]>(currentDataset?.partial_member_list || [])
+  const [memberList, setMemberList] = useState<Member[]>([])
   const [indexMethod, setIndexMethod] = useState(currentDataset?.indexing_technique)
   const [retrievalConfig, setRetrievalConfig] = useState(currentDataset?.retrieval_model_dict as RetrievalConfig)
   const [embeddingModel, setEmbeddingModel] = useState<DefaultModel>(
@@ -78,6 +77,18 @@ const Form = () => {
   } = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.rerank)
   const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding)
 
+  const getMembers = async () => {
+    const { accounts } = await fetchMembers({ url: '/workspaces/current/members', params: {} })
+    if (!accounts)
+      setMemberList([])
+    else
+      setMemberList(accounts)
+  }
+
+  useMount(() => {
+    getMembers()
+  })
+
   const handleSave = async () => {
     if (loading)
       return
@@ -104,7 +115,7 @@ const Form = () => {
     })
     try {
       setLoading(true)
-      await updateDatasetSetting({
+      const requestParams = {
         datasetId: currentDataset!.id,
         body: {
           name,
@@ -118,7 +129,16 @@ const Form = () => {
           embedding_model: embeddingModel.model,
           embedding_model_provider: embeddingModel.provider,
         },
-      })
+      } as any
+      if (permission === 'partial_members') {
+        requestParams.body.partial_member_list = selectedMemberIDs.map((id) => {
+          return {
+            user_id: id,
+            role: memberList.find(member => member.id === id)?.role,
+          }
+        })
+      }
+      await updateDatasetSetting(requestParams)
       notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
       if (mutateDatasets) {
         await mutateDatasets()
@@ -133,11 +153,6 @@ const Form = () => {
     }
   }
 
-  useInitialValue<string>(currentDataset?.name ?? '', setName)
-  useInitialValue<string>(currentDataset?.description ?? '', setDescription)
-  useInitialValue<DataSet['permission'] | undefined>(currentDataset?.permission, setPermission)
-  useInitialValue<DataSet['indexing_technique'] | undefined>(currentDataset?.indexing_technique, setIndexMethod)
-
   return (
     <div className='w-full sm:w-[800px] p-4 sm:px-16 sm:py-6'>
       <div className={rowClass}>
@@ -174,10 +189,13 @@ const Form = () => {
           <div>{t('datasetSettings.form.permissions')}</div>
         </div>
         <div className='w-full sm:w-[480px]'>
-          <PermissionsRadio
-            disable={!currentDataset?.embedding_available}
-            value={permission}
+          <PermissionSelector
+            disabled={!currentDataset?.embedding_available || isCurrentWorkspaceDatasetOperator}
+            permission={permission}
+            value={selectedMemberIDs}
             onChange={v => setPermission(v)}
+            onMemberSelect={setSelectedMemberIDs}
+            memberList={memberList}
           />
         </div>
       </div>

+ 174 - 0
web/app/components/datasets/settings/permission-selector/index.tsx

@@ -0,0 +1,174 @@
+import { useTranslation } from 'react-i18next'
+import cn from 'classnames'
+import React, { useMemo, useState } from 'react'
+import { useDebounceFn } from 'ahooks'
+import { RiArrowDownSLine } from '@remixicon/react'
+import {
+  PortalToFollowElem,
+  PortalToFollowElemContent,
+  PortalToFollowElemTrigger,
+} from '@/app/components/base/portal-to-follow-elem'
+import Avatar from '@/app/components/base/avatar'
+import SearchInput from '@/app/components/base/search-input'
+import { Check } from '@/app/components/base/icons/src/vender/line/general'
+import { Users01, UsersPlus } from '@/app/components/base/icons/src/vender/solid/users'
+import type { DatasetPermission } from '@/models/datasets'
+import { useAppContext } from '@/context/app-context'
+import type { Member } from '@/models/common'
+export type RoleSelectorProps = {
+  disabled?: boolean
+  permission?: DatasetPermission
+  value: string[]
+  memberList: Member[]
+  onChange: (permission?: DatasetPermission) => void
+  onMemberSelect: (v: string[]) => void
+}
+
+const PermissionSelector = ({ disabled, permission, value, memberList, onChange, onMemberSelect }: RoleSelectorProps) => {
+  const { t } = useTranslation()
+  const { userProfile } = useAppContext()
+  const [open, setOpen] = useState(false)
+
+  const [keywords, setKeywords] = useState('')
+  const [searchKeywords, setSearchKeywords] = useState('')
+  const { run: handleSearch } = useDebounceFn(() => {
+    setSearchKeywords(keywords)
+  }, { wait: 500 })
+  const handleKeywordsChange = (value: string) => {
+    setKeywords(value)
+    handleSearch()
+  }
+  const selectMember = (member: Member) => {
+    if (value.includes(member.id))
+      onMemberSelect(value.filter(v => v !== member.id))
+    else
+      onMemberSelect([...value, member.id])
+  }
+
+  const selectedMembers = useMemo(() => {
+    return [
+      userProfile,
+      ...memberList.filter(member => member.id !== userProfile.id).filter(member => value.includes(member.id)),
+    ].map(member => member.name).join(', ')
+  }, [userProfile, value, memberList])
+  const showMe = useMemo(() => {
+    return userProfile.name.includes(searchKeywords) || userProfile.email.includes(searchKeywords)
+  }, [searchKeywords, userProfile])
+  const filteredMemberList = useMemo(() => {
+    return memberList.filter(member => (member.name.includes(searchKeywords) || member.email.includes(searchKeywords)) && member.id !== userProfile.id && ['owner', 'admin', 'editor', 'dataset_operator'].includes(member.role))
+  }, [memberList, searchKeywords, userProfile])
+
+  return (
+    <PortalToFollowElem
+      open={open}
+      onOpenChange={setOpen}
+      placement='bottom-start'
+      offset={4}
+    >
+      <div className='relative'>
+        <PortalToFollowElemTrigger
+          onClick={() => !disabled && setOpen(v => !v)}
+          className='block'
+        >
+          {permission === 'only_me' && (
+            <div className={cn('flex items-center px-3 py-[6px] rounded-lg bg-gray-100 cursor-pointer hover:bg-gray-200', open && 'bg-gray-200', disabled && 'hover:!bg-gray-100 !cursor-default')}>
+              <Avatar name={userProfile.name} className='shrink-0 mr-2' size={24} />
+              <div className='grow mr-2 text-gray-900 text-sm leading-5'>{t('datasetSettings.form.permissionsOnlyMe')}</div>
+              {!disabled && <RiArrowDownSLine className='shrink-0 w-4 h-4 text-gray-700' />}
+            </div>
+          )}
+          {permission === 'all_team_members' && (
+            <div className={cn('flex items-center px-3 py-[6px] rounded-lg bg-gray-100 cursor-pointer hover:bg-gray-200', open && 'bg-gray-200')}>
+              <div className='mr-2 flex items-center justify-center w-6 h-6 rounded-lg bg-[#EEF4FF]'>
+                <Users01 className='w-3.5 h-3.5 text-[#444CE7]' />
+              </div>
+              <div className='grow mr-2 text-gray-900 text-sm leading-5'>{t('datasetSettings.form.permissionsAllMember')}</div>
+              {!disabled && <RiArrowDownSLine className='shrink-0 w-4 h-4 text-gray-700' />}
+            </div>
+          )}
+          {permission === 'partial_members' && (
+            <div className={cn('flex items-center px-3 py-[6px] rounded-lg bg-gray-100 cursor-pointer hover:bg-gray-200', open && 'bg-gray-200')}>
+              <div className='mr-2 flex items-center justify-center w-6 h-6 rounded-lg bg-[#EEF4FF]'>
+                <Users01 className='w-3.5 h-3.5 text-[#444CE7]' />
+              </div>
+              <div title={selectedMembers} className='grow mr-2 text-gray-900 text-sm leading-5 truncate'>{selectedMembers}</div>
+              {!disabled && <RiArrowDownSLine className='shrink-0 w-4 h-4 text-gray-700' />}
+            </div>
+          )}
+        </PortalToFollowElemTrigger>
+        <PortalToFollowElemContent className='z-[1002]'>
+          <div className='relative w-[480px] bg-white rounded-lg border-[0.5px] bg-gray-200 shadow-lg'>
+            <div className='p-1'>
+              <div className='pl-3 pr-2 py-1 rounded-lg hover:bg-gray-50 cursor-pointer' onClick={() => {
+                onChange('only_me')
+                setOpen(false)
+              }}>
+                <div className='flex items-center gap-2'>
+                  <Avatar name={userProfile.name} className='shrink-0 mr-2' size={24} />
+                  <div className='grow mr-2 text-gray-900 text-sm leading-5'>{t('datasetSettings.form.permissionsOnlyMe')}</div>
+                  {permission === 'only_me' && <Check className='w-4 h-4 text-primary-600' />}
+                </div>
+              </div>
+              <div className='pl-3 pr-2 py-1 rounded-lg hover:bg-gray-50 cursor-pointer' onClick={() => {
+                onChange('all_team_members')
+                setOpen(false)
+              }}>
+                <div className='flex items-center gap-2'>
+                  <div className='mr-2 flex items-center justify-center w-6 h-6 rounded-lg bg-[#EEF4FF]'>
+                    <Users01 className='w-3.5 h-3.5 text-[#444CE7]' />
+                  </div>
+                  <div className='grow mr-2 text-gray-900 text-sm leading-5'>{t('datasetSettings.form.permissionsAllMember')}</div>
+                  {permission === 'all_team_members' && <Check className='w-4 h-4 text-primary-600' />}
+                </div>
+              </div>
+              <div className='pl-3 pr-2 py-1 rounded-lg hover:bg-gray-50 cursor-pointer' onClick={() => {
+                onChange('partial_members')
+                onMemberSelect([userProfile.id])
+              }}>
+                <div className='flex items-center gap-2'>
+                  <div className={cn('mr-2 flex items-center justify-center w-6 h-6 rounded-lg bg-[#FFF6ED]', permission === 'partial_members' && '!bg-[#EEF4FF]')}>
+                    <UsersPlus className={cn('w-3.5 h-3.5 text-[#FB6514]', permission === 'partial_members' && '!text-[#444CE7]')} />
+                  </div>
+                  <div className='grow mr-2 text-gray-900 text-sm leading-5'>{t('datasetSettings.form.permissionsInvitedMembers')}</div>
+                  {permission === 'partial_members' && <Check className='w-4 h-4 text-primary-600' />}
+                </div>
+              </div>
+            </div>
+            {permission === 'partial_members' && (
+              <div className='max-h-[360px] border-t-[1px] border-gray-100 p-1 overflow-y-auto'>
+                <div className='sticky left-0 top-0 p-2 pb-1 bg-white'>
+                  <SearchInput white value={keywords} onChange={handleKeywordsChange} />
+                </div>
+                {showMe && (
+                  <div className='pl-3 pr-[10px] py-1 flex gap-2 items-center rounded-lg'>
+                    <Avatar name={userProfile.name} className='shrink-0' size={24} />
+                    <div className='grow'>
+                      <div className='text-[13px] text-gray-700 font-medium leading-[18px] truncate'>
+                        {userProfile.name}
+                        <span className='text-xs text-gray-500 font-normal'>{t('datasetSettings.form.me')}</span>
+                      </div>
+                      <div className='text-xs text-gray-500 leading-[18px] truncate'>{userProfile.email}</div>
+                    </div>
+                    <Check className='shrink-0 w-4 h-4 text-primary-600 opacity-30' />
+                  </div>
+                )}
+                {filteredMemberList.map(member => (
+                  <div key={member.id} className='pl-3 pr-[10px] py-1 flex gap-2 items-center rounded-lg hover:bg-gray-100 cursor-pointer' onClick={() => selectMember(member)}>
+                    <Avatar name={member.name} className='shrink-0' size={24} />
+                    <div className='grow'>
+                      <div className='text-[13px] text-gray-700 font-medium leading-[18px] truncate'>{member.name}</div>
+                      <div className='text-xs text-gray-500 leading-[18px] truncate'>{member.email}</div>
+                    </div>
+                    {value.includes(member.id) && <Check className='shrink-0 w-4 h-4 text-primary-600' />}
+                  </div>
+                ))}
+              </div>
+            )}
+          </div>
+        </PortalToFollowElemContent>
+      </div>
+    </PortalToFollowElem>
+  )
+}
+
+export default PermissionSelector

File diff suppressed because it is too large
+ 0 - 7
web/app/components/datasets/settings/permissions-radio/assets/user.svg


+ 0 - 46
web/app/components/datasets/settings/permissions-radio/index.module.css

@@ -1,46 +0,0 @@
-.user-icon {
-  width: 24px;
-  height: 24px;
-  background: url(./assets/user.svg) center center;
-  background-size: contain;
-}
-
-.wrapper .item:hover {
-  background-color: #ffffff;
-  border-color: #B2CCFF;
-  box-shadow: 0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03);
-}
-
-.wrapper .item-active {
-  background-color: #ffffff;
-  border-width: 1.5px;
-  border-color: #528BFF;
-  box-shadow: 0px 1px 3px rgba(16, 24, 40, 0.1), 0px 1px 2px rgba(16, 24, 40, 0.06);
-}
-
-.wrapper .item-active .radio {
-  border-width: 5px;
-  border-color: #155EEF;
-}
-
-.wrapper .item-active:hover {
-  border-width: 1.5px;
-  border-color: #528BFF;
-  box-shadow: 0px 1px 3px rgba(16, 24, 40, 0.1), 0px 1px 2px rgba(16, 24, 40, 0.06);
-}
-
-.wrapper .item.disable {
-  @apply opacity-60;
-}
-.wrapper .item-active.disable {
-  @apply opacity-60;
-}
-.wrapper .item.disable:hover {
-  @apply bg-gray-25 border border-gray-100 shadow-none cursor-default opacity-60;
-}
-.wrapper .item-active.disable:hover {
-  @apply cursor-default opacity-60;
-  border-width: 1.5px;
-  border-color: #528BFF;
-  box-shadow: 0px 1px 3px rgba(16, 24, 40, 0.1), 0px 1px 2px rgba(16, 24, 40, 0.06);
-}

+ 0 - 66
web/app/components/datasets/settings/permissions-radio/index.tsx

@@ -1,66 +0,0 @@
-'use client'
-import { useTranslation } from 'react-i18next'
-import classNames from 'classnames'
-import s from './index.module.css'
-import type { DataSet } from '@/models/datasets'
-
-const itemClass = `
-  flex items-center w-full sm:w-[234px] h-12 px-3 rounded-xl bg-gray-25 border border-gray-100 cursor-pointer
-`
-const radioClass = `
-  w-4 h-4 border-[2px] border-gray-200 rounded-full
-`
-type IPermissionsRadioProps = {
-  value?: DataSet['permission']
-  onChange: (v?: DataSet['permission']) => void
-  itemClassName?: string
-  disable?: boolean
-}
-
-const PermissionsRadio = ({
-  value,
-  onChange,
-  itemClassName,
-  disable,
-}: IPermissionsRadioProps) => {
-  const { t } = useTranslation()
-  const options = [
-    {
-      key: 'only_me',
-      text: t('datasetSettings.form.permissionsOnlyMe'),
-    },
-    {
-      key: 'all_team_members',
-      text: t('datasetSettings.form.permissionsAllMember'),
-    },
-  ]
-
-  return (
-    <div className={classNames(s.wrapper, 'flex justify-between w-full flex-wrap gap-y-2')}>
-      {
-        options.map(option => (
-          <div
-            key={option.key}
-            className={classNames(
-              itemClass,
-              itemClassName,
-              s.item,
-              option.key === value && s['item-active'],
-              disable && s.disable,
-            )}
-            onClick={() => {
-              if (!disable)
-                onChange(option.key as DataSet['permission'])
-            }}
-          >
-            <div className={classNames(s['user-icon'], 'mr-3')} />
-            <div className='grow text-sm text-gray-900'>{option.text}</div>
-            <div className={classNames(radioClass, s.radio)} />
-          </div>
-        ))
-      }
-    </div>
-  )
-}
-
-export default PermissionsRadio

+ 8 - 1
web/app/components/explore/index.tsx

@@ -1,6 +1,7 @@
 'use client'
 import type { FC } from 'react'
 import React, { useEffect, useState } from 'react'
+import { useRouter } from 'next/navigation'
 import { useTranslation } from 'react-i18next'
 import ExploreContext from '@/context/explore-context'
 import Sidebar from '@/app/components/explore/sidebar'
@@ -16,8 +17,9 @@ const Explore: FC<IExploreProps> = ({
   children,
 }) => {
   const { t } = useTranslation()
+  const router = useRouter()
   const [controlUpdateInstalledApps, setControlUpdateInstalledApps] = useState(0)
-  const { userProfile } = useAppContext()
+  const { userProfile, isCurrentWorkspaceDatasetOperator } = useAppContext()
   const [hasEditPermission, setHasEditPermission] = useState(false)
   const [installedApps, setInstalledApps] = useState<InstalledApp[]>([])
 
@@ -32,6 +34,11 @@ const Explore: FC<IExploreProps> = ({
     })()
   }, [])
 
+  useEffect(() => {
+    if (isCurrentWorkspaceDatasetOperator)
+      return router.replace('/datasets')
+  }, [isCurrentWorkspaceDatasetOperator])
+
   return (
     <div className='flex h-full bg-gray-100 border-t border-gray-200 overflow-hidden'>
       <ExploreContext.Provider

+ 7 - 1
web/app/components/header/account-setting/index.tsx

@@ -35,6 +35,7 @@ import CustomPage from '@/app/components/custom/custom-page'
 import Modal from '@/app/components/base/modal'
 import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
 import { useProviderContext } from '@/context/provider-context'
+import { useAppContext } from '@/context/app-context'
 
 const iconClassName = `
   w-4 h-4 ml-3 mr-2
@@ -64,8 +65,11 @@ export default function AccountSetting({
   const [activeMenu, setActiveMenu] = useState(activeTab)
   const { t } = useTranslation()
   const { enableBilling, enableReplaceWebAppLogo } = useProviderContext()
+  const { isCurrentWorkspaceDatasetOperator } = useAppContext()
 
   const workplaceGroupItems = (() => {
+    if (isCurrentWorkspaceDatasetOperator)
+      return []
     return [
       {
         key: 'provider',
@@ -172,7 +176,9 @@ export default function AccountSetting({
             {
               menuItems.map(menuItem => (
                 <div key={menuItem.key} className='mb-4'>
-                  <div className='px-2 mb-[6px] text-[10px] sm:text-xs font-medium text-gray-500'>{menuItem.name}</div>
+                  {!isCurrentWorkspaceDatasetOperator && (
+                    <div className='px-2 mb-[6px] text-[10px] sm:text-xs font-medium text-gray-500'>{menuItem.name}</div>
+                  )}
                   <div>
                     {
                       menuItem.items.map(item => (

+ 1 - 0
web/app/components/header/account-setting/members-page/index.tsx

@@ -29,6 +29,7 @@ const MembersPage = () => {
     owner: t('common.members.owner'),
     admin: t('common.members.admin'),
     editor: t('common.members.editor'),
+    dataset_operator: t('common.members.datasetOperator'),
     normal: t('common.members.normal'),
   }
   const { locale } = useContext(I18n)

+ 7 - 67
web/app/components/header/account-setting/members-page/invite-modal/index.tsx

@@ -1,13 +1,12 @@
 'use client'
-import { Fragment, useCallback, useMemo, useState } from 'react'
+import { useCallback, useState } from 'react'
 import { useContext } from 'use-context-selector'
 import { XMarkIcon } from '@heroicons/react/24/outline'
 import { useTranslation } from 'react-i18next'
 import { ReactMultiEmail } from 'react-multi-email'
-import { Listbox, Transition } from '@headlessui/react'
-import { CheckIcon } from '@heroicons/react/20/solid'
 import cn from 'classnames'
 import s from './index.module.css'
+import RoleSelector from './role-selector'
 import Modal from '@/app/components/base/modal'
 import Button from '@/app/components/base/button'
 import { inviteMember } from '@/service/common'
@@ -31,29 +30,14 @@ const InviteModal = ({
   const { notify } = useContext(ToastContext)
 
   const { locale } = useContext(I18n)
-
-  const InvitingRoles = useMemo(() => [
-    {
-      name: 'normal',
-      description: t('common.members.normalTip'),
-    },
-    {
-      name: 'editor',
-      description: t('common.members.editorTip'),
-    },
-    {
-      name: 'admin',
-      description: t('common.members.adminTip'),
-    },
-  ], [t])
-  const [role, setRole] = useState(InvitingRoles[0])
+  const [role, setRole] = useState<string>('normal')
 
   const handleSend = useCallback(async () => {
     if (emails.map((email: string) => emailRegex.test(email)).every(Boolean)) {
       try {
         const { result, invitation_results } = await inviteMember({
           url: '/workspaces/current/members/invite-email',
-          body: { emails, role: role.name, language: locale },
+          body: { emails, role, language: locale },
         })
 
         if (result === 'success') {
@@ -99,53 +83,9 @@ const InviteModal = ({
               placeholder={t('common.members.emailPlaceholder') || ''}
             />
           </div>
-          <Listbox value={role} onChange={setRole}>
-            <div className="relative pb-6">
-              <Listbox.Button className="relative w-full py-2 pl-3 pr-10 text-left bg-gray-100 outline-none border-none appearance-none text-sm text-gray-900 rounded-lg">
-                <span className="block truncate capitalize">{t('common.members.invitedAsRole', { role: t(`common.members.${role.name}`) })}</span>
-              </Listbox.Button>
-              <Transition
-                as={Fragment}
-                leave="transition ease-in duration-200"
-                leaveFrom="opacity-200"
-                leaveTo="opacity-0"
-              >
-                <Listbox.Options className="absolute w-full py-1 my-2 overflow-auto text-base bg-white rounded-md shadow-lg max-h-60 ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
-                  {InvitingRoles.map(role =>
-                    <Listbox.Option
-                      key={role.name}
-                      className={({ active }) =>
-                        `${active ? ' bg-gray-50 rounded-xl' : ' bg-transparent'}
-                          cursor-default select-none relative py-2 px-4 mx-2 flex flex-col`
-                      }
-                      value={role}
-                    >
-                      {({ selected }) => (
-                        <div className='flex flex-row'>
-                          <span
-                            className={cn(
-                              'text-indigo-600 mr-2',
-                              'flex items-center',
-                            )}
-                          >
-                            {selected && (<CheckIcon className="h-5 w-5" aria-hidden="true" />)}
-                          </span>
-                          <div className=' flex flex-col flex-grow'>
-                            <span className={`${selected ? 'font-medium' : 'font-normal'} capitalize block truncate`}>
-                              {t(`common.members.${role.name}`)}
-                            </span>
-                            <span className={`${selected ? 'font-medium' : 'font-normal'} capitalize block text-gray-500`}>
-                              {role.description}
-                            </span>
-                          </div>
-                        </div>
-                      )}
-                    </Listbox.Option>,
-                  )}
-                </Listbox.Options>
-              </Transition>
-            </div>
-          </Listbox>
+          <div className='mb-6'>
+            <RoleSelector value={role} onChange={setRole} />
+          </div>
           <Button
             tabIndex={0}
             className='w-full'

+ 95 - 0
web/app/components/header/account-setting/members-page/invite-modal/role-selector.tsx

@@ -0,0 +1,95 @@
+import { useTranslation } from 'react-i18next'
+import cn from 'classnames'
+import React, { useState } from 'react'
+import { RiArrowDownSLine } from '@remixicon/react'
+import { useProviderContext } from '@/context/provider-context'
+import {
+  PortalToFollowElem,
+  PortalToFollowElemContent,
+  PortalToFollowElemTrigger,
+} from '@/app/components/base/portal-to-follow-elem'
+import { Check } from '@/app/components/base/icons/src/vender/line/general'
+
+export type RoleSelectorProps = {
+  value: string
+  onChange: (role: string) => void
+}
+
+const RoleSelector = ({ value, onChange }: RoleSelectorProps) => {
+  const { t } = useTranslation()
+  const [open, setOpen] = useState(false)
+  const { datasetOperatorEnabled } = useProviderContext()
+
+  const toHump = (name: string) => name.replace(/_(\w)/g, (all, letter) => letter.toUpperCase())
+
+  return (
+    <PortalToFollowElem
+      open={open}
+      onOpenChange={setOpen}
+      placement='bottom-start'
+      offset={4}
+    >
+      <div className='relative'>
+        <PortalToFollowElemTrigger
+          onClick={() => setOpen(v => !v)}
+          className='block'
+        >
+          <div className={cn('flex items-center px-3 py-2 rounded-lg bg-gray-100 cursor-pointer hover:bg-gray-200', open && 'bg-gray-200')}>
+            <div className='grow mr-2 text-gray-900 text-sm leading-5'>{t('common.members.invitedAsRole', { role: t(`common.members.${toHump(value)}`) })}</div>
+            <RiArrowDownSLine className='shrink-0 w-4 h-4 text-gray-700' />
+          </div>
+        </PortalToFollowElemTrigger>
+        <PortalToFollowElemContent className='z-[1002]'>
+          <div className='relative w-[336px] bg-white rounded-lg border-[0.5px] bg-gray-200 shadow-lg'>
+            <div className='p-1'>
+              <div className='p-2 rounded-lg hover:bg-gray-50 cursor-pointer' onClick={() => {
+                onChange('normal')
+                setOpen(false)
+              }}>
+                <div className='relative pl-5'>
+                  <div className='text-gray-700 text-sm leading-5'>{t('common.members.normal')}</div>
+                  <div className='text-gray-500 text-xs leading-[18px]'>{t('common.members.normalTip')}</div>
+                  {value === 'normal' && <Check className='absolute top-0.5 left-0 w-4 h-4 text-primary-600'/>}
+                </div>
+              </div>
+              <div className='p-2 rounded-lg hover:bg-gray-50 cursor-pointer' onClick={() => {
+                onChange('editor')
+                setOpen(false)
+              }}>
+                <div className='relative pl-5'>
+                  <div className='text-gray-700 text-sm leading-5'>{t('common.members.editor')}</div>
+                  <div className='text-gray-500 text-xs leading-[18px]'>{t('common.members.editorTip')}</div>
+                  {value === 'editor' && <Check className='absolute top-0.5 left-0 w-4 h-4 text-primary-600'/>}
+                </div>
+              </div>
+              <div className='p-2 rounded-lg hover:bg-gray-50 cursor-pointer' onClick={() => {
+                onChange('admin')
+                setOpen(false)
+              }}>
+                <div className='relative pl-5'>
+                  <div className='text-gray-700 text-sm leading-5'>{t('common.members.admin')}</div>
+                  <div className='text-gray-500 text-xs leading-[18px]'>{t('common.members.adminTip')}</div>
+                  {value === 'admin' && <Check className='absolute top-0.5 left-0 w-4 h-4 text-primary-600'/>}
+                </div>
+              </div>
+              {datasetOperatorEnabled && (
+                <div className='p-2 rounded-lg hover:bg-gray-50 cursor-pointer' onClick={() => {
+                  onChange('dataset_operator')
+                  setOpen(false)
+                }}>
+                  <div className='relative pl-5'>
+                    <div className='text-gray-700 text-sm leading-5'>{t('common.members.datasetOperator')}</div>
+                    <div className='text-gray-500 text-xs leading-[18px]'>{t('common.members.datasetOperatorTip')}</div>
+                    {value === 'dataset_operator' && <Check className='absolute top-0.5 left-0 w-4 h-4 text-primary-600'/>}
+                  </div>
+                </div>
+              )}
+            </div>
+          </div>
+        </PortalToFollowElemContent>
+      </div>
+    </PortalToFollowElem>
+  )
+}
+
+export default RoleSelector

+ 14 - 4
web/app/components/header/account-setting/members-page/operation/index.tsx

@@ -1,11 +1,12 @@
 'use client'
 import { useTranslation } from 'react-i18next'
-import { Fragment } from 'react'
+import { Fragment, useMemo } from 'react'
 import { useContext } from 'use-context-selector'
 import { Menu, Transition } from '@headlessui/react'
 import cn from 'classnames'
 import { CheckIcon, ChevronDownIcon } from '@heroicons/react/24/outline'
 import s from './index.module.css'
+import { useProviderContext } from '@/context/provider-context'
 import type { Member } from '@/models/common'
 import { deleteMemberOrCancelInvitation, updateMemberRole } from '@/service/common'
 import { ToastContext } from '@/app/components/base/toast'
@@ -33,13 +34,22 @@ const Operation = ({
   onOperate,
 }: IOperationProps) => {
   const { t } = useTranslation()
+  const { datasetOperatorEnabled } = useProviderContext()
   const RoleMap = {
     owner: t('common.members.owner'),
     admin: t('common.members.admin'),
     editor: t('common.members.editor'),
     normal: t('common.members.normal'),
+    dataset_operator: t('common.members.datasetOperator'),
   }
+  const roleList = useMemo(() => {
+    return [
+      ...['admin', 'editor', 'normal'],
+      ...(datasetOperatorEnabled ? ['dataset_operator'] : []),
+    ]
+  }, [datasetOperatorEnabled])
   const { notify } = useContext(ToastContext)
+  const toHump = (name: string) => name.replace(/_(\w)/g, (all, letter) => letter.toUpperCase())
   const handleDeleteMemberOrCancelInvitation = async () => {
     try {
       await deleteMemberOrCancelInvitation({ url: `/workspaces/current/members/${member.id}` })
@@ -99,7 +109,7 @@ const Operation = ({
               >
                 <div className="px-1 py-1">
                   {
-                    ['admin', 'editor', 'normal'].map(role => (
+                    roleList.map(role => (
                       <Menu.Item key={role}>
                         <div className={itemClassName} onClick={() => handleUpdateMemberRole(role)}>
                           {
@@ -108,8 +118,8 @@ const Operation = ({
                               : <div className={itemIconClassName} />
                           }
                           <div>
-                            <div className={itemTitleClassName}>{t(`common.members.${role}`)}</div>
-                            <div className={itemDescClassName}>{t(`common.members.${role}Tip`)}</div>
+                            <div className={itemTitleClassName}>{t(`common.members.${toHump(role)}`)}</div>
+                            <div className={itemDescClassName}>{t(`common.members.${toHump(role)}Tip`)}</div>
                           </div>
                         </div>
                       </Menu.Item>

+ 9 - 9
web/app/components/header/index.tsx

@@ -26,7 +26,7 @@ const navClassName = `
 `
 
 const Header = () => {
-  const { isCurrentWorkspaceEditor } = useAppContext()
+  const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator } = useAppContext()
 
   const selectedSegment = useSelectedLayoutSegment()
   const media = useBreakpoints()
@@ -72,10 +72,10 @@ const Header = () => {
       )}
       {!isMobile && (
         <div className='flex items-center'>
-          <ExploreNav className={navClassName} />
-          <AppNav />
-          {isCurrentWorkspaceEditor && <DatasetNav />}
-          <ToolsNav className={navClassName} />
+          {!isCurrentWorkspaceDatasetOperator && <ExploreNav className={navClassName} />}
+          {!isCurrentWorkspaceDatasetOperator && <AppNav />}
+          {(isCurrentWorkspaceEditor || isCurrentWorkspaceDatasetOperator) && <DatasetNav />}
+          {!isCurrentWorkspaceDatasetOperator && <ToolsNav className={navClassName} />}
         </div>
       )}
       <div className='flex items-center flex-shrink-0'>
@@ -91,10 +91,10 @@ const Header = () => {
       </div>
       {(isMobile && isShowNavMenu) && (
         <div className='w-full flex flex-col p-2 gap-y-1'>
-          <ExploreNav className={navClassName} />
-          <AppNav />
-          {isCurrentWorkspaceEditor && <DatasetNav />}
-          <ToolsNav className={navClassName} />
+          {!isCurrentWorkspaceDatasetOperator && <ExploreNav className={navClassName} />}
+          {!isCurrentWorkspaceDatasetOperator && <AppNav />}
+          {(isCurrentWorkspaceEditor || isCurrentWorkspaceDatasetOperator) && <DatasetNav />}
+          {!isCurrentWorkspaceDatasetOperator && <ToolsNav className={navClassName} />}
         </div>
       )}
     </div>

+ 1 - 1
web/app/components/header/nav/nav-selector/index.tsx

@@ -113,7 +113,7 @@ const NavSelector = ({ curNav, navs, createText, isApp, onCreate, onLoadmore }:
                   ))
                 }
               </div>
-              {!isApp && (
+              {!isApp && isCurrentWorkspaceEditor && (
                 <Menu.Button className='p-1 w-full'>
                   <div onClick={() => onCreate('')} className={cn(
                     'flex items-center gap-2 px-3 py-[6px] rounded-lg cursor-pointer hover:bg-gray-100',

+ 4 - 0
web/context/app-context.tsx

@@ -20,6 +20,7 @@ export type AppContextValue = {
   isCurrentWorkspaceManager: boolean
   isCurrentWorkspaceOwner: boolean
   isCurrentWorkspaceEditor: boolean
+  isCurrentWorkspaceDatasetOperator: boolean
   mutateCurrentWorkspace: VoidFunction
   pageContainerRef: React.RefObject<HTMLDivElement>
   langeniusVersionInfo: LangGeniusVersionResponse
@@ -61,6 +62,7 @@ const AppContext = createContext<AppContextValue>({
   isCurrentWorkspaceManager: false,
   isCurrentWorkspaceOwner: false,
   isCurrentWorkspaceEditor: false,
+  isCurrentWorkspaceDatasetOperator: false,
   mutateUserProfile: () => { },
   mutateCurrentWorkspace: () => { },
   pageContainerRef: createRef(),
@@ -89,6 +91,7 @@ export const AppContextProvider: FC<AppContextProviderProps> = ({ children }) =>
   const isCurrentWorkspaceManager = useMemo(() => ['owner', 'admin'].includes(currentWorkspace.role), [currentWorkspace.role])
   const isCurrentWorkspaceOwner = useMemo(() => currentWorkspace.role === 'owner', [currentWorkspace.role])
   const isCurrentWorkspaceEditor = useMemo(() => ['owner', 'admin', 'editor'].includes(currentWorkspace.role), [currentWorkspace.role])
+  const isCurrentWorkspaceDatasetOperator = useMemo(() => currentWorkspace.role === 'dataset_operator', [currentWorkspace.role])
   const updateUserProfileAndVersion = useCallback(async () => {
     if (userProfileResponse && !userProfileResponse.bodyUsed) {
       const result = await userProfileResponse.json()
@@ -125,6 +128,7 @@ export const AppContextProvider: FC<AppContextProviderProps> = ({ children }) =>
       isCurrentWorkspaceManager,
       isCurrentWorkspaceOwner,
       isCurrentWorkspaceEditor,
+      isCurrentWorkspaceDatasetOperator,
       mutateCurrentWorkspace,
     }}>
       <div className='flex flex-col h-full overflow-y-auto'>

+ 8 - 0
web/context/provider-context.tsx

@@ -34,6 +34,7 @@ type ProviderContextState = {
   onPlanInfoChanged: () => void
   enableReplaceWebAppLogo: boolean
   modelLoadBalancingEnabled: boolean
+  datasetOperatorEnabled: boolean
 }
 const ProviderContext = createContext<ProviderContextState>({
   modelProviders: [],
@@ -47,12 +48,14 @@ const ProviderContext = createContext<ProviderContextState>({
       buildApps: 12,
       teamMembers: 1,
       annotatedResponse: 1,
+      documentsUploadQuota: 50,
     },
     total: {
       vectorSpace: 200,
       buildApps: 50,
       teamMembers: 1,
       annotatedResponse: 10,
+      documentsUploadQuota: 500,
     },
   },
   isFetchedPlan: false,
@@ -60,6 +63,7 @@ const ProviderContext = createContext<ProviderContextState>({
   onPlanInfoChanged: () => { },
   enableReplaceWebAppLogo: false,
   modelLoadBalancingEnabled: false,
+  datasetOperatorEnabled: false,
 })
 
 export const useProviderContext = () => useContext(ProviderContext)
@@ -86,6 +90,7 @@ export const ProviderContextProvider = ({
   const [enableBilling, setEnableBilling] = useState(true)
   const [enableReplaceWebAppLogo, setEnableReplaceWebAppLogo] = useState(false)
   const [modelLoadBalancingEnabled, setModelLoadBalancingEnabled] = useState(false)
+  const [datasetOperatorEnabled, setDatasetOperatorEnabled] = useState(false)
 
   const fetchPlan = async () => {
     const data = await fetchCurrentPlanInfo()
@@ -98,6 +103,8 @@ export const ProviderContextProvider = ({
     }
     if (data.model_load_balancing_enabled)
       setModelLoadBalancingEnabled(true)
+    if (data.dataset_operator_enabled)
+      setDatasetOperatorEnabled(true)
   }
   useEffect(() => {
     fetchPlan()
@@ -115,6 +122,7 @@ export const ProviderContextProvider = ({
       onPlanInfoChanged: fetchPlan,
       enableReplaceWebAppLogo,
       modelLoadBalancingEnabled,
+      datasetOperatorEnabled,
     }}>
       {children}
     </ProviderContext.Provider>

+ 2 - 0
web/i18n/en-US/common.ts

@@ -181,6 +181,8 @@ const translation = {
     builderTip: 'Can build & edit own apps',
     editor: 'Editor',
     editorTip: 'Can build & edit apps',
+    datasetOperator: 'Knowledge Admin',
+    datasetOperatorTip: 'Only can manage the knowledge base',
     inviteTeamMember: 'Add team member',
     inviteTeamMemberTip: 'They can access your team data directly after signing in.',
     email: 'Email',

+ 2 - 0
web/i18n/en-US/dataset-settings.ts

@@ -12,6 +12,8 @@ const translation = {
     permissions: 'Permissions',
     permissionsOnlyMe: 'Only me',
     permissionsAllMember: 'All team members',
+    permissionsInvitedMembers: 'Partial team members',
+    me: '(You)',
     indexMethod: 'Index Method',
     indexMethodHighQuality: 'High Quality',
     indexMethodHighQualityTip: 'Call Embedding model for processing to provide higher accuracy when users query.',

+ 2 - 0
web/i18n/zh-Hans/common.ts

@@ -179,6 +179,8 @@ const translation = {
     normalTip: '只能使用应用程序,不能建立应用程序',
     editor: '编辑',
     editorTip: '能够建立并编辑应用程序,不能管理团队设置',
+    datasetOperator: '知识库管理员',
+    datasetOperatorTip: '只能管理知识库',
     inviteTeamMember: '添加团队成员',
     inviteTeamMemberTip: '对方在登录后可以访问你的团队数据。',
     email: '邮箱',

+ 2 - 0
web/i18n/zh-Hans/dataset-settings.ts

@@ -12,6 +12,8 @@ const translation = {
     permissions: '可见权限',
     permissionsOnlyMe: '只有我',
     permissionsAllMember: '所有团队成员',
+    permissionsInvitedMembers: '部分团队成员',
+    me: '(你)',
     indexMethod: '索引模式',
     indexMethodHighQuality: '高质量',
     indexMethodHighQualityTip: '调用 Embedding 模型进行处理,以在用户查询时提供更高的准确度。',

+ 2 - 2
web/models/common.ts

@@ -65,7 +65,7 @@ export type TenantInfoResponse = {
 export type Member = Pick<UserProfileResponse, 'id' | 'name' | 'email' | 'last_login_at' | 'last_active_at' | 'created_at'> & {
   avatar: string
   status: 'pending' | 'active' | 'banned' | 'closed'
-  role: 'owner' | 'admin' | 'editor' | 'normal'
+  role: 'owner' | 'admin' | 'editor' | 'normal' | 'dataset_operator'
 }
 
 export enum ProviderName {
@@ -126,7 +126,7 @@ export type IWorkspace = {
 }
 
 export type ICurrentWorkspace = Omit<IWorkspace, 'current'> & {
-  role: 'owner' | 'admin' | 'editor' | 'normal'
+  role: 'owner' | 'admin' | 'editor' | 'dataset_operator' | 'normal'
   providers: Provider[]
   in_trail: boolean
   trial_end_reason?: string

+ 4 - 1
web/models/datasets.ts

@@ -8,13 +8,15 @@ export enum DataSourceType {
   WEB = 'website_crawl',
 }
 
+export type DatasetPermission = 'only_me' | 'all_team_members' | 'partial_members'
+
 export type DataSet = {
   id: string
   name: string
   icon: string
   icon_background: string
   description: string
-  permission: 'only_me' | 'all_team_members'
+  permission: DatasetPermission
   data_source_type: DataSourceType
   indexing_technique: 'high_quality' | 'economy'
   created_by: string
@@ -29,6 +31,7 @@ export type DataSet = {
   retrieval_model_dict: RetrievalConfig
   retrieval_model: RetrievalConfig
   tags: Tag[]
+  partial_member_list?: any[]
 }
 
 export type CustomFile = File & {

+ 1 - 1
web/service/datasets.ts

@@ -53,7 +53,7 @@ export const fetchDatasetDetail: Fetcher<DataSet, string> = (datasetId: string)
 export const updateDatasetSetting: Fetcher<DataSet, {
   datasetId: string
   body: Partial<Pick<DataSet,
-    'name' | 'description' | 'permission' | 'indexing_technique' | 'retrieval_model' | 'embedding_model' | 'embedding_model_provider'
+    'name' | 'description' | 'permission' | 'partial_member_list' | 'indexing_technique' | 'retrieval_model' | 'embedding_model' | 'embedding_model_provider'
   >>
 }> = ({ datasetId, body }) => {
   return patch<DataSet>(`/datasets/${datasetId}`, { body })