mirror of
https://gitee.com/dify_ai/dify.git
synced 2024-11-30 02:08:37 +08:00
fix dataset operator (#6064)
Co-authored-by: JzoNg <jzongcode@gmail.com>
This commit is contained in:
parent
3b14939d66
commit
ce930f19b9
@ -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):
|
||||
"""
|
||||
|
@ -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')
|
||||
|
@ -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()
|
||||
|
@ -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()
|
||||
|
@ -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')
|
||||
|
@ -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 ###
|
@ -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 ###
|
@ -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):
|
||||
|
@ -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)'))
|
||||
|
@ -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"""
|
||||
|
@ -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
|
||||
|
@ -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):
|
||||
|
@ -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}
|
||||
|
@ -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
|
||||
|
@ -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={{
|
||||
|
@ -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'>
|
||||
|
@ -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={
|
||||
|
@ -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)
|
||||
|
@ -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>
|
||||
|
@ -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 |
@ -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"
|
||||
}
|
@ -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,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'
|
||||
|
@ -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',
|
||||
|
@ -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 = {
|
||||
|
@ -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>
|
||||
|
@ -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
|
@ -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 |
@ -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);
|
||||
}
|
@ -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
|
||||
|
@ -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 => (
|
||||
|
@ -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)
|
||||
|
@ -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'
|
||||
|
@ -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
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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',
|
||||
|
@ -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'>
|
||||
|
@ -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>
|
||||
|
@ -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',
|
||||
|
@ -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.',
|
||||
|
@ -179,6 +179,8 @@ const translation = {
|
||||
normalTip: '只能使用应用程序,不能建立应用程序',
|
||||
editor: '编辑',
|
||||
editorTip: '能够建立并编辑应用程序,不能管理团队设置',
|
||||
datasetOperator: '知识库管理员',
|
||||
datasetOperatorTip: '只能管理知识库',
|
||||
inviteTeamMember: '添加团队成员',
|
||||
inviteTeamMemberTip: '对方在登录后可以访问你的团队数据。',
|
||||
email: '邮箱',
|
||||
|
@ -12,6 +12,8 @@ const translation = {
|
||||
permissions: '可见权限',
|
||||
permissionsOnlyMe: '只有我',
|
||||
permissionsAllMember: '所有团队成员',
|
||||
permissionsInvitedMembers: '部分团队成员',
|
||||
me: '(你)',
|
||||
indexMethod: '索引模式',
|
||||
indexMethodHighQuality: '高质量',
|
||||
indexMethodHighQualityTip: '调用 Embedding 模型进行处理,以在用户查询时提供更高的准确度。',
|
||||
|
@ -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
|
||||
|
@ -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 & {
|
||||
|
@ -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 })
|
||||
|
Loading…
Reference in New Issue
Block a user