merge hitl

This commit is contained in:
JzoNg
2026-05-13 11:34:14 +08:00
16 changed files with 424 additions and 196 deletions

View File

@@ -83,6 +83,11 @@ def _normalize_enum_value(value: object) -> str:
normalized = getattr(value, "value", value)
return str(normalized) if normalized is not None else ""
def _is_role_enabled(role: TenantAccountRole | str, tenant_id: str) -> bool:
if role != TenantAccountRole.DATASET_OPERATOR:
return True
return FeatureService.get_features(tenant_id=tenant_id).dataset_operator_enabled
@console_ns.route("/workspaces/current/members")
class MemberListApi(Resource):
@@ -154,6 +159,8 @@ class MemberInviteEmailApi(Resource):
inviter = current_user
if not inviter.current_tenant:
raise ValueError("No current tenant")
if not _is_role_enabled(invitee_role, inviter.current_tenant.id):
return {"code": "invalid-role", "message": "Invalid role"}, 400
# Check workspace permission for member invitations
from libs.workspace_permission import check_workspace_member_invite_permission
@@ -252,6 +259,8 @@ class MemberUpdateRoleApi(Resource):
current_user, _ = current_account_with_tenant()
if not current_user.current_tenant:
raise ValueError("No current tenant")
if not _is_role_enabled(new_role, current_user.current_tenant.id):
return {"code": "invalid-role", "message": "Invalid role"}, 400
member = db.session.get(Account, str(member_id))
if not member:
abort(404)
@@ -259,11 +268,17 @@ class MemberUpdateRoleApi(Resource):
try:
assert member is not None, "Member not found"
TenantService.update_member_role(current_user.current_tenant, member, new_role, current_user)
except services.errors.account.CannotOperateSelfError as e:
return {"code": "cannot-operate-self", "message": str(e)}, 400
except services.errors.account.NoPermissionError as e:
return {"code": "forbidden", "message": str(e)}, 403
except services.errors.account.MemberNotInTenantError as e:
return {"code": "member-not-found", "message": str(e)}, 404
except services.errors.account.RoleAlreadyAssignedError as e:
return {"code": "role-already-assigned", "message": str(e)}, 400
except Exception as e:
raise ValueError(str(e))
# todo: 403
return {"result": "success"}

View File

