fix dataset operator (#6064)

Co-authored-by: JzoNg <jzongcode@gmail.com>
This commit is contained in:
Joe 2024-07-09 17:47:54 +08:00 committed by GitHub
parent 3b14939d66
commit ce930f19b9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 1072 additions and 290 deletions

View File

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

View File

@ -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,8 +114,8 @@ 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
if not current_user.is_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_dataset_editor:
raise Forbidden()
try:
@ -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,20 @@ 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)
tenant_id = current_user.current_tenant_id
if data.get('partial_member_list') and data.get('permission') == 'partial_members':
DatasetPermissionService.update_partial_member_list(
tenant_id, 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,11 +247,12 @@ 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:
if DatasetService.delete_dataset(dataset_id_str, current_user):
DatasetPermissionService.clear_partial_member_list(dataset_id_str)
return {'result': 'success'}, 204
else:
raise NotFound("Dataset not found.")
@ -569,6 +602,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 +636,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')

View File

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

View File

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

View File

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

View File

@ -0,0 +1,34 @@
"""add dataset permission tenant id
Revision ID: 161cadc1af8d
Revises: 7e6a8693e07a
Create Date: 2024-07-05 14:30:59.472593
"""
import sqlalchemy as sa
from alembic import op
import models as models
# revision identifiers, used by Alembic.
revision = '161cadc1af8d'
down_revision = '7e6a8693e07a'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('dataset_permissions', schema=None) as batch_op:
# Step 1: Add column without NOT NULL constraint
op.add_column('dataset_permissions', sa.Column('tenant_id', sa.UUID(), nullable=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_column('tenant_id')
# ### end Alembic commands ###

View File

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

View File

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

View File

@ -663,3 +663,20 @@ 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'),
db.Index('idx_dataset_permissions_tenant_id', 'tenant_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)
tenant_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)'))

View File

@ -369,6 +369,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"""

View File

@ -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,55 @@ 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).order_by(
Dataset.created_at.desc()
)
if user:
permission_filter = db.or_(Dataset.created_by == user.id,
Dataset.permission == 'all_team_members')
# get permitted dataset ids
dataset_permission = DatasetPermission.query.filter_by(
account_id=user.id,
tenant_id=tenant_id
).all()
permitted_dataset_ids = {dp.dataset_id for dp in dataset_permission} if dataset_permission else None
if user.current_role == TenantAccountRole.DATASET_OPERATOR:
# only show datasets that the user has permission to access
if permitted_dataset_ids:
query = query.filter(Dataset.id.in_(permitted_dataset_ids))
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())
return [], 0
else:
# show all datasets that the user has permission to access
if permitted_dataset_ids:
query = query.filter(
db.or_(
Dataset.permission == 'all_team_members',
db.and_(Dataset.permission == 'only_me', Dataset.created_by == user.id),
db.and_(Dataset.permission == 'partial_members', Dataset.id.in_(permitted_dataset_ids))
)
)
else:
query = query.filter(
db.or_(
Dataset.permission == 'all_team_members',
db.and_(Dataset.permission == 'only_me', Dataset.created_by == user.id)
)
)
else:
# if no user, only show datasets that are shared with all team members
query = query.filter(Dataset.permission == 'all_team_members')
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,
@ -102,9 +136,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 +149,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 +189,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,7 +232,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)
else:
@ -215,7 +258,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 +303,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):
@ -547,6 +618,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 +628,11 @@ class DocumentService:
return 1
@staticmethod
def save_document_with_dataset_id(dataset: Dataset, document_data: dict,
def save_document_with_dataset_id(
dataset: Dataset, document_data: dict,
account: Account, dataset_process_rule: Optional[DatasetProcessRule] = None,
created_from: str = 'web'):
created_from: str = 'web'
):
# check document limit
features = FeatureService.get_features(current_user.current_tenant_id)
@ -618,7 +692,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 +761,14 @@ class DocumentService:
documents.append(document)
duplicate_document_ids.append(document.id)
continue
document = DocumentService.build_document(dataset, dataset_process_rule.id,
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)
account, file_name, batch
)
db.session.add(document)
db.session.flush()
document_ids.append(document.id)
@ -732,12 +809,14 @@ class DocumentService:
"notion_page_icon": page['page_icon'],
"type": page['type']
}
document = DocumentService.build_document(dataset, dataset_process_rule.id,
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)
account, page['page_name'], batch
)
db.session.add(document)
db.session.flush()
document_ids.append(document.id)
@ -759,12 +838,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 = 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)
account, url, batch
)
db.session.add(document)
db.session.flush()
document_ids.append(document.id)
@ -785,13 +866,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,
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):
name: str, batch: str
):
document = Document(
tenant_id=dataset.tenant_id,
dataset_id=dataset.id,
@ -810,16 +894,20 @@ class DocumentService:
@staticmethod
def get_tenant_documents_count():
documents_count = Document.query.filter(Document.completed_at.isnot(None),
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()
Document.tenant_id == current_user.current_tenant_id
).count()
return documents_count
@staticmethod
def update_document_with_dataset_id(dataset: Dataset, document_data: dict,
def update_document_with_dataset_id(
dataset: Dataset, document_data: dict,
account: Account, dataset_process_rule: Optional[DatasetProcessRule] = None,
created_from: str = 'web'):
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':
@ -1437,12 +1525,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,
filter(
DatasetCollectionBinding.provider_name == provider_name,
DatasetCollectionBinding.model_name == model_name,
DatasetCollectionBinding.type == collection_type). \
DatasetCollectionBinding.type == collection_type
). \
order_by(DatasetCollectionBinding.created_at). \
first()
@ -1458,12 +1550,77 @@ 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, tenant_id, dataset_id, user_list):
try:
db.session.query(DatasetPermission).filter(DatasetPermission.dataset_id == dataset_id).delete()
permissions = []
for user in user_list:
permission = DatasetPermission(
tenant_id=tenant_id,
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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,8 @@
'use client'
// Libraries
import { useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useTranslation } from 'react-i18next'
import { useDebounceFn } from 'ahooks'
import useSWR from 'swr'
@ -22,15 +23,20 @@ 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 = [
const options = useMemo(() => {
return [
{ value: 'dataset', text: t('dataset.datasets') },
{ value: 'api', text: t('dataset.datasetsApi') },
...(currentWorkspace.role === 'dataset_operator' ? [] : [{ value: 'api', text: t('dataset.datasetsApi') }]),
]
}, [currentWorkspace.role, t])
const [activeTab, setActiveTab] = useTabSearchParams({
defaultTab: 'dataset',
@ -57,6 +63,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'>

View File

@ -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,6 +84,8 @@ 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>
{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'
@ -91,6 +95,8 @@ const DatasetCard = ({
{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={

View File

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

View File

@ -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 { RiCloseLine } from '@remixicon/react'
@ -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>

View File

@ -0,0 +1,10 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="users-plus">
<g id="Solid">
<path d="M20 15C20 14.4477 19.5523 14 19 14C18.4477 14 18 14.4477 18 15V17H16C15.4477 17 15 17.4477 15 18C15 18.5523 15.4477 19 16 19H18V21C18 21.5523 18.4477 22 19 22C19.5523 22 20 21.5523 20 21V19H22C22.5523 19 23 18.5523 23 18C23 17.4477 22.5523 17 22 17H20V15Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.181 14.1635C12.4632 14.3073 12.6927 14.5368 12.8365 14.819C12.9896 15.1194 13.0001 15.4476 13 15.7769C13 15.7847 13 15.7924 13 15.8C13 17.2744 12.9995 18.7488 13 20.2231C13.0001 20.3422 13.0001 20.4845 12.9899 20.6098C12.978 20.755 12.9476 20.963 12.8365 21.181C12.6927 21.4632 12.4632 21.6927 12.181 21.8365C11.963 21.9476 11.7551 21.978 11.6098 21.9899C11.4845 22.0001 11.3423 22.0001 11.2231 22C8.4077 21.999 5.59226 21.999 2.77682 22C2.65755 22.0001 2.51498 22.0001 2.38936 21.9898C2.24364 21.9778 2.03523 21.9472 1.81695 21.8356C1.53435 21.6911 1.30428 21.46 1.16109 21.1767C1.05079 20.9585 1.02087 20.7506 1.0095 20.6046C0.999737 20.4791 1.00044 20.3369 1.00103 20.2185C1.00619 19.1792 0.975203 18.0653 1.38061 17.0866C1.88808 15.8614 2.86145 14.8881 4.08659 14.3806C4.59629 14.1695 5.13457 14.0819 5.74331 14.0404C6.33532 14 7.06273 14 7.96449 14C9.05071 14 10.1369 14.0004 11.2231 14C11.5524 13.9999 11.8806 14.0104 12.181 14.1635Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.5731 2.91554C14.7803 2.40361 15.3633 2.1566 15.8752 2.36382C17.7058 3.10481 19 4.90006 19 7C19 9.09994 17.7058 10.8952 15.8752 11.6362C15.3633 11.8434 14.7803 11.5964 14.5731 11.0845C14.3658 10.5725 14.6129 9.98953 15.1248 9.7823C16.2261 9.33652 17 8.25744 17 7C17 5.74256 16.2261 4.66348 15.1248 4.2177C14.6129 4.01047 14.3658 3.42748 14.5731 2.91554Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.50001 7C4.50001 4.23858 6.73858 2 9.50001 2C12.2614 2 14.5 4.23858 14.5 7C14.5 9.76142 12.2614 12 9.50001 12C6.73858 12 4.50001 9.76142 4.50001 7Z" fill="black"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -0,0 +1,77 @@
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "24",
"height": "24",
"viewBox": "0 0 24 24",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "g",
"attributes": {
"id": "users-plus"
},
"children": [
{
"type": "element",
"name": "g",
"attributes": {
"id": "Solid"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"d": "M20 15C20 14.4477 19.5523 14 19 14C18.4477 14 18 14.4477 18 15V17H16C15.4477 17 15 17.4477 15 18C15 18.5523 15.4477 19 16 19H18V21C18 21.5523 18.4477 22 19 22C19.5523 22 20 21.5523 20 21V19H22C22.5523 19 23 18.5523 23 18C23 17.4477 22.5523 17 22 17H20V15Z",
"fill": "currentColor"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"fill-rule": "evenodd",
"clip-rule": "evenodd",
"d": "M12.181 14.1635C12.4632 14.3073 12.6927 14.5368 12.8365 14.819C12.9896 15.1194 13.0001 15.4476 13 15.7769C13 15.7847 13 15.7924 13 15.8C13 17.2744 12.9995 18.7488 13 20.2231C13.0001 20.3422 13.0001 20.4845 12.9899 20.6098C12.978 20.755 12.9476 20.963 12.8365 21.181C12.6927 21.4632 12.4632 21.6927 12.181 21.8365C11.963 21.9476 11.7551 21.978 11.6098 21.9899C11.4845 22.0001 11.3423 22.0001 11.2231 22C8.4077 21.999 5.59226 21.999 2.77682 22C2.65755 22.0001 2.51498 22.0001 2.38936 21.9898C2.24364 21.9778 2.03523 21.9472 1.81695 21.8356C1.53435 21.6911 1.30428 21.46 1.16109 21.1767C1.05079 20.9585 1.02087 20.7506 1.0095 20.6046C0.999737 20.4791 1.00044 20.3369 1.00103 20.2185C1.00619 19.1792 0.975203 18.0653 1.38061 17.0866C1.88808 15.8614 2.86145 14.8881 4.08659 14.3806C4.59629 14.1695 5.13457 14.0819 5.74331 14.0404C6.33532 14 7.06273 14 7.96449 14C9.05071 14 10.1369 14.0004 11.2231 14C11.5524 13.9999 11.8806 14.0104 12.181 14.1635Z",
"fill": "currentColor"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"fill-rule": "evenodd",
"clip-rule": "evenodd",
"d": "M14.5731 2.91554C14.7803 2.40361 15.3633 2.1566 15.8752 2.36382C17.7058 3.10481 19 4.90006 19 7C19 9.09994 17.7058 10.8952 15.8752 11.6362C15.3633 11.8434 14.7803 11.5964 14.5731 11.0845C14.3658 10.5725 14.6129 9.98953 15.1248 9.7823C16.2261 9.33652 17 8.25744 17 7C17 5.74256 16.2261 4.66348 15.1248 4.2177C14.6129 4.01047 14.3658 3.42748 14.5731 2.91554Z",
"fill": "currentColor"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"fill-rule": "evenodd",
"clip-rule": "evenodd",
"d": "M4.50001 7C4.50001 4.23858 6.73858 2 9.50001 2C12.2614 2 14.5 4.23858 14.5 7C14.5 9.76142 12.2614 12 9.50001 12C6.73858 12 4.50001 9.76142 4.50001 7Z",
"fill": "currentColor"
},
"children": []
}
]
}
]
}
]
},
"name": "UsersPlus"
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,12 +1,12 @@
'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 { 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 cn from '@/utils/classnames'
import RetrievalMethodConfig from '@/app/components/datasets/common/retrieval-method-config'
@ -14,18 +14,20 @@ import EconomicalRetrievalMethodConfig from '@/app/components/datasets/common/ec
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>

View File

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

View File

@ -1,7 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" rx="8" fill="#EEF4FF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.4043 14.2586C15.5696 13.9296 15.9703 13.7969 16.2993 13.9622C17.3889 14.5095 18.31 15.381 18.9766 16.4548C19.0776 16.6174 19.2246 16.8347 19.2702 17.1291C19.3191 17.4443 19.2335 17.7457 19.1061 17.9749C18.9786 18.2041 18.7676 18.4357 18.4741 18.5605C18.1949 18.6791 17.8913 18.6666 17.6667 18.6666C17.2985 18.6666 17.0001 18.3682 17.0001 18C17.0001 17.6318 17.2985 17.3333 17.6667 17.3333C17.8102 17.3333 17.8856 17.3329 17.9395 17.3292L17.9409 17.3268C17.9536 17.3038 17.8568 17.1789 17.8438 17.158C17.2956 16.2749 16.5524 15.5814 15.7008 15.1536C15.3718 14.9884 15.2391 14.5877 15.4043 14.2586Z" fill="#444CE7"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.0697 6.01513C14.2336 5.68541 14.6337 5.55095 14.9634 5.71481C16.1691 6.314 17.0001 7.55934 17.0001 8.99998C17.0001 10.4406 16.1691 11.686 14.9634 12.2851C14.6337 12.449 14.2336 12.3145 14.0697 11.9848C13.9059 11.6551 14.0403 11.255 14.37 11.0911C15.14 10.7085 15.6667 9.91515 15.6667 8.99998C15.6667 8.08481 15.14 7.29144 14.37 6.90883C14.0403 6.74497 13.9059 6.34485 14.0697 6.01513Z" fill="#444CE7"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.66673 8.99998C6.66673 6.97494 8.30835 5.33331 10.3334 5.33331C12.3584 5.33331 14.0001 6.97494 14.0001 8.99998C14.0001 11.025 12.3584 12.6666 10.3334 12.6666C8.30835 12.6666 6.66673 11.025 6.66673 8.99998Z" fill="#444CE7"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.3334 13.3333C12.4642 13.3333 14.3691 14.5361 15.5315 16.2801C15.6339 16.4337 15.7431 16.5976 15.8194 16.7533C15.9113 16.9407 15.9773 17.156 15.9619 17.4132C15.9496 17.6183 15.8816 17.8086 15.8007 17.9597C15.7198 18.1107 15.5991 18.2728 15.4352 18.3968C15.2157 18.5628 14.9791 18.621 14.77 18.6453C14.5858 18.6667 14.3677 18.6667 14.148 18.6667C11.6059 18.6662 9.06185 18.6662 6.51877 18.6667C6.29908 18.6667 6.08098 18.6667 5.89682 18.6453C5.68769 18.621 5.4511 18.5628 5.23155 18.3968C5.06767 18.2728 4.94702 18.1107 4.86612 17.9597C4.78523 17.8086 4.71719 17.6183 4.70488 17.4132C4.68945 17.156 4.75545 16.9407 4.84734 16.7533C4.92369 16.5976 5.0329 16.4337 5.13531 16.2801C6.2977 14.5361 8.20257 13.3333 10.3334 13.3333Z" fill="#444CE7"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -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);
}

View File

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

View File

@ -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'>
{!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 => (

View File

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

View File

@ -1,11 +1,10 @@
'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 RoleSelector from './role-selector'
import s from './index.module.css'
import cn from '@/utils/classnames'
import Modal from '@/app/components/base/modal'
@ -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 className='mb-6'>
<RoleSelector value={role} onChange={setRole} />
</div>
</div>
)}
</Listbox.Option>,
)}
</Listbox.Options>
</Transition>
</div>
</Listbox>
<Button
tabIndex={0}
className='w-full'

View File

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

View File

@ -1,10 +1,11 @@
'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 { CheckIcon, ChevronDownIcon } from '@heroicons/react/24/outline'
import s from './index.module.css'
import { useProviderContext } from '@/context/provider-context'
import cn from '@/utils/classnames'
import type { Member } from '@/models/common'
import { deleteMemberOrCancelInvitation, updateMemberRole } from '@/service/common'
@ -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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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