@@ -161,35 +161,39 @@ class AdvancedPromptTransform(PromptTransform):
prompt_messages: list[PromptMessage] = []
for prompt_item in prompt_template:
raw_prompt = prompt_item.text
if prompt_item.edition_type == "basic" or not prompt_item.edition_type:
if self.with_variable_tmpl:
vp = VariablePool.empty()
for k, v in inputs.items():
if k.startswith("#"):
vp.add(k[1:-1].split("."), v)
raw_prompt = raw_prompt.replace("{{#context#}}", context or "")
prompt = vp.convert_template(raw_prompt).text
else:
parser = PromptTemplateParser(template=raw_prompt, with_variable_tmpl=self.with_variable_tmpl)
prompt_inputs: Mapping[str, str] = {k: inputs[k] for k in parser.variable_keys if k in inputs}
prompt_inputs = self._set_context_variable(
context=context, parser=parser, prompt_inputs=prompt_inputs
)
prompt = parser.format(prompt_inputs)
elif prompt_item.edition_type == "jinja2":
prompt = raw_prompt
prompt_inputs = inputs
prompt = Jinja2Formatter.format(template=prompt, inputs=prompt_inputs)
else:
raise ValueError(f"Invalid edition type: {prompt_item.edition_type}")
if prompt_item.role == PromptMessageRole.USER:
prompt_messages.append(UserPromptMessage(content=prompt))
elif prompt_item.role == PromptMessageRole.SYSTEM and prompt:
prompt_messages.append(SystemPromptMessage(content=prompt))
elif prompt_item.role == PromptMessageRole.ASSISTANT:
prompt_messages.append(AssistantPromptMessage(content=prompt))
edition_type = prompt_item.edition_type or "basic"
match edition_type:
case "basic":
if self.with_variable_tmpl:
vp = VariablePool.empty()
for k, v in inputs.items():
if k.startswith("#"):
vp.add(k[1:-1].split("."), v)
raw_prompt = raw_prompt.replace("{{#context#}}", context or "")
prompt = vp.convert_template(raw_prompt).text
else:
parser = PromptTemplateParser(template=raw_prompt, with_variable_tmpl=self.with_variable_tmpl)
prompt_inputs: Mapping[str, str] = {k: inputs[k] for k in parser.variable_keys if k in inputs}
prompt_inputs = self._set_context_variable(
context=context, parser=parser, prompt_inputs=prompt_inputs
)
prompt = parser.format(prompt_inputs)
case "jinja2":
prompt = raw_prompt
prompt_inputs = inputs
prompt = Jinja2Formatter.format(template=prompt, inputs=prompt_inputs)
case _:
raise ValueError(f"Invalid edition type: {prompt_item.edition_type}")
match prompt_item.role:
case PromptMessageRole.USER:
prompt_messages.append(UserPromptMessage(content=prompt))
case PromptMessageRole.SYSTEM:
if prompt:
prompt_messages.append(SystemPromptMessage(content=prompt))
case PromptMessageRole.ASSISTANT:
prompt_messages.append(AssistantPromptMessage(content=prompt))
case PromptMessageRole.TOOL:
pass
if query and memory_config and memory_config.query_prompt_template:
parser = PromptTemplateParser(

View File

@@ -183,34 +183,35 @@ class ExtractProcessor:
return extractor.extract()
elif extract_setting.datasource_type == DatasourceType.WEBSITE:
assert extract_setting.website_info is not None, "website_info is required"
if extract_setting.website_info.provider == "firecrawl":
extractor = FirecrawlWebExtractor(
url=extract_setting.website_info.url,
job_id=extract_setting.website_info.job_id,
tenant_id=extract_setting.website_info.tenant_id,
mode=extract_setting.website_info.mode,
only_main_content=extract_setting.website_info.only_main_content,
)
return extractor.extract()
elif extract_setting.website_info.provider == "watercrawl":
extractor = WaterCrawlWebExtractor(
url=extract_setting.website_info.url,
job_id=extract_setting.website_info.job_id,
tenant_id=extract_setting.website_info.tenant_id,
mode=extract_setting.website_info.mode,
only_main_content=extract_setting.website_info.only_main_content,
)
return extractor.extract()
elif extract_setting.website_info.provider == "jinareader":
extractor = JinaReaderWebExtractor(
url=extract_setting.website_info.url,
job_id=extract_setting.website_info.job_id,
tenant_id=extract_setting.website_info.tenant_id,
mode=extract_setting.website_info.mode,
only_main_content=extract_setting.website_info.only_main_content,
)
return extractor.extract()
else:
raise ValueError(f"Unsupported website provider: {extract_setting.website_info.provider}")
match extract_setting.website_info.provider:
case "firecrawl":
extractor = FirecrawlWebExtractor(
url=extract_setting.website_info.url,
job_id=extract_setting.website_info.job_id,
tenant_id=extract_setting.website_info.tenant_id,
mode=extract_setting.website_info.mode,
only_main_content=extract_setting.website_info.only_main_content,
)
return extractor.extract()
case "watercrawl":
extractor = WaterCrawlWebExtractor(
url=extract_setting.website_info.url,
job_id=extract_setting.website_info.job_id,
tenant_id=extract_setting.website_info.tenant_id,
mode=extract_setting.website_info.mode,
only_main_content=extract_setting.website_info.only_main_content,
)
return extractor.extract()
case "jinareader":
extractor = JinaReaderWebExtractor(
url=extract_setting.website_info.url,
job_id=extract_setting.website_info.job_id,
tenant_id=extract_setting.website_info.tenant_id,
mode=extract_setting.website_info.mode,
only_main_content=extract_setting.website_info.only_main_content,
)
return extractor.extract()
case _:
raise ValueError(f"Unsupported website provider: {extract_setting.website_info.provider}")
else:
raise ValueError(f"Unsupported datasource type: {extract_setting.datasource_type}")

View File

@@ -1281,8 +1281,8 @@ class TenantService:
"""Check member permission"""
perms = {
"add": [TenantAccountRole.OWNER, TenantAccountRole.ADMIN],
"remove": [TenantAccountRole.OWNER],
"update": [TenantAccountRole.OWNER],
"remove": [TenantAccountRole.OWNER, TenantAccountRole.ADMIN],
"update": [TenantAccountRole.OWNER, TenantAccountRole.ADMIN],
}
if action not in {"add", "remove", "update"}:
raise InvalidActionError("Invalid action.")
@@ -1300,6 +1300,15 @@ class TenantService:
if not ta_operator or ta_operator.role not in perms[action]:
raise NoPermissionError(f"No permission to {action} member.")
if action == "remove" and ta_operator.role == TenantAccountRole.ADMIN and member:
ta_member = db.session.scalar(
select(TenantAccountJoin)
.where(TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.account_id == member.id)
.limit(1)
)
if ta_member and ta_member.role == TenantAccountRole.OWNER:
raise NoPermissionError(f"No permission to {action} member.")
@staticmethod
def remove_member_from_tenant(tenant: Tenant, account: Account, operator: Account):
"""Remove member from tenant.
@@ -1371,6 +1380,7 @@ class TenantService:
def update_member_role(tenant: Tenant, member: Account, new_role: str, operator: Account):
"""Update member role"""
TenantService.check_member_permission(tenant, operator, member, "update")
new_tenant_role = TenantAccountRole(new_role)
target_member_join = db.session.scalar(
select(TenantAccountJoin)
@@ -1381,6 +1391,11 @@ class TenantService:
if not target_member_join:
raise MemberNotInTenantError("Member not in tenant.")
operator_role = TenantService.get_user_role(operator, tenant)
target_role = TenantAccountRole(target_member_join.role)
if operator_role == TenantAccountRole.ADMIN and (TenantAccountRole.OWNER in {target_role, new_tenant_role}):
raise NoPermissionError("No permission to update member.")
if target_member_join.role == new_role:
raise RoleAlreadyAssignedError("The provided role is already assigned to the member.")
@@ -1395,7 +1410,7 @@ class TenantService:
current_owner_join.role = TenantAccountRole.ADMIN
# Update the role of the target member
target_member_join.role = TenantAccountRole(new_role)
target_member_join.role = new_tenant_role
db.session.commit()
@staticmethod

View File

@@ -13,6 +13,7 @@ from services.errors.account import (
AccountPasswordError,
AccountRegisterError,
CurrentPasswordIncorrectError,
NoPermissionError,
)
@@ -817,8 +818,8 @@ class TestTenantService:
# Mock the database queries in update_member_role method
with patch("services.account_service.db") as mock_db:
# scalar calls: permission check, target member lookup
mock_db.session.scalar.side_effect = [mock_operator_join, mock_target_join]
# scalar calls: permission check, target member lookup, operator role lookup
mock_db.session.scalar.side_effect = [mock_operator_join, mock_target_join, mock_operator_join]
# Execute test
TenantService.update_member_role(mock_tenant, mock_member, "admin", mock_operator)
@@ -827,6 +828,65 @@ class TestTenantService:
assert mock_target_join.role == "admin"
self._assert_database_operations_called(mock_db)
def test_admin_can_update_admin_member_role(self):
"""Test admin can update another non-owner member, including an admin."""
mock_tenant = MagicMock()
mock_tenant.id = "tenant-456"
mock_member = TestAccountAssociatedDataFactory.create_account_mock(account_id="member-789")
mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123")
mock_target_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
tenant_id="tenant-456", account_id="member-789", role="admin"
)
mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
tenant_id="tenant-456", account_id="operator-123", role="admin"
)
with patch("services.account_service.db") as mock_db:
mock_db.session.scalar.side_effect = [mock_operator_join, mock_target_join, mock_operator_join]
TenantService.update_member_role(mock_tenant, mock_member, "editor", mock_operator)
assert mock_target_join.role == "editor"
self._assert_database_operations_called(mock_db)
def test_admin_cannot_update_owner_member_role(self):
"""Test admin cannot update an owner member."""
mock_tenant = MagicMock()
mock_tenant.id = "tenant-456"
mock_member = TestAccountAssociatedDataFactory.create_account_mock(account_id="member-789")
mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123")
mock_target_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
tenant_id="tenant-456", account_id="member-789", role="owner"
)
mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
tenant_id="tenant-456", account_id="operator-123", role="admin"
)
with patch("services.account_service.db") as mock_db:
mock_db.session.scalar.side_effect = [mock_operator_join, mock_target_join, mock_operator_join]
with pytest.raises(NoPermissionError):
TenantService.update_member_role(mock_tenant, mock_member, "editor", mock_operator)
def test_admin_cannot_promote_member_to_owner(self):
"""Test admin cannot promote a non-owner member to owner."""
mock_tenant = MagicMock()
mock_tenant.id = "tenant-456"
mock_member = TestAccountAssociatedDataFactory.create_account_mock(account_id="member-789")
mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123")
mock_target_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
tenant_id="tenant-456", account_id="member-789", role="admin"
)
mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
tenant_id="tenant-456", account_id="operator-123", role="admin"
)
with patch("services.account_service.db") as mock_db:
mock_db.session.scalar.side_effect = [mock_operator_join, mock_target_join, mock_operator_join]
with pytest.raises(NoPermissionError):
TenantService.update_member_role(mock_tenant, mock_member, "owner", mock_operator)
# ==================== Permission Check Tests ====================
def test_check_member_permission_success(self, mock_db_dependencies):
@@ -864,6 +924,39 @@ class TestTenantService:
"add",
)
def test_admin_can_remove_non_owner_member(self, mock_db_dependencies):
"""Test admin can remove a non-owner member."""
mock_tenant = MagicMock()
mock_tenant.id = "tenant-456"
mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123")
mock_member = TestAccountAssociatedDataFactory.create_account_mock(account_id="member-789")
mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
tenant_id="tenant-456", account_id="operator-123", role="admin"
)
mock_member_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
tenant_id="tenant-456", account_id="member-789", role="admin"
)
mock_db_dependencies["db"].session.scalar.side_effect = [mock_operator_join, mock_member_join]
TenantService.check_member_permission(mock_tenant, mock_operator, mock_member, "remove")
def test_admin_cannot_remove_owner_member(self, mock_db_dependencies):
"""Test admin cannot remove an owner member."""
mock_tenant = MagicMock()
mock_tenant.id = "tenant-456"
mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123")
mock_member = TestAccountAssociatedDataFactory.create_account_mock(account_id="member-789")
mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
tenant_id="tenant-456", account_id="operator-123", role="admin"
)
mock_member_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
tenant_id="tenant-456", account_id="member-789", role="owner"
)
mock_db_dependencies["db"].session.scalar.side_effect = [mock_operator_join, mock_member_join]
with pytest.raises(NoPermissionError):
TenantService.check_member_permission(mock_tenant, mock_operator, mock_member, "remove")
class TestRegisterService:
"""

View File

@@ -4699,9 +4699,6 @@
},
"ts/no-explicit-any": {
"count": 3
},
"ts/no-non-null-asserted-optional-chain": {
"count": 1
}
},
"web/service/use-tools.ts": {

View File

@@ -314,6 +314,37 @@ describe('MembersPage', () => {
expect(screen.queryByRole('button', { name: /transfer ownership/i })).not.toBeInTheDocument()
})
it('should allow admins to operate other non-owner members only', () => {
vi.mocked(useAppContext).mockReturnValue({
userProfile: { email: 'admin@example.com' },
currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace,
isCurrentWorkspaceOwner: false,
isCurrentWorkspaceManager: true,
} as unknown as AppContextValue)
vi.mocked(useMembers).mockReturnValue({
data: {
accounts: [
mockAccounts[0],
mockAccounts[1],
{ ...mockAccounts[1]!, id: '3', email: 'editor@example.com', name: 'Editor User', role: 'editor' },
{ ...mockAccounts[1]!, id: '4', email: 'normal@example.com', name: 'Normal User', role: 'normal' },
{ ...mockAccounts[1]!, id: '5', email: 'dataset@example.com', name: 'Dataset User', role: 'dataset_operator' },
{ ...mockAccounts[1]!, id: '6', email: 'other-admin@example.com', name: 'Other Admin User', role: 'admin' },
],
},
refetch: mockRefetch,
} as unknown as ReturnType<typeof useMembers>)
renderMembersPage()
expect(screen.getByText('Member Operation editor'))!.toBeInTheDocument()
expect(screen.getByText('Member Operation normal'))!.toBeInTheDocument()
expect(screen.getByText('Member Operation dataset_operator'))!.toBeInTheDocument()
expect(screen.getByText('Member Operation admin'))!.toBeInTheDocument()
expect(screen.getAllByText('common.members.admin')).toHaveLength(1)
expect(screen.queryByText('Member Operation owner')).not.toBeInTheDocument()
})
it('should use created_at as fallback when last_active_at is empty', () => {
const memberNoLastActive: Member = {
...mockAccounts[1]!,

View File

@@ -8,8 +8,8 @@ const mockUpdateMemberRole = vi.fn()
const mockDeleteMemberOrCancelInvitation = vi.fn()
vi.mock('@/service/common', () => ({
deleteMemberOrCancelInvitation: () => mockDeleteMemberOrCancelInvitation(),
updateMemberRole: () => mockUpdateMemberRole(),
deleteMemberOrCancelInvitation: (args: unknown) => mockDeleteMemberOrCancelInvitation(args),
updateMemberRole: (args: unknown) => mockUpdateMemberRole(args),
}))
const mockUseProviderContext = vi.fn(() => ({
@@ -65,18 +65,21 @@ describe('Operation', () => {
expect(await screen.findByText('common.members.datasetOperator')).toBeInTheDocument()
})
it('should show owner-allowed role options when operator role is admin', async () => {
it('should show admin-allowed role options when operator role is admin', async () => {
const user = userEvent.setup()
renderOperation({}, 'admin')
await user.click(screen.getByText('common.members.editor'))
expect(screen.queryByText('common.members.admin')).not.toBeInTheDocument()
expect(screen.getByText('common.members.admin')).toBeInTheDocument()
expect(screen.getAllByText('common.members.editor')).toHaveLength(2)
expect(screen.getByText('common.members.normal')).toBeInTheDocument()
expect(screen.queryByText('common.members.datasetOperator')).not.toBeInTheDocument()
expect(screen.getByText('common.members.removeFromTeam')).toBeInTheDocument()
})
it('should not show role options when operator role is unsupported', async () => {
it('should not show role options or remove action when operator role is unsupported', async () => {
const user = userEvent.setup()
renderOperation({}, 'normal')
@@ -84,7 +87,7 @@ describe('Operation', () => {
await user.click(screen.getByText('common.members.editor'))
expect(screen.queryByText('common.members.normal')).not.toBeInTheDocument()
expect(screen.getByText('common.members.removeFromTeam')).toBeInTheDocument()
expect(screen.queryByText('common.members.removeFromTeam')).not.toBeInTheDocument()
})
it('should call updateMemberRole and onOperate when selecting another role', async () => {
@@ -96,7 +99,10 @@ describe('Operation', () => {
await user.click(await screen.findByText('common.members.normal'))
await waitFor(() => {
expect(mockUpdateMemberRole).toHaveBeenCalled()
expect(mockUpdateMemberRole).toHaveBeenCalledWith({
url: '/workspaces/current/members/member-id/update-role',
body: { role: 'normal' },
})
expect(onOperate).toHaveBeenCalled()
})
})
@@ -109,7 +115,7 @@ describe('Operation', () => {
await user.click(screen.getByText('common.members.editor'))
expect(await screen.findByText('common.members.datasetOperator')).toBeInTheDocument()
expect(screen.queryByText('common.members.admin')).not.toBeInTheDocument()
expect(screen.getByText('common.members.admin')).toBeInTheDocument()
})
it('should fall back to normal role label when member role is unknown', () => {
@@ -127,7 +133,9 @@ describe('Operation', () => {
await user.click(await screen.findByText('common.members.removeFromTeam'))
await waitFor(() => {
expect(mockDeleteMemberOrCancelInvitation).toHaveBeenCalled()
expect(mockDeleteMemberOrCancelInvitation).toHaveBeenCalledWith({
url: '/workspaces/current/members/member-id',
})
expect(onOperate).toHaveBeenCalled()
})
})

View File

@@ -26,6 +26,9 @@ const roleI18nKeyMap = {
dataset_operator: { label: 'members.datasetOperator', tip: 'members.datasetOperatorTip' },
} as const
type OperationRoleKey = keyof typeof roleI18nKeyMap
const nonOwnerRoles = ['admin', 'editor', 'normal'] as const
const isNonOwnerRole = (role: Member['role']) => role !== 'owner'
const Operation = ({ member, operatorRole, onOperate }: IOperationProps) => {
const [open, setOpen] = useState(false)
const { t } = useTranslation()
@@ -48,13 +51,13 @@ const Operation = ({ member, operatorRole, onOperate }: IOperationProps) => {
}
if (operatorRole === 'admin') {
return [
'editor',
'normal',
...nonOwnerRoles,
...(datasetOperatorEnabled ? ['dataset_operator'] as const : []),
]
}
return []
}, [operatorRole, datasetOperatorEnabled])
const canRemoveMember = operatorRole === 'owner' || (operatorRole === 'admin' && isNonOwnerRole(member.role))
const handleDeleteMemberOrCancelInvitation = async () => {
setOpen(false)
try {
@@ -81,7 +84,7 @@ const Operation = ({ member, operatorRole, onOperate }: IOperationProps) => {
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
render={<div className={cn('group flex h-full w-full cursor-pointer items-center justify-between px-3 system-sm-regular text-text-secondary hover:bg-state-base-hover', open && 'bg-state-base-hover')} />}
render={<button type="button" className={cn('group flex h-full w-full cursor-pointer items-center justify-between border-none bg-transparent px-3 text-left system-sm-regular text-text-secondary hover:bg-state-base-hover', open && 'bg-state-base-hover')} />}
>
{RoleMap[member.role] || RoleMap.normal}
<span aria-hidden className={cn('i-ri-arrow-down-s-line h-4 w-4 shrink-0 group-hover:block', open ? 'block' : 'hidden')} />
@@ -108,19 +111,23 @@ const Operation = ({ member, operatorRole, onOperate }: IOperationProps) => {
</DropdownMenuItem>
))}
</div>
<DropdownMenuSeparator className="my-0" />
<div className="p-1">
<DropdownMenuItem
className="h-auto items-start gap-2 rounded-lg px-3 py-2"
onClick={handleDeleteMemberOrCancelInvitation}
>
<span aria-hidden className="mt-[2px] h-4 w-4 shrink-0" />
<div>
<div className="system-sm-semibold whitespace-nowrap text-text-secondary">{t('members.removeFromTeam', { ns: 'common' })}</div>
<div className="system-xs-regular whitespace-nowrap text-text-tertiary">{t('members.removeFromTeamTip', { ns: 'common' })}</div>
{canRemoveMember && (
<>
<DropdownMenuSeparator className="my-0" />
<div className="p-1">
<DropdownMenuItem
className="h-auto items-start gap-2 rounded-lg px-3 py-2"
onClick={handleDeleteMemberOrCancelInvitation}
>
<span aria-hidden className="mt-[2px] h-4 w-4 shrink-0" />
<div>
<div className="system-sm-semibold whitespace-nowrap text-text-secondary">{t('members.removeFromTeam', { ns: 'common' })}</div>
<div className="system-xs-regular whitespace-nowrap text-text-tertiary">{t('members.removeFromTeamTip', { ns: 'common' })}</div>
</div>
</DropdownMenuItem>
</div>
</DropdownMenuItem>
</div>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)

View File

@@ -57,7 +57,7 @@ const InstallBundle: FC<Props> = ({
foldAnimInto()
}}
>
<DialogContent className={cn('relative w-full max-w-[480px] overflow-hidden! text-left align-middle', cn(modalClassName, 'shadows-shadow-xl flex min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0'))}>
<DialogContent className={cn('w-full max-w-[480px] overflow-hidden! text-left align-middle', cn(modalClassName, 'shadows-shadow-xl flex min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0'))}>
<DialogCloseButton />
<div className="flex items-start gap-2 self-stretch pt-6 pr-14 pb-3 pl-6">

View File

@@ -77,7 +77,7 @@ const PublishAsKnowledgePipelineModal = ({
return (
<>
<Dialog open>
<DialogContent className="relative w-full max-w-[480px]! overflow-hidden! border-none p-0! text-left align-middle">
<DialogContent className="w-full max-w-[480px]! overflow-hidden! border-none p-0! text-left align-middle">
<div className="relative flex items-center p-6 pr-14 pb-3 title-2xl-semi-bold text-text-primary">
{t('common.publishAs', { ns: 'pipeline' })}

View File

@@ -480,7 +480,9 @@ describe('publisher', () => {
it('should show publish as knowledge pipeline modal when permitted', async () => {
mockPublishedAt.mockReturnValue(1700000000)
mockIsAllowPublishAsCustomKnowledgePipelineTemplate.mockReturnValue(true)
renderWithQueryClient(<Popup />)
renderWithQueryClient(<Publisher />)
fireEvent.click(screen.getByText('workflow.common.publish'))
const publishAsButton = screen.getAllByRole('button').find(btn =>
btn.textContent?.includes('pipeline.common.publishAs'),
@@ -495,7 +497,9 @@ describe('publisher', () => {
it('should close publish as knowledge pipeline modal when cancel is clicked', async () => {
mockPublishedAt.mockReturnValue(1700000000)
mockIsAllowPublishAsCustomKnowledgePipelineTemplate.mockReturnValue(true)
renderWithQueryClient(<Popup />)
renderWithQueryClient(<Publisher />)
fireEvent.click(screen.getByText('workflow.common.publish'))
const publishAsButton = screen.getAllByRole('button').find(btn =>
btn.textContent?.includes('pipeline.common.publishAs'),
@@ -516,7 +520,9 @@ describe('publisher', () => {
it('should call publishAsCustomizedPipeline when confirm is clicked in modal', async () => {
mockPublishedAt.mockReturnValue(1700000000)
mockPublishAsCustomizedPipeline.mockResolvedValue({})
renderWithQueryClient(<Popup />)
renderWithQueryClient(<Publisher />)
fireEvent.click(screen.getByText('workflow.common.publish'))
const publishAsButton = screen.getAllByRole('button').find(btn =>
btn.textContent?.includes('pipeline.common.publishAs'),
@@ -538,6 +544,35 @@ describe('publisher', () => {
})
})
})
it('should publish as template with empty pipeline id fallback', async () => {
mockPublishedAt.mockReturnValue(1700000000)
mockPipelineId.mockReturnValue(undefined as unknown as string)
mockPublishAsCustomizedPipeline.mockResolvedValue({})
renderWithQueryClient(<Publisher />)
fireEvent.click(screen.getByText('workflow.common.publish'))
const publishAsButton = screen.getAllByRole('button').find(btn =>
btn.textContent?.includes('pipeline.common.publishAs'),
)
fireEvent.click(publishAsButton!)
await waitFor(() => {
expect(screen.getByTestId('publish-as-knowledge-pipeline-modal')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('modal-confirm'))
await waitFor(() => {
expect(mockPublishAsCustomizedPipeline).toHaveBeenCalledWith({
pipelineId: '',
name: 'Test Pipeline',
icon_info: { type: 'emoji', emoji: '📚', background: '#fff' },
description: 'Test description',
})
})
})
})
describe('API Calls and Async Operations', () => {
@@ -607,7 +642,9 @@ describe('publisher', () => {
it('should show success notification for publish as template', async () => {
mockPublishedAt.mockReturnValue(1700000000)
mockPublishAsCustomizedPipeline.mockResolvedValue({})
renderWithQueryClient(<Popup />)
renderWithQueryClient(<Publisher />)
fireEvent.click(screen.getByText('workflow.common.publish'))
const publishAsButton = screen.getAllByRole('button').find(btn =>
btn.textContent?.includes('pipeline.common.publishAs'),
@@ -633,7 +670,9 @@ describe('publisher', () => {
it('should invalidate customized template list after publish as template', async () => {
mockPublishedAt.mockReturnValue(1700000000)
mockPublishAsCustomizedPipeline.mockResolvedValue({})
renderWithQueryClient(<Popup />)
renderWithQueryClient(<Publisher />)
fireEvent.click(screen.getByText('workflow.common.publish'))
const publishAsButton = screen.getAllByRole('button').find(btn =>
btn.textContent?.includes('pipeline.common.publishAs'),
@@ -686,7 +725,9 @@ describe('publisher', () => {
it('should show error notification when publish as template fails', async () => {
mockPublishedAt.mockReturnValue(1700000000)
mockPublishAsCustomizedPipeline.mockRejectedValue(new Error('Template publish failed'))
renderWithQueryClient(<Popup />)
renderWithQueryClient(<Publisher />)
fireEvent.click(screen.getByText('workflow.common.publish'))
const publishAsButton = screen.getAllByRole('button').find(btn =>
btn.textContent?.includes('pipeline.common.publishAs'),
@@ -710,7 +751,9 @@ describe('publisher', () => {
it('should close modal after publish as template error', async () => {
mockPublishedAt.mockReturnValue(1700000000)
mockPublishAsCustomizedPipeline.mockRejectedValue(new Error('Template publish failed'))
renderWithQueryClient(<Popup />)
renderWithQueryClient(<Publisher />)
fireEvent.click(screen.getByText('workflow.common.publish'))
const publishAsButton = screen.getAllByRole('button').find(btn =>
btn.textContent?.includes('pipeline.common.publishAs'),
@@ -1051,7 +1094,9 @@ describe('publisher', () => {
it('should complete full publish as template flow', async () => {
mockPublishedAt.mockReturnValue(1700000000)
mockPublishAsCustomizedPipeline.mockResolvedValue({})
renderWithQueryClient(<Popup />)
renderWithQueryClient(<Publisher />)
fireEvent.click(screen.getByText('workflow.common.publish'))
const publishAsButton = screen.getAllByRole('button').find(btn =>
btn.textContent?.includes('pipeline.common.publishAs'),

View File

@@ -327,11 +327,18 @@ describe('Popup', () => {
it('should request closing the outer popover before opening publish-as modal', () => {
const onRequestClose = vi.fn()
render(<Popup onRequestClose={onRequestClose} />)
const onShowPublishAsKnowledgePipelineModal = vi.fn()
render(
<Popup
onRequestClose={onRequestClose}
onShowPublishAsKnowledgePipelineModal={onShowPublishAsKnowledgePipelineModal}
/>,
)
fireEvent.click(screen.getByText('pipeline.common.publishAs'))
expect(onRequestClose).toHaveBeenCalledTimes(1)
expect(onShowPublishAsKnowledgePipelineModal).toHaveBeenCalledTimes(1)
})
})
@@ -352,27 +359,6 @@ describe('Popup', () => {
})
})
describe('Publish params', () => {
it('should publish as template with empty pipeline id fallback', async () => {
mockPipelineId = undefined
mockUseBoolean
.mockImplementationOnce((initial: boolean) => [initial, { setFalse: vi.fn(), setTrue: vi.fn() }])
.mockImplementationOnce((initial: boolean) => [initial, { setFalse: vi.fn(), setTrue: vi.fn() }])
.mockImplementationOnce(() => [true, { setFalse: vi.fn(), setTrue: vi.fn() }])
.mockImplementationOnce((initial: boolean) => [initial, { setFalse: vi.fn(), setTrue: vi.fn() }])
render(<Popup />)
fireEvent.click(screen.getByTestId('publish-as-confirm'))
expect(mockPublishAsCustomizedPipeline).toHaveBeenCalledWith({
pipelineId: '',
name: 'My Pipeline',
icon_info: { icon_type: 'emoji' },
description: 'desc',
})
})
})
describe('Time formatting', () => {
it('should format published time', () => {
render(<Popup />)

View File

@@ -1,5 +1,8 @@
import type { IconInfo } from '@/models/datasets'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { toast } from '@langgenius/dify-ui/toast'
import { RiArrowDownSLine } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import {
@@ -9,6 +12,11 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import { useNodesSyncDraft } from '@/app/components/workflow/hooks'
import { useStore } from '@/app/components/workflow/store'
import { useDocLink } from '@/context/i18n'
import Link from '@/next/link'
import { useInvalidCustomizedTemplateList, usePublishAsCustomizedPipeline } from '@/service/use-pipeline'
import PublishAsKnowledgePipelineModal from '../../publish-as-knowledge-pipeline-modal'
import Popup from './popup'
const Publisher = () => {
@@ -16,6 +24,12 @@ const Publisher = () => {
const [open, setOpen] = useState(false)
const [confirmVisible, { setFalse: hideConfirm, setTrue: showConfirm }] = useBoolean(false)
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const docLink = useDocLink()
const pipelineId = useStore(s => s.pipelineId)
const { mutateAsync: publishAsCustomizedPipeline } = usePublishAsCustomizedPipeline()
const invalidCustomizedTemplateList = useInvalidCustomizedTemplateList()
const [showPublishAsKnowledgePipelineModal, setShowPublishAsKnowledgePipelineModal] = useState(false)
const [isPublishingAsCustomizedPipeline, setIsPublishingAsCustomizedPipeline] = useState(false)
const handleOpenChange = useCallback((newOpen: boolean) => {
if (!newOpen && confirmVisible)
@@ -27,38 +41,86 @@ const Publisher = () => {
const closePopover = useCallback(() => {
setOpen(false)
}, [])
const openPublishAsKnowledgePipelineModal = useCallback(() => {
setShowPublishAsKnowledgePipelineModal(true)
}, [])
const hidePublishAsKnowledgePipelineModal = useCallback(() => {
setShowPublishAsKnowledgePipelineModal(false)
}, [])
const handlePublishAsKnowledgePipeline = useCallback(async (name: string, icon: IconInfo, description?: string) => {
try {
setIsPublishingAsCustomizedPipeline(true)
await publishAsCustomizedPipeline({
pipelineId: pipelineId || '',
name,
icon_info: icon,
description,
})
toast.success(t('publishTemplate.success.message', { ns: 'datasetPipeline' }), {
description: (
<div className="flex flex-col gap-y-1">
<span className="system-xs-regular text-text-secondary">
{t('publishTemplate.success.tip', { ns: 'datasetPipeline' })}
</span>
<Link href={docLink()} target="_blank" className="inline-block system-xs-medium-uppercase text-text-accent">
{t('publishTemplate.success.learnMore', { ns: 'datasetPipeline' })}
</Link>
</div>
),
})
invalidCustomizedTemplateList()
}
catch {
toast.error(t('publishTemplate.error.message', { ns: 'datasetPipeline' }))
}
finally {
setIsPublishingAsCustomizedPipeline(false)
hidePublishAsKnowledgePipelineModal()
}
}, [docLink, hidePublishAsKnowledgePipelineModal, invalidCustomizedTemplateList, pipelineId, publishAsCustomizedPipeline, t])
return (
<Popover
open={open}
onOpenChange={handleOpenChange}
>
<PopoverTrigger
nativeButton
render={(
<Button
className="px-2"
variant="primary"
>
<span className="pl-1">{t('common.publish', { ns: 'workflow' })}</span>
<RiArrowDownSLine className="h-4 w-4" />
</Button>
)}
/>
<PopoverContent
placement="bottom-end"
sideOffset={4}
alignOffset={40}
popupClassName="border-none bg-transparent shadow-none"
<>
<Popover
open={open}
onOpenChange={handleOpenChange}
>
<Popup
onRequestClose={closePopover}
confirmVisible={confirmVisible}
onShowConfirm={showConfirm}
onHideConfirm={hideConfirm}
<PopoverTrigger
nativeButton
render={(
<Button
className="px-2"
variant="primary"
>
<span className="pl-1">{t('common.publish', { ns: 'workflow' })}</span>
<RiArrowDownSLine className="h-4 w-4" />
</Button>
)}
/>
</PopoverContent>
</Popover>
<PopoverContent
placement="bottom-end"
sideOffset={4}
alignOffset={40}
popupClassName={cn('border-none bg-transparent shadow-none', confirmVisible && 'hidden')}
>
<Popup
onRequestClose={closePopover}
confirmVisible={confirmVisible}
onShowConfirm={showConfirm}
onHideConfirm={hideConfirm}
isPublishingAsCustomizedPipeline={isPublishingAsCustomizedPipeline}
onShowPublishAsKnowledgePipelineModal={openPublishAsKnowledgePipelineModal}
/>
</PopoverContent>
</Popover>
{showPublishAsKnowledgePipelineModal && (
<PublishAsKnowledgePipelineModal
confirmDisabled={isPublishingAsCustomizedPipeline}
onConfirm={handlePublishAsKnowledgePipeline}
onCancel={hidePublishAsKnowledgePipelineModal}
/>
)}
</>
)
}

View File

@@ -1,4 +1,3 @@
import type { IconInfo } from '@/models/datasets'
import type { PublishWorkflowParams } from '@/types/workflow'
import {
AlertDialog,
@@ -25,7 +24,6 @@ import ShortcutsName from '@/app/components/workflow/shortcuts-name'
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useDocLink } from '@/context/i18n'
import { useModalContextSelector } from '@/context/modal-context'
import { useProviderContextSelector } from '@/context/provider-context'
import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url'
@@ -34,9 +32,8 @@ import Link from '@/next/link'
import { useParams, useRouter } from '@/next/navigation'
import { useInvalidDatasetList } from '@/service/knowledge/use-dataset'
import { useInvalid } from '@/service/use-base'
import { publishedPipelineInfoQueryKeyPrefix, useInvalidCustomizedTemplateList, usePublishAsCustomizedPipeline } from '@/service/use-pipeline'
import { publishedPipelineInfoQueryKeyPrefix } from '@/service/use-pipeline'
import { usePublishWorkflow } from '@/service/use-workflow'
import PublishAsKnowledgePipelineModal from '../../publish-as-knowledge-pipeline-modal'
const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P']
type PopupProps = {
@@ -44,6 +41,8 @@ type PopupProps = {
confirmVisible?: boolean
onShowConfirm?: () => void
onHideConfirm?: () => void
isPublishingAsCustomizedPipeline?: boolean
onShowPublishAsKnowledgePipelineModal?: () => void
}
const Popup = ({
@@ -51,11 +50,12 @@ const Popup = ({
confirmVisible: controlledConfirmVisible,
onShowConfirm,
onHideConfirm,
isPublishingAsCustomizedPipeline = false,
onShowPublishAsKnowledgePipelineModal,
}: PopupProps) => {
const { t } = useTranslation()
const { datasetId } = useParams()
const { push } = useRouter()
const docLink = useDocLink()
const publishedAt = useStore(s => s.publishedAt)
const draftUpdatedAt = useStore(s => s.draftUpdatedAt)
const pipelineId = useStore(s => s.pipelineId)
@@ -73,9 +73,6 @@ const Popup = ({
const showConfirm = onShowConfirm ?? showLocalConfirm
const hideConfirm = onHideConfirm ?? hideLocalConfirm
const [publishing, { setFalse: hidePublishing, setTrue: showPublishing }] = useBoolean(false)
const { mutateAsync: publishAsCustomizedPipeline } = usePublishAsCustomizedPipeline()
const [showPublishAsKnowledgePipelineModal, { setFalse: hidePublishAsKnowledgePipelineModal, setTrue: setShowPublishAsKnowledgePipelineModal }] = useBoolean(false)
const [isPublishingAsCustomizedPipeline, { setFalse: hidePublishingAsCustomizedPipeline, setTrue: showPublishingAsCustomizedPipeline }] = useBoolean(false)
const invalidPublishedPipelineInfo = useInvalid([...publishedPipelineInfoQueryKeyPrefix, pipelineId])
const invalidDatasetList = useInvalidDatasetList()
const handleHideConfirm = useCallback(() => {
@@ -145,47 +142,15 @@ const Popup = ({
const goToAddDocuments = useCallback(() => {
push(`/datasets/${datasetId}/documents/create-from-pipeline`)
}, [datasetId, push])
const invalidCustomizedTemplateList = useInvalidCustomizedTemplateList()
const handlePublishAsKnowledgePipeline = useCallback(async (name: string, icon: IconInfo, description?: string) => {
try {
showPublishingAsCustomizedPipeline()
await publishAsCustomizedPipeline({
pipelineId: pipelineId || '',
name,
icon_info: icon,
description,
})
toast.success(t('publishTemplate.success.message', { ns: 'datasetPipeline' }), {
description: (
<div className="flex flex-col gap-y-1">
<span className="system-xs-regular text-text-secondary">
{t('publishTemplate.success.tip', { ns: 'datasetPipeline' })}
</span>
<Link href={docLink()} target="_blank" className="inline-block system-xs-medium-uppercase text-text-accent">
{t('publishTemplate.success.learnMore', { ns: 'datasetPipeline' })}
</Link>
</div>
),
})
invalidCustomizedTemplateList()
}
catch {
toast.error(t('publishTemplate.error.message', { ns: 'datasetPipeline' }))
}
finally {
hidePublishingAsCustomizedPipeline()
hidePublishAsKnowledgePipelineModal()
}
}, [showPublishingAsCustomizedPipeline, publishAsCustomizedPipeline, pipelineId, t, invalidCustomizedTemplateList, hidePublishingAsCustomizedPipeline, hidePublishAsKnowledgePipelineModal, docLink])
const handleClickPublishAsKnowledgePipeline = useCallback(() => {
onRequestClose?.()
if (!isAllowPublishAsCustomKnowledgePipelineTemplate) {
setShowPricingModal()
}
else {
setShowPublishAsKnowledgePipelineModal()
onShowPublishAsKnowledgePipelineModal?.()
}
}, [isAllowPublishAsCustomKnowledgePipelineTemplate, onRequestClose, setShowPublishAsKnowledgePipelineModal, setShowPricingModal])
}, [isAllowPublishAsCustomKnowledgePipelineTemplate, onRequestClose, onShowPublishAsKnowledgePipelineModal, setShowPricingModal])
return (
<div className={cn('rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5', isAllowPublishAsCustomKnowledgePipelineTemplate ? 'w-[360px]' : 'w-[400px]')}>
<div className="p-4 pt-3">
@@ -279,7 +244,6 @@ const Popup = ({
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
{showPublishAsKnowledgePipelineModal && (<PublishAsKnowledgePipelineModal confirmDisabled={isPublishingAsCustomizedPipeline} onConfirm={handlePublishAsKnowledgePipeline} onCancel={hidePublishAsKnowledgePipelineModal} />)}
</div>
)
}

View File

@@ -336,7 +336,7 @@ export const useInstallOrUpdate = ({
}
if (item.type === 'marketplace') {
const data = item as GitHubItemAndMarketPlaceDependency
uniqueIdentifier = data.value.marketplace_plugin_unique_identifier! || plugin[i]?.plugin_id!
uniqueIdentifier = data.value.marketplace_plugin_unique_identifier! || (plugin[i]?.latest_package_identifier ?? '') || (plugin[i]?.plugin_id ?? '')
if (uniqueIdentifier === installedPayload?.uniqueIdentifier) {
return {
status: TaskStatus.success,