From 7caf51bfc7577cc9e720fe23274f57a23cdf4207 Mon Sep 17 00:00:00 2001 From: hzw Date: Tue, 10 Nov 2026 10:23:17 +0800 Subject: [PATCH 1/3] feat: unify resource repository workflows --- backend/contracts/constants/constants.py | 3 +- backend/contracts/errors/exceptions.py | 4 + backend/contracts/review.py | 129 ++ backend/contracts/tool.py | 10 + backend/management/api/agent/agent.py | 6 +- .../management/api/agent/agent_repository.py | 11 +- backend/management/api/application.py | 2 + backend/management/api/mcp/mcp_management.py | 49 +- backend/management/api/mcp/remote_mcp.py | 11 +- backend/management/api/repository/reviews.py | 176 +++ .../management/api/skill/skill_repository.py | 43 + backend/management/db/agent/catalog.py | 13 + backend/management/db/agent/models.py | 3 +- backend/management/db/client.py | 12 +- backend/management/db/mcp/market.py | 46 +- backend/management/db/mcp/models.py | 3 +- backend/management/db/models/__init__.py | 2 + .../management/db/notification/repository.py | 4 + .../management/db/repository_review_fields.py | 56 + backend/management/db/repository_reviews.py | 95 ++ .../management/db/repository_transactions.py | 79 ++ .../management/db/resource_review_model.py | 77 ++ backend/management/db/skill/catalog.py | 17 + backend/management/db/skill/models.py | 7 +- backend/management/db/skill/repository.py | 4 +- backend/management/services/mcp/permission.py | 16 + backend/management/services/mcp/service.py | 31 +- .../services/repository/agent/common.py | 1 + .../services/repository/agent/listing.py | 27 +- .../services/repository/agent/publishing.py | 136 +- .../services/repository/mcp/service.py | 189 ++- .../repository/review_notifications.py | 3 + .../management/services/repository/reviews.py | 637 ++++++++++ .../repository/skill_installations.py | 70 ++ backend/management/services/skill/service.py | 75 +- backend/services/skill_repository_service.py | 141 +-- .../v2.8.0_002_repository_review_metadata.sql | 11 + .../v2.8.0_003_repository_review_indexes.sql | 6 + ...v2.8.0_004_resource_review_submissions.sql | 116 ++ .../app/[locale]/agent-space/agent-space.tsx | 386 +----- .../agent-space/components/AgentCardTags.tsx | 107 +- .../components/AgentRepositoryCopyDialog.tsx | 739 +---------- .../AgentRepositoryReviewConfirmModal.tsx | 100 +- .../components/AgentUsageGuideModal.tsx | 260 +--- .../components/CreateNewAgentCard.tsx | 20 +- .../components/MineApplyListingModal.tsx | 444 +------ .../components/MineReviewStatusModal.tsx | 208 +--- .../agent-space/components/MyAgentCard.tsx | 323 +---- .../agent-space/components/MyAgentIcon.tsx | 46 +- .../components/RepositoryAgentIcon.tsx | 32 +- .../components/ReviewAgentList.tsx | 156 +-- .../app/[locale]/agent-space/my-agent.tsx | 796 +----------- .../[locale]/agent-space/review-center.tsx | 205 +-- .../components/agent-config-actions.tsx | 122 +- .../agents/components/agent-list-card.tsx | 1 + .../agents/components/agent-list-table.tsx | 1 + .../capability/SkillDetailModal.tsx | 10 +- .../components/capability/SkillDraftPanel.tsx | 45 +- .../app/[locale]/mcp-space/agent-space.tsx | 396 +----- .../components/AddMcpServiceCard.tsx | 19 +- .../McpRepositoryReviewConfirmModal.tsx | 95 +- .../mcp-space/components/McpServiceCard.tsx | 70 +- .../components/McpServiceDetailModal.tsx | 1099 +---------------- .../components/McpServicesFilterBar.tsx | 90 +- .../components/McpToolsPagination.tsx | 103 +- .../components/McpToolsSearchFilterBar.tsx | 139 +-- .../components/MineApplyListingModal.tsx | 155 +-- .../components/MineMcpReviewStatusModal.tsx | 185 +-- .../components/MineMcpServiceCard.tsx | 343 +---- .../components/PublishConfirmModal.tsx | 336 +---- .../components/PublishedServiceCard.tsx | 78 +- .../PublishedServiceDetailModal.tsx | 555 +-------- .../components/RepositoryMcpCard.tsx | 160 +-- .../components/add/AddMcpServiceModal.tsx | 75 +- .../AddMcpServiceCommunitySection.tsx | 71 +- .../add/community/CommunityQuickAddModal.tsx | 221 +--- .../add/community/McpCommunityCard.tsx | 89 +- .../add/community/McpCommunityCardList.tsx | 79 +- .../add/community/McpCommunityDetailModal.tsx | 398 +----- .../community/McpCommunityRequestError.tsx | 20 +- .../add/community/McpCommunityToolbar.tsx | 88 +- .../add/local/AddMcpServiceLocalSection.tsx | 762 +----------- .../components/shared/ContainerPortField.tsx | 76 +- .../components/shared/JsonPreviewModal.tsx | 39 +- .../components/shared/StatusBadge.tsx | 52 +- .../mcp-space/components/shared/TagEditor.tsx | 102 +- .../components/shared/TransportIcon.tsx | 116 +- frontend/app/[locale]/mcp-space/my-mcp.tsx | 834 +------------ .../app/[locale]/mcp-space/review-center.tsx | 298 +---- frontend/app/[locale]/resource-space/page.tsx | 9 + .../components/CreateNewSkillCard.tsx | 20 +- .../components/MineApplyListingModal.tsx | 102 +- .../skill-space/components/MineSkillsView.tsx | 589 +-------- .../skill-space/components/RepositoryView.tsx | 129 +- .../components/ReviewSkillList.tsx | 190 +-- .../components/SkillRepositoryCard.tsx | 135 +- .../components/SkillRepositoryControls.tsx | 161 +-- .../SkillRepositoryReviewConfirmModal.tsx | 97 +- .../components/SkillReviewStatusModal.tsx | 169 +-- .../components/skillRepositoryShared.ts | 94 +- .../app/[locale]/skill-space/my-skill.tsx | 335 +---- .../[locale]/skill-space/review-center.tsx | 182 +-- .../app/[locale]/skill-space/skill-space.tsx | 319 +---- frontend/components/agent/agent-detail.tsx | 355 +++++- frontend/components/common/StandardDrawer.tsx | 31 +- frontend/components/common/StandardTable.tsx | 4 +- .../components/mcp/McpContainerLogsModal.tsx | 151 ++- frontend/components/mcp/mcp-detail.tsx | 317 +++-- .../navigation/NotificationBell.tsx | 43 +- .../components/navigation/SideNavigation.tsx | 2 +- .../repository/RepositoryDisplayControls.tsx | 92 ++ .../repository/ResourcePublishedAt.tsx | 26 + .../repository/ResourceRepositoryPage.tsx | 238 ++++ .../repository/ResourceReviewCenter.tsx | 574 +++++++++ .../repository/ResourceReviewLink.tsx | 33 + .../repository/agent/agent-space.tsx | 650 ++++++++++ .../agent/components/AgentCardTags.tsx | 123 ++ .../components/AgentRepositoryCopyDialog.tsx | 743 +++++++++++ .../AgentRepositoryReviewConfirmModal.tsx | 101 ++ .../agent/components/AgentUsageGuideModal.tsx | 270 ++++ .../agent/components/CreateNewAgentCard.tsx | 21 + .../components/MineApplyListingModal.tsx | 448 +++++++ .../components/MineReviewStatusModal.tsx | 209 ++++ .../agent/components/MyAgentCard.tsx | 335 +++++ .../agent/components/MyAgentIcon.tsx | 47 + .../agent/components/RepositoryAgentIcon.tsx | 37 + .../agent/components/ReviewAgentList.tsx | 159 +++ .../components/repository/agent/my-agent.tsx | 816 ++++++++++++ .../repository/agent/review-center.tsx | 206 +++ .../components/repository/mcp/agent-space.tsx | 447 +++++++ .../mcp/components/AddMcpServiceCard.tsx | 19 + .../mcp/components/McpEditInspection.tsx | 254 ++++ .../McpRepositoryReviewConfirmModal.tsx | 98 ++ .../mcp/components/McpServiceCard.tsx | 73 ++ .../mcp/components/McpServiceDetailModal.tsx | 955 ++++++++++++++ .../mcp/components/McpServicesFilterBar.tsx | 90 ++ .../mcp/components/McpToolsPagination.tsx | 106 ++ .../components/McpToolsSearchFilterBar.tsx | 207 ++++ .../mcp/components/MineApplyListingModal.tsx | 156 +++ .../components/MineMcpReviewStatusModal.tsx | 193 +++ .../mcp/components/MineMcpServiceCard.tsx | 423 +++++++ .../mcp/components/PublishConfirmModal.tsx | 354 ++++++ .../mcp/components/PublishedServiceCard.tsx | 78 ++ .../PublishedServiceDetailModal.tsx | 606 +++++++++ .../mcp/components/RepositoryMcpCard.tsx | 298 +++++ .../mcp/components/add/AddMcpServiceModal.tsx | 72 ++ .../AddMcpServiceCommunitySection.tsx | 71 ++ .../add/community/CommunityQuickAddModal.tsx | 238 ++++ .../add/community/McpCommunityCard.tsx | 89 ++ .../add/community/McpCommunityCardList.tsx | 79 ++ .../add/community/McpCommunityDetailModal.tsx | 437 +++++++ .../community/McpCommunityRequestError.tsx | 20 + .../add/community/McpCommunityToolbar.tsx | 88 ++ .../add/local/AddMcpServiceLocalSection.tsx | 589 +++++++++ .../components/shared/ContainerPortField.tsx | 76 ++ .../components/shared/JsonPreviewModal.tsx | 38 + .../shared/McpGroupSelectionTable.tsx | 73 ++ .../mcp/components/shared/StatusBadge.tsx | 52 + .../mcp/components/shared/TagEditor.tsx | 103 ++ .../mcp/components/shared/TransportIcon.tsx | 115 ++ frontend/components/repository/mcp/my-mcp.tsx | 982 +++++++++++++++ .../repository/mcp/review-center.tsx | 301 +++++ .../components/repository/resource-space.css | 241 ++++ .../skill/components/CreateNewSkillCard.tsx | 21 + .../components/MineApplyListingModal.tsx | 104 ++ .../skill/components/MineSkillsView.tsx | 678 ++++++++++ .../skill/components/RepositoryView.tsx | 236 ++++ .../skill/components/ReviewSkillList.tsx | 193 +++ .../skill/components/SkillRepositoryCard.tsx | 176 +++ .../components/SkillRepositoryControls.tsx | 162 +++ .../SkillRepositoryReviewConfirmModal.tsx | 98 ++ .../components/SkillReviewStatusModal.tsx | 170 +++ .../skill/components/skillRepositoryShared.ts | 95 ++ .../components/repository/skill/my-skill.tsx | 372 ++++++ .../repository/skill/review-center.tsx | 183 +++ .../repository/skill/skill-space.tsx | 383 ++++++ .../resource/CreateResourceCard.tsx | 11 + frontend/components/resource/ResourceCard.tsx | 172 ++- .../components/resource/ResourceCardGrid.tsx | 488 +++++++- .../components/resource/ResourceDetail.tsx | 39 + frontend/components/skill/skill-detail.tsx | 73 +- .../components/tag/RepositoryTagFilter.tsx | 24 + frontend/components/tag/TagFilterPopover.tsx | 78 +- frontend/const/mcpTools.ts | 5 + frontend/const/resourceRepository.ts | 10 + frontend/hooks/agent/useAgentVersionDetail.ts | 20 +- .../useAgentRepositoryListings.ts | 16 +- .../useRepositoryAgentDetail.ts | 81 +- .../hooks/mcpTools/useMcpCommunityBrowser.ts | 95 +- .../resourceReview/useResourceReviews.ts | 60 + frontend/hooks/useRepositoryPresentation.ts | 18 + frontend/hooks/useRepositoryQueryState.ts | 84 ++ frontend/hooks/useRepositoryTagPredicates.ts | 56 + frontend/lib/myAgentDetail.ts | 88 +- frontend/lib/notificationMessage.ts | 14 +- frontend/lib/notificationNavigation.ts | 58 +- frontend/lib/resourceRepositoryRoutes.ts | 117 ++ frontend/public/locales/en/common.json | 118 +- frontend/public/locales/zh/common.json | 118 +- frontend/services/agentConfigService.ts | 2 + frontend/services/agentRepositoryService.ts | 37 +- frontend/services/agentVersionService.ts | 7 +- frontend/services/api.ts | 40 +- frontend/services/mcpService.ts | 3 + frontend/services/mcpToolsService.ts | 82 +- frontend/services/resourceReviewService.ts | 45 + frontend/services/skillRepositoryService.ts | 71 +- .../component/casRepositoryIdentity.test.tsx | 12 + .../component/mcpCommunityRecovery.test.tsx | 13 +- .../component/mcpMineBatchDelete.test.tsx | 206 +++ .../component/mcpUnpublishProtocol.test.tsx | 42 + .../myAgentOwnershipRemoval.test.tsx | 33 +- .../component/resourceRepositoryCard.test.tsx | 53 + .../resourceRepositoryDetail.test.tsx | 159 +++ .../resourceRepositoryRoutes.test.tsx | 115 ++ .../component/resourceReviewCache.test.tsx | 106 ++ .../resourceReviewSubmission.test.tsx | 105 ++ .../skillRepositoryInstallAction.test.tsx | 127 ++ frontend/types/agentConfig.ts | 4 + frontend/types/agentRepository.ts | 8 + frontend/types/mcpTools.ts | 39 + frontend/types/notification.ts | 11 +- frontend/types/resourceRepository.ts | 21 + frontend/types/resourceReview.ts | 79 ++ frontend/types/skillRepository.ts | 13 + .../RESOURCE-CENTER-ACCESS-D2-001/case.yaml | 99 ++ .../RESOURCE-CENTER-ACCESS-D4-001/case.yaml | 155 +++ .../RESOURCE-CENTER-ACCESS-D5-001/case.yaml | 96 ++ .../execution.yaml | 13 + .../RESOURCE-CENTER-ACCESS-D5-001/test.py | 14 + .../case.yaml | 90 ++ .../case.yaml | 126 ++ .../case.yaml | 106 ++ .../case.yaml | 109 ++ .../execution.yaml | 17 + .../RESOURCE-CENTER-LIFECYCLE-D5-001/test.py | 13 + .../case.yaml | 109 ++ .../execution.yaml | 17 + .../RESOURCE-CENTER-LIFECYCLE-D5-002/test.py | 13 + .../RESOURCE-CENTER-MANAGE-D2-001/case.yaml | 93 ++ .../RESOURCE-CENTER-MANAGE-D3-001/case.yaml | 94 ++ .../RESOURCE-CENTER-MANAGE-D4-001/case.yaml | 109 ++ .../RESOURCE-CENTER-MANAGE-D4-002/case.yaml | 94 ++ .../RESOURCE-CENTER-REVIEW-D2-001/case.yaml | 123 ++ .../execution.yaml | 19 + .../RESOURCE-CENTER-REVIEW-D2-001/test.py | 13 + .../RESOURCE-CENTER-REVIEW-D2-002/case.yaml | 131 ++ .../execution.yaml | 19 + .../RESOURCE-CENTER-REVIEW-D2-002/test.py | 13 + .../RESOURCE-CENTER-REVIEW-D3-001/case.yaml | 122 ++ .../RESOURCE-CENTER-REVIEW-D4-001/case.yaml | 109 ++ .../RESOURCE-CENTER-20261031.yaml | 93 ++ .../features/RESOURCE-CENTER-ACCESS/cases.md | 7 + .../RESOURCE-CENTER-ACCESS/feature.yaml | 23 + .../RESOURCE-CENTER-LIFECYCLE/cases.md | 9 + .../RESOURCE-CENTER-LIFECYCLE/feature.yaml | 29 + .../features/RESOURCE-CENTER-MANAGE/cases.md | 8 + .../RESOURCE-CENTER-MANAGE/feature.yaml | 29 + .../features/RESOURCE-CENTER-REVIEW/cases.md | 8 + .../RESOURCE-CENTER-REVIEW/feature.yaml | 27 + .../resource_center/access_scenario.py | 90 ++ .../resource_center/backend_scenarios.py | 455 +++++++ .../resource_center/migration_scenario.py | 148 +++ .../resource_center/reliability_scenario.py | 129 ++ .../automation/shared/resource_center.py | 30 + .../infra/config/resource-center.example.json | 6 + .../infra/config/resource-center.schema.json | 29 + test/backend/app/test_agent_app.py | 11 + test/backend/app/test_agent_repository_app.py | 21 +- test/backend/app/test_mcp_management_app.py | 45 +- test/backend/app/test_remote_mcp_app.py | 9 + test/backend/app/test_skill_repository_app.py | 8 + .../database/test_agent_repository_db.py | 9 + test/backend/database/test_market_mcp_db.py | 9 + test/backend/database/test_notification_db.py | 13 +- .../database/test_skill_repository_db.py | 6 + .../test_repository_review_contracts.py | 72 ++ .../test_repository_transactions.py | 103 ++ .../management/test_resource_review_api.py | 98 ++ .../test_resource_review_submissions.py | 238 ++++ .../services/test_agent_repository_service.py | 614 +++------ .../services/test_mcp_management_service.py | 268 ++-- .../services/test_mcp_publication_state.py | 56 + .../test_mcp_record_edit_permission.py | 24 + ...st_repository_mcp_pending_notifications.py | 53 + ...st_repository_same_status_authorization.py | 35 + .../test_repository_skill_upload_cleanup.py | 34 + .../services/test_skill_repository_service.py | 343 ++--- 288 files changed, 26304 insertions(+), 14996 deletions(-) create mode 100644 backend/contracts/review.py create mode 100644 backend/management/api/repository/reviews.py create mode 100644 backend/management/db/repository_review_fields.py create mode 100644 backend/management/db/repository_reviews.py create mode 100644 backend/management/db/repository_transactions.py create mode 100644 backend/management/db/resource_review_model.py create mode 100644 backend/management/services/mcp/permission.py create mode 100644 backend/management/services/repository/reviews.py create mode 100644 backend/management/services/repository/skill_installations.py create mode 100644 deploy/sql/migrations/v2.8.0_002_repository_review_metadata.sql create mode 100644 deploy/sql/migrations/v2.8.0_003_repository_review_indexes.sql create mode 100644 deploy/sql/migrations/v2.8.0_004_resource_review_submissions.sql create mode 100644 frontend/app/[locale]/resource-space/page.tsx create mode 100644 frontend/components/repository/RepositoryDisplayControls.tsx create mode 100644 frontend/components/repository/ResourcePublishedAt.tsx create mode 100644 frontend/components/repository/ResourceRepositoryPage.tsx create mode 100644 frontend/components/repository/ResourceReviewCenter.tsx create mode 100644 frontend/components/repository/ResourceReviewLink.tsx create mode 100644 frontend/components/repository/agent/agent-space.tsx create mode 100644 frontend/components/repository/agent/components/AgentCardTags.tsx create mode 100644 frontend/components/repository/agent/components/AgentRepositoryCopyDialog.tsx create mode 100644 frontend/components/repository/agent/components/AgentRepositoryReviewConfirmModal.tsx create mode 100644 frontend/components/repository/agent/components/AgentUsageGuideModal.tsx create mode 100644 frontend/components/repository/agent/components/CreateNewAgentCard.tsx create mode 100644 frontend/components/repository/agent/components/MineApplyListingModal.tsx create mode 100644 frontend/components/repository/agent/components/MineReviewStatusModal.tsx create mode 100644 frontend/components/repository/agent/components/MyAgentCard.tsx create mode 100644 frontend/components/repository/agent/components/MyAgentIcon.tsx create mode 100644 frontend/components/repository/agent/components/RepositoryAgentIcon.tsx create mode 100644 frontend/components/repository/agent/components/ReviewAgentList.tsx create mode 100644 frontend/components/repository/agent/my-agent.tsx create mode 100644 frontend/components/repository/agent/review-center.tsx create mode 100644 frontend/components/repository/mcp/agent-space.tsx create mode 100644 frontend/components/repository/mcp/components/AddMcpServiceCard.tsx create mode 100644 frontend/components/repository/mcp/components/McpEditInspection.tsx create mode 100644 frontend/components/repository/mcp/components/McpRepositoryReviewConfirmModal.tsx create mode 100644 frontend/components/repository/mcp/components/McpServiceCard.tsx create mode 100644 frontend/components/repository/mcp/components/McpServiceDetailModal.tsx create mode 100644 frontend/components/repository/mcp/components/McpServicesFilterBar.tsx create mode 100644 frontend/components/repository/mcp/components/McpToolsPagination.tsx create mode 100644 frontend/components/repository/mcp/components/McpToolsSearchFilterBar.tsx create mode 100644 frontend/components/repository/mcp/components/MineApplyListingModal.tsx create mode 100644 frontend/components/repository/mcp/components/MineMcpReviewStatusModal.tsx create mode 100644 frontend/components/repository/mcp/components/MineMcpServiceCard.tsx create mode 100644 frontend/components/repository/mcp/components/PublishConfirmModal.tsx create mode 100644 frontend/components/repository/mcp/components/PublishedServiceCard.tsx create mode 100644 frontend/components/repository/mcp/components/PublishedServiceDetailModal.tsx create mode 100644 frontend/components/repository/mcp/components/RepositoryMcpCard.tsx create mode 100644 frontend/components/repository/mcp/components/add/AddMcpServiceModal.tsx create mode 100644 frontend/components/repository/mcp/components/add/community/AddMcpServiceCommunitySection.tsx create mode 100644 frontend/components/repository/mcp/components/add/community/CommunityQuickAddModal.tsx create mode 100644 frontend/components/repository/mcp/components/add/community/McpCommunityCard.tsx create mode 100644 frontend/components/repository/mcp/components/add/community/McpCommunityCardList.tsx create mode 100644 frontend/components/repository/mcp/components/add/community/McpCommunityDetailModal.tsx create mode 100644 frontend/components/repository/mcp/components/add/community/McpCommunityRequestError.tsx create mode 100644 frontend/components/repository/mcp/components/add/community/McpCommunityToolbar.tsx create mode 100644 frontend/components/repository/mcp/components/add/local/AddMcpServiceLocalSection.tsx create mode 100644 frontend/components/repository/mcp/components/shared/ContainerPortField.tsx create mode 100644 frontend/components/repository/mcp/components/shared/JsonPreviewModal.tsx create mode 100644 frontend/components/repository/mcp/components/shared/McpGroupSelectionTable.tsx create mode 100644 frontend/components/repository/mcp/components/shared/StatusBadge.tsx create mode 100644 frontend/components/repository/mcp/components/shared/TagEditor.tsx create mode 100644 frontend/components/repository/mcp/components/shared/TransportIcon.tsx create mode 100644 frontend/components/repository/mcp/my-mcp.tsx create mode 100644 frontend/components/repository/mcp/review-center.tsx create mode 100644 frontend/components/repository/resource-space.css create mode 100644 frontend/components/repository/skill/components/CreateNewSkillCard.tsx create mode 100644 frontend/components/repository/skill/components/MineApplyListingModal.tsx create mode 100644 frontend/components/repository/skill/components/MineSkillsView.tsx create mode 100644 frontend/components/repository/skill/components/RepositoryView.tsx create mode 100644 frontend/components/repository/skill/components/ReviewSkillList.tsx create mode 100644 frontend/components/repository/skill/components/SkillRepositoryCard.tsx create mode 100644 frontend/components/repository/skill/components/SkillRepositoryControls.tsx create mode 100644 frontend/components/repository/skill/components/SkillRepositoryReviewConfirmModal.tsx create mode 100644 frontend/components/repository/skill/components/SkillReviewStatusModal.tsx create mode 100644 frontend/components/repository/skill/components/skillRepositoryShared.ts create mode 100644 frontend/components/repository/skill/my-skill.tsx create mode 100644 frontend/components/repository/skill/review-center.tsx create mode 100644 frontend/components/repository/skill/skill-space.tsx create mode 100644 frontend/const/resourceRepository.ts create mode 100644 frontend/hooks/resourceReview/useResourceReviews.ts create mode 100644 frontend/hooks/useRepositoryPresentation.ts create mode 100644 frontend/hooks/useRepositoryQueryState.ts create mode 100644 frontend/hooks/useRepositoryTagPredicates.ts create mode 100644 frontend/lib/resourceRepositoryRoutes.ts create mode 100644 frontend/services/resourceReviewService.ts create mode 100644 frontend/tests/component/mcpMineBatchDelete.test.tsx create mode 100644 frontend/tests/component/mcpUnpublishProtocol.test.tsx create mode 100644 frontend/tests/component/resourceRepositoryCard.test.tsx create mode 100644 frontend/tests/component/resourceRepositoryDetail.test.tsx create mode 100644 frontend/tests/component/resourceRepositoryRoutes.test.tsx create mode 100644 frontend/tests/component/resourceReviewCache.test.tsx create mode 100644 frontend/tests/component/resourceReviewSubmission.test.tsx create mode 100644 frontend/tests/component/skillRepositoryInstallAction.test.tsx create mode 100644 frontend/types/resourceRepository.ts create mode 100644 frontend/types/resourceReview.ts create mode 100644 test-e2e/cases/RESOURCE-CENTER-ACCESS-D2-001/case.yaml create mode 100644 test-e2e/cases/RESOURCE-CENTER-ACCESS-D4-001/case.yaml create mode 100644 test-e2e/cases/RESOURCE-CENTER-ACCESS-D5-001/case.yaml create mode 100644 test-e2e/cases/RESOURCE-CENTER-ACCESS-D5-001/execution.yaml create mode 100644 test-e2e/cases/RESOURCE-CENTER-ACCESS-D5-001/test.py create mode 100644 test-e2e/cases/RESOURCE-CENTER-LIFECYCLE-D2-001/case.yaml create mode 100644 test-e2e/cases/RESOURCE-CENTER-LIFECYCLE-D3-001/case.yaml create mode 100644 test-e2e/cases/RESOURCE-CENTER-LIFECYCLE-D3-002/case.yaml create mode 100644 test-e2e/cases/RESOURCE-CENTER-LIFECYCLE-D5-001/case.yaml create mode 100644 test-e2e/cases/RESOURCE-CENTER-LIFECYCLE-D5-001/execution.yaml create mode 100644 test-e2e/cases/RESOURCE-CENTER-LIFECYCLE-D5-001/test.py create mode 100644 test-e2e/cases/RESOURCE-CENTER-LIFECYCLE-D5-002/case.yaml create mode 100644 test-e2e/cases/RESOURCE-CENTER-LIFECYCLE-D5-002/execution.yaml create mode 100644 test-e2e/cases/RESOURCE-CENTER-LIFECYCLE-D5-002/test.py create mode 100644 test-e2e/cases/RESOURCE-CENTER-MANAGE-D2-001/case.yaml create mode 100644 test-e2e/cases/RESOURCE-CENTER-MANAGE-D3-001/case.yaml create mode 100644 test-e2e/cases/RESOURCE-CENTER-MANAGE-D4-001/case.yaml create mode 100644 test-e2e/cases/RESOURCE-CENTER-MANAGE-D4-002/case.yaml create mode 100644 test-e2e/cases/RESOURCE-CENTER-REVIEW-D2-001/case.yaml create mode 100644 test-e2e/cases/RESOURCE-CENTER-REVIEW-D2-001/execution.yaml create mode 100644 test-e2e/cases/RESOURCE-CENTER-REVIEW-D2-001/test.py create mode 100644 test-e2e/cases/RESOURCE-CENTER-REVIEW-D2-002/case.yaml create mode 100644 test-e2e/cases/RESOURCE-CENTER-REVIEW-D2-002/execution.yaml create mode 100644 test-e2e/cases/RESOURCE-CENTER-REVIEW-D2-002/test.py create mode 100644 test-e2e/cases/RESOURCE-CENTER-REVIEW-D3-001/case.yaml create mode 100644 test-e2e/cases/RESOURCE-CENTER-REVIEW-D4-001/case.yaml create mode 100644 test-e2e/changes/requirements/RESOURCE-CENTER-20261031.yaml create mode 100644 test-e2e/features/RESOURCE-CENTER-ACCESS/cases.md create mode 100644 test-e2e/features/RESOURCE-CENTER-ACCESS/feature.yaml create mode 100644 test-e2e/features/RESOURCE-CENTER-LIFECYCLE/cases.md create mode 100644 test-e2e/features/RESOURCE-CENTER-LIFECYCLE/feature.yaml create mode 100644 test-e2e/features/RESOURCE-CENTER-MANAGE/cases.md create mode 100644 test-e2e/features/RESOURCE-CENTER-MANAGE/feature.yaml create mode 100644 test-e2e/features/RESOURCE-CENTER-REVIEW/cases.md create mode 100644 test-e2e/features/RESOURCE-CENTER-REVIEW/feature.yaml create mode 100644 test-e2e/infra/automation/resource_center/access_scenario.py create mode 100644 test-e2e/infra/automation/resource_center/backend_scenarios.py create mode 100644 test-e2e/infra/automation/resource_center/migration_scenario.py create mode 100644 test-e2e/infra/automation/resource_center/reliability_scenario.py create mode 100644 test-e2e/infra/automation/shared/resource_center.py create mode 100644 test-e2e/infra/config/resource-center.example.json create mode 100644 test-e2e/infra/config/resource-center.schema.json create mode 100644 test/backend/management/test_repository_review_contracts.py create mode 100644 test/backend/management/test_repository_transactions.py create mode 100644 test/backend/management/test_resource_review_api.py create mode 100644 test/backend/management/test_resource_review_submissions.py create mode 100644 test/backend/services/test_mcp_publication_state.py create mode 100644 test/backend/services/test_mcp_record_edit_permission.py create mode 100644 test/backend/services/test_repository_mcp_pending_notifications.py create mode 100644 test/backend/services/test_repository_same_status_authorization.py create mode 100644 test/backend/services/test_repository_skill_upload_cleanup.py diff --git a/backend/contracts/constants/constants.py b/backend/contracts/constants/constants.py index 765f4d54ab..5ccc7d6ed6 100644 --- a/backend/contracts/constants/constants.py +++ b/backend/contracts/constants/constants.py @@ -264,7 +264,8 @@ RESOURCE_TYPE_AGENT_REPOSITORY = 'agent_repository' RESOURCE_TYPE_SKILL_REPOSITORY = 'skill_repository' RESOURCE_TYPE_MCP_REPOSITORY = 'mcp_repository' -VALID_RESOURCE_TYPES = frozenset({RESOURCE_TYPE_AGENT_REPOSITORY, RESOURCE_TYPE_SKILL_REPOSITORY, RESOURCE_TYPE_MCP_REPOSITORY}) +RESOURCE_TYPE_RESOURCE_REVIEW = 'resource_review' +VALID_RESOURCE_TYPES = frozenset({RESOURCE_TYPE_AGENT_REPOSITORY, RESOURCE_TYPE_SKILL_REPOSITORY, RESOURCE_TYPE_MCP_REPOSITORY, RESOURCE_TYPE_RESOURCE_REVIEW}) # Event types (drive title copy / icon) EVENT_TYPE_REPOSITORY_REVIEW_APPROVED = 'repository_review_approved' EVENT_TYPE_REPOSITORY_REVIEW_REJECTED = 'repository_review_rejected' diff --git a/backend/contracts/errors/exceptions.py b/backend/contracts/errors/exceptions.py index 66010f2f6b..944bcec7e4 100644 --- a/backend/contracts/errors/exceptions.py +++ b/backend/contracts/errors/exceptions.py @@ -622,3 +622,7 @@ class HistorySummaryPersistenceError(ValueError): class StorageObjectConflictError(ValueError): """Raised when an object identity is already bound to different accounting data.""" + + +class ReviewConflictError(Exception): + """The submitted review state or revision is no longer current.""" diff --git a/backend/contracts/review.py b/backend/contracts/review.py new file mode 100644 index 0000000000..4320e9793b --- /dev/null +++ b/backend/contracts/review.py @@ -0,0 +1,129 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Contracts for the unified repository review API, independent of legacy APIs.""" + +from datetime import datetime +from typing import Annotated, Literal + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + computed_field, + field_validator, +) + +ResourceType = Literal["agent", "skill", "mcp"] +ReviewStatus = Literal["pending_review", "approved", "rejected"] +RepositoryId = Annotated[str, Field(pattern=r"^[1-9][0-9]{0,18}$")] + + +class ReviewListQuery(BaseModel): + """Use one page across all authorized resource branches.""" + + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + resource_type: Literal["all", "agent", "skill", "mcp"] = Field("all", alias="type") + status: Literal["all", "pending_review", "approved", "rejected"] = "all" + search: str = "" + page: int = Field(1, ge=1) + page_size: int = Field(10, ge=1, le=100) + + @field_validator("search") + @classmethod + def trim_search(cls, value: str) -> str: + return value.strip() + + +class ReviewDecisionRequest(BaseModel): + """Never accept a client-selected user, tenant, role or query scope.""" + + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + decision: Literal["approve", "reject"] + reason: str | None = None + + @field_validator("reason") + @classmethod + def normalize_reason(cls, value: str | None) -> str | None: + if value is None: + return None + value = value.strip() + if len(value) > 1000: + raise ValueError( + "Review reason must contain at most 1000 Unicode code points" + ) + return value or None + + +class ReviewSubmitter(BaseModel): + """Display identity must not be substituted with the resource author.""" + + user_id: str | None = None + display_name: str | None = None + cas_username: str | None = None + + +class ReviewListItem(BaseModel): + """Public projection without archives, credentials or mutable source config.""" + + resource_type: ResourceType + review_id: RepositoryId + source_resource_id: RepositoryId + version_no: int | None = None + repository_id: RepositoryId | None = None + tenant_id: str + tenant_name: str | None = None + name: str + description: str | None = None + version_label: str | None = None + status: ReviewStatus + submitter: ReviewSubmitter + submitted_user_id: str | None = None + submitted_at: datetime | None = None + published_at: datetime | None = None + submit_description: str | None = None + review_comment: str | None = None + reviewed_by: str | None = None + reviewed_at: datetime | None = None + current_repository_status: str | None = None + allowed_actions: list[Literal["approve", "reject"]] = Field(default_factory=list) + + @field_validator("review_id", "source_resource_id", "repository_id") + @classmethod + def check_repository_id(cls, value: str) -> str: + if value is not None and int(value) > 9223372036854775807: + raise ValueError("Repository ID exceeds the signed BIGINT range") + return value + + +class ReviewPagination(BaseModel): + page: int = Field(ge=1) + page_size: int = Field(ge=1, le=100) + total: int = Field(ge=0) + + @computed_field + @property + def total_pages(self) -> int: + return (self.total + self.page_size - 1) // self.page_size + + +class ReviewListResponse(BaseModel): + items: list[ReviewListItem] + pagination: ReviewPagination + + +class ReviewSubmissionRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + resource_type: ResourceType + source_resource_id: RepositoryId + version_no: int | None = Field(None, ge=0) + submit_description: str | None = None + listing_fields: dict = Field(default_factory=dict) + + @field_validator("source_resource_id") + @classmethod + def validate_source_id(cls, value): + if int(value) > 2147483647: + raise ValueError("Source ID exceeds supported local identifier range") + return value diff --git a/backend/contracts/tool.py b/backend/contracts/tool.py index bbf9492339..a263fd8dd6 100644 --- a/backend/contracts/tool.py +++ b/backend/contracts/tool.py @@ -6,6 +6,15 @@ from pydantic import BaseModel, Field, field_validator +class McpDeleteTarget(BaseModel): + kind: Literal["local", "community"] + id: int = Field(gt=0, strict=True) + + +class McpBatchDeleteRequest(BaseModel): + items: list[McpDeleteTarget] = Field(min_length=1, max_length=100) + + class AgentToolParamsRequest(BaseModel): """Request-scoped tool parameter overrides for a single agent.""" @@ -324,6 +333,7 @@ class CommunityListRequest(BaseModel): None, description="Filter by transport: url or container" ) cursor: Optional[str] = Field(None, description="Pagination cursor") + sort_by_published_time: bool = False page: Optional[int] = Field(None, ge=1, description="Offset pagination page") limit: int = Field(default=30, ge=1, le=100, description="Items per page") diff --git a/backend/management/api/agent/agent.py b/backend/management/api/agent/agent.py index 2106694404..45bb9f3a70 100644 --- a/backend/management/api/agent/agent.py +++ b/backend/management/api/agent/agent.py @@ -683,7 +683,11 @@ async def list_agent_page_api( kwargs[key] = [TagAssignmentFilter.model_validate(item) for item in parsed] if additional_tenant_id: kwargs["additional_tenant_id"] = additional_tenant_id - return await list_agent_page_impl(**kwargs) + result = await list_agent_page_impl(**kwargs) + if include_repository_info: + from management.services.repository.reviews import attach_latest_reviews + attach_latest_reviews(result["items"], "agent", "agent_id", user_id, resolved_tenant_id) + return result except ValueError as error: raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(error)) from error except Exception as error: diff --git a/backend/management/api/agent/agent_repository.py b/backend/management/api/agent/agent_repository.py index ff3affd56d..d4d0b071e3 100644 --- a/backend/management/api/agent/agent_repository.py +++ b/backend/management/api/agent/agent_repository.py @@ -171,11 +171,13 @@ async def list_agent_repository_listings_api( ), tag: Optional[str] = Query(None, description="Filter by an exact tag"), sort_by_update_time: bool = Query(False, description="Sort by repository publish time descending"), + sort_by_published_time: bool = Query(False, description="Sort by actual approval time descending"), + sort_by_downloads: bool = Query(False, description="Sort by cumulative agent copies descending"), authorization: str = Header(None), ): """List all marketplace repository listings with optional status filter.""" try: - _, tenant_id = get_current_user_id(authorization) + user_id, tenant_id = get_current_user_id(authorization) filters = { "status": status, "agent_id": agent_id, @@ -183,7 +185,12 @@ async def list_agent_repository_listings_api( "page_size": page_size, "search": search, "sort_by_update_time": sort_by_update_time, + "user_id": user_id, } + if sort_by_published_time: + filters["sort_by_published_time"] = True + if sort_by_downloads: + filters["sort_by_downloads"] = True search_predicates = _parse_tag_predicates(search_tag_predicates) if search_predicates: filters["search_tag_predicates"] = search_predicates @@ -263,6 +270,8 @@ async def list_my_editable_agents_api( if search_predicates: filters["search_tag_predicates"] = search_predicates result = await list_my_editable_agents_impl(**filters) + from management.services.repository.reviews import attach_latest_reviews + attach_latest_reviews(result["items"], "agent", "agent_id", user_id, tenant_id) return JSONResponse(status_code=HTTPStatus.OK, content=result) except UnauthorizedError as e: logger.warning(f"Unauthorized my editable agents access attempt: {str(e)}") diff --git a/backend/management/api/application.py b/backend/management/api/application.py index bac3de3c82..24838342db 100644 --- a/backend/management/api/application.py +++ b/backend/management/api/application.py @@ -6,6 +6,7 @@ from contextlib import asynccontextmanager from management.utils.app_factory import create_app +from management.api.repository.reviews import router as repository_reviews_router from management.api.agent.agent import agent_config_router as agent_router from management.api.agent.agent_repository import agent_repository_router from management.api.skill.skill_repository import skill_repository_router @@ -144,6 +145,7 @@ async def thread_capacity(): return config_thread_manager.snapshot() +app.include_router(repository_reviews_router) app.include_router(model_manager_router) app.include_router(config_sync_router) app.include_router(agent_router) diff --git a/backend/management/api/mcp/mcp_management.py b/backend/management/api/mcp/mcp_management.py index 84a5bb1489..1f5e4ee962 100644 --- a/backend/management/api/mcp/mcp_management.py +++ b/backend/management/api/mcp/mcp_management.py @@ -23,6 +23,9 @@ delete_community_mcp_service, ) from management.db.mcp.market import increment_mcp_market_download_count +from contracts.errors.exceptions import ForbiddenError +from contracts.tool import McpBatchDeleteRequest +from management.services.mcp.service import delete_mcp_service from management.services.monitor.audit import record_security_event from gateway.authentication.auth import get_current_user_info @@ -53,6 +56,8 @@ async def list_community_mcp_services_api( cursor=query.cursor, limit=query.limit, ) + if query.sort_by_published_time: + list_kwargs["sort_by_published_time"] = True if query.page is not None: list_kwargs["page"] = query.page data = await list_community_mcp_services(**list_kwargs) @@ -183,7 +188,7 @@ async def publish_community_mcp_service_api( ) return JSONResponse( status_code=HTTPStatus.OK, - content={"status": "success", "data": {"market_id": market_id}}, + content={"status": "success", "data": market_id}, ) except McpNotFoundError as exc: raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(exc)) @@ -247,6 +252,44 @@ async def update_community_mcp_service_legacy_api( ) +@router.post("/batch_delete") +async def batch_delete_mcp_services_api( + payload: McpBatchDeleteRequest, + authorization: Optional[str] = Header(None), + http_request: Request = None, +): + """Delete selected local services and community listings independently.""" + try: + user_id, tenant_id, _ = get_current_user_info(authorization, http_request) + except UnauthorizedError as exc: + raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(exc)) from exc + + results = [] + for kind, target_id in dict.fromkeys((item.kind, item.id) for item in payload.items): + result = {"kind": kind, "id": target_id} + try: + if kind == "local": + await delete_mcp_service(tenant_id=tenant_id, user_id=user_id, mcp_id=target_id) + else: + await delete_community_mcp_service(tenant_id=tenant_id, user_id=user_id, market_id=target_id) + except (McpNotFoundError, ForbiddenError, UnauthorizedError) as exc: + results.append({**result, "success": False, "message": str(exc)}) + except Exception: + logger.exception("Failed to batch delete MCP %s %s", kind, target_id) + results.append({**result, "success": False, "message": "Failed to delete MCP service"}) + else: + record_security_event( + "mcp_delete" if kind == "local" else "mcp_market_delete", + request=http_request, + user_id=user_id, + tenant_id=tenant_id, + details={"mcp_id" if kind == "local" else "market_id": target_id}, + ) + results.append({**result, "success": True}) + + return JSONResponse(status_code=HTTPStatus.OK, content={"status": "success", "data": results}) + + @router.delete("/community/delete") async def delete_community_mcp_service_legacy_api( market_id: int = Query(gt=0), @@ -371,11 +414,11 @@ async def create_community_mcp_service_api( request=http_request, user_id=user_id, tenant_id=tenant_id, - details={"market_id": market_id, "mcp_id": payload.mcp_id, "mcp_server": payload.mcp_server}, + details={"review_id": market_id.get("review_id"), "mcp_id": payload.mcp_id}, ) return JSONResponse( status_code=HTTPStatus.OK, - content={"status": "success", "data": {"market_id": market_id}}, + content={"status": "success", "data": market_id}, ) except McpNotFoundError as exc: raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(exc)) diff --git a/backend/management/api/mcp/remote_mcp.py b/backend/management/api/mcp/remote_mcp.py index 9639e301de..251cf91912 100644 --- a/backend/management/api/mcp/remote_mcp.py +++ b/backend/management/api/mcp/remote_mcp.py @@ -1,6 +1,4 @@ # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -from contracts.errors.exceptions import AppException, MCPConnectionError, MCPNameIllegal, MCPContainerError, McpNotFoundError, McpValidationError, McpNameConflictError, McpPortConflictError, UnauthorizedError, TenantResourceLimitError -from contracts.tool import MCPConfigRequest, AddMcpServiceRequest, AddContainerMcpServiceRequest, UpdateMcpServiceRequest, EnableMcpServiceRequest, DisableMcpServiceRequest, HealthcheckMcpServiceRequest, TestMcpConnectionRequest, ListMcpServicesQuery import logging import json import asyncio @@ -12,7 +10,7 @@ from contracts.constants.environment import ENABLE_UPLOAD_IMAGE from contracts.errors.codes import ErrorCode -from contracts.errors.exceptions import AppException, MCPConnectionError, MCPNameIllegal, MCPContainerError, McpNotFoundError, McpValidationError, McpNameConflictError, McpPortConflictError, UnauthorizedError, TenantResourceLimitError +from contracts.errors.exceptions import AppException, MCPConnectionError, MCPNameIllegal, MCPContainerError, McpNotFoundError, McpValidationError, McpNameConflictError, McpPortConflictError, UnauthorizedError, TenantResourceLimitError, ForbiddenError from contracts.tool import MCPConfigRequest, AddMcpServiceRequest, AddContainerMcpServiceRequest, UpdateMcpServiceRequest, EnableMcpServiceRequest, DisableMcpServiceRequest, HealthcheckMcpServiceRequest, TestMcpConnectionRequest, ListMcpServicesQuery from management.services.mcp.service import get_remote_mcp_server_list, check_mcp_health_and_update_db, delete_mcp_by_container_id, upload_and_start_mcp_image, update_remote_mcp_server_list, attach_mcp_container_permissions, get_mcp_record_by_id, list_mcp_service_tools_by_id, refresh_mcp_service_tool_count, add_container_mcp_service, update_mcp_service, update_mcp_service_enabled, delete_mcp_service, check_mcp_service_health, test_mcp_connection, check_container_port_conflict, suggest_container_port from management.services.mcp.registration import add_mcp_service @@ -435,6 +433,8 @@ async def delete_mcp_by_id( ) except McpNotFoundError as e: raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e)) + except ForbiddenError as e: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=str(e)) except Exception as e: logger.error(f"Failed to delete MCP service: {e}") raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Failed to delete MCP service") @@ -514,9 +514,12 @@ async def get_mcp_list( effective_tenant_id = tenant_id or auth_tenant_id remote_mcp_list = await get_remote_mcp_server_list( - tenant_id=effective_tenant_id, user_id=user_id, is_need_auth=True + tenant_id=effective_tenant_id, user_id=user_id, is_need_auth=True, include_author=True ) + from management.services.repository.reviews import attach_latest_reviews + attach_latest_reviews(remote_mcp_list, "mcp", "mcp_id", user_id, effective_tenant_id) + return JSONResponse( status_code=HTTPStatus.OK, content={ diff --git a/backend/management/api/repository/reviews.py b/backend/management/api/repository/reviews.py new file mode 100644 index 0000000000..a841efed6c --- /dev/null +++ b/backend/management/api/repository/reviews.py @@ -0,0 +1,176 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Unified review HTTP boundary; legacy repository routes remain available.""" + +from typing import Annotated + +from contracts.errors.exceptions import ReviewConflictError, UnauthorizedError +from contracts.review import ( + ResourceType, + ReviewDecisionRequest, + ReviewListQuery, + ReviewSubmissionRequest, +) +from fastapi import APIRouter, Header, HTTPException, Path, Request +from fastapi.exceptions import RequestValidationError +from fastapi.routing import APIRoute +from gateway.authentication.auth import get_current_user_context +from management.services.repository.reviews import ( + decide_repository_review, + get_repository_review, + list_repository_reviews, + submit_repository_review, +) +from pydantic import ValidationError + + +class ReviewRoute(APIRoute): + def get_route_handler(self): + original = super().get_route_handler() + + async def handler(request): + try: + return await original(request) + except RequestValidationError as exc: + raise HTTPException( + status_code=400, + detail={ + "code": "INVALID_REVIEW_REQUEST", + "message": "Invalid review request", + }, + ) from exc + + return handler + + +router = APIRouter(prefix="/reviews", route_class=ReviewRoute) +ReviewId = Annotated[int, Path(ge=1, le=9223372036854775807)] + + +def identity(authorization): + try: + return get_current_user_context(authorization) + except UnauthorizedError as exc: + raise HTTPException( + status_code=401, + detail={ + "code": "AUTHENTICATION_REQUIRED", + "message": "Authentication required", + }, + ) from exc + + +def invoke(operation, *args): + try: + return operation(*args) + except UnauthorizedError as exc: + raise HTTPException( + status_code=403, detail={"code": "REVIEW_FORBIDDEN", "message": str(exc)} + ) from exc + except LookupError as exc: + raise HTTPException( + status_code=404, + detail={"code": "REVIEW_NOT_FOUND", "message": "Review record not found"}, + ) from exc + except ReviewConflictError as exc: + raise HTTPException( + status_code=409, detail={"code": str(exc), "message": str(exc)} + ) from exc + + +@router.get("") +def list_reviews_api(request: Request, authorization: str | None = Header(None)): + user_id, tenant_id, role = identity(authorization) + try: + query = ReviewListQuery.model_validate(dict(request.query_params)) + except ValidationError as exc: + raise HTTPException( + status_code=400, + detail={ + "code": "INVALID_REVIEW_REQUEST", + "message": "Invalid review query", + }, + ) from exc + return invoke(list_repository_reviews, user_id, tenant_id, role, query) + + +@router.post("") +async def submit_review_api( + payload: ReviewSubmissionRequest, authorization: str | None = Header(None) +): + user_id, tenant_id, role = identity(authorization) + try: + return await submit_repository_review(user_id, tenant_id, role, payload) + except UnauthorizedError as exc: + raise HTTPException( + status_code=403, detail={"code": "REVIEW_FORBIDDEN", "message": str(exc)} + ) from exc + except ReviewConflictError as exc: + raise HTTPException( + status_code=409, + detail={"code": "PENDING_REVIEW_EXISTS", "message": str(exc)}, + ) from exc + except ValueError as exc: + raise HTTPException( + status_code=400, + detail={"code": "INVALID_REVIEW_REQUEST", "message": str(exc)}, + ) from exc + + +@router.get("/{review_id}") +def get_review_api(review_id: ReviewId, authorization: str | None = Header(None)): + user_id, tenant_id, role = identity(authorization) + return invoke(get_repository_review, review_id, user_id, tenant_id, role) + + +@router.post("/{review_id}/decision") +def decide_review_api( + review_id: ReviewId, + payload: ReviewDecisionRequest, + authorization: str | None = Header(None), +): + user_id, tenant_id, role = identity(authorization) + return invoke( + decide_repository_review, review_id, user_id, tenant_id, role, payload + ) + + +@router.post("/{resource_type}/{repository_id}/decision") +def legacy_decide_review_api( + resource_type: ResourceType, + repository_id: ReviewId, + payload: ReviewDecisionRequest, + authorization: str | None = Header(None), +): + from management.services.repository.reviews import decide_legacy_review + + user_id, tenant_id, role = identity(authorization) + return invoke( + decide_legacy_review, + resource_type, + repository_id, + user_id, + tenant_id, + role, + "shared" if payload.decision == "approve" else "rejected", + payload.reason, + ) + + +@router.get("/{resource_type}/{repository_id}") +def legacy_get_review_api( + resource_type: ResourceType, + repository_id: ReviewId, + authorization: str | None = Header(None), +): + from management.services.repository.reviews import get_legacy_repository_review + + user_id, tenant_id, role = identity(authorization) + return invoke( + get_legacy_repository_review, + resource_type, + repository_id, + user_id, + tenant_id, + role, + ) diff --git a/backend/management/api/skill/skill_repository.py b/backend/management/api/skill/skill_repository.py index 6e0c81de42..96d79aee8c 100644 --- a/backend/management/api/skill/skill_repository.py +++ b/backend/management/api/skill/skill_repository.py @@ -8,6 +8,7 @@ from contracts.skill import SkillRepositoryInstallRequest, SkillRepositoryListingCreateRequest from contracts.tag import TagAssignmentFilter from fastapi import APIRouter, Body, Header, HTTPException, Query +from contracts.errors.exceptions import SkillException from services.skill_repository_service import ( count_my_editable_skills_impl, create_skill_repository_listing_impl, @@ -55,6 +56,8 @@ async def list_skill_repository_listings_api( ), tag: Optional[str] = Query(None, description="Filter by an exact tag"), sort_by_update_time: bool = Query(False, description="Sort by repository update time descending"), + include_installations: bool = Query(False, description="Include visible installed copies"), + sort_by_published_time: bool = Query(False, description="Sort by actual approval time descending"), authorization: str = Header(None), ): """List all skill marketplace repository listings with optional filters.""" @@ -71,6 +74,10 @@ async def list_skill_repository_listings_api( "search": search, "sort_by_update_time": sort_by_update_time, } + if sort_by_published_time: + filters["sort_by_published_time"] = True + if include_installations: + filters["include_installations"] = True predicates = _parse_tag_predicates(tag_predicates) if predicates: filters["tag_predicates"] = predicates @@ -151,6 +158,8 @@ async def list_my_editable_skills_api( if predicates: filters["tag_predicates"] = predicates result = list_my_editable_skills_impl(**filters) + from management.services.repository.reviews import attach_latest_reviews + attach_latest_reviews(result["items"], "skill", "skill_id", user_id, tenant_id) return JSONResponse(status_code=HTTPStatus.OK, content=result) except UnauthorizedError as e: logger.warning(f"Unauthorized my editable skills access attempt: {str(e)}") @@ -312,3 +321,37 @@ async def install_skill_from_repository_api( "duplicate_skills": e.duplicate_names, }, ) + + +@skill_repository_router.delete("/{skill_repository_id}/installations/{skill_id}") +def uninstall_repository_copy_api(skill_repository_id: int, skill_id: int, authorization: str = Header(None)): + from management.services.repository.skill_installations import uninstall_repository_copy + + user_id, tenant_id = get_current_user_id(authorization) + try: + ensure_skill_repository_access(user_id) + uninstall_repository_copy(skill_repository_id, skill_id, tenant_id, user_id) + return {"success": True} + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except (ForbiddenError, UnauthorizedError) as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + except (ValueError, SkillException) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@skill_repository_router.delete("/local/{skill_id}") +def delete_local_skill_api(skill_id: int, authorization: str = Header(None)): + from management.services.repository.skill_installations import uninstall_repository_copy + + user_id, tenant_id = get_current_user_id(authorization) + try: + ensure_skill_repository_access(user_id) + uninstall_repository_copy(None, skill_id, tenant_id, user_id) + return {"success": True} + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except (ForbiddenError, UnauthorizedError) as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + except (ValueError, SkillException) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc diff --git a/backend/management/db/agent/catalog.py b/backend/management/db/agent/catalog.py index 0ac8122391..2ed55743c8 100644 --- a/backend/management/db/agent/catalog.py +++ b/backend/management/db/agent/catalog.py @@ -8,6 +8,7 @@ from contracts.constants.constants import CAN_EDIT_ALL_USER_ROLES, PERMISSION_EDIT from management.utils.orm import as_dict, filter_property from management.db.client import get_db_session +from management.db.repository_transactions import current_repository_session, lock_repository_source from management.db.models import AgentInfo, AgentRepository, AgentVersion from management.db.identity.group import query_group_ids_by_user @@ -54,6 +55,7 @@ def insert_agent_repository_record( if payload.get("status") is None: payload["status"] = STATUS_NOT_SHARED + new_record = AgentRepository(**filter_property(payload, AgentRepository)) session.add(new_record) session.flush() @@ -75,6 +77,9 @@ def get_agent_repository_by_id( ) .first() ) + if record is not None and current_repository_session() is not None: + lock_repository_source(session, "agent", publisher_tenant_id, record.agent_id) + session.refresh(record) return as_dict(record) if record else None @@ -86,6 +91,7 @@ def get_agent_repository_by_agent_id( ) -> Optional[dict]: """Fetch an active repository listing by root agent_id and optional version.""" with get_db_session() as session: + lock_repository_source(session, "agent", publisher_tenant_id, agent_id) query = session.query(AgentRepository).filter( AgentRepository.agent_id == agent_id, AgentRepository.delete_flag != "Y", @@ -173,6 +179,8 @@ def list_agent_repository_summaries( AgentRepository.version_no, AgentRepository.author, AgentRepository.submitted_by, + AgentRepository.submitted_user_id, + AgentRepository.publisher_user_id, AgentRepository.name, AgentRepository.display_name, AgentRepository.description, @@ -183,6 +191,7 @@ def list_agent_repository_summaries( AgentRepository.icon_url, AgentRepository.downloads, AgentRepository.content, + AgentRepository.published_at, AgentRepository.create_time, ).filter( AgentRepository.delete_flag != "Y", @@ -200,6 +209,8 @@ def list_agent_repository_summaries( "version_no": row.version_no, "author": row.author, "submitted_by": row.submitted_by, + "submitted_user_id": getattr(row, "submitted_user_id", None), + "publisher_user_id": getattr(row, "publisher_user_id", None), "name": row.name, "display_name": row.display_name, "description": row.description, @@ -210,6 +221,7 @@ def list_agent_repository_summaries( "icon_url": row.icon_url, "downloads": row.downloads, "content": row.content, + "published_at": getattr(row, "published_at", None), "create_time": row.create_time, } for row in rows @@ -284,6 +296,7 @@ def update_agent_repository_status_by_id( if content is not None: update_values["content"] = content + with get_db_session() as session: where_clauses = [ AgentRepository.agent_repository_id == repository_id, diff --git a/backend/management/db/agent/models.py b/backend/management/db/agent/models.py index eef9d8b6cb..c3f66a52c8 100644 --- a/backend/management/db/agent/models.py +++ b/backend/management/db/agent/models.py @@ -23,6 +23,7 @@ _PUBLISHER_TENANT_ID_DOC, _PUBLISHER_USER_ID_DOC, ) +from management.db.repository_review_fields import RepositoryReviewFields class AgentInfo(TableBase): @@ -158,7 +159,7 @@ class AgentVersion(TableBase): doc="Version status: RELEASED / DISABLED / ARCHIVED") -class AgentRepository(TableBase): +class AgentRepository(RepositoryReviewFields, TableBase): """ Agent repository (marketplace) table. Frozen snapshot of a published agent tree for sharing. """ diff --git a/backend/management/db/client.py b/backend/management/db/client.py index aa8375a5f2..39c0dbebf7 100644 --- a/backend/management/db/client.py +++ b/backend/management/db/client.py @@ -10,6 +10,7 @@ from contracts.constants.environment import MINIO_ACCESS_KEY, MINIO_DEFAULT_BUCKET, MINIO_ENDPOINT, MINIO_REGION, MINIO_SECRET_KEY, NEXENT_POSTGRES_PASSWORD, POSTGRES_DB, POSTGRES_HOST, POSTGRES_PORT, POSTGRES_USER from management.db.models import TableBase +from management.db.repository_transactions import current_repository_session from nexent.storage.storage_client_factory import create_storage_client_from_config, MinIOStorageConfig @@ -42,6 +43,7 @@ def __init__(self): "client_encoding": "utf8", }, echo=False, + hide_parameters=True, pool_size=10, pool_pre_ping=True, pool_timeout=30, @@ -301,18 +303,20 @@ def get_db_session(db_session=None): param db_session: Optional session to use, if None, a new session will be created. Provide a transactional scope around a series of operations. """ - session = db_client.session_maker() if db_session is None else db_session + shared_session = db_session if db_session is not None else current_repository_session() + owns_session = shared_session is None + session = db_client.session_maker() if owns_session else shared_session try: yield session - if db_session is None: + if owns_session: session.commit() except Exception as e: - if db_session is None: + if owns_session: session.rollback() logger.error(f"Database operation failed: {str(e)}") raise e finally: - if db_session is None: + if owns_session: session.close() diff --git a/backend/management/db/mcp/market.py b/backend/management/db/mcp/market.py index 5e8287bca8..908cd75513 100644 --- a/backend/management/db/mcp/market.py +++ b/backend/management/db/mcp/market.py @@ -7,6 +7,7 @@ from management.utils.orm import as_dict, filter_property from management.db.client import get_db_session +from management.db.repository_transactions import current_repository_session, lock_repository_source from management.db.models import McpMarketRecord logger = logging.getLogger("market_mcp_db") @@ -41,6 +42,7 @@ def get_mcp_market_records( page: int | None = None, limit: int = 30, user_id: str | None = None, + sort_by_published_time: bool = False, user_group_ids: Optional[List[int]] = None, ) -> Dict[str, Any]: """List shared market records using cursor or offset pagination.""" @@ -82,10 +84,13 @@ def get_mcp_market_records( if user_id is not None and user_group_ids is not None: query = _apply_group_permission_filter(query, user_id, user_group_ids) + ordering = (McpMarketRecord.published_at.desc().nullslast(), McpMarketRecord.market_id.desc()) if sort_by_published_time else (McpMarketRecord.market_id.desc(),) + if sort_by_published_time and page is None: + page = 1 if page is not None: total = query.count() page_rows: List[McpMarketRecord] = ( - query.order_by(McpMarketRecord.market_id.desc()).offset((page - 1) * limit).limit(limit).all() + query.order_by(*ordering).offset((page - 1) * limit).limit(limit).all() ) return { "count": len(page_rows), @@ -161,6 +166,9 @@ def get_mcp_market_record_by_id(market_id: int) -> Dict[str, Any] | None: ) .first() ) + if record is not None and current_repository_session() is not None: + lock_repository_source(session, "mcp", record.tenant_id, record.source_mcp_id) + session.refresh(record) return as_dict(record) if record else None @@ -180,24 +188,20 @@ def get_mcp_market_record_by_source_mcp_id(*, tenant_id: str, source_mcp_id: int return as_dict(record) if record else None -def check_mcp_market_name_exists(mcp_name: str, tenant_id: str) -> bool: +def check_mcp_market_name_exists(mcp_name: str, tenant_id: str, *, exclude_source_mcp_id: int | None = None) -> bool: """Check if a shared market record with the given name already exists in this tenant. Matches the partial unique index uq_mcp_market_name_active: WHERE delete_flag = 'N' AND review_status = 'shared' """ with get_db_session() as session: - record = ( - session.query(McpMarketRecord) - .filter( - McpMarketRecord.mcp_name == mcp_name, - McpMarketRecord.tenant_id == tenant_id, - McpMarketRecord.delete_flag != "Y", - McpMarketRecord.review_status == "shared", - ) - .first() - ) - return record is not None + query = session.query(McpMarketRecord).filter( + McpMarketRecord.mcp_name == mcp_name, McpMarketRecord.tenant_id == tenant_id, + McpMarketRecord.delete_flag != "Y", McpMarketRecord.review_status == "shared") + if exclude_source_mcp_id is not None: + query = query.filter(or_(McpMarketRecord.source_mcp_id.is_(None), + McpMarketRecord.source_mcp_id != exclude_source_mcp_id)) + return query.first() is not None def update_mcp_market_record( @@ -248,6 +252,8 @@ def update_mcp_market_record( ).update(update_fields) +_SUBMISSION_DESCRIPTION_UNSET = object() + def update_mcp_market_status( *, market_id: int, @@ -255,6 +261,8 @@ def update_mcp_market_status( review_status: str, submitted_by: str | None = None, content: str | None = None, + is_resubmission: bool = False, + submit_description: str | None | object = _SUBMISSION_DESCRIPTION_UNSET, ) -> None: """Atomically update the review_status, optionally recording the submitter.""" update_fields: Dict[str, Any] = {"updated_by": user_id, "review_status": review_status} @@ -263,6 +271,7 @@ def update_mcp_market_status( if content is not None: update_fields["content"] = content + with get_db_session() as session: session.query(McpMarketRecord).filter( McpMarketRecord.market_id == market_id, @@ -380,3 +389,14 @@ def get_mcp_market_tag_stats_by_tenant(tenant_id: str) -> List[Dict[str, Any]]: .all() ) return [{"tag": str(row.tag), "count": int(row.count)} for row in rows if row.tag] + + +def get_shared_mcp_market_ids(market_ids: list[int]) -> set[int]: + """Resolve current publication state independently of retained origin links.""" + if not market_ids: + return set() + with get_db_session() as session: + rows = session.query(McpMarketRecord.market_id).filter( + McpMarketRecord.market_id.in_(market_ids), McpMarketRecord.review_status == "shared", + McpMarketRecord.delete_flag != "Y").all() + return {int(row[0]) for row in rows} diff --git a/backend/management/db/mcp/models.py b/backend/management/db/mcp/models.py index 6c22b0255e..6967718f3e 100644 --- a/backend/management/db/mcp/models.py +++ b/backend/management/db/mcp/models.py @@ -23,6 +23,7 @@ _PUBLISHER_USER_ID_DOC, _TENANT_ID_DOC, ) +from management.db.repository_review_fields import RepositoryReviewFields class McpRecord(TableBase): @@ -106,7 +107,7 @@ class McpCommunityRecord(TableBase): description = Column(Text, doc="Description") -class McpMarketRecord(TableBase): +class McpMarketRecord(RepositoryReviewFields, TableBase): """MCP market (community) record — single table covering all listing states.""" __tablename__ = "mcp_market_record_t" diff --git a/backend/management/db/models/__init__.py b/backend/management/db/models/__init__.py index aacc358674..2a1fa7cc3f 100644 --- a/backend/management/db/models/__init__.py +++ b/backend/management/db/models/__init__.py @@ -86,3 +86,5 @@ ) from management.db.evaluation.models import AgentEvaluation, AgentEvaluationCase + +from management.db.resource_review_model import ResourceReview diff --git a/backend/management/db/notification/repository.py b/backend/management/db/notification/repository.py index 3880cc605e..f569703258 100644 --- a/backend/management/db/notification/repository.py +++ b/backend/management/db/notification/repository.py @@ -9,6 +9,7 @@ import logging from typing import Any, Dict, List, Optional +from management.db.repository_transactions import after_repository_commit from contracts.constants.constants import VALID_EVENT_TYPES, VALID_RESOURCE_TYPES, VALID_NOTIFICATION_SCOPES, TENANT_REQUIRED_SCOPES, SCOPE_SU, SCOPE_TENANT, SCOPE_TENANT_ADMIN, SCOPE_TENANT_USER, SCOPE_USER, SU_ROLES, TENANT_ADMIN_ROLES, TENANT_USER_ROLES from management.db.client import get_db_session @@ -125,6 +126,7 @@ def create_notification( return {"notification_id": notification_id, "receiver_count": len(receivers)} +@after_repository_commit def deactivate_notifications( *, event_type: str, @@ -195,6 +197,8 @@ def list_notifications_by_user( "notification_id": recv.notification_id, "event_type": notif.event_type, "resource_type": notif.resource_type, + "review_id": str(notif.unique_id) if notif.unique_id is not None and notif.resource_type == "resource_review" else None, + "repository_id": str(notif.unique_id) if notif.unique_id is not None and notif.resource_type in {"agent_repository", "skill_repository", "mcp_repository"} else None, "details": notif.details, "scope": notif.scope, "is_read": recv.is_read, diff --git a/backend/management/db/repository_review_fields.py b/backend/management/db/repository_review_fields.py new file mode 100644 index 0000000000..7a0fb2695d --- /dev/null +++ b/backend/management/db/repository_review_fields.py @@ -0,0 +1,56 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Read-only compatibility projections; no review columns live in repository tables.""" + +from management.db.resource_review_model import ResourceReview +from sqlalchemy import func, select +from sqlalchemy.orm import column_property, declared_attr + + +class RepositoryReviewFields: + @declared_attr + def published_at(cls): + kind, key = { + "ag_agent_repository_t": ("agent", "agent_repository_id"), + "ag_skill_repository_t": ("skill", "skill_repository_id"), + "mcp_market_record_t": ("mcp", "market_id"), + }[cls.__tablename__] + return column_property( + select(func.max(ResourceReview.reviewed_at)) + .where( + ResourceReview.resource_type == kind, + ResourceReview.repository_id == getattr(cls, key), + ResourceReview.tenant_id + == getattr( + cls, "tenant_id" if kind == "mcp" else "publisher_tenant_id" + ), + ResourceReview.review_status == "approved", + ResourceReview.delete_flag == "N", + ) + .correlate_except(ResourceReview) + .scalar_subquery() + ) + + @declared_attr + def submitted_user_id(cls): + kind, key = { + "ag_agent_repository_t": ("agent", "agent_repository_id"), + "ag_skill_repository_t": ("skill", "skill_repository_id"), + "mcp_market_record_t": ("mcp", "market_id"), + }[cls.__tablename__] + return column_property( + select(ResourceReview.submitted_user_id) + .where( + ResourceReview.resource_type == kind, + ResourceReview.repository_id == getattr(cls, key), + ResourceReview.tenant_id + == getattr( + cls, "tenant_id" if kind == "mcp" else "publisher_tenant_id" + ), + ResourceReview.delete_flag == "N", + ) + .order_by(ResourceReview.review_id.desc()) + .limit(1) + .correlate_except(ResourceReview) + .scalar_subquery() + ) diff --git a/backend/management/db/repository_reviews.py b/backend/management/db/repository_reviews.py new file mode 100644 index 0000000000..9bc5f452ad --- /dev/null +++ b/backend/management/db/repository_reviews.py @@ -0,0 +1,95 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Authorized review-table queries and legacy repository mappings.""" + +from management.db.agent.models import AgentRepository +from management.db.mcp.models import McpMarketRecord +from management.db.resource_review_model import ResourceReview +from management.db.skill.models import SkillRepository +from sqlalchemy import and_, func, or_, select + +REVIEW_MODELS = { + "agent": ( + AgentRepository, + "agent_repository_id", + "publisher_tenant_id", + "status", + "agent_id", + ), + "skill": ( + SkillRepository, + "skill_repository_id", + "publisher_tenant_id", + "status", + "skill_id", + ), + "mcp": ( + McpMarketRecord, + "market_id", + "tenant_id", + "review_status", + "source_mcp_id", + ), +} + + +def scope_conditions(resource_type, user_id, tenant_id, role): + r = ResourceReview + conditions = [r.delete_flag == "N", r.resource_type == resource_type] + global_mcp = resource_type == "mcp" and role in {"SU", "SUPER_ADMIN"} + if not global_mcp: + conditions.append(r.tenant_id == tenant_id) + if role != "ADMIN" and not global_mcp: + conditions.append(r.submitted_user_id == user_id) + return conditions + + +def authorized_scope(user_id, tenant_id, role): + return or_( + *( + and_(*scope_conditions(kind, user_id, tenant_id, role)) + for kind in REVIEW_MODELS + ) + ) + + +def list_reviews(session, user_id, tenant_id, role, query): + r = ResourceReview + filters = [authorized_scope(user_id, tenant_id, role)] + if query.resource_type != "all": + filters.append(r.resource_type == query.resource_type) + if query.status != "all": + filters.append(r.review_status == query.status) + if query.search: + filters.append( + r.resource_snapshot["payload"]["display_name"].astext.icontains( + query.search, autoescape=True + ) + | r.resource_snapshot["payload"]["name"].astext.icontains( + query.search, autoescape=True + ) + | r.resource_snapshot["payload"]["mcp_name"].astext.icontains( + query.search, autoescape=True + ) + ) + total = session.scalar(select(func.count()).select_from(r).where(*filters)) + rows = session.scalars( + select(r) + .where(*filters) + .order_by(r.submitted_at.desc(), r.review_id.desc()) + .offset((query.page - 1) * query.page_size) + .limit(query.page_size) + ).all() + return rows, int(total or 0) + + +def get_review(session, review_id, user_id, tenant_id, role, *, for_update=False): + statement = select(ResourceReview).where( + ResourceReview.review_id == review_id, + authorized_scope(user_id, tenant_id, role), + ) + if for_update: + statement = statement.with_for_update().execution_options( + populate_existing=True + ) + return session.scalar(statement) diff --git a/backend/management/db/repository_transactions.py b/backend/management/db/repository_transactions.py new file mode 100644 index 0000000000..2d66a0fd81 --- /dev/null +++ b/backend/management/db/repository_transactions.py @@ -0,0 +1,79 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Repository-owned transactions shared by existing CRUD collaborators.""" + +import hashlib +import inspect +import logging +from contextlib import contextmanager +from contextvars import ContextVar +from functools import wraps + +logger = logging.getLogger(__name__) +_session = ContextVar("repository_session", default=None) +_callbacks = ContextVar("repository_after_commit", default=None) + + +def current_repository_session(): + return _session.get() + + +@contextmanager +def repository_unit_of_work(): + """Commit once; deliver queued notifications only after the owner commits.""" + if _session.get() is not None: + yield _session.get() + return + from management.db.client import get_db_session + + pending = [] + with get_db_session() as session: + session_token = _session.set(session) + callback_token = _callbacks.set(pending) + try: + yield session + finally: + _session.reset(session_token) + _callbacks.reset(callback_token) + for callback, args, kwargs in pending: + try: + callback(*args, **kwargs) + except Exception: + logger.exception("Repository committed, but a notification failed") + + +def repository_transaction(operation): + if inspect.iscoroutinefunction(operation): + @wraps(operation) + async def asynchronous(*args, **kwargs): + with repository_unit_of_work(): + return await operation(*args, **kwargs) + return asynchronous + + @wraps(operation) + def synchronous(*args, **kwargs): + with repository_unit_of_work(): + return operation(*args, **kwargs) + return synchronous + + +def after_repository_commit(operation): + @wraps(operation) + def wrapped(*args, **kwargs): + pending = _callbacks.get() + if pending is not None: + pending.append((operation, args, kwargs)) + return None + return operation(*args, **kwargs) + return wrapped + + +def lock_repository_source(session, resource_type: str, tenant_id: str, source_id) -> None: + """Serialize peer-listing changes even when no listing exists yet.""" + if _session.get() is None: + return + from sqlalchemy import func, select + + raw = f"{resource_type}:{tenant_id}:{source_id}".encode() + key = int.from_bytes(hashlib.blake2b(raw, digest_size=8).digest(), "big", signed=True) + session.execute(select(func.pg_advisory_xact_lock(key))) diff --git a/backend/management/db/resource_review_model.py b/backend/management/db/resource_review_model.py new file mode 100644 index 0000000000..1a60f7176e --- /dev/null +++ b/backend/management/db/resource_review_model.py @@ -0,0 +1,77 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Immutable submission snapshots with independent publication results.""" + +from management.db.base import SCHEMA, TableBase +from sqlalchemy import ( + BigInteger, + CheckConstraint, + Column, + DateTime, + Integer, + String, + Text, + func, +) +from sqlalchemy.dialects.postgresql import JSONB + + +class ResourceReview(TableBase): + __tablename__ = "ag_resource_review_t" + __table_args__ = ( + CheckConstraint("resource_type IN ('agent','skill','mcp')"), + CheckConstraint("review_status IN ('pending_review','approved','rejected')"), + CheckConstraint( + "(resource_type = 'agent' AND version_no IS NOT NULL AND version_no >= 0) OR " + "(resource_type <> 'agent' AND version_no IS NULL)" + ), + {"schema": SCHEMA}, + ) + + review_id = Column( + BigInteger, + primary_key=True, + autoincrement=True, + comment="Independent submission identifier", + ) + tenant_id = Column(String(100), nullable=False, comment="Resource owner tenant") + resource_type = Column( + String(30), nullable=False, comment="Resource kind: agent, skill or mcp" + ) + source_resource_id = Column( + BigInteger, nullable=False, comment="Original local resource identifier" + ) + version_no = Column(Integer, comment="Agent version; NULL for Skill and MCP") + repository_id = Column( + BigInteger, + comment="Published repository identifier; NULL before first publication", + ) + submitted_user_id = Column( + String(100), + nullable=False, + comment="Authenticated submitter internal user identifier", + ) + submitted_at = Column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + comment="Submission instant; legacy submissions use original update time", + ) + submit_description = Column( + Text, comment="Description supplied with this submission" + ) + resource_snapshot = Column( + JSONB, nullable=False, comment="Versioned validated frozen publication payload" + ) + review_status = Column( + String(30), + nullable=False, + default="pending_review", + comment="Independent review status: pending_review, approved or rejected", + ) + review_comment = Column(Text, comment="Final review opinion") + reviewed_by = Column(String(100), comment="Decision actor internal user identifier") + reviewed_at = Column( + DateTime(timezone=True), + comment="Decision instant; publication instant for approvals", + ) diff --git a/backend/management/db/skill/catalog.py b/backend/management/db/skill/catalog.py index 1c0d86ad56..294c069fe0 100644 --- a/backend/management/db/skill/catalog.py +++ b/backend/management/db/skill/catalog.py @@ -8,6 +8,7 @@ from contracts.constants.constants import STATUS_NOT_SHARED from management.utils.orm import as_dict, filter_property from management.db.client import get_db_session +from management.db.repository_transactions import current_repository_session, lock_repository_source from management.db.models import SkillRepository _UPDATE_ALLOWED_FIELDS = frozenset( @@ -46,6 +47,7 @@ def insert_skill_repository_record( if payload.get("status") is None: payload["status"] = STATUS_NOT_SHARED + new_record = SkillRepository(**filter_property(payload, SkillRepository)) session.add(new_record) session.flush() @@ -67,6 +69,9 @@ def get_skill_repository_by_id_and_publisher( ) .first() ) + if record is not None and current_repository_session() is not None: + lock_repository_source(session, "skill", publisher_tenant_id, record.skill_id) + session.refresh(record) return as_dict(record) if record else None @@ -78,6 +83,7 @@ def get_skill_repository_by_skill_id( ) -> Optional[dict]: """Fetch an active repository listing by source skill_id.""" with get_db_session() as session: + lock_repository_source(session, "skill", publisher_tenant_id, skill_id) query = session.query(SkillRepository).filter( SkillRepository.skill_id == skill_id, SkillRepository.delete_flag != "Y", @@ -145,6 +151,8 @@ def list_skill_repository_summaries( search: Optional[str] = None, tag: Optional[str] = None, sort_by_update_time: bool = False, + sort_by_published_time: bool = False, + review_user_id: Optional[str] = None, ) -> Dict[str, Any]: """List active repository summaries for a publisher tenant without heavy payloads.""" safe_page = max(int(page or 1), 1) @@ -165,6 +173,7 @@ def list_skill_repository_summaries( SkillRepository.icon, SkillRepository.downloads, SkillRepository.content, + SkillRepository.published_at, SkillRepository.create_time, ) query = _apply_skill_repository_filters( @@ -178,12 +187,18 @@ def list_skill_repository_summaries( tag=tag, ) + if review_user_id is not None: + query = query.filter(or_(SkillRepository.status == "shared", + SkillRepository.submitted_user_id == review_user_id, + (SkillRepository.status == "not_shared") & (SkillRepository.publisher_user_id == review_user_id))) total = query.count() order_by_fields = ( (SkillRepository.update_time.desc(), SkillRepository.skill_repository_id.desc()) if sort_by_update_time else (SkillRepository.skill_repository_id.desc(),) ) + if sort_by_published_time: + order_by_fields = (SkillRepository.published_at.desc().nullslast(), SkillRepository.skill_repository_id.desc()) rows = query.order_by(*order_by_fields).offset((safe_page - 1) * safe_page_size).limit(safe_page_size).all() items = [ @@ -201,6 +216,7 @@ def list_skill_repository_summaries( "icon": row.icon, "downloads": row.downloads or 0, "content": row.content, + "published_at": getattr(row, "published_at", None).isoformat() if getattr(row, "published_at", None) else None, "created_at": row.create_time.isoformat() if row.create_time else None, } for row in rows @@ -288,6 +304,7 @@ def update_skill_repository_status_by_id( if content is not None: update_values["content"] = content + with get_db_session() as session: where_clauses = [ SkillRepository.skill_repository_id == repository_id, diff --git a/backend/management/db/skill/models.py b/backend/management/db/skill/models.py index c65f0b1623..a315f0bb1c 100644 --- a/backend/management/db/skill/models.py +++ b/backend/management/db/skill/models.py @@ -24,9 +24,10 @@ _PUBLISHER_TENANT_ID_DOC, _PUBLISHER_USER_ID_DOC, ) +from management.db.repository_review_fields import RepositoryReviewFields -class SkillRepository(TableBase): +class SkillRepository(RepositoryReviewFields, TableBase): """ Skill repository (marketplace) table. Frozen snapshot of a shared skill for installation. """ @@ -81,6 +82,10 @@ class SkillInfo(TableBase): skill_id = Column(Integer, Sequence("ag_skill_info_t_skill_id_seq", schema=SCHEMA), primary_key=True, nullable=False, autoincrement=True, doc="Skill ID") + source_repository_id = Column( + BigInteger, nullable=True, + comment="Source Skill repository listing ID for an installed copy; NULL when not associated", + ) skill_name = Column(String(100), nullable=False, doc="Skill name, unique among active skills within its tenant scope") tenant_id = Column(String(100), nullable=True, diff --git a/backend/management/db/skill/repository.py b/backend/management/db/skill/repository.py index 1517f7f9dc..f4d61c562e 100644 --- a/backend/management/db/skill/repository.py +++ b/backend/management/db/skill/repository.py @@ -343,6 +343,7 @@ def _to_dict(skill: SkillInfo) -> Dict[str, Any]: "config_schemas": skill.config_schemas, "config_values": skill.config_values, "source": skill.source, + "source_repository_id": skill.source_repository_id, "group_ids": convert_string_to_list(skill.group_ids), "ingroup_permission": skill.ingroup_permission, "created_by": skill.created_by, @@ -521,6 +522,7 @@ def create_skill(skill_data: Dict[str, Any], tenant_id: str) -> Dict[str, Any]: config_schemas=_params_value_for_db(skill_data.get("config_schemas")), config_values=_params_value_for_db(skill_data.get("config_values")), source=skill_data.get("source", "custom"), + source_repository_id=skill_data.get("source_repository_id"), group_ids=( convert_list_to_string(skill_data.get("group_ids")) if isinstance(skill_data.get("group_ids"), list) @@ -552,7 +554,6 @@ def create_skill(skill_data: Dict[str, Any], tenant_id: str) -> Dict[str, Any]: ) session.add(rel) - session.commit() result = _to_dict(skill) result["tool_ids"] = tool_ids @@ -746,7 +747,6 @@ def delete_skill(skill_name: str, tenant_id: str, updated_by: Optional[str] = No SkillInstance.skill_id == skill_id, SkillInstance.delete_flag != "Y" ).update({SkillInstance.delete_flag: "Y", "updated_by": updated_by}) - session.commit() return True diff --git a/backend/management/services/mcp/permission.py b/backend/management/services/mcp/permission.py new file mode 100644 index 0000000000..2dfba23fd0 --- /dev/null +++ b/backend/management/services/mcp/permission.py @@ -0,0 +1,16 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Apply the existing MCP list's public, owner and group edit rules to writes.""" + +from contracts.constants.constants import CAN_EDIT_ALL_USER_ROLES + + +def can_edit_mcp_record(record: dict, user_id: str, role: str, group_ids: list) -> bool: + owner = record.get("created_by") or record.get("user_id") + if str(owner or "") == str(user_id) or record.get("group_ids") is None: + return True + allowed = {value.strip() for value in str(record.get("group_ids") or "").split(",") if value.strip()} + permission = str(record.get("ingroup_permission") or "READ_ONLY").upper() + if permission == "PRIVATE" or not allowed.intersection(map(str, group_ids)): + return False + return str(role).upper() in CAN_EDIT_ALL_USER_ROLES or permission == "EDIT" diff --git a/backend/management/services/mcp/service.py b/backend/management/services/mcp/service.py index 441055f03f..70aa020528 100644 --- a/backend/management/services/mcp/service.py +++ b/backend/management/services/mcp/service.py @@ -17,7 +17,7 @@ from management.db.mcp.market import get_mcp_market_record_by_source_mcp_id, update_mcp_market_record from management.db.mcp.remote import check_enabled_mcp_name_exists, check_mcp_name_exists, create_mcp_record, delete_mcp_record_by_container_id, delete_mcp_record_by_id, get_mcp_authorization_token_by_name_and_url, get_mcp_custom_headers_by_name_and_url, get_mcp_record_by_id_and_tenant, get_mcp_records_by_tenant, update_mcp_record_by_name_and_url, update_mcp_record_container_fields_by_id, update_mcp_record_enabled_by_id, update_mcp_record_manage_fields_by_id, update_mcp_record_registry_json_by_id, update_mcp_record_status_by_id, update_mcp_status_by_name_and_url from management.db.tool.repository import set_mcp_tools_unavailable -from management.db.identity.user_tenant import get_user_tenant_by_user_id +from management.db.identity.user_tenant import get_user_email_map, get_user_tenant_by_user_id from fastmcp import Client from fastmcp.client.transports import SSETransport, StreamableHttpTransport from infrastructure.http.client import create_httpx_client @@ -422,6 +422,14 @@ async def delete_mcp_service( current_record = get_mcp_record_by_id_and_tenant(mcp_id=mcp_id, tenant_id=tenant_id) if not current_record: raise McpNotFoundError("MCP record not found") + from contracts.errors.exceptions import ForbiddenError + from management.services.mcp.permission import can_edit_mcp_record + + identity = get_user_tenant_by_user_id(user_id) or {} + groups = query_group_ids_by_user(user_id) or [] + if not can_edit_mcp_record(current_record, user_id, identity.get("user_role", ""), groups): + raise ForbiddenError("MCP service is read-only") + container_id = current_record.get("container_id") if container_id: try: @@ -511,6 +519,8 @@ async def get_remote_mcp_server_list( tenant_id: str, user_id: str | None = None, is_need_auth: bool = True, + *, + include_author: bool = False, ) -> list[dict]: """Get list of remote MCP servers with full details. @@ -518,6 +528,7 @@ async def get_remote_mcp_server_list( tenant_id: Tenant ID user_id: User ID for permission checking is_need_auth: Whether to include authorization tokens + include_author: Whether to include the creator's display name Returns: List of MCP server records with all fields including container_id, description, @@ -566,6 +577,20 @@ async def get_remote_mcp_server_list( ) mcp_records = filtered_records + author_emails = {} + if include_author: + creator_ids = { + record.get("created_by") or record.get("user_id") + for record in mcp_records + } + author_emails = get_user_email_map([creator_id for creator_id in creator_ids if creator_id]) + + market_ids = list({record["market_id"] for record in mcp_records if record.get("market_id") is not None}) + shared_market_ids = set() + if market_ids: + from management.db.mcp.market import get_shared_mcp_market_ids + shared_market_ids = get_shared_mcp_market_ids(market_ids) + container_status_map = {} try: manager = MCPContainerManager() @@ -637,7 +662,7 @@ async def get_remote_mcp_server_list( "registry_json": record.get("registry_json"), "config_json": record.get("config_json"), "market_id": record.get("market_id"), - "is_listed_in_repository": record.get("market_id") is not None, + "is_listed_in_repository": record.get("market_id") in shared_market_ids, "container_status": container_status, "group_ids": record.get("group_ids"), "ingroup_permission": record.get("ingroup_permission"), @@ -646,6 +671,8 @@ async def get_remote_mcp_server_list( if is_need_auth: record_dict["authorization_token"] = record.get("authorization_token") record_dict["custom_headers"] = record.get("custom_headers") + if include_author: + record_dict["author_display_name"] = author_emails.get(created_by) mcp_records_list.append(record_dict) return mcp_records_list diff --git a/backend/management/services/repository/agent/common.py b/backend/management/services/repository/agent/common.py index d2cd9e8d87..e1b7c92ac9 100644 --- a/backend/management/services/repository/agent/common.py +++ b/backend/management/services/repository/agent/common.py @@ -142,6 +142,7 @@ def _to_summary_item( "icon_url": record.get("icon_url"), "downloads": downloads, "content": record.get("content"), + "published_at": _serialize_created_at(record.get("published_at")), "publisher_tenant_id": record.get("publisher_tenant_id"), "is_official": record.get("publisher_tenant_id") == SYSTEM_TENANT_ID, "is_main_agent": record.get("is_main_agent", True), diff --git a/backend/management/services/repository/agent/listing.py b/backend/management/services/repository/agent/listing.py index 69c8599446..78a6ad5682 100644 --- a/backend/management/services/repository/agent/listing.py +++ b/backend/management/services/repository/agent/listing.py @@ -121,6 +121,9 @@ def list_agent_repository_listings_impl( tag_predicates: Optional[list] = None, tag: Optional[str] = None, sort_by_update_time: bool = False, + sort_by_published_time: bool = False, + sort_by_downloads: bool = False, + user_id: Optional[str] = None, ) -> Dict[str, Any]: """List repository listings for the caller tenant with optional status filter.""" if status is not None and status not in VALID_REPOSITORY_STATUSES: @@ -193,10 +196,29 @@ def list_agent_repository_listings_impl( records = [ record for record in records if tag.strip() in (record.get("tags") or []) ] - if sort_by_update_time: + if not sort_by_downloads and sort_by_published_time: + records.sort(key=lambda row: (row.get("published_at") is not None, + str(row.get("published_at") or ""), _repository_id_sort_key(row)), reverse=True) + elif not sort_by_downloads and sort_by_update_time: # Order the merged tenant+official set as one list; ordering only in # SQL would interleave the two query blocks incorrectly. records = _order_listings_by_publish_time(records) + if user_id is not None and _get_user_role(user_id) != "ADMIN": + records = [row for row in records if row.get("status") == STATUS_SHARED + or row.get("submitted_user_id") == user_id + or (row.get("status") == STATUS_NOT_SHARED and row.get("publisher_user_id") == user_id)] + download_totals = None + if sort_by_downloads: + download_totals = _get_agent_download_totals( + [int(row["agent_id"]) for row in records if row.get("agent_id") is not None] + ) + records.sort( + key=lambda row: ( + download_totals.get(int(row["agent_id"]), 0) if row.get("agent_id") is not None else 0, + _repository_id_sort_key(row), + ), + reverse=True, + ) total = len(records) start = (page - 1) * page_size paged_records = records[start : start + page_size] @@ -207,7 +229,8 @@ def list_agent_repository_listings_impl( if record.get("agent_id") is not None ] _apply_agent_main_flags(paged_records) - download_totals = _get_agent_download_totals(paged_agent_ids) + if download_totals is None: + download_totals = _get_agent_download_totals(paged_agent_ids) return { "items": [ _to_summary_item( diff --git a/backend/management/services/repository/agent/publishing.py b/backend/management/services/repository/agent/publishing.py index 4fcaf58739..533096d1d0 100644 --- a/backend/management/services/repository/agent/publishing.py +++ b/backend/management/services/repository/agent/publishing.py @@ -1,6 +1,7 @@ # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. from typing import Any, Dict, Optional +from management.db.repository_transactions import repository_transaction, repository_unit_of_work from contracts.constants.constants import STATUS_PENDING_REVIEW, STATUS_REJECTED, STATUS_SHARED, VALID_REPOSITORY_STATUSES from contracts.constants.constants import SYSTEM_TENANT_ID @@ -30,6 +31,16 @@ _PUBLISHER_RESUBMIT_TRANSITIONS, _PUBLISHER_STATUS_TRANSITIONS, _SU_STATUS_TRANSITIONS, + _get_user_role, + _resolve_submitter_email, + _reset_repository_peer_statuses, + _to_summary_item, + _validate_card_fields, + _validate_create_listing_permission, + _count_tools_in_snapshot, + _normalize_listing_tags, + _repository_image_id, + _repository_icon_object_name, ) @@ -50,6 +61,8 @@ def _validate_repository_status_transition( transition = (current_status, new_status) if user_role == "SU": + if current_status == new_status: + return None if transition not in _SU_STATUS_TRANSITIONS: raise ValueError( f"Invalid status transition from '{current_status}' to '{new_status}'" @@ -61,6 +74,8 @@ def _validate_repository_status_transition( raise UnauthorizedError("Not authorized to update this repository listing") if user_role == "DEV" and record.get("publisher_user_id") != user_id: raise UnauthorizedError("Not authorized to update this repository listing") + if current_status == new_status: + return None if user_role == "ADMIN" and transition in _ADMIN_REVIEW_STATUS_TRANSITIONS: return None if transition not in _PUBLISHER_STATUS_TRANSITIONS: @@ -79,6 +94,7 @@ def _validate_repository_status_transition( ) +@repository_transaction def update_agent_repository_status_impl( *, agent_repository_id: int, @@ -110,19 +126,32 @@ def update_agent_repository_status_impl( current_status = record.get("status") publisher_updates: Optional[Dict[str, str]] = None submitted_by: Optional[str] = None + user_role = _get_user_role(user_id) + publisher_updates = _validate_repository_status_transition( + user_role=user_role, + current_status=current_status, + new_status=status, + record=record, + user_id=user_id, + tenant_id=tenant_id, + ) if current_status != status: - user_role = _get_user_role(user_id) - publisher_updates = _validate_repository_status_transition( - user_role=user_role, - current_status=current_status, - new_status=status, - record=record, - user_id=user_id, - tenant_id=tenant_id, - ) if status == STATUS_PENDING_REVIEW: submitted_by = _resolve_submitter_email(user_id) + if status in (STATUS_SHARED, STATUS_REJECTED): + from management.services.repository.reviews import decide_legacy_review + return decide_legacy_review("agent", agent_repository_id, user_id, tenant_id, user_role, status, content) + if status == STATUS_PENDING_REVIEW: + from management.services.repository.reviews import save_submission + payload = {field: record.get(field) for field in ( + "agent_id", "version_no", "name", "display_name", "description", "author", "version_name", + "agent_info_json", "icon_url", "tags", "tool_count")} + payload["content"] = content or "" + payload["submitted_by"] = _resolve_submitter_email(user_id) + return save_submission("agent", record["agent_id"], tenant_id, user_id, user_role, payload, + version_no=record["version_no"], repository_id=agent_repository_id) + rows_affected = update_agent_repository_status_by_id( repository_id=agent_repository_id, status=status, @@ -164,6 +193,15 @@ def update_agent_repository_status_impl( notify_content=notify_content, ) + if status == STATUS_PENDING_REVIEW and (updated.get("submission_revision") or 0) > (record.get("submission_revision") or 0): + deactivate_notifications(event_type=EVENT_TYPE_REPOSITORY_REVIEW_PENDING, + resource_type=RESOURCE_TYPE_AGENT_REPOSITORY, unique_id=agent_repository_id, updated_by=user_id) + create_repository_pending_review_notification(resource_type=RESOURCE_TYPE_AGENT_REPOSITORY, + tenant_id=tenant_id, unique_id=agent_repository_id, created_by=user_id, + details={"name": updated.get("display_name") or updated.get("name"), + "agent_repository_id": agent_repository_id, "agent_id": updated.get("agent_id"), + "content": updated.get("submit_description") or ""}) + enrich_repository_identities([updated], "agent") return _to_summary_item(updated) @@ -191,14 +229,14 @@ def _handle_review_status_notifications( create_repository_review_notification( resource_type=RESOURCE_TYPE_AGENT_REPOSITORY, review_status=new_status, - receiver_user_id=updated["publisher_user_id"], + receiver_user_id=updated.get("submitted_user_id") or updated["publisher_user_id"], details=details, tenant_id=updated.get("publisher_tenant_id"), unique_id=agent_repository_id, created_by=user_id, ) - if current_status == STATUS_PENDING_REVIEW: + if current_status == STATUS_PENDING_REVIEW and new_status != STATUS_PENDING_REVIEW: deactivate_notifications( event_type=EVENT_TYPE_REPOSITORY_REVIEW_PENDING, resource_type=RESOURCE_TYPE_AGENT_REPOSITORY, @@ -354,6 +392,7 @@ async def _build_repository_data_from_agent( return repository_data +@repository_transaction async def create_agent_repository_listing_impl( agent_id: int, tenant_id: str, @@ -373,6 +412,8 @@ async def create_agent_repository_listing_impl( if version_no < 0: raise ValueError("version_no must be >= 0") + from management.db.repository_transactions import current_repository_session, lock_repository_source + lock_repository_source(current_repository_session(), "agent", tenant_id, agent_id) repository_data = await _build_repository_data_from_agent( agent_id, tenant_id, @@ -392,73 +433,6 @@ async def create_agent_repository_listing_impl( except FileNotFoundError as exc: raise ValueError("Repository icon upload not found") from exc - existing = get_agent_repository_by_agent_id( - agent_id, - version_no, - publisher_tenant_id=tenant_id, - ) - if not existing: - repository_id = insert_agent_repository_record( - repository_data=repository_data, - publisher_tenant_id=tenant_id, - publisher_user_id=user_id, - ) - is_updated = False - else: - repository_id = int(existing["agent_repository_id"]) - updates: Dict[str, Any] = { - "status": STATUS_PENDING_REVIEW, - "content": repository_data["content"], - } - for key in ("icon_url", "tags", "tool_count"): - if key in repository_data: - updates[key] = repository_data[key] - affected = update_agent_repository_by_id( - repository_id=repository_id, - publisher_tenant_id=tenant_id, - user_id=user_id, - updates=updates, - ) - if affected == 0: - raise ValueError("Failed to update repository listing") - is_updated = True - - record = get_agent_repository_by_id( - repository_id, - tenant_id, - ) - if not record: - raise ValueError("Failed to load repository listing after write") - _reset_repository_peer_statuses( - agent_repository_id=repository_id, - agent_id=agent_id, - status=repository_data["status"], - publisher_tenant_id=tenant_id, - ) - create_repository_pending_review_notification( - resource_type=RESOURCE_TYPE_AGENT_REPOSITORY, - tenant_id=tenant_id, - unique_id=repository_id, - details={ - "name": record.get("display_name") or record.get("name"), - "agent_repository_id": repository_id, - "agent_id": record.get("agent_id"), - "content": record.get("content") or "", - }, - created_by=user_id, - ) - return _to_detail_item(record, is_updated=is_updated) - - -from management.services.repository.agent.common import ( - _count_tools_in_snapshot, - _get_user_role, - _normalize_listing_tags, - _repository_icon_object_name, - _repository_image_id, - _reset_repository_peer_statuses, - _resolve_submitter_email, - _to_summary_item, - _validate_card_fields, - _validate_create_listing_permission, -) + from management.services.repository.reviews import save_submission + return save_submission("agent", agent_id, tenant_id, user_id, _get_user_role(user_id), + repository_data, version_no=version_no) diff --git a/backend/management/services/repository/mcp/service.py b/backend/management/services/repository/mcp/service.py index e41f038512..f682efa0f5 100644 --- a/backend/management/services/repository/mcp/service.py +++ b/backend/management/services/repository/mcp/service.py @@ -3,6 +3,7 @@ import logging from datetime import datetime from typing import Any, Dict, FrozenSet, List, Tuple +from management.db.repository_transactions import repository_transaction from contracts.constants.constants import CAN_EDIT_ALL_USER_ROLES from contracts.errors.exceptions import MCPConnectionError, McpNameConflictError, McpNotFoundError, McpValidationError, UnauthorizedError from contracts.constants.constants import STATUS_NOT_SHARED, STATUS_PENDING_REVIEW, STATUS_REJECTED, STATUS_SHARED, VALID_MARKET_STATUSES @@ -116,6 +117,7 @@ def _to_community_card(row: Dict[str, Any]) -> Dict[str, Any]: shared_fields = None return { "communityId": row.get("market_id"), + "publishedAt": row["published_at"].isoformat() if row.get("published_at") else None, "marketId": row.get("market_id"), "reviewId": row.get("market_id"), "sourceMcpId": source_mcp_id, @@ -211,6 +213,7 @@ async def list_community_mcp_services( cursor: str | None = None, page: int | None = None, limit: int = 30, + sort_by_published_time: bool = False, ) -> Dict[str, Any]: """List shared (approved) community MCP services scoped to a tenant with permission filtering.""" user_role = _get_user_role(user_id) @@ -231,6 +234,8 @@ async def list_community_mcp_services( user_id=user_id if user_role not in CAN_EDIT_ALL_USER_ROLES else None, user_group_ids=user_group_ids, ) + if sort_by_published_time: + list_kwargs["sort_by_published_time"] = True if page is not None: list_kwargs["page"] = page db_result = get_mcp_market_records(**list_kwargs) @@ -308,7 +313,7 @@ def _handle_mcp_review_status_notifications( create_repository_review_notification( resource_type=RESOURCE_TYPE_MCP_REPOSITORY, review_status=new_status, - receiver_user_id=record["user_id"], + receiver_user_id=record.get("submitted_user_id") or record["user_id"], details=_mcp_notification_details( name=record.get("mcp_name"), market_id=market_id, @@ -320,7 +325,7 @@ def _handle_mcp_review_status_notifications( created_by=user_id, ) - if current_status == STATUS_PENDING_REVIEW: + if current_status == STATUS_PENDING_REVIEW and new_status != STATUS_PENDING_REVIEW: deactivate_notifications( event_type=EVENT_TYPE_REPOSITORY_REVIEW_PENDING, resource_type=RESOURCE_TYPE_MCP_REPOSITORY, @@ -329,6 +334,7 @@ def _handle_mcp_review_status_notifications( ) +@repository_transaction async def publish_community_mcp_service( *, tenant_id: str, @@ -343,12 +349,13 @@ async def publish_community_mcp_service( ingroup_permission: str | None = None, shared_fields: dict | None = None, content: str | None = None, -) -> int: +) -> dict: """Submit a local MCP service for review. - Creates a market record with status=pending_review directly (no separate review table). - Returns the new market_id. + Stores a frozen review application without creating a marketplace record. """ + from management.db.repository_transactions import current_repository_session, lock_repository_source + lock_repository_source(current_repository_session(), "mcp", tenant_id, mcp_id) source_record = get_mcp_record_by_id_and_tenant(mcp_id=mcp_id, tenant_id=tenant_id) if not source_record: raise McpNotFoundError("MCP record not found") @@ -373,61 +380,24 @@ async def publish_community_mcp_service( community_transport_type = "container" if final_config_json is not None else "url" # Check name uniqueness among shared records only - if check_mcp_market_name_exists(final_name, tenant_id): + if check_mcp_market_name_exists(final_name, tenant_id, exclude_source_mcp_id=mcp_id): raise McpNameConflictError(f"MCP name '{final_name}' already exists in the community market") - market_id = create_mcp_market_record( - mcp_data={ - "mcp_name": final_name, - "source_mcp_id": mcp_id, - "mcp_server": final_mcp_server, - "registry_json": source_registry_json, - "transport_type": source_record.get("transport_type") or community_transport_type, - "config_json": final_config_json, - "review_status": STATUS_PENDING_REVIEW, - "submitted_by": _resolve_user_email(user_id), - "tags": final_tags, - "description": final_description, - "content": content, - "group_ids": convert_list_to_string(group_ids) if group_ids else None, - "ingroup_permission": ingroup_permission, - "shared_fields": shared_fields, - "container_port": source_record.get("container_port"), - }, - tenant_id=tenant_id, - user_id=user_id, - ) - - # Update shared_fields on the source MCP record - if shared_fields is not None: - update_mcp_record_manage_fields_by_id( - mcp_id=mcp_id, - tenant_id=tenant_id, - user_id=user_id, - name=source_record.get("mcp_name", ""), - server_url=source_record.get("mcp_server", ""), - description=source_record.get("description") or "", - tags=source_record.get("tags"), - source=source_record.get("source") or "local", - authorization_token=source_record.get("authorization_token"), - custom_headers=source_record.get("custom_headers"), - config_json=source_record.get("config_json"), - market_id=source_record.get("market_id"), - shared_fields=shared_fields, - ) - - _create_mcp_pending_review_notification( - tenant_id=tenant_id, - user_id=user_id, - market_id=market_id, - name=final_name, - source_mcp_id=mcp_id, - content=content, - ) - - return market_id + from management.services.repository.reviews import save_submission + role = _get_user_role(user_id) + payload = { + "mcp_name": final_name, "source_mcp_id": mcp_id, "mcp_server": final_mcp_server, + "registry_json": source_registry_json, "transport_type": source_record.get("transport_type") or community_transport_type, + "config_json": final_config_json, "submitted_by": _resolve_user_email(user_id), + "tags": final_tags, "description": final_description, "content": content, + "group_ids": convert_list_to_string(group_ids) if group_ids else None, + "ingroup_permission": ingroup_permission or "READ_ONLY", "shared_fields": shared_fields, + "source": "community", + } + return save_submission("mcp", mcp_id, tenant_id, user_id, role, payload) +@repository_transaction async def update_community_mcp_service( *, tenant_id: str, @@ -450,6 +420,11 @@ async def update_community_mcp_service( if not current: raise McpNotFoundError("Market MCP record not found") + user_role = _get_user_role(user_id) + if (user_role not in {"ADMIN", "DEV"} or current.get("tenant_id") != tenant_id + or (user_role == "DEV" and current.get("user_id") != user_id)): + raise UnauthorizedError("Not authorized to update this marketplace listing") + existing_config_json = ( current.get("config_json") if isinstance(current.get("config_json"), dict) else None ) @@ -469,66 +444,31 @@ async def update_community_mcp_service( final_name = name if name is not None else current.get("mcp_name") final_content = content if content is not None else current.get("content") - - # Update fields - update_mcp_market_record( - market_id=market_id, - user_id=user_id, - mcp_name=name, - description=description, - tags=tags, - registry_json=next_registry_json, - mcp_server=mcp_server, - config_json=next_config_json, - transport_type=next_transport_type, - group_ids=convert_list_to_string(group_ids) if group_ids else None, - ingroup_permission=ingroup_permission, - shared_fields=shared_fields, - content=content, - ) - - # Set back to pending_review for re-approval - update_mcp_market_status( - market_id=market_id, - user_id=user_id, - review_status=STATUS_PENDING_REVIEW, - submitted_by=_resolve_user_email(user_id), - ) - - # Update shared_fields on the source MCP record - if shared_fields is not None and current.get("source_mcp_id"): - update_mcp_record_manage_fields_by_id( - mcp_id=current["source_mcp_id"], - tenant_id=tenant_id, - user_id=user_id, - name=current.get("mcp_name") or "", - server_url=current.get("mcp_server") or "", - description=current.get("description") or "", - tags=current.get("tags"), - source="local", - authorization_token=None, - custom_headers=None, - config_json=current.get("config_json"), - market_id=market_id, - shared_fields=shared_fields, - ) - - deactivate_notifications( - event_type=EVENT_TYPE_REPOSITORY_REVIEW_PENDING, - resource_type=RESOURCE_TYPE_MCP_REPOSITORY, - unique_id=market_id, - updated_by=user_id, - ) - _create_mcp_pending_review_notification( - tenant_id=tenant_id, - user_id=user_id, - market_id=market_id, - name=final_name, - source_mcp_id=current.get("source_mcp_id"), - content=final_content, - ) - - + submission_description = content if content is not None else current.get("submit_description") + supplied = {"mcp_name": name, "description": description, "tags": tags, + "registry_json": registry_json, "config_json": config_json, "mcp_server": mcp_server, + "transport_type": next_transport_type, "ingroup_permission": ingroup_permission, + "shared_fields": shared_fields} + if group_ids: + supplied["group_ids"] = convert_list_to_string(group_ids) + changed = any(value is not None and value != current.get(field) for field, value in supplied.items()) + if content is not None: + changed = changed or (content.strip() or None) != current.get("submit_description") + + from management.services.repository.reviews import save_submission + payload = {field: current.get(field) for field in ( + "mcp_name", "description", "tags", "registry_json", "config_json", "mcp_server", + "transport_type", "ingroup_permission", "shared_fields", "group_ids", "source_mcp_id", "source")} + payload.update({field: value for field, value in supplied.items() if value is not None}) + payload["content"] = content or "" + payload["submitted_by"] = _resolve_user_email(user_id) + if current.get("source_mcp_id") is None: + raise ValueError("Source MCP is required for submission") + return save_submission("mcp", current["source_mcp_id"], tenant_id, user_id, user_role, + payload, repository_id=market_id) + + +@repository_transaction async def change_mcp_market_status( *, tenant_id: str, @@ -563,6 +503,20 @@ async def change_mcp_market_status( tenant_id=tenant_id, ) + from management.services.repository.reviews import decide_legacy_review, save_submission + if new_status in (STATUS_SHARED, STATUS_REJECTED): + return decide_legacy_review("mcp", market_id, user_id, tenant_id, user_role, new_status, content) + if new_status == STATUS_PENDING_REVIEW: + if current.get("source_mcp_id") is None: + raise ValueError("Source MCP is required for submission") + payload = {field: current.get(field) for field in ( + "mcp_name", "description", "tags", "registry_json", "config_json", "mcp_server", + "transport_type", "ingroup_permission", "shared_fields", "group_ids", "source_mcp_id", "source")} + payload["content"] = content or "" + payload["submitted_by"] = _resolve_user_email(user_id) + return save_submission("mcp", current["source_mcp_id"], tenant_id, user_id, user_role, + payload, repository_id=market_id) + update_mcp_market_status( market_id=market_id, user_id=user_id, @@ -645,6 +599,9 @@ async def delete_community_mcp_service( if not current: raise McpNotFoundError("Market MCP record not found") + if current.get("tenant_id") != tenant_id or current.get("user_id") != user_id: + raise UnauthorizedError("You can only delete your own MCP listings") + delete_mcp_market_record_by_id(market_id=market_id, user_id=user_id) clear_mcp_record_market_id( tenant_id=tenant_id, diff --git a/backend/management/services/repository/review_notifications.py b/backend/management/services/repository/review_notifications.py index 4ca165a597..08659f7bae 100644 --- a/backend/management/services/repository/review_notifications.py +++ b/backend/management/services/repository/review_notifications.py @@ -2,6 +2,7 @@ from typing import Any +from management.db.repository_transactions import after_repository_commit from typing import Dict from contracts.constants.constants import EVENT_TYPE_REPOSITORY_REVIEW_PENDING from typing import Optional @@ -20,6 +21,7 @@ STATUS_REJECTED: EVENT_TYPE_REPOSITORY_REVIEW_REJECTED, } +@after_repository_commit def create_repository_review_notification( *, resource_type: str, @@ -69,6 +71,7 @@ def create_repository_review_notification( ) +@after_repository_commit def create_repository_pending_review_notification( *, resource_type: str, diff --git a/backend/management/services/repository/reviews.py b/backend/management/services/repository/reviews.py new file mode 100644 index 0000000000..2259ba07d9 --- /dev/null +++ b/backend/management/services/repository/reviews.py @@ -0,0 +1,637 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Snapshot submission and atomic publication, independent of repository state.""" + +import base64 +import io +import json +import logging +import zipfile +from datetime import datetime, timezone +from types import SimpleNamespace +from urllib.parse import urlsplit, urlunsplit + +from contracts.errors.exceptions import ReviewConflictError, UnauthorizedError +from contracts.review import ReviewListItem, ReviewListResponse, ReviewPagination +from management.db.agent.models import AgentInfo +from management.db.client import get_db_session +from management.db.identity.models import UserTenant +from management.db.identity.role_permission import check_role_permission +from management.db.mcp.models import McpRecord +from management.db.repository_reviews import REVIEW_MODELS, get_review, list_reviews +from management.db.repository_transactions import ( + lock_repository_source, + repository_unit_of_work, +) +from management.db.resource_review_model import ResourceReview +from management.db.skill.models import SkillInfo +from management.services.repository.review_notifications import ( + create_repository_pending_review_notification, + create_repository_review_notification, +) +from sqlalchemy import select, update + +logger = logging.getLogger(__name__) + + +def require_repository_access(role): + if not check_role_permission( + role, permission_type="LEFT_NAV_MENU", permission_subtype="/resource-space" + ): + raise UnauthorizedError("Resource repository access is required") + + +def can_decide(resource_type, role): + return role == "ADMIN" or (resource_type == "mcp" and role in {"SU", "SUPER_ADMIN"}) + + +def public_item(record, role, names=None): + payload = record.resource_snapshot["payload"] + return ReviewListItem( + review_id=str(record.review_id), + source_resource_id=str(record.source_resource_id), + resource_type=record.resource_type, + version_no=record.version_no, + repository_id=str(record.repository_id) + if record.repository_id is not None + else None, + tenant_id=record.tenant_id, + name=payload.get("display_name") + or payload.get("name") + or payload.get("mcp_name") + or "", + description=payload.get("description"), + version_label=payload.get("version_name"), + status=record.review_status, + submitted_user_id=record.submitted_user_id, + submitter={ + "user_id": record.submitted_user_id, + **(names or {}).get(record.submitted_user_id, {}), + }, + submitted_at=record.submitted_at, + submit_description=record.submit_description, + review_comment=record.review_comment, + reviewed_by=record.reviewed_by, + reviewed_at=record.reviewed_at, + published_at=record.reviewed_at if record.review_status == "approved" else None, + allowed_actions=["approve", "reject"] + if record.review_status == "pending_review" + and can_decide(record.resource_type, role) + else [], + ) + + +def _names(session, rows: list[dict]) -> dict: + ids = {row["submitted_user_id"] for row in rows if row.get("submitted_user_id")} + if not ids: + return {} + from management.db.models import UserOAuthAccount + + names = { + user_id: {"display_name": email} + for user_id, email in session.execute( + select(UserTenant.user_id, UserTenant.user_email).where( + UserTenant.user_id.in_(ids), UserTenant.delete_flag != "Y" + ) + ).all() + } + identities = session.execute( + select(UserOAuthAccount.user_id, UserOAuthAccount.provider_username) + .where( + UserOAuthAccount.user_id.in_(ids), + UserOAuthAccount.provider == "cas", + UserOAuthAccount.delete_flag == "N", + ) + .order_by(UserOAuthAccount.oauth_account_id.asc()) + ).all() + for user_id, username in identities: + if username and user_id in names: + names[user_id].setdefault("cas_username", username) + return names + + +def _safe_url(value: str | None) -> str | None: + if not value: + return value + parts = urlsplit(value) + host = parts.netloc.rsplit("@", 1)[-1] + return urlunsplit((parts.scheme, host, parts.path, "", "")) + + +def _field_states(value, prefix: str = "") -> list[dict]: + """Expose configuration field paths and presence, never their values.""" + if not isinstance(value, dict): + return [] + result = [] + for key, child in value.items(): + path = f"{prefix}.{key}" if prefix else str(key) + if isinstance(child, dict): + result.extend(_field_states(child, path)) + else: + result.append( + { + "name": path, + "configured": child is not None and child != "", + "masked": True, + } + ) + return result + + +def resource_detail(resource_type: str, record) -> dict: + if resource_type == "mcp": + return { + "source_mcp_id": record.source_mcp_id, + "transport_type": record.transport_type, + "server_url": _safe_url(record.mcp_server), + "shared_fields": { + str(key): bool(value) + for key, value in (record.shared_fields or {}).items() + }, + "sensitive_fields": _field_states(record.config_json) + + _field_states(record.registry_json), + } + if resource_type == "skill": + snapshot = record.skill_info_json or {} + files = [] + unavailable = False + try: + with zipfile.ZipFile( + io.BytesIO(base64.b64decode(record.skill_zip_base64, validate=True)) + ) as archive: + files = [ + {"path": item.filename, "size_bytes": item.file_size} + for item in archive.infolist() + if not item.is_dir() + ] + except (ValueError, zipfile.BadZipFile): + unavailable = True + return { + "skill_id": record.skill_id, + "name": record.name, + "tags": record.tags or [], + "description": record.description, + "readme": snapshot.get("content") or snapshot.get("skill_content"), + "icon": record.icon, + "file_manifest": files[:200], + "file_count": len(files), + "files_unavailable": unavailable, + } + snapshot = record.agent_info_json or {} + agents = snapshot.get("agent_info") or {} + return { + "agent_id": record.agent_id, + "version_no": record.version_no, + "version_name": record.version_name, + "agents": [ + { + "name": info.get("display_name") or info.get("name"), + "description": info.get("description"), + "prompt": info.get("system_prompt") or info.get("prompt"), + "tools": [ + tool.get("name") + for tool in info.get("tools", []) + if isinstance(tool, dict) + ], + } + for info in agents.values() + if isinstance(info, dict) + ] + if isinstance(agents, dict) + else [], + } + + +def _public(session, record, role): + return public_item( + record, role, _names(session, [{"submitted_user_id": record.submitted_user_id}]) + ).model_dump(mode="json") + + +def list_repository_reviews(user_id, tenant_id, role, query): + require_repository_access(role) + with get_db_session() as session: + session.connection(execution_options={"isolation_level": "REPEATABLE READ"}) + rows, total = list_reviews(session, user_id, tenant_id, role, query) + names = _names( + session, [{"submitted_user_id": row.submitted_user_id} for row in rows] + ) + return ReviewListResponse( + items=[public_item(row, role, names) for row in rows], + pagination=ReviewPagination( + page=query.page, page_size=query.page_size, total=total + ), + ).model_dump(mode="json") + + +def get_repository_review(review_id, user_id, tenant_id, role): + require_repository_access(role) + with get_db_session() as session: + record = get_review(session, review_id, user_id, tenant_id, role) + if record is None: + raise LookupError("Review record not found") + result = _public(session, record, role) + if record.repository_id is not None: + model, key, tenant_field, status_field, _ = REVIEW_MODELS[ + record.resource_type + ] + result["current_repository_status"] = session.scalar( + select(getattr(model, status_field)).where( + getattr(model, key) == record.repository_id, + getattr(model, tenant_field) == record.tenant_id, + model.delete_flag != "Y", + ) + ) + payload = record.resource_snapshot["payload"] + result["resource_detail"] = resource_detail( + record.resource_type, SimpleNamespace(**payload) + ) + return result + + +def save_submission( + resource_type, + source_id, + tenant_id, + user_id, + role, + payload, + version_no=None, + repository_id=None, +): + """Persist a frozen validated payload without touching repository contents.""" + model = REVIEW_MODELS[resource_type][0] + for column in model.__table__.columns: + value = payload.get(column.name) + maximum = getattr(column.type, "length", None) + if isinstance(value, str) and maximum is not None and len(value) > maximum: + raise ValueError( + f"{column.name} exceeds the publication field length limit" + ) + snapshot = { + "schema_version": 1, + "payload": json.loads(json.dumps(payload, default=str)), + } + description = (payload.get("content") or "").strip() or None + with repository_unit_of_work() as session: + lock_repository_source(session, resource_type, tenant_id, source_id) + pending = session.scalar( + select(ResourceReview) + .where( + ResourceReview.tenant_id == tenant_id, + ResourceReview.resource_type == resource_type, + ResourceReview.source_resource_id == source_id, + ResourceReview.review_status == "pending_review", + ResourceReview.delete_flag == "N", + ) + .with_for_update() + ) + if pending: + if pending.submitted_user_id != user_id: + raise ReviewConflictError("PENDING_REVIEW_EXISTS") + if ( + pending.resource_snapshot != snapshot + or pending.version_no != version_no + ): + raise ReviewConflictError("PENDING_REVIEW_EXISTS") + return _public(session, pending, role) + record = ResourceReview( + tenant_id=tenant_id, + resource_type=resource_type, + source_resource_id=source_id, + version_no=version_no, + repository_id=repository_id, + submitted_user_id=user_id, + submitted_at=datetime.now(timezone.utc), + submit_description=description, + resource_snapshot=snapshot, + review_status="pending_review", + created_by=user_id, + updated_by=user_id, + delete_flag="N", + ) + session.add(record) + session.flush() + result = _public(session, record, role) + create_repository_pending_review_notification( + resource_type="resource_review", + tenant_id=tenant_id, + unique_id=record.review_id, + created_by=user_id, + details={ + "review_id": str(record.review_id), + "resource_type": resource_type, + "name": result["name"], + }, + ) + return result + + +async def submit_repository_review(user_id, tenant_id, role, request): + require_repository_access(role) + fields = dict(request.listing_fields) + allowed = { + "agent": {"icon_url", "tags"}, + "skill": {"icon", "category_id", "tags"}, + "mcp": { + "name", + "description", + "tags", + "mcp_server", + "config_json", + "group_ids", + "ingroup_permission", + "shared_fields", + }, + } + if fields.keys() - allowed[request.resource_type]: + raise ValueError("Unsupported listing fields") + if (request.resource_type == "agent") != (request.version_no is not None): + raise ValueError("Only Agent submissions require version_no") + source_id = int(request.source_resource_id) + fields["content"] = request.submit_description + from contracts.agent import AgentRepositoryListingCreateRequest + from contracts.skill import SkillRepositoryListingCreateRequest + from contracts.tool import CommunityPublishRequest + + if request.resource_type == "agent": + fields = AgentRepositoryListingCreateRequest.model_validate(fields).model_dump( + exclude_unset=True + ) + elif request.resource_type == "skill": + fields = SkillRepositoryListingCreateRequest.model_validate(fields).model_dump( + exclude_unset=True + ) + else: + validated = CommunityPublishRequest.model_validate( + {"mcp_id": source_id, **fields} + ) + fields = validated.model_dump(exclude_unset=True, exclude={"mcp_id"}) + + if request.resource_type == "agent": + from management.services.repository.agent.publishing import ( + create_agent_repository_listing_impl, + ) + + return await create_agent_repository_listing_impl( + source_id, tenant_id, user_id, request.version_no, card_fields=fields + ) + if request.resource_type == "skill": + from services.skill_repository_service import ( + create_skill_repository_listing_impl, + ) + + return create_skill_repository_listing_impl( + source_id, tenant_id, user_id, card_fields=fields + ) + from management.services.repository.mcp.service import publish_community_mcp_service + + return await publish_community_mcp_service( + tenant_id=tenant_id, user_id=user_id, mcp_id=source_id, **fields + ) + + +def _publish(session, record, user_id): + model, key, tenant_field, status_field, source_field = REVIEW_MODELS[ + record.resource_type + ] + source_model = {"agent": AgentInfo, "skill": SkillInfo, "mcp": McpRecord}[ + record.resource_type + ] + source_key = {"agent": "agent_id", "skill": "skill_id", "mcp": "mcp_id"}[ + record.resource_type + ] + source_filters = [ + getattr(source_model, source_key) == record.source_resource_id, + source_model.tenant_id == record.tenant_id, + source_model.delete_flag != "Y", + ] + if record.resource_type == "agent": + source_filters.append(source_model.version_no == record.version_no) + if ( + session.scalar(select(source_model).where(*source_filters).with_for_update()) + is None + ): + raise ReviewConflictError("SOURCE_UNAVAILABLE") + filters = [ + getattr(model, tenant_field) == record.tenant_id, + getattr(model, source_field) == record.source_resource_id, + model.delete_flag != "Y", + ] + if record.resource_type == "agent": + filters.append(model.version_no == record.version_no) + if record.repository_id is not None: + filters.append(getattr(model, key) == record.repository_id) + target = session.scalar( + select(model) + .where(*filters) + .order_by(getattr(model, key).desc()) + .with_for_update() + ) + if record.repository_id is not None and target is None: + raise ReviewConflictError("REVIEW_MAPPING_CONFLICT") + peers = [ + getattr(model, tenant_field) == record.tenant_id, + getattr(model, source_field) == record.source_resource_id, + getattr(model, status_field) == "shared", + model.delete_flag != "Y", + ] + if target is not None: + peers.append(getattr(model, key) != getattr(target, key)) + session.execute( + update(model) + .where(*peers) + .values(**{status_field: "not_shared", "updated_by": user_id}) + ) + data = record.resource_snapshot["payload"] + protected = { + key, + tenant_field, + "created_by", + "create_time", + "updated_by", + "update_time", + "delete_flag", + "downloads", + "download_count", + "publisher_user_id", + "user_id", + } + values = { + field: value + for field, value in data.items() + if field in model.__table__.columns and field not in protected + } + values.update( + { + source_field: record.source_resource_id, + status_field: "shared", + "updated_by": user_id, + } + ) + if target is None: + values.update( + { + tenant_field: record.tenant_id, + "created_by": record.submitted_user_id, + "delete_flag": "N", + "user_id" + if record.resource_type == "mcp" + else "publisher_user_id": record.submitted_user_id, + } + ) + target = model(**values) + session.add(target) + session.flush() + else: + for field, value in values.items(): + setattr(target, field, value) + record.repository_id = getattr(target, key) + if record.resource_type == "mcp": + session.execute( + update(McpRecord) + .where( + McpRecord.mcp_id == record.source_resource_id, + McpRecord.tenant_id == record.tenant_id, + ) + .values(market_id=record.repository_id, updated_by=user_id) + ) + + +def decide_repository_review(review_id, user_id, tenant_id, role, request): + require_repository_access(role) + with repository_unit_of_work() as session: + record = get_review(session, review_id, user_id, tenant_id, role) + if record is None: + raise LookupError("Review record not found") + if not can_decide(record.resource_type, role): + raise UnauthorizedError("Review decision permission is required") + lock_repository_source( + session, record.resource_type, record.tenant_id, record.source_resource_id + ) + record = get_review( + session, review_id, user_id, tenant_id, role, for_update=True + ) + if record.review_status != "pending_review": + raise ReviewConflictError("REVIEW_ALREADY_PROCESSED") + if request.decision == "approve": + _publish(session, record, user_id) + record.review_status = ( + "approved" if request.decision == "approve" else "rejected" + ) + record.review_comment = request.reason + record.reviewed_by = user_id + record.reviewed_at = datetime.now(timezone.utc) + record.updated_by = user_id + session.flush() + from management.services.notification.service import deactivate_notifications + + deactivate_notifications( + event_type="repository_review_pending", + resource_type="resource_review", + unique_id=record.review_id, + updated_by=user_id, + ) + result = _public(session, record, role) + create_repository_review_notification( + resource_type="resource_review", + review_status="shared" if request.decision == "approve" else "rejected", + receiver_user_id=record.submitted_user_id, + tenant_id=record.tenant_id, + unique_id=record.review_id, + created_by=user_id, + details={ + "review_id": str(record.review_id), + "resource_type": record.resource_type, + "repository_id": str(record.repository_id) + if record.repository_id + else None, + "name": result["name"], + "content": request.reason or "", + }, + ) + return result + + +def decide_legacy_review( + resource_type, repository_id, user_id, tenant_id, role, status, reason=None +): + """Resolve a unique authorized application, never infer the latest completed one.""" + with repository_unit_of_work() as session: + rows = session.scalars( + select(ResourceReview).where( + ResourceReview.resource_type == resource_type, + ResourceReview.repository_id == repository_id, + ResourceReview.review_status == "pending_review", + ResourceReview.delete_flag == "N", + ) + ).all() + if len(rows) != 1: + raise ReviewConflictError("REVIEW_MAPPING_CONFLICT") + return decide_repository_review( + rows[0].review_id, + user_id, + tenant_id, + role, + SimpleNamespace( + decision="approve" if status == "shared" else "reject", reason=reason + ), + ) + + +def get_legacy_repository_review( + resource_type, repository_id, user_id, tenant_id, role +): + """Resolve only a unique visible application for an old repository identity.""" + from management.db.repository_reviews import scope_conditions + + require_repository_access(role) + with get_db_session() as session: + ids = session.scalars( + select(ResourceReview.review_id).where( + ResourceReview.repository_id == repository_id, + *scope_conditions(resource_type, user_id, tenant_id, role), + ) + ).all() + if not ids: + raise LookupError("Review record not found") + if len(ids) != 1: + raise ReviewConflictError("REVIEW_MAPPING_CONFLICT") + return get_repository_review(ids[0], user_id, tenant_id, role) + + +def attach_latest_reviews(items, resource_type, source_key, user_id, tenant_id): + """Batch project application status separately from current repository state.""" + from management.db.identity.user_tenant import get_user_tenant_by_user_id + from management.db.repository_reviews import scope_conditions + + source_ids = [ + int(item[source_key]) for item in items if item.get(source_key) is not None + ] + if not source_ids: + return + role = (get_user_tenant_by_user_id(user_id) or {}).get("user_role", "USER") + with get_db_session() as session: + rows = session.scalars( + select(ResourceReview) + .where( + ResourceReview.source_resource_id.in_(source_ids), + *scope_conditions(resource_type, user_id, tenant_id, role), + ) + .distinct(ResourceReview.source_resource_id) + .order_by( + ResourceReview.source_resource_id, ResourceReview.review_id.desc() + ) + ).all() + summaries = { + row.source_resource_id: { + "review_id": str(row.review_id), + "status": row.review_status, + "submitted_at": row.submitted_at.isoformat(), + "reviewed_at": row.reviewed_at.isoformat() if row.reviewed_at else None, + } + for row in rows + } + for item in items: + if item.get(source_key) is not None: + item["latest_review"] = summaries.get(int(item[source_key])) diff --git a/backend/management/services/repository/skill_installations.py b/backend/management/services/repository/skill_installations.py new file mode 100644 index 0000000000..d8f9b7c145 --- /dev/null +++ b/backend/management/services/repository/skill_installations.py @@ -0,0 +1,70 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Batch installation projections based only on user-visible Skill copies.""" + +from sqlalchemy import and_, or_, select + +from management.db.client import get_db_session +from management.db.identity.group import query_group_ids_by_user +from management.db.skill.models import SkillInfo, SkillInstance +from contracts.errors.exceptions import ForbiddenError +from management.services.skill.permission import _can_edit_skill +from management.services.skill.permission import can_view_skill, resolve_skill_permission +from management.utils.serialization import convert_string_to_list + + +def installation_states(repository_ids: list[int], visible: list[dict]) -> dict: + copies = {str(repository_id): [] for repository_id in repository_ids} + unknown = False + for skill in visible: + source_id = skill.get("source_repository_id") + if skill.get("source") == "repository" and source_id is None: + unknown = True + if source_id is not None and str(source_id) in copies: + copies[str(source_id)].append({"skill_id": skill["skill_id"], "name": skill["name"], + "can_uninstall": skill.get("permission") == "EDIT"}) + return {key: {"installed_copies": value, + "installation_state": "installed" if value else "unknown" if unknown else "not_installed"} + for key, value in copies.items()} + + +def get_installation_states(repository_ids: list[int], tenant_id: str, user_id: str, role: str) -> dict: + if not repository_ids: + return {} + groups = set(query_group_ids_by_user(user_id) or []) + with get_db_session() as session: + rows = session.execute(select(SkillInfo.skill_id, SkillInfo.skill_name, SkillInfo.source, + SkillInfo.source_repository_id, SkillInfo.created_by, SkillInfo.group_ids, + SkillInfo.ingroup_permission).where( + SkillInfo.tenant_id == tenant_id, SkillInfo.delete_flag != "Y", + or_(SkillInfo.source_repository_id.in_(repository_ids), + and_(SkillInfo.source == "repository", SkillInfo.source_repository_id.is_(None))))).mappings().all() + visible = [] + for row in rows: + skill = {**dict(row), "name": row["skill_name"], "group_ids": convert_string_to_list(row["group_ids"])} + if can_view_skill(skill=skill, user_id=user_id, user_role=role, user_group_ids=groups): + skill["permission"] = resolve_skill_permission(skill=skill, user_id=user_id, user_role=role, user_group_ids=groups) + visible.append(skill) + return installation_states(repository_ids, visible) + + +def uninstall_repository_copy(repository_id: int | None, skill_id: int, tenant_id: str, user_id: str) -> None: + from management.db.repository_transactions import repository_unit_of_work + from management.services.skill.service import SkillService + + with repository_unit_of_work() as session: + skill = session.scalar(select(SkillInfo).where( + SkillInfo.skill_id == skill_id, SkillInfo.tenant_id == tenant_id, + *([SkillInfo.source_repository_id == repository_id] if repository_id is not None else []), SkillInfo.delete_flag != "Y", + ).with_for_update()) + if skill is None: + raise LookupError("Installed copy not found") + policy = {"created_by": skill.created_by, "group_ids": convert_string_to_list(skill.group_ids), + "ingroup_permission": skill.ingroup_permission} + if not _can_edit_skill(policy, user_id): + raise ForbiddenError("Installed copy is read-only") + if session.scalar(select(SkillInstance.skill_instance_id).where( + SkillInstance.skill_id == skill_id, SkillInstance.tenant_id == tenant_id, + SkillInstance.delete_flag != "Y").limit(1)) is not None: + raise ValueError("Skill is referenced by an agent") + SkillService(tenant_id=tenant_id).delete_skill(skill.skill_name, tenant_id=tenant_id, user_id=user_id) diff --git a/backend/management/services/skill/service.py b/backend/management/services/skill/service.py index b8273bece3..3936180eb7 100644 --- a/backend/management/services/skill/service.py +++ b/backend/management/services/skill/service.py @@ -165,6 +165,7 @@ _tooltip_for_commented_seq_index, _tuple_slot2, ) +from management.db.repository_transactions import repository_unit_of_work from management.services.skill.permission import ( _apply_default_skill_permission_fields, _can_edit_skill, @@ -522,6 +523,7 @@ def _save_skill_upload( skip_duplicate_check: bool = False, ingroup_permission: Optional[str] = None, rewrite_name: bool = False, + source_repository_id: Optional[int] = None, ) -> Dict[str, Any]: """Share parsing and persistence while keeping operation-specific policies.""" if len(content) > MAX_SKILL_UPLOAD_SIZE_BYTES: @@ -664,29 +666,58 @@ def _save_skill_upload( if ingroup_permission is not None: data["ingroup_permission"] = ingroup_permission _apply_default_skill_permission_fields(data, user_id) - result = skill_db.create_skill(data, tenant_id) + if source_repository_id is not None: + data["source_repository_id"] = source_repository_id + result = None + else: + result = skill_db.create_skill(data, tenant_id) - self.skill_manager.save_skill(data, tenant_id=tenant_id) - if is_zip: - overrides = None - if rewrite_name and str(parsed.get("name") or "") != name: - overrides = { - manifest_path: _replace_skill_frontmatter_name(text, name).encode( - "utf-8" + try: + self.skill_manager.save_skill(data, tenant_id=tenant_id) + if is_zip: + overrides = None + if rewrite_name and str(parsed.get("name") or "") != name: + overrides = { + manifest_path: _replace_skill_frontmatter_name(text, name).encode( + "utf-8" + ) + } + if rewrite_name: + self._upload_zip_files( + content, + name, + original_root, + tenant_id=tenant_id, + file_overrides=overrides, ) - } - if rewrite_name: - self._upload_zip_files( - content, - name, - original_root, - tenant_id=tenant_id, - file_overrides=overrides, - ) - else: - self._upload_zip_files( - content, name, original_root, tenant_id=tenant_id - ) + else: + self._upload_zip_files( + content, name, original_root, tenant_id=tenant_id + ) + except Exception: + if source_repository_id is not None: + self._delete_local_skill_files(name, tenant_id=tenant_id) + raise + if source_repository_id is not None: + from sqlalchemy import select + from management.db.skill.models import SkillRepository + from management.db.skill.catalog import increment_skill_repository_downloads + + try: + with repository_unit_of_work() as session: + listing = session.scalar(select(SkillRepository).where( + SkillRepository.skill_repository_id == source_repository_id, + SkillRepository.publisher_tenant_id == tenant_id, + SkillRepository.status == "shared", SkillRepository.delete_flag != "Y", + ).with_for_update()) + if listing is None: + raise SkillException("Repository listing is no longer available") + result = skill_db.create_skill(data, tenant_id) + if increment_skill_repository_downloads(repository_id=source_repository_id, user_id=user_id) != 1: + raise SkillException("Could not record repository installation") + except Exception: + self._delete_local_skill_files(name, tenant_id=tenant_id) + raise return self._enrich_configs_from_yaml(result) def update_skill_from_file( @@ -1265,6 +1296,7 @@ def create_skill_from_zip_bytes( tenant_id: Optional[str] = None, skip_duplicate_check: bool = False, ingroup_permission: Optional[str] = None, + source_repository_id: Optional[int] = None, ) -> Dict[str, Any]: """Import through the shared pipeline with optional frontmatter rename.""" return self._save_skill_upload( @@ -1274,6 +1306,7 @@ def create_skill_from_zip_bytes( tenant_id=tenant_id, user_id=user_id, source=source, + source_repository_id=source_repository_id, skip_duplicate_check=skip_duplicate_check, ingroup_permission=ingroup_permission, rewrite_name=True, diff --git a/backend/services/skill_repository_service.py b/backend/services/skill_repository_service.py index 7d6c86e10f..6610acc930 100644 --- a/backend/services/skill_repository_service.py +++ b/backend/services/skill_repository_service.py @@ -6,6 +6,7 @@ import math import re from typing import Any, Dict, FrozenSet, List, Optional, Tuple +from management.db.repository_transactions import repository_transaction from contracts.constants.constants import OWNERSHIP_ALL, OWNERSHIP_CREATED, OWNERSHIP_OTHERS, STATUS_NOT_SHARED, STATUS_PENDING_REVIEW, STATUS_REJECTED, STATUS_SHARED, VALID_OWNERSHIP_FILTERS, VALID_REPOSITORY_STATUSES from contracts.constants.constants import PERMISSION_PRIVATE, PERMISSION_READ @@ -109,6 +110,7 @@ def _to_summary_item( "created_at": record.get("created_at") or _serialize_created_at(record.get("create_time")), "updated_at": record.get("updated_at") or _serialize_created_at(record.get("update_time")), "content": record.get("content"), + "published_at": _serialize_created_at(record.get("published_at")), } if can_take_down is not None: item["can_take_down"] = can_take_down @@ -266,10 +268,13 @@ def _get_user_role(user_id: str) -> str: def ensure_skill_repository_access(user_id: str) -> None: - """Reject ordinary users from the Skill Repository API surface.""" + """Honor explicit unified repository grants while preserving default role access.""" user_role = _get_user_role(user_id).upper() if user_role == "USER": - raise ForbiddenError("User role USER is not authorized to access Skill Repository") + from management.db.identity.role_permission import check_role_permission + + if not check_role_permission(user_role, permission_type="LEFT_NAV_MENU", permission_subtype="/resource-space"): + raise ForbiddenError("User role USER is not authorized to access Skill Repository") def _can_publish_skill( @@ -503,6 +508,7 @@ def _validate_create_payload(repository_data: Dict[str, Any]) -> None: _validate_card_fields(repository_data) +@repository_transaction def create_skill_repository_listing_impl( skill_id: int, tenant_id: str, @@ -511,6 +517,8 @@ def create_skill_repository_listing_impl( card_fields: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """Create or update a repository listing from the current skill snapshot.""" + from management.db.repository_transactions import current_repository_session, lock_repository_source + lock_repository_source(current_repository_session(), "skill", tenant_id, skill_id) repository_data = _build_repository_data_from_skill( skill_id, tenant_id, @@ -519,64 +527,8 @@ def create_skill_repository_listing_impl( ) _validate_create_payload(repository_data) - existing = _find_resubmittable_repository_record( - skill_id, - tenant_id, - ) - was_pending_review = bool( - existing and existing.get("status") == STATUS_PENDING_REVIEW - ) - if not existing: - repository_id = insert_skill_repository_record( - repository_data=repository_data, - publisher_tenant_id=tenant_id, - publisher_user_id=user_id, - ) - is_updated = False - else: - repository_id = int(existing["skill_repository_id"]) - updates = { - key: repository_data[key] - for key in _UPDATE_SNAPSHOT_FIELDS - if key in repository_data - } - affected = update_skill_repository_by_id( - repository_id=repository_id, - publisher_tenant_id=tenant_id, - user_id=user_id, - updates=updates, - ) - if affected == 0: - raise ValueError("Failed to update repository listing") - is_updated = True - - _reset_repository_peer_statuses( - skill_repository_id=repository_id, - skill_id=skill_id, - status=STATUS_PENDING_REVIEW, - publisher_tenant_id=tenant_id, - ) - - record = get_skill_repository_by_id_and_publisher( - repository_id, - tenant_id, - ) - if not record: - raise ValueError("Failed to load repository listing after write") - if not was_pending_review: - create_repository_pending_review_notification( - resource_type=RESOURCE_TYPE_SKILL_REPOSITORY, - tenant_id=tenant_id, - unique_id=repository_id, - details={ - "name": record.get("name"), - "skill_repository_id": repository_id, - "skill_id": record.get("skill_id"), - "content": record.get("content") or "", - }, - created_by=user_id, - ) - return _to_detail_item(record, is_updated=is_updated) + from management.services.repository.reviews import save_submission + return save_submission("skill", skill_id, tenant_id, user_id, _get_user_role(user_id), repository_data) def _validate_su_status_transition( @@ -607,6 +559,8 @@ def _validate_publisher_status_transition( owner_user_id = snapshot.get("created_by") or record.get("publisher_user_id") if str(owner_user_id) != str(user_id): raise ForbiddenError("Not authorized to update this repository listing") + if current_status == new_status: + return None if user_role == "ADMIN" and transition in _ADMIN_REVIEW_STATUS_TRANSITIONS: return None if transition not in _PUBLISHER_STATUS_TRANSITIONS: @@ -634,6 +588,8 @@ def _validate_repository_status_transition( transition = (current_status, new_status) if user_role == "SU": + if current_status == new_status: + return None _validate_su_status_transition(transition, current_status, new_status) return None @@ -653,6 +609,7 @@ def _validate_repository_status_transition( ) +@repository_transaction def update_skill_repository_status_impl( *, skill_repository_id: int, @@ -678,19 +635,25 @@ def update_skill_repository_status_impl( current_status = record.get("status") publisher_updates: Optional[Dict[str, str]] = None submitted_by: Optional[str] = None + user_role = _get_user_role(user_id) + publisher_updates = _validate_repository_status_transition( + user_role=user_role, + current_status=current_status, + new_status=status, + record=record, + user_id=user_id, + tenant_id=tenant_id, + ) if current_status != status: - user_role = _get_user_role(user_id) - publisher_updates = _validate_repository_status_transition( - user_role=user_role, - current_status=current_status, - new_status=status, - record=record, - user_id=user_id, - tenant_id=tenant_id, - ) if status == STATUS_PENDING_REVIEW: submitted_by = _resolve_submitter_email(user_id) + if status in (STATUS_SHARED, STATUS_REJECTED): + from management.services.repository.reviews import decide_legacy_review + return decide_legacy_review("skill", skill_repository_id, user_id, tenant_id, user_role, status, content) + if status == STATUS_PENDING_REVIEW: + return create_skill_repository_listing_impl(record["skill_id"], tenant_id, user_id, card_fields={"content": content}) + rows_affected = update_skill_repository_status_by_id( repository_id=skill_repository_id, status=status, @@ -735,6 +698,15 @@ def update_skill_repository_status_impl( content=content, ) + if status == STATUS_PENDING_REVIEW and (updated.get("submission_revision") or 0) > (record.get("submission_revision") or 0): + deactivate_notifications(event_type=EVENT_TYPE_REPOSITORY_REVIEW_PENDING, + resource_type=RESOURCE_TYPE_SKILL_REPOSITORY, unique_id=skill_repository_id, updated_by=user_id) + create_repository_pending_review_notification(resource_type=RESOURCE_TYPE_SKILL_REPOSITORY, + tenant_id=tenant_id, unique_id=skill_repository_id, created_by=user_id, + details={"name": updated.get("display_name") or updated.get("name"), + "skill_repository_id": skill_repository_id, "skill_id": updated.get("skill_id"), + "content": updated.get("submit_description") or ""}) + enrich_repository_identities([updated], "skill") return _to_summary_item(updated) @@ -760,14 +732,14 @@ def _handle_review_status_notifications( create_repository_review_notification( resource_type=RESOURCE_TYPE_SKILL_REPOSITORY, review_status=new_status, - receiver_user_id=updated["publisher_user_id"], + receiver_user_id=updated.get("submitted_user_id") or updated["publisher_user_id"], details=details, tenant_id=updated.get("publisher_tenant_id"), unique_id=skill_repository_id, created_by=user_id, ) - if current_status == STATUS_PENDING_REVIEW: + if current_status == STATUS_PENDING_REVIEW and new_status != STATUS_PENDING_REVIEW: deactivate_notifications( event_type=EVENT_TYPE_REPOSITORY_REVIEW_PENDING, resource_type=RESOURCE_TYPE_SKILL_REPOSITORY, @@ -845,6 +817,7 @@ def install_skill_from_repository_impl( zip_bytes=zip_bytes, skill_name=copy_skill_name, source="repository", + source_repository_id=skill_repository_id, user_id=user_id, tenant_id=tenant_id, ingroup_permission=PERMISSION_READ, @@ -856,17 +829,6 @@ def install_skill_from_repository_impl( raise SkillDuplicateError([duplicate_name]) from exc raise - affected = increment_skill_repository_downloads( - repository_id=skill_repository_id, - user_id=user_id, - ) - if affected == 0: - logger.warning( - "Failed to increment skill repository downloads after install " - "(skill_repository_id=%s)", - skill_repository_id, - ) - return { "skill_id": created_skill.get("skill_id"), "name": created_skill.get("name"), @@ -1081,6 +1043,8 @@ def list_skill_repository_listings_impl( tag_predicates: Optional[list] = None, tag: Optional[str] = None, sort_by_update_time: bool = False, + include_installations: bool = False, + sort_by_published_time: bool = False, ) -> Dict[str, Any]: """List skill repository listings for the caller tenant with optional filters.""" if status is not None and status not in VALID_REPOSITORY_STATUSES: @@ -1109,6 +1073,7 @@ def list_skill_repository_listings_impl( ) skill_ids = [int(skill_id) for skill_id in matched_ids] + user_role = _get_user_role(user_id) result = list_skill_repository_summaries( publisher_tenant_id=tenant_id, status=status, @@ -1120,12 +1085,20 @@ def list_skill_repository_listings_impl( search=search, tag=tag, sort_by_update_time=sort_by_update_time, + **({"review_user_id": user_id} if user_role != "ADMIN" and status != STATUS_SHARED else {}), + **({"sort_by_published_time": True} if sort_by_published_time else {}), ) user_role = _get_user_role(user_id) enrich_repository_identities(result.get("items", []), "skill") + installation_info = {} + if include_installations: + from management.services.repository.skill_installations import get_installation_states + installation_info = get_installation_states( + [record["skill_repository_id"] for record in result.get("items", [])], tenant_id, user_id, user_role) + return { "items": [ - _to_summary_item( + {**installation_info.get(str(record["skill_repository_id"]), {}), **_to_summary_item( record, can_take_down=( record.get("status") == STATUS_SHARED @@ -1137,7 +1110,7 @@ def list_skill_repository_listings_impl( ) ) ), - ) + )} for record in result.get("items", []) ], "pagination": result.get("pagination"), diff --git a/deploy/sql/migrations/v2.8.0_002_repository_review_metadata.sql b/deploy/sql/migrations/v2.8.0_002_repository_review_metadata.sql new file mode 100644 index 0000000000..0ef5cf0d6f --- /dev/null +++ b/deploy/sql/migrations/v2.8.0_002_repository_review_metadata.sql @@ -0,0 +1,11 @@ +-- Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +-- Preserve the installation source link independently of resource review storage. + +ALTER TABLE nexent.ag_skill_info_t + ADD COLUMN IF NOT EXISTS source_repository_id BIGINT NULL; +COMMENT ON COLUMN nexent.ag_skill_info_t.source_repository_id IS + 'Source Skill repository listing ID for an installed copy; NULL when not associated'; + +-- Historical identities, business timestamps and installation sources are not inferred. +-- Query indexes will be added with the actual query implementation and plan evidence. diff --git a/deploy/sql/migrations/v2.8.0_003_repository_review_indexes.sql b/deploy/sql/migrations/v2.8.0_003_repository_review_indexes.sql new file mode 100644 index 0000000000..5a7209874e --- /dev/null +++ b/deploy/sql/migrations/v2.8.0_003_repository_review_indexes.sql @@ -0,0 +1,6 @@ +-- Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +-- Index the installation source relationship; reviews use the independent review table. +CREATE INDEX IF NOT EXISTS ix_ag_skill_info_repository_source + ON nexent.ag_skill_info_t (tenant_id, source_repository_id) + WHERE delete_flag <> 'Y'; diff --git a/deploy/sql/migrations/v2.8.0_004_resource_review_submissions.sql b/deploy/sql/migrations/v2.8.0_004_resource_review_submissions.sql new file mode 100644 index 0000000000..5b1a91a3f5 --- /dev/null +++ b/deploy/sql/migrations/v2.8.0_004_resource_review_submissions.sql @@ -0,0 +1,116 @@ +-- Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +-- Run with submission and decision writers stopped; migrate legacy pending submissions atomically. +BEGIN; +LOCK TABLE nexent.ag_agent_repository_t, nexent.ag_skill_repository_t, nexent.mcp_market_record_t IN ACCESS EXCLUSIVE MODE; +CREATE TABLE IF NOT EXISTS nexent.ag_resource_review_t ( + review_id BIGSERIAL PRIMARY KEY, + tenant_id VARCHAR(100) NOT NULL, + resource_type VARCHAR(30) NOT NULL CHECK (resource_type IN ('agent','skill','mcp')), + source_resource_id BIGINT NOT NULL, + version_no INTEGER, + repository_id BIGINT, + submitted_user_id VARCHAR(100) NOT NULL, + submitted_at TIMESTAMPTZ NOT NULL, + submit_description TEXT, + resource_snapshot JSONB NOT NULL, + review_status VARCHAR(30) NOT NULL DEFAULT 'pending_review' CHECK (review_status IN ('pending_review','approved','rejected')), + review_comment TEXT, + reviewed_by VARCHAR(100), + reviewed_at TIMESTAMPTZ, + created_by VARCHAR(100), create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_by VARCHAR(100), update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + delete_flag VARCHAR(1) NOT NULL DEFAULT 'N', + CHECK ((resource_type = 'agent' AND version_no IS NOT NULL AND version_no >= 0) + OR (resource_type <> 'agent' AND version_no IS NULL)) +); +COMMENT ON TABLE nexent.ag_resource_review_t IS 'Immutable submitted resource snapshots and final decisions; publishing occurs only on approval'; +COMMENT ON COLUMN nexent.ag_resource_review_t.review_id IS 'Independent submission identifier'; +COMMENT ON COLUMN nexent.ag_resource_review_t.tenant_id IS 'Resource owner tenant'; +COMMENT ON COLUMN nexent.ag_resource_review_t.resource_type IS 'Resource kind: agent, skill or mcp'; +COMMENT ON COLUMN nexent.ag_resource_review_t.source_resource_id IS 'Original local resource identifier'; +COMMENT ON COLUMN nexent.ag_resource_review_t.version_no IS 'Agent version; NULL for Skill and MCP'; +COMMENT ON COLUMN nexent.ag_resource_review_t.repository_id IS 'Published listing ID; nullable before initial approval'; +COMMENT ON COLUMN nexent.ag_resource_review_t.submitted_user_id IS 'Internal submitter ID; migrated pending rows use original source creator'; +COMMENT ON COLUMN nexent.ag_resource_review_t.submitted_at IS 'Submission instant; legacy pending rows use original update time interpreted as UTC'; +COMMENT ON COLUMN nexent.ag_resource_review_t.submit_description IS 'Submission listing description, migrated from legacy content'; +COMMENT ON COLUMN nexent.ag_resource_review_t.resource_snapshot IS 'Schema-versioned immutable publication payload'; +COMMENT ON COLUMN nexent.ag_resource_review_t.review_status IS 'Independent submission state: pending_review, approved or rejected'; +COMMENT ON COLUMN nexent.ag_resource_review_t.review_comment IS 'Final review opinion'; +COMMENT ON COLUMN nexent.ag_resource_review_t.reviewed_by IS 'Decision actor internal ID'; +COMMENT ON COLUMN nexent.ag_resource_review_t.reviewed_at IS 'Decision instant; publication instant for approvals'; +COMMENT ON COLUMN nexent.ag_resource_review_t.created_by IS 'Creating actor'; +COMMENT ON COLUMN nexent.ag_resource_review_t.create_time IS 'Creation time'; +COMMENT ON COLUMN nexent.ag_resource_review_t.updated_by IS 'Last updating actor'; +COMMENT ON COLUMN nexent.ag_resource_review_t.update_time IS 'Last update time'; +COMMENT ON COLUMN nexent.ag_resource_review_t.delete_flag IS 'Soft deletion marker N or Y'; + +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM nexent.ag_agent_repository_t r LEFT JOIN nexent.ag_tenant_agent_t s ON s.agent_id=r.agent_id AND s.tenant_id=r.publisher_tenant_id AND s.delete_flag <> 'Y' AND s.version_no=r.version_no + WHERE r.status='pending_review' AND r.delete_flag <> 'Y' + AND (s.agent_id IS NULL OR NULLIF(s.created_by,'') IS NULL OR r.update_time IS NULL OR NOT (r.agent_info_json IS NOT NULL AND r.agent_info_json ?& ARRAY['agent_id','agent_info','mcp_info']))) + THEN RAISE EXCEPTION 'Pending agent migration has missing source, creator, time or snapshot; inspect and repair before retry'; END IF; +END $$; +INSERT INTO nexent.ag_resource_review_t + (tenant_id,resource_type,source_resource_id,version_no,repository_id,submitted_user_id,submitted_at, + submit_description,resource_snapshot,review_status,created_by,updated_by,delete_flag) +SELECT r.publisher_tenant_id,'agent',r.agent_id,r.version_no,r.agent_repository_id,s.created_by, + r.update_time AT TIME ZONE 'UTC',r.content, + jsonb_build_object('schema_version',1,'payload',to_jsonb(r)), + 'pending_review','review_migration','review_migration','N' +FROM nexent.ag_agent_repository_t r JOIN nexent.ag_tenant_agent_t s ON s.agent_id=r.agent_id AND s.tenant_id=r.publisher_tenant_id AND s.delete_flag <> 'Y' AND s.version_no=r.version_no +WHERE r.status='pending_review' AND r.delete_flag <> 'Y' +AND NOT EXISTS (SELECT 1 FROM nexent.ag_resource_review_t a WHERE a.resource_type='agent' + AND a.tenant_id=r.publisher_tenant_id AND a.repository_id=r.agent_repository_id AND a.delete_flag='N'); + +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM nexent.ag_skill_repository_t r LEFT JOIN nexent.ag_skill_info_t s ON s.skill_id=r.skill_id AND s.tenant_id=r.publisher_tenant_id AND s.delete_flag <> 'Y' + WHERE r.status='pending_review' AND r.delete_flag <> 'Y' + AND (s.skill_id IS NULL OR NULLIF(s.created_by,'') IS NULL OR r.update_time IS NULL OR NOT (r.skill_info_json IS NOT NULL AND r.skill_zip_base64 IS NOT NULL AND length(decode(r.skill_zip_base64,'base64')) >= 22))) + THEN RAISE EXCEPTION 'Pending skill migration has missing source, creator, time or snapshot; inspect and repair before retry'; END IF; +END $$; +INSERT INTO nexent.ag_resource_review_t + (tenant_id,resource_type,source_resource_id,version_no,repository_id,submitted_user_id,submitted_at, + submit_description,resource_snapshot,review_status,created_by,updated_by,delete_flag) +SELECT r.publisher_tenant_id,'skill',r.skill_id,NULL,r.skill_repository_id,s.created_by, + r.update_time AT TIME ZONE 'UTC',r.content, + jsonb_build_object('schema_version',1,'payload',to_jsonb(r)), + 'pending_review','review_migration','review_migration','N' +FROM nexent.ag_skill_repository_t r JOIN nexent.ag_skill_info_t s ON s.skill_id=r.skill_id AND s.tenant_id=r.publisher_tenant_id AND s.delete_flag <> 'Y' +WHERE r.status='pending_review' AND r.delete_flag <> 'Y' +AND NOT EXISTS (SELECT 1 FROM nexent.ag_resource_review_t a WHERE a.resource_type='skill' + AND a.tenant_id=r.publisher_tenant_id AND a.repository_id=r.skill_repository_id AND a.delete_flag='N'); + +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM nexent.mcp_market_record_t r LEFT JOIN nexent.mcp_record_t s ON s.mcp_id=r.source_mcp_id AND s.tenant_id=r.tenant_id AND s.delete_flag <> 'Y' + WHERE r.review_status='pending_review' AND r.delete_flag <> 'Y' + AND (s.mcp_id IS NULL OR NULLIF(s.created_by,'') IS NULL OR r.update_time IS NULL OR NOT (r.mcp_name IS NOT NULL))) + THEN RAISE EXCEPTION 'Pending mcp migration has missing source, creator, time or snapshot; inspect and repair before retry'; END IF; +END $$; +INSERT INTO nexent.ag_resource_review_t + (tenant_id,resource_type,source_resource_id,version_no,repository_id,submitted_user_id,submitted_at, + submit_description,resource_snapshot,review_status,created_by,updated_by,delete_flag) +SELECT r.tenant_id,'mcp',r.source_mcp_id,NULL,r.market_id,s.created_by, + r.update_time AT TIME ZONE 'UTC',r.content, + jsonb_build_object('schema_version',1,'payload',to_jsonb(r)), + 'pending_review','review_migration','review_migration','N' +FROM nexent.mcp_market_record_t r JOIN nexent.mcp_record_t s ON s.mcp_id=r.source_mcp_id AND s.tenant_id=r.tenant_id AND s.delete_flag <> 'Y' +WHERE r.review_status='pending_review' AND r.delete_flag <> 'Y' +AND NOT EXISTS (SELECT 1 FROM nexent.ag_resource_review_t a WHERE a.resource_type='mcp' + AND a.tenant_id=r.tenant_id AND a.repository_id=r.market_id AND a.delete_flag='N'); + +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM nexent.ag_resource_review_t WHERE review_status='pending_review' AND delete_flag='N' + GROUP BY tenant_id,resource_type,source_resource_id HAVING count(*)>1) + THEN RAISE EXCEPTION 'Multiple pending applications for one source; reconcile before switching readers'; END IF; +END $$; +CREATE INDEX IF NOT EXISTS idx_resource_review_mine ON nexent.ag_resource_review_t + (tenant_id,submitted_user_id,review_status,submitted_at DESC,review_id DESC) WHERE delete_flag='N'; +CREATE INDEX IF NOT EXISTS idx_resource_review_tenant ON nexent.ag_resource_review_t + (tenant_id,review_status,submitted_at DESC,review_id DESC) WHERE delete_flag='N'; +CREATE INDEX IF NOT EXISTS idx_resource_review_source ON nexent.ag_resource_review_t + (resource_type,tenant_id,source_resource_id,review_status) WHERE delete_flag='N'; +CREATE INDEX IF NOT EXISTS idx_resource_review_publication ON nexent.ag_resource_review_t + (resource_type,tenant_id,repository_id,review_status,reviewed_at DESC) WHERE delete_flag='N'; + +COMMIT; diff --git a/frontend/app/[locale]/agent-space/agent-space.tsx b/frontend/app/[locale]/agent-space/agent-space.tsx index 861206b043..db0e9460f4 100644 --- a/frontend/app/[locale]/agent-space/agent-space.tsx +++ b/frontend/app/[locale]/agent-space/agent-space.tsx @@ -2,388 +2,4 @@ "use client"; -import { useMemo, useState } from "react"; -import type { MenuProps } from "antd"; -import { App, Button, Dropdown, Empty, Input, Modal, Select, Spin } from "antd"; -import { - Bot, - Copy, - Download, - MoreHorizontal, - PackageX, - Search, - ShieldCheck, -} from "lucide-react"; -import { useUserDisplayName } from "@/hooks/auth/useUserDisplayName"; -import { useTranslation } from "react-i18next"; -import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; -import { USER_ROLES } from "@/const/auth"; -import { useTagLibraries, useTagDefinitions } from "@/hooks/useTagManagement"; -import { getTagSearchPredicates } from "@/lib/systemTagLabels"; - -import { REPOSITORY_PAGE_SIZE } from "@/const/resource"; -import ResourceCardGrid from "@/components/resource/ResourceCardGrid"; -import { AgentCardTags } from "./components/AgentCardTags"; -import ResourceCard from "@/components/resource/ResourceCard"; -import { AgentDetail } from "@/components/agent/agent-detail"; -import TagFilterPopover from "@/components/tag/TagFilterPopover"; -import { getAgentRepositoryTagLabel } from "@/lib/agentRepositoryLabels"; -import type { TagResourcePredicate } from "@/types/tagManagement"; -import type { AgentRepositoryListingItem } from "@/types/agentRepository"; -import { AgentRepositoryCopyDialog } from "./components/AgentRepositoryCopyDialog"; -import { RepositoryAgentIcon } from "./components/RepositoryAgentIcon"; -import { - useAgentRepositoryListings, - useUpdateAgentRepositoryStatus, -} from "@/hooks/agentRepository/useAgentRepositoryListings"; -import { useRepositoryAgentDetail } from "@/hooks/agentRepository/useRepositoryAgentDetail"; - -type RepositorySortOrder = "default" | "latest"; - -export function AgentSpace({ active }: { active: boolean }) { - const { t } = useTranslation("common"); - const displayName = useUserDisplayName(); - const { message } = App.useApp(); - const { user } = useAuthorizationContext(); - const showAdminMenu = user?.role === USER_ROLES.ADMIN; - const [searchQuery, setSearchQuery] = useState(""); - const [tagPredicates, setTagPredicates] = useState( - [] - ); - const [sortOrder, setSortOrder] = useState("default"); - const [page, setPage] = useState(1); - const { data: tagLibraries } = useTagLibraries(); - const defaultTagLibrary = - tagLibraries?.find( - (library) => library.bucket_key === "default_resource" - ) ?? null; - const { data: tagDefinitions } = useTagDefinitions( - defaultTagLibrary?.bucket_id ?? null - ); - const searchTagPredicates = useMemo( - () => getTagSearchPredicates(tagDefinitions, searchQuery, t), - [tagDefinitions, searchQuery, t] - ); - const listingParams = useMemo( - () => ({ - status: "shared" as const, - page, - page_size: REPOSITORY_PAGE_SIZE, - ...(searchQuery.trim() ? { search: searchQuery.trim() } : {}), - ...(searchTagPredicates.length > 0 - ? { search_tag_predicates: searchTagPredicates } - : {}), - ...(tagPredicates.length > 0 ? { tag_predicates: tagPredicates } : {}), - ...(sortOrder === "latest" ? { sort_by_update_time: true } : {}), - }), - [page, searchQuery, searchTagPredicates, tagPredicates, sortOrder] - ); - const { data, isLoading, isError, isFetching, refetch } = - useAgentRepositoryListings(listingParams, active); - const updateStatusMutation = useUpdateAgentRepositoryStatus(); - const listings = data?.items ?? []; - const total = data?.pagination?.total ?? 0; - const updatingRepositoryId = updateStatusMutation.isPending - ? (updateStatusMutation.variables?.agentRepositoryId ?? null) - : null; - const onSearchChange = (value: string) => { - setSearchQuery(value); - setPage(1); - }; - const onTagPredicatesChange = (value: TagResourcePredicate[]) => { - setTagPredicates(value); - setPage(1); - }; - const onSortOrderChange = (value: RepositorySortOrder) => { - setSortOrder(value); - setPage(1); - }; - const onTakeDown = (listing: AgentRepositoryListingItem) => - updateStatusMutation.mutateAsync({ - agentRepositoryId: listing.agent_repository_id, - status: "not_shared", - }); - const [copyListing, setCopyListing] = - useState(null); - const [detailListing, setDetailListing] = - useState(null); - const { - detail, - repositoryDetail, - isLoading: isDetailLoading, - isError: isDetailError, - isFetching: isDetailFetching, - retry: refetchDetail, - } = useRepositoryAgentDetail(detailListing, active); - const confirmTakeDown = (listing: AgentRepositoryListingItem) => { - const title = - listing.display_name?.trim() || - listing.name?.trim() || - t("agentRepository.card.untitled"); - - Modal.confirm({ - title: t("repository.listingStatus.confirmTakeDownTitle"), - content: t("repository.listingStatus.confirmTakeDownContent", { - name: title, - }), - okText: t("repository.listingStatus.takeDown"), - cancelText: t("common.cancel"), - okButtonProps: { danger: true }, - onOk: async () => { - try { - await onTakeDown(listing); - message.success(t("repository.mine.takeDownSuccess")); - } catch { - message.error(t("repository.mine.takeDownError")); - throw new Error("Take down failed"); - } - }, - }); - }; - - const renderListing = (listing: AgentRepositoryListingItem) => { - const title = - listing.display_name?.trim() || - listing.name?.trim() || - t("agentRepository.card.untitled"); - const author = displayName( - listing.author_cas_username, - listing.author?.trim() || "" - ); - const tags = listing.tags?.filter((tag) => tag.trim()) ?? []; - const toolCount = listing.tool_count ?? 0; - const downloads = listing.downloads ?? 0; - const isTakingDown = updatingRepositoryId === listing.agent_repository_id; - const isOfficialListing = listing.is_official === true; - const isMainAgent = listing.is_main_agent !== false; - const showBadge = - isOfficialListing || Boolean(listing.version_label) || isMainAgent; - // Official templates are managed from the super-admin resource page. - // The ordinary repository status menu must not expose take-down actions - // for official listings to tenant administrators. - const canManageListing = showAdminMenu && !listing.is_official; - const menuItems: MenuProps["items"] = canManageListing - ? [ - { - key: "takeDown", - label: t("repository.listingStatus.takeDown"), - icon: , - danger: true, - disabled: isTakingDown, - onClick: () => confirmTakeDown(listing), - }, - ] - : []; - - return ( - setDetailListing(listing)} - icon={ -
- -
- } - description={ - listing.description?.trim() || t("agentRepository.card.noDescription") - } - badge={ - showBadge ? ( - <> - {isOfficialListing ? ( - - - {t("agentRepository.card.official")} - - ) : null} - {isMainAgent ? ( - - - {t("agentRepository.card.mainAgent")} - - ) : null} - {listing.version_label ? ( - - - - {t("agentRepository.mine.currentVersion", { - version: listing.version_label, - })} - - - ) : null} - - ) : undefined - } - tags={ - tags.length > 0 || toolCount > 0 ? ( -
- {tags.length > 0 ? ( - getAgentRepositoryTagLabel(tag, t))} - /> - ) : null} - {toolCount > 0 ? ( - - {t("agentRepository.card.toolCount", { count: toolCount })} - - ) : null} -
- ) : undefined - } - footerLayout="inline" - meta={ - author ? ( - - {author} - - ) : undefined - } - headerActions={ -
- - - {downloads.toLocaleString()} - - {canManageListing ? ( - -
- } - footer={ - - } - /> - ); - }; - - return ( -
-
-
- onSearchChange(e.target.value)} - placeholder={t("agentRepository.page.searchPlaceholder")} - prefix={} - className="rounded-xl" - allowClear - /> -
- - - value={sortOrder} - onChange={onSortOrderChange} - className="w-44 shrink-0" - aria-label={t("agentRepository.sort.label")} - options={[ - { value: "default", label: t("agentRepository.sort.default") }, - { value: "latest", label: t("agentRepository.sort.latest") }, - ]} - /> -
-
- {isLoading ? ( -
- -
- ) : isError ? ( -
-

- {t("agentRepository.page.loadError")} -

- -
- ) : ( - - } - renderItem={renderListing} - /> - )} -
- setDetailListing(null)} - detail={detail} - agentIcon={ - detailListing ? ( - - ) : undefined - } - published - status={repositoryDetail?.status} - showRepositoryInfo - isLoading={isDetailLoading} - isError={isDetailError} - isFetching={isDetailFetching} - onRetry={refetchDetail} - /> - { - if (!open) setCopyListing(null); - }} - /> -
- ); -} +export * from "@/components/repository/agent/agent-space"; diff --git a/frontend/app/[locale]/agent-space/components/AgentCardTags.tsx b/frontend/app/[locale]/agent-space/components/AgentCardTags.tsx index 250d9727eb..73f6154431 100644 --- a/frontend/app/[locale]/agent-space/components/AgentCardTags.tsx +++ b/frontend/app/[locale]/agent-space/components/AgentCardTags.tsx @@ -2,109 +2,4 @@ "use client"; -import { useLayoutEffect, useRef, useState } from "react"; -import { Popover } from "antd"; - -const chipClassName = - "block max-w-[120px] shrink-0 truncate rounded-md bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-700 dark:bg-slate-800 dark:text-slate-200"; - -export function AgentCardTags({ labels }: { labels: string[] }) { - const rowRef = useRef(null); - const measureRef = useRef(null); - const [visibleCount, setVisibleCount] = useState(labels.length); - const labelKey = JSON.stringify(labels); - - useLayoutEffect(() => { - const row = rowRef.current; - const measure = measureRef.current; - if (!row || !measure) return; - const update = () => { - if (row.clientWidth === 0) return; - const chips = Array.from(measure.children) as HTMLElement[]; - const widths = chips - .slice(0, -1) - .map((chip) => chip.getBoundingClientRect().width); - const gap = 8; - const fullWidth = - widths.reduce((sum, width) => sum + width, 0) + - Math.max(0, widths.length - 1) * gap; - if (fullWidth <= row.clientWidth) { - setVisibleCount(widths.length); - return; - } - const moreWidth = chips.at(-1)?.getBoundingClientRect().width ?? 0; - let usedWidth = moreWidth; - let count = 0; - for (const width of widths) { - if (usedWidth + gap + width > row.clientWidth) break; - usedWidth += gap + width; - count += 1; - } - setVisibleCount(count); - }; - update(); - const observer = new ResizeObserver(update); - observer.observe(row); - observer.observe(measure); - return () => observer.disconnect(); - }, [labelKey]); - - if (labels.length === 0) return null; - const count = Math.min(visibleCount, labels.length); - const hiddenCount = labels.length - count; - - return ( -
event.stopPropagation()} - onDoubleClick={(event) => event.stopPropagation()} - > - - {labels.slice(0, count).map((label, index) => ( - - {label} - - ))} - {hiddenCount > 0 ? ( - event.stopPropagation()} - > - {labels.map((label, index) => ( - - {label} - - ))} -
- } - > - - - ) : null} - - ); -} +export * from "@/components/repository/agent/components/AgentCardTags"; diff --git a/frontend/app/[locale]/agent-space/components/AgentRepositoryCopyDialog.tsx b/frontend/app/[locale]/agent-space/components/AgentRepositoryCopyDialog.tsx index 2d45f57536..28302311d0 100644 --- a/frontend/app/[locale]/agent-space/components/AgentRepositoryCopyDialog.tsx +++ b/frontend/app/[locale]/agent-space/components/AgentRepositoryCopyDialog.tsx @@ -2,741 +2,4 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; -import { App, Button, Modal, Radio, Select, Space, Spin, Tag } from "antd"; -import { - AlertCircle, - CheckCircle2, - ChevronDown, - ChevronRight, - Copy, - Cpu, - Database, - ExternalLink, - Plug, - RefreshCw, - Sparkles, - Wrench, - X, -} from "lucide-react"; -import { useParams, useRouter } from "next/navigation"; -import { useTranslation } from "react-i18next"; -import { useModelList } from "@/hooks/model/useModelList"; -import { - useImportAgentFromRepository, - useRepositoryImportPrecheck, -} from "@/hooks/agentRepository/useAgentRepositoryListings"; -import { - getRepositoryRequirementActivatePath, - getRepositoryRequirementReasonLabel, - getRepositoryRequirementTypeLabel, - getRepositoryRequirementTypeOrder, -} from "@/lib/agentRepositoryLabels"; -import log from "@/lib/logger"; -import type { - AgentRepositoryListingItem, - RepositoryImportRequirementItem, - RepositoryImportRequirementType, -} from "@/types/agentRepository"; - -const TYPE_ICON: Record = { - model: Cpu, - knowledge_base: Database, - mcp: Plug, - skill: Sparkles, - tool: Wrench, -}; - -const SYSTEM_TENANT_ID = "system"; - -interface AgentRepositoryCopyDialogProps { - listing: AgentRepositoryListingItem | null; - open: boolean; - onOpenChange: (open: boolean) => void; - onSuccess?: () => void; -} - -function groupByType(items: RepositoryImportRequirementItem[]) { - const order = getRepositoryRequirementTypeOrder(); - return order - .map((type) => ({ - type, - items: items.filter((item) => item.type === type), - })) - .filter((group) => group.items.length > 0); -} - -export function AgentRepositoryCopyDialog({ - listing, - open, - onOpenChange, - onSuccess, -}: AgentRepositoryCopyDialogProps) { - const { t } = useTranslation("common"); - const { message } = App.useApp(); - const router = useRouter(); - const params = useParams<{ locale: string }>(); - const locale = params.locale || "zh"; - - const [warningDismissed, setWarningDismissed] = useState(false); - const [abnormalOpen, setAbnormalOpen] = useState(true); - const [availableOpen, setAvailableOpen] = useState(true); - const [skillResolutionActions, setSkillResolutionActions] = useState< - Record - >({}); - const [knowledgeResolutionActions, setKnowledgeResolutionActions] = useState< - Record - >({}); - const [selectedModelId, setSelectedModelId] = useState(); - const [selectedEmbeddingModelId, setSelectedEmbeddingModelId] = - useState(); - - const agentRepositoryId = listing?.agent_repository_id ?? null; - const listingTitle = - listing?.display_name?.trim() || - listing?.name?.trim() || - t("agentRepository.card.untitled"); - const isOfficialListing = - listing?.is_official === true || - listing?.publisher_tenant_id === SYSTEM_TENANT_ID; - - const { - data: precheck, - isLoading, - isError, - isFetching, - refetch, - } = useRepositoryImportPrecheck(agentRepositoryId, open); - - const importMutation = useImportAgentFromRepository(); - const { availableLlmModels, models: tenantModels } = useModelList({ - enabled: open, - }); - - const abnormalItems = useMemo( - () => - precheck?.items.filter( - (item) => - !item.available || - (item.type === "knowledge_base" && item.resolution_required) - ) ?? [], - [precheck] - ); - const availableItems = useMemo( - () => - precheck?.items.filter( - (item) => - item.available && - !(item.type === "knowledge_base" && item.resolution_required) - ) ?? [], - [precheck] - ); - - const skillConflictItems = useMemo( - () => - precheck?.items.filter( - (item) => - item.type === "skill" && item.reason_code === "skill_duplicate" - ) ?? [], - [precheck] - ); - const hasSkillConflicts = skillConflictItems.length > 0; - const officialKnowledgeItems = useMemo( - () => - precheck?.items.filter((item) => item.type === "knowledge_base") ?? [], - [precheck] - ); - const hasOfficialKnowledge = - isOfficialListing && officialKnowledgeItems.length > 0; - const hasExistingOfficialKnowledge = Boolean( - hasOfficialKnowledge && - officialKnowledgeItems.every((item) => item.resolution_required === true) - ); - const officialKnowledgeConflictItems = useMemo( - () => officialKnowledgeItems.filter((item) => item.resolution_required), - [officialKnowledgeItems] - ); - const hasOfficialKnowledgeToCreate = Boolean( - hasOfficialKnowledge && - (officialKnowledgeItems.some((item) => !item.resolution_required) || - officialKnowledgeConflictItems.some( - (item) => knowledgeResolutionActions[item.name] === "create_new" - )) - ); - const availableEmbeddingModels = useMemo( - () => - tenantModels.filter( - (model) => - (model.type === "embedding" || model.type === "multi_embedding") && - model.connect_status === "available" - ), - [tenantModels] - ); - const officialEmbeddingModelMissing = Boolean( - isOfficialListing && - hasOfficialKnowledge && - hasOfficialKnowledgeToCreate && - availableEmbeddingModels.length === 0 - ); - - useEffect(() => { - if (!open || !isOfficialListing) return; - setSelectedModelId((current) => current ?? availableLlmModels[0]?.id); - if (hasOfficialKnowledgeToCreate) { - setSelectedEmbeddingModelId( - (current) => current ?? availableEmbeddingModels[0]?.id - ); - } else if (hasExistingOfficialKnowledge) { - setSelectedEmbeddingModelId(undefined); - } - }, [ - open, - isOfficialListing, - hasOfficialKnowledge, - hasExistingOfficialKnowledge, - hasOfficialKnowledgeToCreate, - availableLlmModels, - availableEmbeddingModels, - ]); - - const percent = precheck?.percent ?? 0; - const hasAbnormal = precheck?.has_abnormal ?? false; - - const handleOpenActivate = (type: RepositoryImportRequirementType) => { - const path = getRepositoryRequirementActivatePath(type); - if (!path) { - return; - } - router.push(`/${locale}${path}`); - }; - - const handleCopy = async () => { - if (!agentRepositoryId) { - return; - } - - const skillResolutions = hasSkillConflicts - ? skillConflictItems.map((item) => ({ - skill_name: item.name, - action: (skillResolutionActions[item.name] ?? "rename") as - "rename" | "use_existing", - ...(skillResolutionActions[item.name] !== "use_existing" - ? { new_name: item.suggested_new_name || `${item.name} 副本` } - : {}), - })) - : undefined; - const knowledgeBaseResolutions = - officialKnowledgeConflictItems.length > 0 - ? officialKnowledgeConflictItems.map((item) => ({ - knowledge_name: item.name, - action: knowledgeResolutionActions[item.name] ?? "reuse", - })) - : undefined; - - if ( - isOfficialListing && - (!selectedModelId || - (hasOfficialKnowledge && - hasOfficialKnowledgeToCreate && - !selectedEmbeddingModelId)) - ) { - message.error( - officialEmbeddingModelMissing - ? t("ui.localization.embeddingRequiredToCopy") - : t("ui.localization.chooseRequiredModels") - ); - return; - } - - try { - await importMutation.mutateAsync({ - agentRepositoryId, - skillResolutions, - modelOptions: isOfficialListing - ? { - modelIds: selectedModelId - ? { [listing.name]: selectedModelId } - : undefined, - embeddingModelIds: - hasOfficialKnowledge && - hasOfficialKnowledgeToCreate && - selectedEmbeddingModelId - ? { [listing.name]: selectedEmbeddingModelId } - : undefined, - knowledgeBaseResolutions, - } - : undefined, - }); - message.success( - t("agentRepository.copy.success", { name: listingTitle }) - ); - onOpenChange(false); - onSuccess?.(); - } catch (error) { - const err = error as Error & { - status?: number; - detail?: { type?: string; duplicate_skills?: string[] } | string; - }; - const detail = - typeof err.detail === "object" && err.detail !== null - ? err.detail - : null; - if ( - err.status === 409 && - detail?.type === "skill_duplicate" && - Array.isArray(detail.duplicate_skills) - ) { - message.error( - t("agentRepository.copy.skillDuplicate", { - names: detail.duplicate_skills.join(", "), - }) - ); - return; - } - log.error("Failed to import agent from repository:", error); - message.error(t("agentRepository.copy.failed")); - } - }; - - const handleClose = () => { - onOpenChange(false); - setWarningDismissed(false); - setAbnormalOpen(true); - setAvailableOpen(true); - setSkillResolutionActions({}); - setKnowledgeResolutionActions({}); - setSelectedModelId(undefined); - setSelectedEmbeddingModelId(undefined); - }; - - return ( - - - - - } - styles={{ - body: { maxHeight: "70vh", overflowY: "auto", paddingTop: 8 }, - }} - > - {isLoading ? ( -
- -
- ) : isError ? ( -
-

- {t("agentRepository.copy.loadError")} -

- -
- ) : precheck ? ( -
-
-
- - {t("agentRepository.copy.configList")} - - - {t("agentRepository.copy.percent", { percent })} - -
- -
- - {hasAbnormal && !warningDismissed ? ( -
- -

- {t("agentRepository.copy.warning")} -

- -
- ) : null} - -
-
-
-
-
- - {t("agentRepository.copy.availableCount", { - count: availableItems.length, - })} - - - {t("agentRepository.copy.pendingCount")}{" "} - - {abnormalItems.length} - - -
-
- - {hasSkillConflicts ? ( -
-

- {t( - "agentRepository.copy.skillDuplicate.title", - "Skill Name Conflict Detected" - )} -

-

- {t( - "agentRepository.copy.skillDuplicate.message", - "Choose how to handle each conflicting skill:" - )} -

-
- {skillConflictItems.map((item) => ( -
-
- {item.name} -
- { - setSkillResolutionActions((prev) => ({ - ...prev, - [item.name]: event.target.value, - })); - }} - > - - - {t( - "agentRepository.copy.skillDuplicate.rename", - "Install as new skill" - )} - - {t( - "agentRepository.copy.skillDuplicate.renameTarget", - { - name: - item.suggested_new_name || - `${item.name} 副本`, - defaultValue: `New name: ${item.suggested_new_name || `${item.name} 副本`}`, - } - )} - - - - {t( - "agentRepository.copy.skillDuplicate.useExisting", - "Use existing local skill" - )} - - - -
- ))} -
-
- ) : null} - - {isOfficialListing && officialKnowledgeConflictItems.length > 0 ? ( -
-

- {t("ui.localization.knowledgeConflictTitle")} -

-

- {t("ui.localization.knowledgeConflictHelp")} -

-
- {officialKnowledgeConflictItems.map((item) => ( -
-
- {item.name} -
- { - setKnowledgeResolutionActions((prev) => ({ - ...prev, - [item.name]: event.target.value, - })); - }} - > - - - {t("ui.localization.reuseKnowledge")} - - - {t("ui.localization.createKnowledge")} - - {t("ui.localization.newName")} - {item.name} {t("ui.localization.copySuffix")} - - - - -
- ))} -
-
- ) : null} - - {isOfficialListing ? ( -
-

- {t("ui.localization.installationModels")} -

-
- - ({ - value: model.id, - label: model.displayName || model.name, - }))} - /> -
- ) : null} - {officialEmbeddingModelMissing ? ( -

- {t("ui.localization.embeddingUnavailable")} -

- ) : null} -
- ) : null} - - {hasAbnormal ? ( -
- - {abnormalOpen - ? groupByType(abnormalItems).map((group) => ( - - handleOpenActivate( - group.type as RepositoryImportRequirementType - ) - } - /> - )) - : null} -
- ) : null} - - {availableItems.length > 0 ? ( -
- - {availableOpen - ? groupByType(availableItems).map((group) => ( - - )) - : null} -
- ) : null} -
- ) : null} - - ); -} - -function RequirementTypeGroup({ - type, - items, - status, - t, - onActivate, -}: { - type: RepositoryImportRequirementType; - items: RepositoryImportRequirementItem[]; - status: "abnormal" | "available"; - t: ReturnType["t"]; - onActivate?: () => void; -}) { - const Icon = TYPE_ICON[type]; - const abnormal = status === "abnormal"; - const typeLabel = getRepositoryRequirementTypeLabel(type, t); - const activatePath = getRepositoryRequirementActivatePath(type); - const hasKnowledgeConflict = - type === "knowledge_base" && - items.some((item) => item.resolution_required === true); - const hasNameConflict = - (type === "knowledge_base" && hasKnowledgeConflict) || - (type === "skill" && - items.some((item) => item.reason_code === "skill_duplicate")); - const reasonLabel = - hasNameConflict && type === "knowledge_base" - ? t( - "agentRepository.copy.reason.kb_duplicate", - "Knowledge base name conflict" - ) - : hasNameConflict && type === "skill" - ? t( - "agentRepository.copy.reason.skill_duplicate", - "Skill name conflict" - ) - : getRepositoryRequirementReasonLabel(items[0]?.reason_code, t); - - return ( -
-
-
- - {typeLabel} -
- {abnormal ? ( - activatePath && !hasNameConflict ? ( - - ) : ( - - - {reasonLabel || t("agentRepository.copy.unavailable")} - - ) - ) : ( - - - {t("agentRepository.copy.activated")} - - )} -
- -
    - {items.map((item) => ( -
  • - -
    -

    - {item.name} -

    - {item.description ? ( -

    - {item.description} -

    - ) : null} -
    -
  • - ))} -
-
- ); -} +export * from "@/components/repository/agent/components/AgentRepositoryCopyDialog"; diff --git a/frontend/app/[locale]/agent-space/components/AgentRepositoryReviewConfirmModal.tsx b/frontend/app/[locale]/agent-space/components/AgentRepositoryReviewConfirmModal.tsx index 0337169031..87ad37dd9c 100644 --- a/frontend/app/[locale]/agent-space/components/AgentRepositoryReviewConfirmModal.tsx +++ b/frontend/app/[locale]/agent-space/components/AgentRepositoryReviewConfirmModal.tsx @@ -1,99 +1,5 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { Input, Modal } from "antd"; -import { useTranslation } from "react-i18next"; -import type { AgentRepositoryListingItem } from "@/types/agentRepository"; - -export type AgentRepositoryReviewAction = "approve" | "reject"; - -interface AgentRepositoryReviewConfirmModalProps { - open: boolean; - action: AgentRepositoryReviewAction | null; - listing: AgentRepositoryListingItem | null; - loading?: boolean; - onClose: () => void; - onConfirm: (content?: string) => Promise; -} - -function getListingTitle(listing: AgentRepositoryListingItem) { - return listing.display_name?.trim() || listing.name?.trim() || ""; -} +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -export function AgentRepositoryReviewConfirmModal({ - open, - action, - listing, - loading = false, - onClose, - onConfirm, -}: AgentRepositoryReviewConfirmModalProps) { - const { t } = useTranslation("common"); - const [reviewOpinion, setReviewOpinion] = useState(""); - - useEffect(() => { - if (!open) { - setReviewOpinion(""); - } - }, [open]); - - if (!action || !listing) { - return null; - } - - const isApprove = action === "approve"; - const title = - getListingTitle(listing) || t("agentRepository.card.untitled"); - - const handleOk = async () => { - const trimmed = reviewOpinion.trim(); - await onConfirm(trimmed || undefined); - }; +"use client"; - return ( - -
-

- {isApprove - ? t("repository.review.confirmApproveContent", { name: title }) - : t("repository.review.confirmRejectContent", { name: title })} -

-
- - setReviewOpinion(event.target.value)} - placeholder={t("repository.review.reviewOpinionPlaceholder")} - rows={4} - disabled={loading} - /> -
-
-
- ); -} +export * from "@/components/repository/agent/components/AgentRepositoryReviewConfirmModal"; diff --git a/frontend/app/[locale]/agent-space/components/AgentUsageGuideModal.tsx b/frontend/app/[locale]/agent-space/components/AgentUsageGuideModal.tsx index 1b9e407ca6..bed713eeaf 100644 --- a/frontend/app/[locale]/agent-space/components/AgentUsageGuideModal.tsx +++ b/frontend/app/[locale]/agent-space/components/AgentUsageGuideModal.tsx @@ -2,262 +2,4 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { Alert, App, Button, Modal, Spin, Tabs, Typography } from "antd"; -import { Copy, ExternalLink } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import A2AServerSettingsPanel from "../../agents/components/a2a/A2AServerSettingsPanel"; -import { - buildAgentShareUrl, - buildNorthboundDocsUrl, - buildNorthboundCurl, - buildNorthboundRunUrl, - buildUserApiKeyPath, - getAgentUsageGuideAccess, - getA2AGuideState, -} from "@/lib/agentUsageGuide"; -import { a2aClientService } from "@/services/a2aService"; -import { fetchPublishedAgentList } from "@/services/agentConfigService"; -import { configService } from "@/services/configService"; -import type { Agent } from "@/types/agentConfig"; -import type { MyEditableAgentItem } from "@/types/agentRepository"; - -interface AgentUsageGuideModalProps { - agent: MyEditableAgentItem | null; - locale: string; - open: boolean; - onClose: () => void; -} - -export function AgentUsageGuideModal({ - agent, - locale, - open, - onClose, -}: AgentUsageGuideModalProps) { - const { t } = useTranslation("common"); - const { message } = App.useApp(); - const [activeTab, setActiveTab] = useState("share"); - const agentId = agent?.agent_id; - const agentName = agent?.name?.trim() || "agent"; - const { canOpen } = getAgentUsageGuideAccess({ - currentVersionNo: agent?.current_version_no, - }); - - useEffect(() => { - if (open) { - setActiveTab("share"); - } - }, [agentId, open]); - - const frontendConfigQuery = useQuery({ - queryKey: ["frontend-config"], - queryFn: () => configService.fetchRuntimeFrontendConfig(), - enabled: open && activeTab === "northbound", - }); - const a2aQuery = useQuery({ - queryKey: ["a2aServerSettings", agentId], - queryFn: () => a2aClientService.getServerSettings(agentId!), - enabled: open && activeTab === "a2a" && agentId != null, - }); - const publishedAgentsQuery = useQuery({ - queryKey: ["publishedAgentsList"], - queryFn: fetchPublishedAgentList, - enabled: open && activeTab === "northbound" && agentId != null, - }); - const publishedAgent = publishedAgentsQuery.data?.data?.find( - (candidate: Agent) => candidate.id === String(agentId) - ); - - const shareUrl = useMemo(() => { - if (!open || !canOpen || agentId == null || typeof window === "undefined") { - return ""; - } - return buildAgentShareUrl(window.location.origin, locale, agentId); - }, [agentId, canOpen, locale, open]); - const northboundUrl = buildNorthboundRunUrl( - frontendConfigQuery.data?.northboundBaseUrl, - typeof window === "undefined" ? undefined : window.location.origin - ); - const northboundCurl = buildNorthboundCurl( - publishedAgent?.name?.trim() || agentName, - northboundUrl - ); - const a2aGuideState = getA2AGuideState({ - isLoading: a2aQuery.isLoading, - isError: a2aQuery.isError, - isEnabled: Boolean( - a2aQuery.data?.success && a2aQuery.data.data?.is_enabled - ), - }); - const copy = async (value: string) => { - try { - await navigator.clipboard.writeText(value); - message.success(t("common.copied")); - } catch { - message.error(t("agentUsageGuide.copyFailed")); - } - }; - - return ( - -
- - -
- - {shareUrl} - -
- - -
-
-
- ) : ( - - ), - }, - { - key: "northbound", - label: t("agentUsageGuide.tabs.northbound"), - children: ( -
-
    -
  1. - - {t("agentUsageGuide.northbound.stepKey")} - -
  2. -
  3. {t("agentUsageGuide.northbound.stepCall")}
  4. -
  5. {t("agentUsageGuide.northbound.stepResult")}
  6. -
- {frontendConfigQuery.isLoading ? ( - - ) : frontendConfigQuery.isError ? ( - - ) : ( -
- -
-                        {northboundCurl}
-                      
-
- )} - - -
- ), - }, - { - key: "a2a", - label: t("agentUsageGuide.tabs.a2a"), - children: - a2aGuideState === "loading" ? ( - - ) : a2aGuideState === "error" ? ( - - ) : a2aGuideState === "enabled" ? ( - - ) : ( - - ), - }, - ]} - /> -
-
- ); -} +export * from "@/components/repository/agent/components/AgentUsageGuideModal"; diff --git a/frontend/app/[locale]/agent-space/components/CreateNewAgentCard.tsx b/frontend/app/[locale]/agent-space/components/CreateNewAgentCard.tsx index 740cb3a5f5..dfcab7f95f 100644 --- a/frontend/app/[locale]/agent-space/components/CreateNewAgentCard.tsx +++ b/frontend/app/[locale]/agent-space/components/CreateNewAgentCard.tsx @@ -1,19 +1,5 @@ -"use client"; - -import { useTranslation } from "react-i18next"; +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -import CreateResourceCard from "@/components/resource/CreateResourceCard"; - -interface CreateNewAgentCardProps { - onClick: () => void; -} +"use client"; -export function CreateNewAgentCard({ onClick }: CreateNewAgentCardProps) { - const { t } = useTranslation("common"); - return ( - - ); -} +export * from "@/components/repository/agent/components/CreateNewAgentCard"; diff --git a/frontend/app/[locale]/agent-space/components/MineApplyListingModal.tsx b/frontend/app/[locale]/agent-space/components/MineApplyListingModal.tsx index 6d8cbed47e..0592842338 100644 --- a/frontend/app/[locale]/agent-space/components/MineApplyListingModal.tsx +++ b/frontend/app/[locale]/agent-space/components/MineApplyListingModal.tsx @@ -2,446 +2,4 @@ "use client"; -import { createElement, useEffect, useMemo, useState } from "react"; -import { - App, - Avatar, - Button, - Input, - Modal, - Spin, - Upload as AntdUpload, -} from "antd"; -import type { UploadProps } from "antd"; -import { Share2, Upload as UploadIcon } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import { API_ENDPOINTS } from "@/services/api"; -import { fetchWithAuth } from "@/lib/auth"; -import { useAgentRepositoryListings } from "@/hooks/agentRepository/useAgentRepositoryListings"; -import { - getAgentRepositoryTagLabel, - resolveAgentRepositoryTagForSubmit, -} from "@/lib/agentRepositoryLabels"; -import { getAgentIcon } from "@/lib/chat/agentIconUtils"; -import { withBasePath } from "@/lib/basePath"; -import { - useTagAssignments, - useTagDefinitions, - useTagLibraries, -} from "@/hooks/useTagManagement"; -import ResourceTagAssignmentModal from "@/components/tag/ResourceTagAssignmentModal"; -import { - buildApplyListingFormPrefill, - getListingTagsFromAssignments, - pickApplyListingPrefillSource, -} from "@/lib/agentRepositoryMine"; -import type { - AgentRepositoryListingCreatePayload, - MyEditableAgentItem, -} from "@/types/agentRepository"; -import type { TagAssignmentValue } from "@/types/tagManagement"; - -const MAX_TAGS = 5; -const MAX_TAG_LENGTH = 20; - -interface MineApplyListingModalProps { - open: boolean; - agent: MyEditableAgentItem | null; - isSubmitting?: boolean; - onClose: () => void; - onSubmit: (payload: AgentRepositoryListingCreatePayload) => Promise; -} - -export function MineApplyListingModal({ - open, - agent, - isSubmitting = false, - onClose, - onSubmit, -}: MineApplyListingModalProps) { - const { t } = useTranslation("common"); - const { message } = App.useApp(); - - const { data: tagLibraries } = useTagLibraries(); - const defaultResourceLibrary = useMemo( - () => - (tagLibraries ?? []).find( - (library) => library.bucket_key === "default_resource" - ) ?? null, - [tagLibraries] - ); - const { data: tagDefinitions } = useTagDefinitions( - defaultResourceLibrary?.bucket_id ?? null - ); - const agentCategory = useMemo( - () => - (tagDefinitions ?? []).find( - (definition) => definition.definition_key === "agent_category" - ) ?? null, - [tagDefinitions] - ); - const categoryValues = agentCategory?.values ?? []; - - const [iconUrl, setIconUrl] = useState(null); - const [iconPreviewUrl, setIconPreviewUrl] = useState(null); - const [iconLoadError, setIconLoadError] = useState(false); - const [selectedIconFile, setSelectedIconFile] = useState(null); - const [uploadingIcon, setUploadingIcon] = useState(false); - const [listingContent, setListingContent] = useState(""); - const [formInitialized, setFormInitialized] = useState(false); - const [tagEditorOpen, setTagEditorOpen] = useState(false); - const [savedAssignments, setSavedAssignments] = useState< - TagAssignmentValue[] | null - >(null); - - const agentId = agent?.agent_id; - const agentTagAssignments = useTagAssignments( - "agent", - agentId == null ? null : String(agentId) - ); - const { - data: listingsData, - isSuccess: isListingsSuccess, - isFetching: isListingsFetching, - } = useAgentRepositoryListings( - agentId != null - ? { agent_id: agentId, page: 1, page_size: 100 } - : undefined, - open && agentId != null - ); - - const assignmentValues = - savedAssignments ?? agentTagAssignments.data?.assignments ?? []; - - const categoryAssignmentValueIds = useMemo( - () => - new Set( - assignmentValues - .filter( - (assignment) => - assignment.definition_id === agentCategory?.definition_id - ) - .map((assignment) => assignment.value_id) - ), - [agentCategory?.definition_id, assignmentValues] - ); - - const selectedListingTags = useMemo( - () => getListingTagsFromAssignments(assignmentValues, agentCategory), - [assignmentValues, agentCategory] - ); - - const legacyCategorySelection = useMemo(() => { - if (!agentCategory || categoryAssignmentValueIds.size > 0) return {}; - const source = pickApplyListingPrefillSource( - listingsData?.items ?? [], - agent?.version_label - ); - const prefill = buildApplyListingFormPrefill(source, { maxTags: MAX_TAGS }); - if (!prefill) return {}; - const legacyTags = new Set( - prefill.tags.map((tag) => tag.trim().toLocaleLowerCase()) - ); - const valueIds = categoryValues - .filter((value) => - [ - value.normalized_value, - value.display_value, - getAgentRepositoryTagLabel(value.normalized_value, t), - ].some((candidate) => - legacyTags.has(candidate.trim().toLocaleLowerCase()) - ) - ) - .map((value) => value.value_id); - return valueIds.length > 0 - ? { [agentCategory.definition_id]: valueIds } - : {}; - }, [ - agent?.version_label, - agentCategory, - categoryAssignmentValueIds.size, - categoryValues, - listingsData?.items, - t, - ]); - - useEffect(() => { - if (!open) { - setFormInitialized(false); - setTagEditorOpen(false); - setSavedAssignments(null); - setIconPreviewUrl(null); - setIconLoadError(false); - setSelectedIconFile(null); - return; - } - - if (!agent || !isListingsSuccess || formInitialized) { - return; - } - - const source = pickApplyListingPrefillSource( - listingsData?.items ?? [], - agent.version_label - ); - const prefill = buildApplyListingFormPrefill(source, { - maxTags: MAX_TAGS, - }); - - if (!prefill) { - setIconUrl(null); - setIconPreviewUrl(null); - setIconLoadError(false); - setSelectedIconFile(null); - setListingContent(""); - setFormInitialized(true); - return; - } - - setIconUrl(prefill.icon_url); - setIconPreviewUrl(null); - setIconLoadError(false); - setSelectedIconFile(null); - - setListingContent(""); - setFormInitialized(true); - }, [open, agent, isListingsSuccess, listingsData, formInitialized]); - - const title = agent?.name?.trim() || t("agentRepository.card.untitled"); - - useEffect(() => { - return () => { - if (iconPreviewUrl) URL.revokeObjectURL(iconPreviewUrl); - }; - }, [iconPreviewUrl]); - - const handleIconUpload = (file: File) => { - if (file.size > 2 * 1024 * 1024) { - message.error(t("agentRepository.mine.applyModal.iconTooLarge")); - return false; - } - setSelectedIconFile(file); - setIconPreviewUrl(URL.createObjectURL(file)); - setIconLoadError(false); - return false; - }; - - const uploadProps: UploadProps = { - accept: "image/png,image/jpeg,image/gif,image/webp", - showUploadList: false, - disabled: uploadingIcon || isSubmitting, - beforeUpload: handleIconUpload, - }; - const defaultIcon = createElement(getAgentIcon({ agent_id: agentId ?? 0 }), { - size: 28, - }); - const selectedIconSource = iconPreviewUrl ?? iconUrl; - const iconSource = - selectedIconSource && !iconLoadError - ? withBasePath(selectedIconSource) - : undefined; - - const handleSubmit = async () => { - if (uploadingIcon) { - return; - } - - if (selectedListingTags.length > MAX_TAGS) { - message.warning( - t("agentRepository.mine.applyModal.validation.tagsMax", { - count: MAX_TAGS, - }) - ); - return; - } - const tags = selectedListingTags.map((tag) => - resolveAgentRepositoryTagForSubmit(tag, t) - ); - if (tags.some((tag) => tag.length > MAX_TAG_LENGTH)) { - message.warning( - t("agentRepository.mine.applyModal.validation.tagLength", { - count: MAX_TAG_LENGTH, - }) - ); - return; - } - - try { - let submittedIconUrl = iconUrl; - if (selectedIconFile && agentId != null && agent) { - setUploadingIcon(true); - try { - const formData = new FormData(); - formData.append("file", selectedIconFile); - const response = await fetchWithAuth( - API_ENDPOINTS.agentRepository.icon( - agentId, - agent.current_version_no ?? 0 - ), - { method: "POST", body: formData } - ); - if (!response.ok) throw new Error("Icon upload failed"); - const data = (await response.json()) as { icon_url: string }; - submittedIconUrl = data.icon_url; - setIconUrl(submittedIconUrl); - setSelectedIconFile(null); - } catch { - message.error(t("agentRepository.mine.applyModal.iconUploadFailed")); - return; - } - } - await onSubmit({ - icon_url: submittedIconUrl, - tags, - content: listingContent.trim(), - }); - } catch (error) { - message.error(error instanceof Error ? error.message : String(error)); - } finally { - setUploadingIcon(false); - } - }; - - return ( - <> - - - {t("agentRepository.mine.applyModal.title")} -
- } - footer={ -
- - -
- } - > -

- {t("agentRepository.mine.applyModal.agentName", { name: title })} -

- - -
-
-
- {t("agent.icon")} -
-
- -
{ - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - event.currentTarget.click(); - } - }} - > - { - setIconLoadError(true); - return false; - }} - className={`border-2 border-dashed border-gray-300 ${iconSource ? "" : "!bg-primary/10 !text-primary"}`} - /> -
- {uploadingIcon ? ( - - ) : ( - - )} -
-
-
-
- {t("agent.iconHint")} -
- {(iconUrl || selectedIconFile) && ( - - )} -
-
- -
-

- {t("agentRepository.mine.applyModal.tags")} -

-
- - {selectedListingTags.length > 0 ? ( - - {selectedListingTags - .map((tag) => getAgentRepositoryTagLabel(tag, t)) - .join(" · ")} - - ) : null} -
-

- {t("agentRepository.mine.applyModal.tagsHint", { - count: MAX_TAGS, - })} -

-
- -
-

- {t("repository.mine.applyModal.content")} -

- setListingContent(event.target.value)} - rows={4} - placeholder={t("repository.mine.applyModal.contentPlaceholder")} - /> -
-
-
- - setTagEditorOpen(false)} - resourceType="agent" - resourceId={String(agentId ?? "")} - definitions={tagDefinitions ?? []} - canEdit={agent?.permission !== "READ_ONLY"} - initialSelection={legacyCategorySelection} - onSaved={(assignment) => setSavedAssignments(assignment.assignments)} - /> - - ); -} +export * from "@/components/repository/agent/components/MineApplyListingModal"; diff --git a/frontend/app/[locale]/agent-space/components/MineReviewStatusModal.tsx b/frontend/app/[locale]/agent-space/components/MineReviewStatusModal.tsx index 1c146c2280..80696b74d0 100644 --- a/frontend/app/[locale]/agent-space/components/MineReviewStatusModal.tsx +++ b/frontend/app/[locale]/agent-space/components/MineReviewStatusModal.tsx @@ -1,207 +1,5 @@ -"use client"; - -import { Button, Modal } from "antd"; -import { CheckCircle2, Clock, PackageX, Store, XCircle } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import { - formatMineDate, - formatRepositoryVersionLabel, - isCancelableRepositoryStatus, - isTakeDownableRepositoryStatus, -} from "@/lib/agentRepositoryMine"; -import type { - MyAgentRepositoryInfoItem, - MyEditableAgentItem, -} from "@/types/agentRepository"; - -interface MineReviewStatusModalProps { - open: boolean; - agent: MyEditableAgentItem | null; - repositoryInfo: MyAgentRepositoryInfoItem | null; - mode: "review" | "reviewUpdate"; - isUpdatingStatus?: boolean; - onClose: () => void; - onSetNotShared: () => Promise; -} - -export function MineReviewStatusModal({ - open, - agent, - repositoryInfo, - mode, - isUpdatingStatus = false, - onClose, - onSetNotShared, -}: MineReviewStatusModalProps) { - const { t } = useTranslation("common"); - - if (!agent || !repositoryInfo) { - return null; - } - - const title = agent.name?.trim() || t("agentRepository.card.untitled"); - const isPending = repositoryInfo.status === "pending_review"; - const isRejected = repositoryInfo.status === "rejected"; - const canCancelApply = isCancelableRepositoryStatus(repositoryInfo.status); - const canTakeDown = isTakeDownableRepositoryStatus(repositoryInfo.status); - const versionLabel = formatRepositoryVersionLabel(repositoryInfo); - const submittedAt = formatMineDate(repositoryInfo.create_time); - const listingContent = repositoryInfo.content?.trim() ?? ""; +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. - const statusConfig = isPending - ? { - icon: Clock, - label: t("repository.listingStatus.pendingLabel"), - description: t("repository.listingStatus.pendingDescription"), - tone: "border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200", - iconClass: "text-amber-600 dark:text-amber-300", - } - : isRejected - ? { - icon: XCircle, - label: t("repository.listingStatus.rejectedLabel"), - description: t("repository.listingStatus.rejectedDescription"), - tone: "border-red-200 bg-red-50 text-red-800 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200", - iconClass: "text-red-600 dark:text-red-300", - } - : { - icon: CheckCircle2, - label: t("repository.listingStatus.listedLabel"), - description: t("repository.listingStatus.listedDescription"), - tone: "border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-200", - iconClass: "text-emerald-600 dark:text-emerald-300", - }; - - const StatusIcon = statusConfig.icon; - const modalTitle = - mode === "reviewUpdate" - ? t("agentRepository.mine.reviewModal.reviewUpdateTitle") - : t("repository.listingStatus.title"); - - const confirmCancelApply = () => { - Modal.confirm({ - title: t("repository.listingStatus.confirmCancelApplyTitle"), - content: t("repository.listingStatus.confirmCancelApplyContent", { - name: title, - }), - okText: t("repository.listingStatus.cancelApply"), - cancelText: t("common.cancel"), - okButtonProps: { danger: true }, - onOk: async () => { - try { - await onSetNotShared(); - } catch { - throw new Error("Cancel listing request failed"); - } - }, - }); - }; - - const confirmTakeDown = () => { - Modal.confirm({ - title: t("repository.listingStatus.confirmTakeDownTitle"), - content: t("repository.listingStatus.confirmTakeDownContent", { - name: title, - }), - okText: t("repository.listingStatus.takeDown"), - cancelText: t("common.cancel"), - okButtonProps: { danger: true }, - onOk: async () => { - try { - await onSetNotShared(); - } catch { - throw new Error("Take down failed"); - } - }, - }); - }; - - return ( - - - {canCancelApply ? ( - - ) : null} - {canTakeDown ? ( - - ) : null} - - } - title={ - - - {modalTitle} - - } - centered - destroyOnHidden - > -

- {t("agentRepository.mine.reviewModal.agentName", { name: title })} -

- -
- -
-

{statusConfig.label}

-

- {statusConfig.description} -

- {listingContent ? ( -

- {isPending - ? t("repository.listingStatus.pendingNote", { - content: listingContent, - }) - : t("repository.listingStatus.reviewOpinion", { - content: listingContent, - })} -

- ) : null} -
-
+"use client"; -
-
- {t("agentRepository.mine.reviewModal.version")} - - {versionLabel} - -
- {submittedAt ? ( -
- {t("repository.listingStatus.submittedAt")} - - {submittedAt} - -
- ) : null} -
-
- ); -} +export * from "@/components/repository/agent/components/MineReviewStatusModal"; diff --git a/frontend/app/[locale]/agent-space/components/MyAgentCard.tsx b/frontend/app/[locale]/agent-space/components/MyAgentCard.tsx index 116fb9f946..5f3b593fa4 100644 --- a/frontend/app/[locale]/agent-space/components/MyAgentCard.tsx +++ b/frontend/app/[locale]/agent-space/components/MyAgentCard.tsx @@ -2,325 +2,4 @@ "use client"; -import { Button, Dropdown, Tooltip } from "antd"; -import type { MenuProps } from "antd"; -import { - Bot, - ClipboardCheck, - Clock, - Eye, - LineChart, - MoreHorizontal, - Pencil, - Share2, - Trash2, -} from "lucide-react"; -import { useTranslation } from "react-i18next"; -import { getAgentUsageGuideAccess } from "@/lib/agentUsageGuide"; -import { getAgentRepositoryTagLabel } from "@/lib/agentRepositoryLabels"; -import { getUnavailableReasonLabels } from "@/lib/agentLabelMapper"; -import { - formatMineDate, - getMineCardMenuActions, - getMineCardRepositoryStatusBadge, - type MineCardMenuAction, -} from "@/lib/agentRepositoryMine"; -import type { MyEditableAgentItem } from "@/types/agentRepository"; -import ResourceCard from "@/components/resource/ResourceCard"; -import { MyAgentIcon } from "./MyAgentIcon"; -import { AgentCardTags } from "./AgentCardTags"; - -interface MyAgentCardProps { - agent: MyEditableAgentItem; - onEdit: () => void; - onView: () => void; - onApplyListing: () => void; - onViewReview: ( - agent: MyEditableAgentItem, - mode: "review" | "reviewUpdate" - ) => void; - onDelete: () => void; - onEvaluate: () => void; - onUsageGuide: () => void; - highlighted?: boolean; - guideMenuOpen?: boolean; - onGuideMenuOpenChange?: (open: boolean) => void; - isApplying?: boolean; - isDeleting?: boolean; -} - -const MENU_ACTION_I18N: Record = { - apply: "agentRepository.mine.menu.apply", - review: "agentRepository.mine.menu.review", - reviewUpdate: "agentRepository.mine.menu.reviewUpdate", -}; - -export function MyAgentCard({ - agent, - onEdit, - onView, - onApplyListing, - onViewReview, - onDelete, - onEvaluate, - onUsageGuide, - highlighted = false, - guideMenuOpen, - onGuideMenuOpenChange, - isApplying = false, - isDeleting = false, -}: MyAgentCardProps) { - const { t } = useTranslation("common"); - - const title = agent.name?.trim() || t("agentRepository.card.untitled"); - const description = - agent.description?.trim() || t("agentRepository.card.noDescription"); - const tags = agent.tags?.filter((tag) => tag.trim()) ?? []; - const unavailableReasonLabels = getUnavailableReasonLabels( - agent.unavailable_reasons ?? [], - t - ); - const { canOpen: published } = getAgentUsageGuideAccess({ - currentVersionNo: agent.current_version_no, - permission: agent.permission, - }); - const footerDate = formatMineDate(agent.version_create_time); - const versionLabel = agent.version_label; - const isMainAgent = agent.is_main_agent !== false; - const canEdit = agent.permission !== "READ_ONLY"; - const canView = (agent.current_version_no ?? 0) > 0; - const canEvaluate = canView; - const menuActions = getMineCardMenuActions(agent); - const repositoryBadge = getMineCardRepositoryStatusBadge( - agent.repository_info - ); - - const menuItems: MenuProps["items"] = menuActions.map((action) => { - const icon = - action === "apply" ? ( - - ) : ( - - ); - - return { - key: action, - label: t(MENU_ACTION_I18N[action]), - icon, - disabled: action === "apply" && isApplying, - onClick: () => { - if (action === "apply") { - onApplyListing(); - return; - } - onViewReview( - agent, - action === "reviewUpdate" ? "reviewUpdate" : "review" - ); - }, - }; - }); - - if (canEvaluate) { - menuItems.push({ - key: "evaluate", - icon: , - label: t("agentRepository.mine.evaluate"), - onClick: onEvaluate, - }); - } - - if (menuItems.length > 0 && canEdit) { - menuItems.push({ type: "divider" }); - } - - if (published) { - menuItems.push({ - key: "usageGuide", - icon: , - label: t("agentRepository.mine.menu.usageGuide"), - className: guideMenuOpen ? "font-semibold" : undefined, - onClick: onUsageGuide, - }); - } - - if (canEdit) { - menuItems.push({ - key: "delete", - danger: true, - icon: , - label: t("common.delete"), - disabled: isDeleting, - onClick: onDelete, - }); - } - - return ( - - {isMainAgent ? ( - - - {t("agentRepository.card.mainAgent")} - - ) : null} - {versionLabel != null ? ( - - - - {t("agentRepository.mine.currentVersion", { - version: versionLabel, - })} - - - ) : null} - - ) : undefined - } - icon={} - description={description} - descriptionLines={2} - tags={ - tags.length > 0 ? ( - getAgentRepositoryTagLabel(tag, t))} - /> - ) : undefined - } - statusRow={ -
- - {published - ? t("agentRepository.mine.lifecycle.published") - : t("agentRepository.mine.lifecycle.draft")} - -
- {repositoryBadge ? ( - - {t(repositoryBadge.labelKey)} - {repositoryBadge.versionLabel - ? ` · ${repositoryBadge.versionLabel}` - : null} - - ) : null} -
-
- } - headerActions={ - menuItems.length > 0 || agent.is_available === false ? ( -
-
- {menuItems.length > 0 ? ( - -
- {agent.is_available === false ? ( -
- 0 - ? unavailableReasonLabels.join(", ") - : t("agentSelector.agentUnavailable") - } - > - - {t("mcpConfig.status.unavailable")} - - -
- ) : null} -
- ) : undefined - } - fixedHeaderLayout - footerLayout="inline" - meta={ - footerDate ? ( - - - {footerDate} - - ) : undefined - } - footer={ - canEdit ? ( - - ) : ( - - ) - } - /> - ); -} +export * from "@/components/repository/agent/components/MyAgentCard"; diff --git a/frontend/app/[locale]/agent-space/components/MyAgentIcon.tsx b/frontend/app/[locale]/agent-space/components/MyAgentIcon.tsx index 0d575a958b..6e07cba104 100644 --- a/frontend/app/[locale]/agent-space/components/MyAgentIcon.tsx +++ b/frontend/app/[locale]/agent-space/components/MyAgentIcon.tsx @@ -1,45 +1,5 @@ -"use client"; - -import { Avatar } from "antd"; -import { createElement } from "react"; -import { - getAgentIcon, - getAgentUploadedIconId, - getAgentUploadedIconRevision, -} from "@/lib/chat/agentIconUtils"; -import { API_ENDPOINTS } from "@/services/api"; -import type { MyEditableAgentItem } from "@/types/agentRepository"; +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -interface MyAgentIconProps { - agent: Pick; - size: number; - iconSize: number; -} - -export function MyAgentIcon({ agent, size, iconSize }: MyAgentIconProps) { - const fallbackIcon = createElement( - getAgentIcon({ agent_id: agent.agent_id }), - { - size: iconSize, - "aria-hidden": true, - } - ); - const uploadedIconId = getAgentUploadedIconId(agent); +"use client"; - return ( - - ); -} +export * from "@/components/repository/agent/components/MyAgentIcon"; diff --git a/frontend/app/[locale]/agent-space/components/RepositoryAgentIcon.tsx b/frontend/app/[locale]/agent-space/components/RepositoryAgentIcon.tsx index f19bddd4d4..1441ad9076 100644 --- a/frontend/app/[locale]/agent-space/components/RepositoryAgentIcon.tsx +++ b/frontend/app/[locale]/agent-space/components/RepositoryAgentIcon.tsx @@ -1,31 +1,5 @@ -"use client"; - -import { Avatar } from "antd"; -import { createElement } from "react"; -import { getAgentIcon } from "@/lib/chat/agentIconUtils"; -import { withBasePath } from "@/lib/basePath"; +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -interface RepositoryAgentIconProps { - agentId?: number | null; - iconUrl?: string | null; - size: number; - iconSize: number; -} +"use client"; -export function RepositoryAgentIcon({ - agentId, - iconUrl, - size, - iconSize, -}: RepositoryAgentIconProps) { - const DefaultIcon = getAgentIcon({ agent_id: agentId ?? 0 }); - return ( - - ); -} +export * from "@/components/repository/agent/components/RepositoryAgentIcon"; diff --git a/frontend/app/[locale]/agent-space/components/ReviewAgentList.tsx b/frontend/app/[locale]/agent-space/components/ReviewAgentList.tsx index 08fba3d2c3..1f6e69e6f3 100644 --- a/frontend/app/[locale]/agent-space/components/ReviewAgentList.tsx +++ b/frontend/app/[locale]/agent-space/components/ReviewAgentList.tsx @@ -2,158 +2,4 @@ "use client"; -import { Button } from "antd"; -import { Check, Eye, X } from "lucide-react"; -import { useUserDisplayName } from "@/hooks/auth/useUserDisplayName"; -import { useTranslation } from "react-i18next"; -import type { TFunction } from "i18next"; -import type { AgentRepositoryListingItem } from "@/types/agentRepository"; -import { RepositoryAgentIcon } from "./RepositoryAgentIcon"; - -const GRID_COLS = "grid-cols-[minmax(0,2fr)_120px_160px_minmax(0,1.5fr)_280px]"; - -interface ReviewAgentListProps { - listings: AgentRepositoryListingItem[]; - currentUserEmail?: string | null; - updatingRepositoryId: number | null; - onDetailClick: (listing: AgentRepositoryListingItem) => void; - onApprove: (listing: AgentRepositoryListingItem) => void; - onReject: (listing: AgentRepositoryListingItem) => void; -} - -function getListingTitle(listing: AgentRepositoryListingItem, t: TFunction) { - return ( - listing.display_name?.trim() || - listing.name?.trim() || - t("agentRepository.card.untitled") - ); -} - -function getSubmitterDisplay( - submittedBy: string | null | undefined, - currentUserEmail: string | null | undefined, - t: TFunction -) { - const trimmed = submittedBy?.trim(); - if (!trimmed) { - return t("repository.review.unknownSubmitter"); - } - if ( - currentUserEmail && - trimmed.toLowerCase() === currentUserEmail.toLowerCase() - ) { - return t("repository.review.me"); - } - return trimmed; -} - -export function ReviewAgentList({ - listings, - currentUserEmail, - updatingRepositoryId, - onDetailClick, - onApprove, - onReject, -}: ReviewAgentListProps) { - const { t } = useTranslation("common"); - const displayName = useUserDisplayName(); - - return ( -
-
-
- {t("repository.review.column.name")} - {t("repository.review.column.version")} - {t("repository.review.column.submitter")} - {t("repository.review.column.listingNote")} - {t("repository.review.column.actions")} -
- -
    - {listings.map((listing) => { - const title = getListingTitle(listing, t); - const isUpdating = - updatingRepositoryId === listing.agent_repository_id; - const versionLabel = - listing.version_label?.trim() || t("repository.review.noVersion"); - const submitter = displayName( - listing.submitted_by_cas_username, - getSubmitterDisplay(listing.submitted_by, currentUserEmail, t) - ); - const listingNote = listing.content?.trim() || "—"; - - return ( -
  • -
    -
    - -
    -

    - {title} -

    -
    - -
    - {versionLabel} -
    - -
    - {submitter} -
    - -
    - {listingNote} -
    - -
    - - - -
    -
  • - ); - })} -
-
-
- ); -} +export * from "@/components/repository/agent/components/ReviewAgentList"; diff --git a/frontend/app/[locale]/agent-space/my-agent.tsx b/frontend/app/[locale]/agent-space/my-agent.tsx index ed61fe43c9..025758dc94 100644 --- a/frontend/app/[locale]/agent-space/my-agent.tsx +++ b/frontend/app/[locale]/agent-space/my-agent.tsx @@ -2,798 +2,4 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useParams, useRouter, useSearchParams } from "next/navigation"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { App, Button, Empty, Input, Select, Spin } from "antd"; -import { Search } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import CreateAgentModal, { - type CreatedAgentResult, -} from "@/components/agent/CreateAgentModal"; -import { useConfirmModal } from "@/hooks/useConfirmModal"; -import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; -import { useAgentList } from "@/hooks/agent/useAgentList"; -import { useToolList } from "@/hooks/agent/useToolList"; -import { useSkillList } from "@/hooks/agent/useSkillList"; -import type { Agent } from "@/types/agentConfig"; -import type { AgentListFilters } from "@/services/agentConfigService"; -import { deleteAgent } from "@/services/agentConfigService"; -import { - AGENTS_LIST_QUERY_KEY, - invalidateAgentRepositoryCaches, - useCreateAgentRepositoryListing, - useMyEditableAgents, - useAgentRepositoryListings, - useUpdateAgentRepositoryStatus, -} from "@/hooks/agentRepository/useAgentRepositoryListings"; -import { useTagDefinitions, useTagLibraries } from "@/hooks/useTagManagement"; -import { getTagSearchPredicates } from "@/lib/systemTagLabels"; -import { parseReviewDeepLinkParams } from "@/lib/notificationNavigation"; -import { parseAgentUsageGuideParams } from "@/lib/agentUsageGuide"; -import log from "@/lib/logger"; -import { - getAgentUsageGuideOpenAction, - parseAgentUsageGuideTargetParams, - resolveAgentUsageGuideTarget, -} from "@/lib/agentUsageGuide"; -import { - isCancelableRepositoryStatus, - isTakeDownableRepositoryStatus, - findRepositoryInfoById, - pickReviewDisplayRepositoryInfo, - resolveReviewModalMode, - toMineRepositoryInfo, -} from "@/lib/agentRepositoryMine"; -import { - isNewAgentPaddingItem, - type AgentRepositoryListingCreatePayload, - type MyAgentRepositoryInfoItem, - type MyEditableAgentItem, -} from "@/types/agentRepository"; -import { MineApplyListingModal } from "./components/MineApplyListingModal"; -import { MineReviewStatusModal } from "./components/MineReviewStatusModal"; -import { AgentUsageGuideModal } from "./components/AgentUsageGuideModal"; -import { CreateNewAgentCard } from "./components/CreateNewAgentCard"; -import { MyAgentCard } from "./components/MyAgentCard"; -import { REPOSITORY_PAGE_SIZE } from "@/const/resource"; -import ResourceCardGrid from "@/components/resource/ResourceCardGrid"; -import TagFilterPopover from "@/components/tag/TagFilterPopover"; -import type { TagResourcePredicate } from "@/types/tagManagement"; -import { useAgentVersionDetail } from "@/hooks/agent/useAgentVersionDetail"; -import { mapMyAgentDetail } from "@/lib/myAgentDetail"; -import { AgentDetail } from "@/components/agent/agent-detail"; -import { MyAgentIcon } from "./components/MyAgentIcon"; - -type MineSortOrder = "default" | "latest"; - -export function MyAgent({ - active, - onTotalChange, -}: { - active: boolean; - onTotalChange?: (total: number) => void; -}) { - const { t } = useTranslation("common"); - const { message } = App.useApp(); - const { confirm } = useConfirmModal(); - const { user } = useAuthorizationContext(); - const router = useRouter(); - const searchParams = useSearchParams(); - const queryClient = useQueryClient(); - const params = useParams<{ locale: string }>(); - const locale = params.locale || "en"; - const [searchQuery, setSearchQuery] = useState(""); - const [sortOrder, setSortOrder] = useState("default"); - const [tagPredicates, setTagPredicates] = useState( - [] - ); - const [page, setPage] = useState(1); - const { data: tagLibraries } = useTagLibraries(); - const defaultTagLibrary = - tagLibraries?.find( - (library) => library.bucket_key === "default_resource" - ) ?? null; - const { data: tagDefinitions } = useTagDefinitions( - defaultTagLibrary?.bucket_id ?? null - ); - const searchTagPredicates = useMemo( - () => getTagSearchPredicates(tagDefinitions, searchQuery, t), - [tagDefinitions, searchQuery, t] - ); - const pageSize = REPOSITORY_PAGE_SIZE - 1; - const listParams = useMemo( - (): AgentListFilters => ({ - tenantId: user?.tenantId ?? null, - enabled: active, - includeRepositoryInfo: true, - page, - pageSize, - search: searchQuery.trim() || undefined, - tagPredicates, - searchTagPredicates, - sortByUpdateTime: sortOrder === "latest" || undefined, - }), - [ - active, - user?.tenantId, - page, - pageSize, - searchQuery, - tagPredicates, - searchTagPredicates, - sortOrder, - ] - ); - const { - agents: listedAgents, - creatorCounts, - pagination, - isLoading, - isError, - isFetching, - refetch, - } = useAgentList(listParams); - const agents: MyEditableAgentItem[] = useMemo( - () => listedAgents.map(toMyAgentItem), - [listedAgents] - ); - const total = pagination?.total ?? 0; - useEffect(() => { - if (active && creatorCounts && tagPredicates.length === 0) { - onTotalChange?.(creatorCounts.all); - } - }, [active, creatorCounts, onTotalChange, tagPredicates.length]); - const reviewDeepLink = useMemo( - () => parseReviewDeepLinkParams(searchParams), - [searchParams] - ); - const usageGuideDeepLink = useMemo( - () => parseAgentUsageGuideParams(searchParams), - [searchParams] - ); - const usageGuideTarget = useMemo( - () => parseAgentUsageGuideTargetParams(searchParams), - [searchParams] - ); - const { data: deepLinkMineData, isLoading: deepLinkFallbackLoading } = - useMyEditableAgents( - { - ownership: "all", - agent_id: reviewDeepLink?.agentId ?? usageGuideTarget?.agentId, - page: 1, - page_size: 1, - new_agent_padding: false, - }, - active && (reviewDeepLink != null || usageGuideTarget != null) - ); - const deepLinkFallbackAgent = useMemo(() => { - const item = deepLinkMineData?.items?.[0]; - return item && !isNewAgentPaddingItem(item) ? item : null; - }, [deepLinkMineData]); - const onReviewDeepLinkConsumed = useCallback(() => { - router.replace(`/${locale}/agent-space?tab=mine`); - }, [locale, router]); - const onUsageGuideDeepLinkConsumed = useCallback(() => { - if (!usageGuideTarget) return; - router.replace( - `/${locale}/agent-space?tab=mine&agent_id=${usageGuideTarget.agentId}` - ); - }, [locale, router, usageGuideTarget]); - const onSortOrderChange = (value: MineSortOrder) => { - setSortOrder(value); - setPage(1); - }; - const onSearchChange = (value: string) => { - setSearchQuery(value); - setPage(1); - }; - const onTagPredicatesChange = (value: TagResourcePredicate[]) => { - setTagPredicates(value); - setPage(1); - }; - const [createAgentModalVisible, setCreateAgentModalVisible] = useState(false); - const [reviewModalOpen, setReviewModalOpen] = useState(false); - const [reviewModalAgent, setReviewModalAgent] = - useState(null); - const [reviewModalInfo, setReviewModalInfo] = - useState(null); - const [reviewModalMode, setReviewModalMode] = useState< - "review" | "reviewUpdate" - >("review"); - const [applyingAgentId, setApplyingAgentId] = useState(null); - const [detailTarget, setDetailTarget] = useState<{ - agentId: number; - versionNo: number; - agent: MyEditableAgentItem; - } | null>(null); - const { - data: versionDetail, - isLoading: isDetailLoading, - isError: isDetailError, - isFetching: isDetailFetching, - refetch: refetchDetail, - } = useAgentVersionDetail( - detailTarget?.agentId ?? null, - detailTarget?.versionNo ?? null, - active && detailTarget != null - ); - const { tools: availableTools } = useToolList({ - enabled: active && detailTarget != null, - }); - const { skills: availableSkills } = useSkillList({ - enabled: active && detailTarget != null, - }); - const { - data: detailListings, - isLoading: isDetailListingsLoading, - isError: isDetailListingsError, - isFetching: isDetailListingsFetching, - refetch: refetchDetailListings, - } = useAgentRepositoryListings( - detailTarget - ? { agent_id: detailTarget.agentId, page: 1, page_size: 100 } - : undefined, - active && detailTarget != null - ); - const detail = useMemo(() => { - if (!detailTarget) return null; - if (!versionDetail) return undefined; - const repositoryInfo = pickReviewDisplayRepositoryInfo( - toMineRepositoryInfo(detailListings?.items ?? []) - ); - return { - ...mapMyAgentDetail(versionDetail, detailTarget.agent, { - tools: availableTools, - skills: availableSkills, - }), - status: repositoryInfo?.status, - }; - }, [ - availableSkills, - availableTools, - detailTarget, - detailListings, - versionDetail, - ]); - const [applyModalOpen, setApplyModalOpen] = useState(false); - const [applyModalAgent, setApplyModalAgent] = - useState(null); - const [usageGuideAgent, setUsageGuideAgent] = - useState(null); - const [guidedMenuAgentId, setGuidedMenuAgentId] = useState( - null - ); - const consumedDeepLinkRef = useRef(null); - const consumedUsageGuideRef = useRef(null); - - const createListingMutation = useCreateAgentRepositoryListing(); - const updateStatusMutation = useUpdateAgentRepositoryStatus(); - const deleteAgentMutation = useMutation({ - mutationFn: (agentId: number) => deleteAgent(agentId), - }); - - const normalizedQuery = searchQuery.trim().toLowerCase(); - const usageGuideTargetState = useMemo( - () => - usageGuideTarget - ? resolveAgentUsageGuideTarget({ - agentId: usageGuideTarget.agentId, - agents, - fallbackAgent: deepLinkFallbackAgent, - isListLoading: isLoading, - isFallbackLoading: deepLinkFallbackLoading, - isActive: active, - getAgentId: (agent) => - isNewAgentPaddingItem(agent) ? null : agent.agent_id, - }) - : null, - [ - agents, - deepLinkFallbackAgent, - deepLinkFallbackLoading, - isLoading, - usageGuideTarget, - ] - ); - const displayedAgents = useMemo(() => { - if (usageGuideTargetState?.state !== "found") { - return agents; - } - const targetAgent = usageGuideTargetState.agent; - if ( - isNewAgentPaddingItem(targetAgent) || - agents.some( - (agent) => - !isNewAgentPaddingItem(agent) && - agent.agent_id === targetAgent.agent_id - ) - ) { - return agents; - } - return [targetAgent, ...agents]; - }, [agents, usageGuideTargetState]); - const highlightedAgentId = - usageGuideTargetState?.state === "found" && - !isNewAgentPaddingItem(usageGuideTargetState.agent) - ? usageGuideTargetState.agent.agent_id - : null; - - const handleCreateAgent = () => { - setCreateAgentModalVisible(true); - }; - - const handleAgentCreated = async ({ agentId }: CreatedAgentResult) => { - setCreateAgentModalVisible(false); - await Promise.all([ - invalidateAgentRepositoryCaches(queryClient), - queryClient.invalidateQueries({ queryKey: [AGENTS_LIST_QUERY_KEY] }), - ]); - router.push(`/${locale}/agents/${agentId}?onboarding=1`); - }; - - const handleEdit = ( - agentId: number, - permission?: MyEditableAgentItem["permission"] - ) => { - if (permission === "READ_ONLY") { - return; - } - router.push(`/${locale}/agents/${agentId}?from=agent-space&tab=mine`); - }; - - const handleDeleteAgent = (agent: MyEditableAgentItem) => { - const name = agent.name?.trim() || t("agentRepository.card.untitled"); - confirm({ - title: t("businessLogic.config.modal.deleteTitle"), - content: t("businessLogic.config.modal.deleteContent", { name }), - onOk: async () => { - try { - const result = await deleteAgentMutation.mutateAsync(agent.agent_id); - if (!result.success) { - throw new Error(result.message || "delete failed"); - } - message.success( - t("businessLogic.config.error.agentDeleteSuccess", { name }) - ); - await Promise.all([ - invalidateAgentRepositoryCaches(queryClient), - queryClient.invalidateQueries({ - queryKey: [AGENTS_LIST_QUERY_KEY], - }), - ]); - } catch (error) { - log.error("Failed to delete agent:", error); - message.error(t("businessLogic.config.error.agentDeleteFailed")); - throw error; - } - }, - }); - }; - - const handleEvaluate = (agent: MyEditableAgentItem) => { - const versionNo = agent.current_version_no ?? 0; - if (versionNo <= 0) { - return; - } - router.push( - `/${locale}/evaluation?agent_ids=${encodeURIComponent(JSON.stringify([agent.agent_id]))}` - ); - }; - - const closeReviewModal = () => { - setReviewModalOpen(false); - setReviewModalAgent(null); - setReviewModalInfo(null); - }; - - const handleApplyListing = (agent: MyEditableAgentItem) => { - const versionNo = agent.current_version_no ?? 0; - if (versionNo <= 0) { - return; - } - setApplyModalAgent(agent); - setApplyModalOpen(true); - }; - - const closeApplyModal = () => { - setApplyModalOpen(false); - setApplyModalAgent(null); - }; - - const handleSubmitApplyListing = async ( - payload: AgentRepositoryListingCreatePayload - ) => { - if (!applyModalAgent) { - return; - } - - const versionNo = applyModalAgent.current_version_no ?? 0; - if (versionNo <= 0) { - return; - } - - setApplyingAgentId(applyModalAgent.agent_id); - try { - await createListingMutation.mutateAsync({ - agentId: applyModalAgent.agent_id, - versionNo, - payload, - }); - message.success(t("repository.mine.applySuccess")); - closeApplyModal(); - } catch { - message.error(t("repository.mine.applyError")); - } finally { - setApplyingAgentId(null); - } - }; - - const handleViewReview = ( - agent: MyEditableAgentItem, - mode: "review" | "reviewUpdate" - ) => { - const repositoryInfo = pickReviewDisplayRepositoryInfo( - agent.repository_info ?? [] - ); - if (!repositoryInfo) { - return; - } - openReviewModal(agent, repositoryInfo, mode); - }; - - const openReviewModal = ( - agent: MyEditableAgentItem, - repositoryInfo: MyAgentRepositoryInfoItem, - mode: "review" | "reviewUpdate" - ) => { - setReviewModalAgent(agent); - setReviewModalInfo(repositoryInfo); - setReviewModalMode(mode); - setReviewModalOpen(true); - }; - - useEffect(() => { - if (!active) return; - if (!reviewDeepLink) { - consumedDeepLinkRef.current = null; - return; - } - - if (consumedDeepLinkRef.current === reviewDeepLink.agentRepositoryId) { - return; - } - - const listStillLoading = isLoading; - const fallbackStillLoading = deepLinkFallbackLoading; - if (listStillLoading && fallbackStillLoading) { - return; - } - - const agentFromList = agents.find( - (item) => item.agent_id === reviewDeepLink.agentId - ); - const agent = deepLinkFallbackAgent ?? agentFromList; - - if (!agent) { - if (listStillLoading || fallbackStillLoading) { - return; - } - message.error(t("notifications.deepLink.agentNotFound")); - consumedDeepLinkRef.current = reviewDeepLink.agentRepositoryId; - onReviewDeepLinkConsumed?.(); - return; - } - - const repositoryInfo = findRepositoryInfoById( - agent.repository_info ?? [], - reviewDeepLink.agentRepositoryId - ); - - if (!repositoryInfo) { - message.error(t("notifications.deepLink.agentNotFound")); - consumedDeepLinkRef.current = reviewDeepLink.agentRepositoryId; - onReviewDeepLinkConsumed?.(); - return; - } - - openReviewModal( - agent, - repositoryInfo, - resolveReviewModalMode(agent, repositoryInfo) - ); - consumedDeepLinkRef.current = reviewDeepLink.agentRepositoryId; - onReviewDeepLinkConsumed?.(); - }, [ - active, - agents, - deepLinkFallbackAgent, - deepLinkFallbackLoading, - isLoading, - message, - onReviewDeepLinkConsumed, - reviewDeepLink, - t, - ]); - - useEffect(() => { - if (!usageGuideDeepLink) { - consumedUsageGuideRef.current = null; - return; - } - - if (!usageGuideTargetState) { - return; - } - const openAction = getAgentUsageGuideOpenAction({ - agentId: usageGuideDeepLink.agentId, - consumedAgentId: consumedUsageGuideRef.current, - target: usageGuideTargetState, - }); - if (openAction.action === "ignore" || openAction.action === "wait") { - return; - } - if (openAction.action === "missing") { - message.error(t("notifications.usageGuide.agentNotFound")); - consumedUsageGuideRef.current = usageGuideDeepLink.agentId; - onUsageGuideDeepLinkConsumed?.(); - return; - } - - if (isNewAgentPaddingItem(openAction.agent)) { - return; - } - setGuidedMenuAgentId(openAction.agent.agent_id); - consumedUsageGuideRef.current = usageGuideDeepLink.agentId; - onUsageGuideDeepLinkConsumed?.(); - }, [ - onUsageGuideDeepLinkConsumed, - t, - usageGuideDeepLink, - usageGuideTargetState, - ]); - - const closeUsageGuide = () => { - setUsageGuideAgent(null); - }; - - const handleSetNotShared = async () => { - if (!reviewModalInfo) { - return; - } - - const canUpdate = - isCancelableRepositoryStatus(reviewModalInfo.status) || - isTakeDownableRepositoryStatus(reviewModalInfo.status); - if (!canUpdate) { - return; - } - - const wasShared = reviewModalInfo.status === "shared"; - - try { - await updateStatusMutation.mutateAsync({ - agentRepositoryId: reviewModalInfo.agent_repository_id, - status: "not_shared", - }); - message.success( - wasShared - ? t("repository.mine.takeDownSuccess") - : t("repository.mine.cancelApplySuccess") - ); - closeReviewModal(); - } catch { - message.error( - wasShared - ? t("repository.mine.takeDownError") - : t("repository.mine.cancelApplyError") - ); - throw new Error("Update repository status failed"); - } - }; - - const hasActiveFilter = - normalizedQuery.length > 0 || tagPredicates.length > 0; - const showFilteredEmpty = - !isLoading && !isError && agents.length === 0 && hasActiveFilter; - return ( -
-
-
- onSearchChange(e.target.value)} - placeholder={t("agentRepository.mine.searchPlaceholder")} - prefix={} - className="rounded-xl" - allowClear - /> -
- - - value={sortOrder} - onChange={onSortOrderChange} - className="w-44 shrink-0" - aria-label={t("agentRepository.sort.label")} - options={[ - { value: "default", label: t("agentRepository.sort.default") }, - { value: "latest", label: t("agentRepository.sort.latest") }, - ]} - /> -
- -
- {isLoading ? ( -
- -
- ) : isError ? ( -
-

- {t("agentRepository.mine.loadError")} -

- -
- ) : ( - <> - - ) : undefined - } - showCreateCard - createCard={} - renderItem={(agent) => ( - handleEdit(agent.agent_id, agent.permission)} - onView={() => - setDetailTarget({ - agentId: agent.agent_id, - versionNo: agent.current_version_no ?? 0, - agent, - }) - } - onApplyListing={() => handleApplyListing(agent)} - onViewReview={handleViewReview} - onDelete={() => handleDeleteAgent(agent)} - onEvaluate={() => handleEvaluate(agent)} - onUsageGuide={() => { - setGuidedMenuAgentId(null); - setUsageGuideAgent(agent); - }} - highlighted={highlightedAgentId === agent.agent_id} - guideMenuOpen={ - guidedMenuAgentId === agent.agent_id ? true : undefined - } - onGuideMenuOpenChange={(open) => { - if (!open && guidedMenuAgentId === agent.agent_id) { - setGuidedMenuAgentId(null); - } - }} - isApplying={ - applyingAgentId === agent.agent_id && - createListingMutation.isPending - } - isDeleting={ - deleteAgentMutation.isPending && - deleteAgentMutation.variables === agent.agent_id - } - /> - )} - /> - {showFilteredEmpty ? ( - - ) : null} - - )} -
- - - - - - - - setCreateAgentModalVisible(false)} - onCreated={handleAgentCreated} - /> - setDetailTarget(null)} - onEdit={ - detailTarget?.agent.permission === "READ_ONLY" - ? undefined - : detailTarget - ? () => - handleEdit( - detailTarget.agentId, - detailTarget.agent.permission - ) - : undefined - } - detail={detail} - agentIcon={ - detailTarget ? ( - - ) : undefined - } - published={(detailTarget?.versionNo ?? 0) > 0} - status={detail?.status} - isLoading={isDetailLoading || isDetailListingsLoading} - isError={isDetailError || isDetailListingsError} - isFetching={isDetailFetching || isDetailListingsFetching} - onRetry={() => { - void refetchDetail(); - void refetchDetailListings(); - }} - /> -
- ); -} - -function toMyAgentItem(agent: Agent): MyEditableAgentItem { - const currentVersionNo = agent.current_version_no ?? 0; - return { - agent_id: Number(agent.id), - icon_url: agent.icon_url, - is_available: agent.is_available, - unavailable_reasons: agent.unavailable_reasons, - name: agent.display_name || agent.name, - description: agent.description, - current_version_no: currentVersionNo, - version_label: - agent.version_label ?? - (currentVersionNo > 0 ? `V${currentVersionNo}` : null), - version_create_time: agent.version_create_time ?? null, - permission: agent.permission, - is_main_agent: agent.is_main_agent, - tags: agent.tags, - repository_info: agent.repository_info ?? [], - }; -} +export * from "@/components/repository/agent/my-agent"; diff --git a/frontend/app/[locale]/agent-space/review-center.tsx b/frontend/app/[locale]/agent-space/review-center.tsx index 8359d21568..b4b8c1bfde 100644 --- a/frontend/app/[locale]/agent-space/review-center.tsx +++ b/frontend/app/[locale]/agent-space/review-center.tsx @@ -1,204 +1,5 @@ -"use client"; - -import { useMemo, useState } from "react"; -import { App, Button, Empty, Spin } from "antd"; -import { ChevronLeft, ChevronRight } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; -import { AgentDetail } from "@/components/agent/agent-detail"; -import { USER_ROLES } from "@/const/auth"; -import { - useAgentRepositoryListings, - useUpdateAgentRepositoryStatus, -} from "@/hooks/agentRepository/useAgentRepositoryListings"; -import { useRepositoryAgentDetail } from "@/hooks/agentRepository/useRepositoryAgentDetail"; - -import type { AgentRepositoryListingItem } from "@/types/agentRepository"; -import { ReviewAgentList } from "./components/ReviewAgentList"; -import { RepositoryAgentIcon } from "./components/RepositoryAgentIcon"; -import { - AgentRepositoryReviewConfirmModal, - type AgentRepositoryReviewAction, -} from "./components/AgentRepositoryReviewConfirmModal"; +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -const REVIEW_PAGE_SIZE = 10; - -export function ReviewCenter({ active }: { active: boolean }) { - const { t } = useTranslation("common"); - const { message } = App.useApp(); - const { user } = useAuthorizationContext(); - const isAdmin = user?.role === USER_ROLES.ADMIN; - const currentUserEmail = user?.email; - const [page, setPage] = useState(1); - const pageSize = REVIEW_PAGE_SIZE; - const reviewListParams = useMemo( - () => ({ status: "pending_review" as const, page, page_size: pageSize }), - [page, pageSize] - ); - const { data, isLoading, isError, isFetching, refetch } = - useAgentRepositoryListings(reviewListParams, isAdmin && active); - const updateStatusMutation = useUpdateAgentRepositoryStatus(); - const listings = data?.items ?? []; - const total = data?.pagination?.total ?? 0; - const updatingRepositoryId = updateStatusMutation.isPending - ? (updateStatusMutation.variables?.agentRepositoryId ?? null) - : null; - const [detailListing, setDetailListing] = - useState(null); - const { - detail, - repositoryDetail, - isLoading: isDetailLoading, - isError: isDetailError, - isFetching: isDetailFetching, - retry: refetchDetail, - } = useRepositoryAgentDetail(detailListing, active); - const [reviewAction, setReviewAction] = - useState(null); - const [reviewListing, setReviewListing] = - useState(null); - const totalPages = total > 0 ? Math.ceil(total / pageSize) : 0; - const showPagination = !isLoading && !isError && totalPages > 1; - - const closeReviewModal = () => { - setReviewAction(null); - setReviewListing(null); - }; - const handleReviewConfirm = async (content?: string) => { - if (!reviewListing || !reviewAction) return; - const title = - reviewListing.display_name?.trim() || - reviewListing.name?.trim() || - t("agentRepository.card.untitled"); - const isApprove = reviewAction === "approve"; - try { - await updateStatusMutation.mutateAsync({ - agentRepositoryId: reviewListing.agent_repository_id, - status: isApprove ? "shared" : "rejected", - content, - }); - message.success( - isApprove - ? t("repository.review.approveSuccess", { name: title }) - : t("repository.review.rejectSuccess", { name: title }) - ); - closeReviewModal(); - } catch { - message.error( - isApprove - ? t("repository.review.approveError") - : t("repository.review.rejectError") - ); - throw new Error("Review action failed"); - } - }; - const isReviewModalLoading = - reviewListing != null && - updatingRepositoryId === reviewListing.agent_repository_id; +"use client"; - return ( -
- {isLoading ? ( -
- -
- ) : isError ? ( -
-

- {t("repository.review.loadError")} -

- -
- ) : listings.length === 0 ? ( - - ) : ( - <> - setDetailListing(listing)} - onApprove={(listing) => { - setReviewListing(listing); - setReviewAction("approve"); - }} - onReject={(listing) => { - setReviewListing(listing); - setReviewAction("reject"); - }} - /> - - {showPagination ? ( -
- - {Array.from({ length: totalPages }, (_, index) => index + 1).map( - (pageNumber) => ( - - ) - )} - -
- ) : null} - - )} - setDetailListing(null)} - detail={detail} - agentIcon={ - detailListing ? ( - - ) : undefined - } - published - status={repositoryDetail?.status} - showRepositoryInfo - isLoading={isDetailLoading} - isError={isDetailError} - isFetching={isDetailFetching} - onRetry={refetchDetail} - /> -
- ); -} +export * from "@/components/repository/agent/review-center"; diff --git a/frontend/app/[locale]/agents/components/agent-config-actions.tsx b/frontend/app/[locale]/agents/components/agent-config-actions.tsx index ea5d1e7981..9277a44fcc 100644 --- a/frontend/app/[locale]/agents/components/agent-config-actions.tsx +++ b/frontend/app/[locale]/agents/components/agent-config-actions.tsx @@ -15,6 +15,9 @@ import { GitBranch, MoreHorizontal, Network, + LineChart, + Share2, + ClipboardCheck, Send, Settings2, Trash2, @@ -36,10 +39,19 @@ import { updateToolConfig, } from "@/services/agentConfigService"; import { useAgentStore } from "@/stores/agentStore"; +import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; +import { RESOURCE_REPOSITORY_PERMISSION_ROUTE } from "@/const/resourceRepository"; +import { buildResourceRepositoryHref } from "@/lib/resourceRepositoryRoutes"; +import { getAgentUsageGuideAccess } from "@/lib/agentUsageGuide"; +import { useCreateAgentRepositoryListing } from "@/hooks/agentRepository/useAgentRepositoryListings"; +import { MineApplyListingModal } from "@/components/repository/agent/components/MineApplyListingModal"; +import { AgentUsageGuideModal } from "@/components/repository/agent/components/AgentUsageGuideModal"; +import type { Agent } from "@/types/agentConfig"; export default function AgentConfigActions({ agentId: agentIdProp, agentName: agentNameProp, + agent, readOnly, variant = "buttons", onConfigure, @@ -48,6 +60,7 @@ export default function AgentConfigActions({ }: { agentId?: number | null; agentName?: string; + agent?: Agent; readOnly?: boolean; variant?: "buttons" | "menu" | "table"; onConfigure?: (agentId: number) => void; @@ -60,7 +73,11 @@ export default function AgentConfigActions({ const queryClient = useQueryClient(); const router = useRouter(); const pathname = usePathname(); - const { agentId: routeAgentId } = useParams<{ agentId?: string }>(); + const { agentId: routeAgentId, locale } = useParams<{ + agentId?: string; + locale?: string; + }>(); + const { canAccessRoute } = useAuthorizationContext(); const storeAgentId = useAgentStore((state) => state.agentId); const agentId = agentIdProp ?? storeAgentId; const editedAgent = useAgentStore((state) => state.editedAgent); @@ -72,6 +89,28 @@ export default function AgentConfigActions({ const { agentInfo } = useAgentInfo(variant === "buttons" ? agentId : null); const [isRelationshipVisible, setIsRelationshipVisible] = useState(false); const [isA2ASettingsVisible, setIsA2ASettingsVisible] = useState(false); + const [repositoryDialog, setRepositoryDialog] = useState< + "apply" | "guide" | null + >(null); + const createListing = useCreateAgentRepositoryListing(); + const versionNo = agent?.current_version_no ?? 0; + const canAccessRepository = canAccessRoute( + RESOURCE_REPOSITORY_PERMISSION_ROUTE + ); + const canApply = + canAccessRepository && + !isReadOnly && + versionNo > 0 && + agent?.latest_review?.status !== "pending_review"; + const repositoryAgent = + agent && agentId !== null + ? { + ...agent, + agent_id: agentId, + name: agentName, + repository_info: agent.repository_info ?? [], + } + : null; const { data: a2aSettingsData, isLoading: isLoadingA2ASettings } = useQuery({ queryKey: ["a2aServerSettings", agentId], queryFn: () => a2aClientService.getServerSettings(agentId!), @@ -309,6 +348,57 @@ export default function AgentConfigActions({ }); } if (onConfigure || onPublish) menuItems.push({ type: "divider" }); + if (repositoryAgent) { + if (canApply) { + menuItems.push({ + key: "applyListing", + icon: , + label: t("agentRepository.mine.menu.apply"), + onClick: () => setRepositoryDialog("apply"), + }); + } + if (canAccessRepository && repositoryAgent.latest_review) { + const reviewId = repositoryAgent.latest_review.review_id; + menuItems.push({ + key: "viewReview", + icon: , + label: t("resourceCenter.tab.review"), + onClick: () => + router.push( + buildResourceRepositoryHref(locale || "en", { + tab: "review", + reviewId, + }) + ), + }); + } + if (versionNo > 0 && canAccessRoute("/evaluation")) { + menuItems.push({ + key: "evaluate", + icon: , + label: t("agentRepository.mine.evaluate"), + onClick: () => { + const query = new URLSearchParams({ + agent_ids: JSON.stringify([repositoryAgent.agent_id]), + }); + router.push(`/${locale || "en"}/evaluation?${query}`); + }, + }); + } + if ( + getAgentUsageGuideAccess({ + currentVersionNo: versionNo, + permission: agent?.permission, + }).canOpen + ) { + menuItems.push({ + key: "usageGuide", + icon: , + label: t("agentRepository.mine.menu.usageGuide"), + onClick: () => setRepositoryDialog("guide"), + }); + } + } menuItems.push( { key: "copy", @@ -376,6 +466,36 @@ export default function AgentConfigActions({ return ( <> + {repositoryAgent && repositoryDialog === "apply" && canApply && ( + setRepositoryDialog(null)} + onSubmit={async (payload) => { + try { + await createListing.mutateAsync({ + agentId: repositoryAgent.agent_id, + versionNo, + payload, + }); + message.success(t("repository.mine.applySuccess")); + setRepositoryDialog(null); + } catch { + message.error(t("repository.mine.applyError")); + } + }} + /> + )} + {repositoryAgent && repositoryDialog === "guide" && ( + setRepositoryDialog(null)} + /> + )} {variant === "table" ? ( <> - - - - -
-
- {isContainer || isLocalImage ? ( - - ) : null} - {hasRegistryJson ? ( - - ) : null} - {hasConfigJson ? ( - - ) : null} - -
- -
- - -
-
- - - - - - setShowServerJson(false)} - /> - - setShowConfigJson(false)} - /> - - {draft.containerId ? ( - setLogsOpen(false)} - containerId={draft.containerId} - /> - ) : null} - - { - setAssignOpen(false); - setTagPreviewRefreshKey((current) => current + 1); - if (selectedService) { - onTagsChanged?.(selectedService.mcpId); - } - }} - resourceType="mcp_service" - resourceId={String(selectedService.mcpId)} - definitions={assignDefinitions ?? []} - canEdit={!isReadOnly} - onManageDefinitions={() => setTagManagementOpen(true)} - /> - - { - setTagManagementOpen(false); - void refreshAssignDefinitions(); - }} - bucketId={defaultLibrary?.bucket_id ?? 0} - bucketName={defaultLibrary?.bucket_name ?? ""} - canManage={!isReadOnly} - /> - - setPublishConfirmOpen(false)} - onConfirm={async (override) => { - const ok = await detail.publish(override); - if (ok) setPublishConfirmOpen(false); - }} - /> - - ); -} +export * from "@/components/repository/mcp/components/McpServiceDetailModal"; +export { default } from "@/components/repository/mcp/components/McpServiceDetailModal"; diff --git a/frontend/app/[locale]/mcp-space/components/McpServicesFilterBar.tsx b/frontend/app/[locale]/mcp-space/components/McpServicesFilterBar.tsx index 736a2a7a8f..e015fb9f0f 100644 --- a/frontend/app/[locale]/mcp-space/components/McpServicesFilterBar.tsx +++ b/frontend/app/[locale]/mcp-space/components/McpServicesFilterBar.tsx @@ -1,88 +1,4 @@ -import { Select } from "antd"; -import { useTranslation } from "react-i18next"; -import { FILTER_ALL, McpSource, McpTransportType } from "@/const/mcpTools"; -import type { - McpSourceFilter, - McpTagStat, - McpTransportFilter, -} from "@/types/mcpTools"; +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -interface McpServicesFilterBarProps { - /** When omitted, the source filter is not shown (e.g. published tab). */ - source?: McpSourceFilter; - onSourceChange?: (value: McpSourceFilter) => void; - transport: McpTransportFilter; - tag: string; - tagStats: McpTagStat[]; - onTransportChange: (value: McpTransportFilter) => void; - onTagChange: (value: string) => void; -} - -/** - * Compact 3-pill filter bar designed to sit inline with the search input on - * desktop. Each select is fixed-width so the whole row stays balanced - * regardless of locale length. - */ -export default function McpServicesFilterBar({ - source, - onSourceChange, - transport, - tag, - tagStats, - onTransportChange, - onTagChange, -}: McpServicesFilterBarProps) { - const { t } = useTranslation("common"); - const showSource = source !== undefined && onSourceChange !== undefined; - - return ( -
- {showSource ? ( - - onSearchChange(event.target.value)} - placeholder={ - searchPlaceholder || t("mcpTools.page.searchNameTagPlaceholder") - } - allowClear - prefix={} - className="h-8 min-w-0 flex-1 rounded-lg border-slate-200 bg-slate-50/60" - /> - {searchActions ? ( -
{searchActions}
- ) : null} - {statusOptions && onStatusChange ? ( - { - patch({ name: event.target.value }); - form.setFieldValue("name", event.target.value); - }} - className="rounded-md" - /> - - - - { - patch({ description: event.target.value }); - form.setFieldValue("description", event.target.value); - }} - autoSize={{ minRows: 2, maxRows: 12 }} - className="rounded-md" - /> - - - - { - patch({ version: event.target.value }); - form.setFieldValue("version", event.target.value); - }} - placeholder="1.0.0" - className="rounded-md" - /> - - - {source?.transportType !== McpTransportType.CONTAINER ? ( -
- - { - patch({ serverUrl: event.target.value }); - form.setFieldValue("serverUrl", event.target.value); - }} - className="w-full rounded-md" - /> - - -
- ) : null} - - {source?.transportType === McpTransportType.CONTAINER ? ( -
- - { - patch({ containerConfigJson: event.target.value }); - form.setFieldValue("containerConfigJson", event.target.value); - }} - rows={6} - className="mt-2 w-full rounded-md font-mono text-sm" - placeholder={t("mcpTools.addModal.containerConfigPlaceholder")} - /> - - -
- ) : null} - - - {assignmentState.loading ? ( - - ) : inheritedAssignments.length > 0 ? ( - - ) : ( - — - )} -

- {t("mcpTools.publish.tagsInheritedHint")} -

-
- - - - ({ - label: group.group_name, - value: group.group_id, - }))} - disabled={isGroupSelectDisabled} - onChange={(values: number[]) => patch({ groupIds: values })} - className="rounded-md" - /> - - - - - ); -} +export * from "@/components/repository/mcp/components/PublishConfirmModal"; +export { default } from "@/components/repository/mcp/components/PublishConfirmModal"; diff --git a/frontend/app/[locale]/mcp-space/components/PublishedServiceCard.tsx b/frontend/app/[locale]/mcp-space/components/PublishedServiceCard.tsx index 02de56c642..3b3e801f4a 100644 --- a/frontend/app/[locale]/mcp-space/components/PublishedServiceCard.tsx +++ b/frontend/app/[locale]/mcp-space/components/PublishedServiceCard.tsx @@ -1,76 +1,4 @@ -import { Tag } from "antd"; -import { useTranslation } from "react-i18next"; -import { - MCP_GRID_CARD_OUTER, - MCP_GRID_CARD_OUTER_STYLE, -} from "@/const/mcpTools"; -import type { CommunityMcpCard } from "@/types/mcpTools"; -import { getTransportLabelKey } from "@/lib/mcpTools"; -import TransportIcon from "./shared/TransportIcon"; +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -interface PublishedServiceCardProps { - service: CommunityMcpCard; - onSelect: (service: CommunityMcpCard) => void; -} - -export default function PublishedServiceCard({ - service, - onSelect, -}: PublishedServiceCardProps) { - const { t } = useTranslation("common"); - const version = (service.version || "").trim(); - const tags = service.tags || []; - const transportLabel = t(getTransportLabelKey(service.transportType)); - - return ( -
onSelect(service)} - className={MCP_GRID_CARD_OUTER} - style={MCP_GRID_CARD_OUTER_STYLE} - > -
- -
-
-

- {service.name} -

- {version ? ( - - v{version} - - ) : null} -
-
- {transportLabel} -
-
-
- -
-

- {service.description || "-"} -

-
- - {tags.length > 0 ? ( -
- {tags.map((tag) => ( - - {tag} - - ))} -
- ) : null} -
- ); -} +export * from "@/components/repository/mcp/components/PublishedServiceCard"; +export { default } from "@/components/repository/mcp/components/PublishedServiceCard"; diff --git a/frontend/app/[locale]/mcp-space/components/PublishedServiceDetailModal.tsx b/frontend/app/[locale]/mcp-space/components/PublishedServiceDetailModal.tsx index fc52ec6de5..923818646d 100644 --- a/frontend/app/[locale]/mcp-space/components/PublishedServiceDetailModal.tsx +++ b/frontend/app/[locale]/mcp-space/components/PublishedServiceDetailModal.tsx @@ -1,553 +1,4 @@ -import { useEffect, useState } from "react"; -import { App, Button, Form, Input, Modal, Select } from "antd"; -import { useTranslation } from "react-i18next"; -import { - Globe, - GitFork, - Link, - Zap, - Wrench, - Calendar, - Activity, - Server, - Tag as TagIcon, - Pencil, - Save, - X, - FileText, - Trash2, -} from "lucide-react"; -import { - MCP_TOOLS_MODAL_WRAP_CLASS, - mcpToolsModalChromeStyles, -} from "@/const/mcpTools"; -import type { CommunityMcpCard } from "@/types/mcpTools"; -import { useMcpFormRules } from "@/hooks/mcpTools/useMcpFormRules"; -import { usePublishedServiceDetailEdit } from "@/hooks/mcpTools/usePublishedServiceDetailEdit"; -import { - extractRegistryLinks, - formatRegistryDate, - getTransportLabelKey, - toPrettyRegistryJson, -} from "@/lib/mcpTools"; -import TransportIcon from "./shared/TransportIcon"; -import JsonPreviewModal from "./shared/JsonPreviewModal"; -import { useGroupList } from "@/hooks/group/useGroupList"; -import { Can } from "@/components/permission/Can"; -import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -interface PublishedServiceDetailModalProps { - open: boolean; - service: CommunityMcpCard | null; - onClose: () => void; -} - -/** - * Editable detail modal for the "my published" tab. Mirrors the layout of - * {@link McpServiceDetailModal} with a rich header, sectioned content, - * and inline edit mode for name/description. Published tags are read-only. - */ -export default function PublishedServiceDetailModal({ - open, - service, - onClose, -}: PublishedServiceDetailModalProps) { - const { t } = useTranslation("common"); - const { modal } = App.useApp(); - const rules = useMcpFormRules(); - const [form] = Form.useForm(); - const [isEditing, setIsEditing] = useState(false); - const edit = usePublishedServiceDetailEdit(service, open); - const { draft, saving, deleting, updateDraft } = edit; - const [showServerJsonModal, setShowServerJsonModal] = useState(false); - const [showConfigJsonModal, setShowConfigJsonModal] = useState(false); - const { user } = useAuthorizationContext(); - const tenantId = user?.tenantId || null; - const { data: groupData } = useGroupList(tenantId); - const groups = groupData?.groups || []; - - const { websiteUrl, repositoryUrl } = extractRegistryLinks( - (service?.registryJson || undefined) as Record | undefined - ); - const serverJsonPretty = toPrettyRegistryJson( - (service?.registryJson || undefined) as Record | undefined - ); - const configJsonPretty = toPrettyRegistryJson( - (service?.configJson || undefined) as Record | undefined - ); - const hasServerJson = Boolean( - service?.registryJson && Object.keys(service.registryJson).length > 0 - ); - const hasConfigJson = Boolean( - service?.configJson && Object.keys(service.configJson).length > 0 - ); - - useEffect(() => { - if (!open) { - setShowServerJsonModal(false); - setShowConfigJsonModal(false); - setIsEditing(false); - } - }, [open]); - - useEffect(() => { - if (!open || !draft) return; - form.setFieldsValue({ - name: draft.name, - description: draft.description, - version: draft.version, - ingroup_permission: draft.ingroupPermission ?? "READ_ONLY", - group_ids: draft.groupIds ? draft.groupIds.split(",").map(Number) : [], - }); - }, [open, draft, form]); - - const handleStartEdit = () => { - if (!draft) return; - form.setFieldsValue({ - name: draft.name, - description: draft.description, - version: draft.version, - ingroup_permission: draft.ingroupPermission ?? "READ_ONLY", - group_ids: draft.groupIds ? draft.groupIds.split(",").map(Number) : [], - }); - setIsEditing(true); - }; - - const handleCancelEdit = () => { - if (!draft) return; - form.setFieldsValue({ - name: draft.name, - description: draft.description, - version: draft.version, - }); - setIsEditing(false); - }; - - const handlePermissionChange = (value: string) => { - const permission = value as "EDIT" | "READ_ONLY" | "PRIVATE"; - updateDraft({ ingroupPermission: permission }); - if (permission === "PRIVATE") { - updateDraft({ groupIds: "" }); - form.setFieldsValue({ group_ids: [] }); - } - }; - - const handleSave = async () => { - try { - await form.validateFields(); - } catch { - return; - } - // Sync form values to draft before saving - const values = form.getFieldsValue(); - edit.updateDraft({ - name: values.name ?? "", - description: values.description ?? "", - version: values.version ?? "", - }); - const ok = await edit.save(); - if (ok) { - setIsEditing(false); - onClose(); - } - }; - - const handleDelete = () => { - if (!service?.communityId) return; - modal.confirm({ - title: t("mcpTools.community.mine.unpublishTitle"), - centered: true, - content: ( -

{service.name}

- ), - okText: t("mcpTools.community.mine.unpublishConfirm"), - cancelText: t("common.cancel"), - okButtonProps: { danger: true }, - onOk: async () => { - if (typeof service.communityId !== "number") return; - const ok = await edit.remove(service.communityId); - if (ok) onClose(); - }, - }); - }; - - if (!service || !draft) return null; - - return ( - <> - { - setIsEditing(false); - onClose(); - }} - wrapClassName={`${MCP_TOOLS_MODAL_WRAP_CLASS} h-[calc(100dvh-80px)]`} - styles={mcpToolsModalChromeStyles()} - > -
- {/* Header */} -
-
-
-
- {isEditing ? ( -
- - - -
- v - - - - - - -
-
- ) : ( - <> -
- -
-

- {draft.name} -

-
-
-

- - v{draft.version || "1.0.0"} - - {draft.description || t("mcpTools.detail.noDescription")} -

- - )} -
-
- - {/* Edit Action Buttons */} -
- {isEditing ? ( - <> - - - - ) : ( - <> - -
-
-
- - {/* Content */} -
- {/* Service Info Section */} -
-

- - {t("mcpTools.detail.serviceStatus")} -

-
- } - label={t("mcpTools.detail.serverType")} - value={t(getTransportLabelKey(service.transportType))} - /> - } - label={t("mcpTools.detail.createdAt")} - value={formatRegistryDate(service.createdAt)} - /> - {service.updatedAt ? ( - } - label={t("mcpTools.detail.updatedAt")} - value={formatRegistryDate(service.updatedAt)} - /> - ) : null} -
-
- - {/* Server URL Section */} - {!service.configJson && ( -
-

- - {t("mcpTools.detail.serverUrl")} -

-
- {service.serverUrl || "-"} -
-
- )} - - {/* Links Section */} - {(websiteUrl || repositoryUrl) && ( -
-

- - {t("mcpTools.detail.links")} -

-
- {websiteUrl && ( - } - label={t("mcpTools.detail.website")} - href={websiteUrl} - /> - )} - {repositoryUrl && ( - } - label={t("mcpTools.detail.repository")} - href={repositoryUrl} - /> - )} -
-
- )} - - {/* Tools Section */} - {(hasServerJson || hasConfigJson) && ( -
-

- - {t("mcpTools.detail.tools")} -

-
- {hasServerJson && ( - - )} - {hasConfigJson && ( - - )} -
-
- )} - - {/* Tags Section */} -
-

- - {t("mcpTools.detail.tags")} -

-
- {(draft.tags ?? []).length > 0 ? ( - (draft.tags ?? []).map((tag) => ( - - {tag} - - )) - ) : ( - — - )} -
-
- - {/* Permissions Section */} -
-

- - {t("tenantResources.knowledgeBase.permission")} -

- {isEditing ? ( -
- - - ({ - label: g.group_name, - value: g.group_id, - }))} - disabled={draft.ingroupPermission === "PRIVATE"} - onChange={(values: number[]) => updateDraft({ groupIds: values.join(",") })} - /> - - -
- ) : ( -
- {draft.ingroupPermission === "PRIVATE" ? ( - - {t("knowledgeBase.ingroup.permission.PRIVATE")} - - ) : draft.groupIds ? ( - - {t("knowledgeBase.ingroup.permission.READ_ONLY")} - - ) : ( - - {t("knowledgeBase.ingroup.permission.READ_ONLY")} - - )} -
- )} -
-
-
-
- - setShowServerJsonModal(false)} - /> - - setShowConfigJsonModal(false)} - /> - - ); -} - -interface InfoRowProps { - icon: React.ReactNode; - label: string; - value: string; - customValue?: React.ReactNode; - valueClass?: string; -} - -function InfoRow({ icon, label, value, customValue, valueClass }: InfoRowProps) { - return ( -
-
- {icon} - {label} -
- {customValue ? ( - {customValue} - ) : ( - - {value} - - )} -
- ); -} - -interface LinkRowProps { - icon: React.ReactNode; - label: string; - href: string; -} - -function LinkRow({ icon, label, href }: LinkRowProps) { - return ( - - ); -} +export * from "@/components/repository/mcp/components/PublishedServiceDetailModal"; +export { default } from "@/components/repository/mcp/components/PublishedServiceDetailModal"; diff --git a/frontend/app/[locale]/mcp-space/components/RepositoryMcpCard.tsx b/frontend/app/[locale]/mcp-space/components/RepositoryMcpCard.tsx index c54e66e464..f0762313bf 100644 --- a/frontend/app/[locale]/mcp-space/components/RepositoryMcpCard.tsx +++ b/frontend/app/[locale]/mcp-space/components/RepositoryMcpCard.tsx @@ -1,160 +1,4 @@ // Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -import { Button, Dropdown, type MenuProps } from "antd"; -import { Download, MoreHorizontal, Trash2 } from "lucide-react"; -import { useUserDisplayName } from "@/hooks/auth/useUserDisplayName"; -import { useTranslation } from "react-i18next"; - -import ResourceCard from "@/components/resource/ResourceCard"; -import { - getDeploymentTypeLabelKey, - resolveDeploymentType, -} from "@/lib/mcpTools"; -import type { CommunityMcpCard } from "@/types/mcpTools"; -import TransportIcon from "./shared/TransportIcon"; - -interface RepositoryMcpCardProps { - service: CommunityMcpCard; - isAdmin: boolean; - installed: boolean; - onInstall: (service: CommunityMcpCard) => void; - onSelect: (service: CommunityMcpCard) => void; - onOffline: (service: CommunityMcpCard) => void; -} - -export default function RepositoryMcpCard({ - service, - isAdmin, - installed, - onInstall, - onSelect, - onOffline, -}: RepositoryMcpCardProps) { - const { t } = useTranslation("common"); - const displayName = useUserDisplayName(); - const tags = service.tags || []; - const deploymentType = resolveDeploymentType(service); - const deploymentLabel = t(getDeploymentTypeLabelKey(deploymentType)); - const installCount = Number(service.installCount || 0); - const toolCount = resolveToolCount(service); - const author = displayName( - service.authorCasUsername, - service.authorDisplayName || - service.authorName || - t("mcpTools.repository.authorFallback") - ); - const actionItems: MenuProps["items"] = isAdmin - ? [ - { - key: "offline", - label: t("mcpTools.repository.offline"), - icon: , - danger: true, - onClick: () => onOffline(service), - }, - ] - : []; - - return ( - - } - description={service.description || t("mcpTools.detail.noDescription")} - tags={ - <> - - {deploymentLabel} - - {tags.slice(0, 3).map((tag) => ( - - {tag} - - ))} - {tags.length > 3 ? ( - - +{tags.length - 3} - - ) : null} - - {t("mcpTools.repository.toolCount", { count: toolCount })} - - - } - meta={ - - {author} - - } - headerActions={ - <> - - - {installCount} - - {actionItems.length > 0 ? ( - - - } - onClick={() => onSelect(service)} - /> - ); -} - -function resolveToolCount(service: CommunityMcpCard): number { - const registryTools = service.registryJson?.tools; - if (Array.isArray(registryTools)) return registryTools.length; - const toolNames = service.registryJson?._toolNames; - if (Array.isArray(toolNames)) return toolNames.length; - if (service.packages?.length) return service.packages.length; - if (service.remotes?.length) return service.remotes.length; - return 0; -} +export * from "@/components/repository/mcp/components/RepositoryMcpCard"; +export { default } from "@/components/repository/mcp/components/RepositoryMcpCard"; diff --git a/frontend/app/[locale]/mcp-space/components/add/AddMcpServiceModal.tsx b/frontend/app/[locale]/mcp-space/components/add/AddMcpServiceModal.tsx index 8fca42debf..556a652655 100644 --- a/frontend/app/[locale]/mcp-space/components/add/AddMcpServiceModal.tsx +++ b/frontend/app/[locale]/mcp-space/components/add/AddMcpServiceModal.tsx @@ -1,73 +1,4 @@ -import { useCallback, useRef, useState } from "react"; -import { Modal } from "antd"; -import { useTranslation } from "react-i18next"; -import { MCP_ADD_SERVICE_MODAL_WIDTH_MARKETS } from "@/const/mcpTools"; -import AddMcpServiceLocalSection from "./local/AddMcpServiceLocalSection"; -import { useMcpServerList } from "@/hooks/mcp/useMcpServerList"; +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -interface AddMcpServiceModalProps { - open: boolean; - onClose: () => void; -} - -export default function AddMcpServiceModal({ - open, - onClose, -}: AddMcpServiceModalProps) { - const { t } = useTranslation("common"); - const { enableUploadImage } = useMcpServerList({ enabled: open }); - const submittingRef = useRef(false); - - const handleClose = useCallback(() => { - if (submittingRef.current) return; - onClose(); - }, [onClose]); - - const setSubmitting = useCallback((v: boolean) => { - submittingRef.current = v; - }, []); - - if (!open) return null; - - return ( - -
- {t("mcpTools.addModal.title")} -
-
- {t("mcpTools.addModal.subtitle")} -
-
- } - footer={null} - closable={!submittingRef.current} - centered - width={MCP_ADD_SERVICE_MODAL_WIDTH_MARKETS} - onCancel={handleClose} - mask={{ closable: !submittingRef.current }} - wrapClassName="[&_.ant-modal]:transition-[width] [&_.ant-modal]:duration-300 [&_.ant-modal]:ease-in-out" - styles={{ - mask: { background: "rgba(4, 4, 4, 0.6)", backdropFilter: "blur(2px)" }, - body: { - padding: 0, - maxHeight: "90vh", - overflow: "hidden", - }, - }} - > -
-
- -
-
- - ); -} +export * from "@/components/repository/mcp/components/add/AddMcpServiceModal"; +export { default } from "@/components/repository/mcp/components/add/AddMcpServiceModal"; diff --git a/frontend/app/[locale]/mcp-space/components/add/community/AddMcpServiceCommunitySection.tsx b/frontend/app/[locale]/mcp-space/components/add/community/AddMcpServiceCommunitySection.tsx index 6a9f20c970..654bb94025 100644 --- a/frontend/app/[locale]/mcp-space/components/add/community/AddMcpServiceCommunitySection.tsx +++ b/frontend/app/[locale]/mcp-space/components/add/community/AddMcpServiceCommunitySection.tsx @@ -1,71 +1,4 @@ // Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -import { useState } from "react"; -import type { CommunityMcpCard } from "@/types/mcpTools"; -import { useMcpCommunityBrowser } from "@/hooks/mcpTools/useMcpCommunityBrowser"; -import { useMcpCommunityQuickAdd } from "@/hooks/mcpTools/useMcpCommunityQuickAdd"; -import McpCommunityToolbar from "./McpCommunityToolbar"; -import McpCommunityCardList from "./McpCommunityCardList"; -import McpCommunityDetailModal from "./McpCommunityDetailModal"; -import CommunityQuickAddModal from "./CommunityQuickAddModal"; - -interface AddMcpServiceCommunitySectionProps { - active: boolean; - onAdded: () => void; -} - -export default function AddMcpServiceCommunitySection({ - active, - onAdded, -}: AddMcpServiceCommunitySectionProps) { - const [selected, setSelected] = useState(null); - const browser = useMcpCommunityBrowser(active); - const quickAdd = useMcpCommunityQuickAdd({ onSuccess: onAdded }); - - if (!active) return null; - - return ( - <> -
- browser.updateFilter("search", value)} - onTransportChange={(value) => - browser.updateFilter("transport", value) - } - onTagChange={(value) => browser.updateFilter("tag", value)} - /> - - void browser.refetch()} - services={browser.services} - hasPrevPage={browser.hasPrevPage} - hasNextPage={browser.hasNextPage} - onPrevPage={browser.prevPage} - onNextPage={browser.nextPage} - onSelect={setSelected} - onQuickAdd={quickAdd.open} - /> -
- - {selected ? ( - setSelected(null)} - onQuickAdd={quickAdd.open} - /> - ) : null} - - {quickAdd.visible ? ( - - ) : null} - - ); -} +export * from "@/components/repository/mcp/components/add/community/AddMcpServiceCommunitySection"; +export { default } from "@/components/repository/mcp/components/add/community/AddMcpServiceCommunitySection"; diff --git a/frontend/app/[locale]/mcp-space/components/add/community/CommunityQuickAddModal.tsx b/frontend/app/[locale]/mcp-space/components/add/community/CommunityQuickAddModal.tsx index cf8a2b797c..e0cb91c3ee 100644 --- a/frontend/app/[locale]/mcp-space/components/add/community/CommunityQuickAddModal.tsx +++ b/frontend/app/[locale]/mcp-space/components/add/community/CommunityQuickAddModal.tsx @@ -1,219 +1,6 @@ -"use client"; - -import { Button, Input, Modal, Tag } from "antd"; -import { useTranslation } from "react-i18next"; -import { useState } from "react"; -import { - MCP_TOOLS_MODAL_WRAP_CLASS, - McpTransportType, - mcpToolsModalChromeStyles, -} from "@/const/mcpTools"; -import { - getDeploymentTypeLabelKey, - resolveDeploymentType, -} from "@/lib/mcpTools"; -import TransportIcon from "../../shared/TransportIcon"; -import McpContainerLogsModal from "@/components/mcp/McpContainerLogsModal"; -import type { useMcpCommunityQuickAdd } from "@/hooks/mcpTools/useMcpCommunityQuickAdd"; - -interface CommunityQuickAddModalProps { - controller: ReturnType; -} - -export default function CommunityQuickAddModal({ - controller, -}: CommunityQuickAddModalProps) { - const { t } = useTranslation("common"); - const { - source, - draft, - submitting, - updateDraft, - close, - confirm, - nameError, - deploymentStarted, - containerId, - } = controller; - const [logsOpen, setLogsOpen] = useState(false); - - if (!source || !draft) return null; - - const deploymentType = resolveDeploymentType(source); - const deploymentLabel = t(getDeploymentTypeLabelKey(deploymentType)); - const isContainer = draft.transportType === McpTransportType.CONTAINER; +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. - return ( - -
- {deploymentStarted ? ( - - ) : null} -
-
- - -
- - } - > -
- {/* Header */} -
-
- -
-

- {t("mcpTools.community.quickAddConfirmTitle", { - name: source.name, - })} -

-
-
-
- - {/* Content */} -
- {/* Editable Name */} -
- - updateDraft({ name: e.target.value })} - autoComplete="off" - status={nameError ? "error" : undefined} - /> -

- {t("mcpTools.addModal.renameBeforeInstallHint")} -

- {nameError ? ( -

{nameError}

- ) : null} -
- - {/* Description */} - {draft.description && ( -
- -

- {draft.description} -

-
- )} - - {/* Server URL or Container Port */} - {isContainer ? ( -
- - { - const val = e.target.value; - const num = parseInt(val, 10); - if (val === "" || (Number.isFinite(num) && num >= 1 && num <= 65535)) { - updateDraft({ containerPort: val === "" ? undefined : num }); - } - }} - type="number" - min={1} - max={65535} - autoComplete="off" - /> -
- -
-                  {draft.containerConfigJson || t("mcpTools.detail.noDescription")}
-                
-
-
- ) : ( -
- -

- {draft.serverUrl || "-"} -

-
- )} - - {/* Authorization Token */} - {!isContainer && ( -
- - updateDraft({ authorizationToken: e.target.value })} - placeholder={t("mcpConfig.editServer.authorizationTokenPlaceholder")} - autoComplete="new-password" - /> -
- )} +"use client"; - {/* Tags */} - {draft.tags && draft.tags.length > 0 && ( -
- -
- - {deploymentLabel} - - {draft.tags.map((tag) => ( - - {tag} - - ))} -
-
- )} -
-
- {deploymentStarted ? ( - setLogsOpen(false)} - containerId={containerId} - /> - ) : null} -
- ); -} +export * from "@/components/repository/mcp/components/add/community/CommunityQuickAddModal"; +export { default } from "@/components/repository/mcp/components/add/community/CommunityQuickAddModal"; diff --git a/frontend/app/[locale]/mcp-space/components/add/community/McpCommunityCard.tsx b/frontend/app/[locale]/mcp-space/components/add/community/McpCommunityCard.tsx index af841c478b..474472536b 100644 --- a/frontend/app/[locale]/mcp-space/components/add/community/McpCommunityCard.tsx +++ b/frontend/app/[locale]/mcp-space/components/add/community/McpCommunityCard.tsx @@ -1,87 +1,4 @@ -import { Button, Tag } from "antd"; -import { useTranslation } from "react-i18next"; -import { - MCP_GRID_CARD_OUTER, - MCP_GRID_CARD_OUTER_STYLE, -} from "@/const/mcpTools"; -import { - formatRegistryDate, - formatRegistryVersion, - getTransportLabelKey, -} from "@/lib/mcpTools"; -import type { CommunityMcpCard } from "@/types/mcpTools"; -import RegistryStatusBadge from "../../shared/StatusBadge"; +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -interface McpCommunityCardProps { - service: CommunityMcpCard; - onSelect: (service: CommunityMcpCard) => void; - onQuickAdd: (service: CommunityMcpCard) => void; -} - -export default function McpCommunityCard({ - service, - onSelect, - onQuickAdd, -}: McpCommunityCardProps) { - const { t } = useTranslation("common"); - const transportLabel = t(getTransportLabelKey(service.transportType)); - const tags = service.tags || []; - - return ( -
onSelect(service)} - className={MCP_GRID_CARD_OUTER} - style={MCP_GRID_CARD_OUTER_STYLE} - > -
-

- {service.name} -

- -
- -
- - {formatRegistryVersion(service.version || "")} - - - {formatRegistryDate(service.createdAt || "")} - -
- -
-

- {service.description || "-"} -

-
- -
- {transportLabel} - {tags.map((tag) => ( - - {tag} - - ))} -
- -
- -
-
- ); -} +export * from "@/components/repository/mcp/components/add/community/McpCommunityCard"; +export { default } from "@/components/repository/mcp/components/add/community/McpCommunityCard"; diff --git a/frontend/app/[locale]/mcp-space/components/add/community/McpCommunityCardList.tsx b/frontend/app/[locale]/mcp-space/components/add/community/McpCommunityCardList.tsx index 16daac21a1..895c1eefea 100644 --- a/frontend/app/[locale]/mcp-space/components/add/community/McpCommunityCardList.tsx +++ b/frontend/app/[locale]/mcp-space/components/add/community/McpCommunityCardList.tsx @@ -1,79 +1,4 @@ // Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -import { Button } from "antd"; -import { useTranslation } from "react-i18next"; -import type { CommunityMcpCard } from "@/types/mcpTools"; -import McpCommunityCard from "./McpCommunityCard"; -import McpCommunityRequestError from "./McpCommunityRequestError"; - -interface McpCommunityCardListProps { - loading: boolean; - error: Error | null; - onRetry: () => void; - services: CommunityMcpCard[]; - hasPrevPage: boolean; - hasNextPage: boolean; - onPrevPage: () => void; - onNextPage: () => void; - onSelect: (service: CommunityMcpCard) => void; - onQuickAdd: (service: CommunityMcpCard) => void; -} - -export default function McpCommunityCardList({ - loading, - error, - onRetry, - services, - hasPrevPage, - hasNextPage, - onPrevPage, - onNextPage, - onSelect, - onQuickAdd, -}: McpCommunityCardListProps) { - const { t } = useTranslation("common"); - - if (loading) { - return ( -
- {t("mcpTools.community.loading")} -
- ); - } - - if (error) { - return ; - } - - if (services.length === 0) { - return ( -
- {t("mcpTools.community.empty")} -
- ); - } - - return ( -
-
- {services.map((service, index) => ( - - ))} -
- -
- - -
-
- ); -} +export * from "@/components/repository/mcp/components/add/community/McpCommunityCardList"; +export { default } from "@/components/repository/mcp/components/add/community/McpCommunityCardList"; diff --git a/frontend/app/[locale]/mcp-space/components/add/community/McpCommunityDetailModal.tsx b/frontend/app/[locale]/mcp-space/components/add/community/McpCommunityDetailModal.tsx index 817d0a1916..50d18486db 100644 --- a/frontend/app/[locale]/mcp-space/components/add/community/McpCommunityDetailModal.tsx +++ b/frontend/app/[locale]/mcp-space/components/add/community/McpCommunityDetailModal.tsx @@ -1,398 +1,4 @@ // Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -import { useState } from "react"; -import { Button, Modal } from "antd"; -import { useTranslation } from "react-i18next"; -import { - Zap, - Globe, - GitFork, - Link, - Wrench, - Calendar, - Server, - Tag as TagIcon, - FileText, -} from "lucide-react"; -import { - MCP_TOOLS_MODAL_WRAP_CLASS, - mcpToolsModalChromeStyles, -} from "@/const/mcpTools"; -import { - extractRegistryLinks, - formatRegistryDate, - formatRegistryVersion, - getTransportLabelKey, - toPrettyRegistryJson, -} from "@/lib/mcpTools"; -import type { CommunityMcpCard } from "@/types/mcpTools"; -import JsonPreviewModal from "../../shared/JsonPreviewModal"; -import TransportIcon from "../../shared/TransportIcon"; - -interface McpCommunityDetailModalProps { - service: CommunityMcpCard; - onClose: () => void; - onQuickAdd?: (service: CommunityMcpCard) => void; -} - -export default function McpCommunityDetailModal({ - service, - onClose, - onQuickAdd, -}: McpCommunityDetailModalProps) { - const { t } = useTranslation("common"); - const [showServerJsonModal, setShowServerJsonModal] = useState(false); - const [showConfigJsonModal, setShowConfigJsonModal] = useState(false); - const { websiteUrl, repositoryUrl } = extractRegistryLinks( - service.registryJson as Record - ); - const serverJsonPretty = toPrettyRegistryJson( - service.registryJson as Record - ); - const configJsonPretty = toPrettyRegistryJson( - (service.configJson || undefined) as Record | undefined - ); - const hasServerJson = Boolean( - service.registryJson && Object.keys(service.registryJson).length > 0 - ); - const hasConfigJson = Boolean( - service.configJson && Object.keys(service.configJson).length > 0 - ); - const serverTypeText = t(getTransportLabelKey(service.transportType)); - const sourceText = t("mcpTools.source.community"); - - return ( - <> - -
- {/* Header */} -
-
-
-
- -
-

- {service.name} -

-
-
-

- {service.description || t("mcpTools.detail.noDescription")} -

-
-
-
- - {/* Content */} -
- {/* Service Info Section */} -
-

- - {t("mcpTools.detail.serviceStatus")} -

-
- } - label={t("mcpTools.detail.serverType")} - value={serverTypeText} - /> - } - label={t("repository.listingStatus.submittedAt")} - value={formatRegistryDate(service.createdAt)} - /> -
-
- - {/* Service Config Section */} - {(!service.configJson || service.authorizationToken || service.customHeaders || (service.configJson && typeof service.configJson === "object" && "mcpServers" in service.configJson)) && ( -
-

- - {t("mcpTools.detail.serviceConfigTitle")} -

-
- {!service.configJson ? ( -
- -
-
- {service.serverUrl || "-"} -
- - {service.sharedFields?.serverUrl ? t("mcpTools.detail.shared") : t("mcpTools.detail.notShared")} - -
-
- ) : null} - - {service.configJson && typeof service.configJson === "object" && "mcpServers" in service.configJson ? ( -
- -
- - - {service.sharedFields?.containerConfigJson ? t("mcpTools.detail.shared") : t("mcpTools.detail.notShared")} - -
-
- ) : null} - - {service.configJson && typeof service.configJson === "object" && "mcpServers" in service.configJson ? ( -
- -
- - - {service.sharedFields?.containerConfigJson ? t("ui.localization.shared") : t("ui.localization.notShared")} - -
-
- ) : null} - - {service.authorizationToken ? ( -
- -
-
- {service.authorizationToken} -
- - {service.sharedFields?.authorizationToken ? t("mcpTools.detail.shared") : t("mcpTools.detail.notShared")} - -
-
- ) : null} - - {service.customHeaders ? ( -
- -
-
- {typeof service.customHeaders === "string" - ? service.customHeaders - : JSON.stringify(service.customHeaders, null, 2)} -
- - {service.sharedFields?.customHeaders ? t("mcpTools.detail.shared") : t("mcpTools.detail.notShared")} - -
-
- ) : null} -
-
- )} - - {/* Links Section */} - {(websiteUrl || repositoryUrl) && ( -
-

- - {t("mcpTools.detail.links")} -

-
- {websiteUrl && ( - } - label={t("mcpTools.detail.website")} - href={websiteUrl} - /> - )} - {repositoryUrl && ( - } - label={t("mcpTools.detail.repository")} - href={repositoryUrl} - /> - )} -
-
- )} - - {/* Tools Section */} - {hasServerJson && ( -
-

- - {t("mcpTools.detail.tools")} -

-
- {hasServerJson && ( - - )} -
-
- )} - - {/* Tags Section */} - {(service.tags || []).length > 0 && ( -
-

- - {t("mcpTools.detail.tags")} -

-
- {(service.tags || []).map((tag) => ( - - {tag} - - ))} -
-
- )} -
- - {onQuickAdd ? ( -
- -
- ) : null} -
-
- - setShowServerJsonModal(false)} - /> - - setShowConfigJsonModal(false)} - /> - - ); -} - -interface InfoRowProps { - icon: React.ReactNode; - label: string; - value?: string; - customValue?: React.ReactNode; -} - -function InfoRow({ icon, label, value, customValue }: InfoRowProps) { - return ( -
-
- {icon} - {label} -
- {customValue ? ( - customValue - ) : ( - - {value} - - )} -
- ); -} - -interface LinkRowProps { - icon: React.ReactNode; - label: string; - href: string; -} - -function LinkRow({ icon, label, href }: LinkRowProps) { - return ( - - ); -} +export * from "@/components/repository/mcp/components/add/community/McpCommunityDetailModal"; +export { default } from "@/components/repository/mcp/components/add/community/McpCommunityDetailModal"; diff --git a/frontend/app/[locale]/mcp-space/components/add/community/McpCommunityRequestError.tsx b/frontend/app/[locale]/mcp-space/components/add/community/McpCommunityRequestError.tsx index 8c5084cc80..73799220ae 100644 --- a/frontend/app/[locale]/mcp-space/components/add/community/McpCommunityRequestError.tsx +++ b/frontend/app/[locale]/mcp-space/components/add/community/McpCommunityRequestError.tsx @@ -1,20 +1,4 @@ // Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -import { Button } from "antd"; -import { useTranslation } from "react-i18next"; - -export default function McpCommunityRequestError({ - onRetry, -}: { - onRetry: () => void; -}) { - const { t } = useTranslation("common"); - return ( -
-

{t("mcpTools.community.requestFailed")}

- -
- ); -} +export * from "@/components/repository/mcp/components/add/community/McpCommunityRequestError"; +export { default } from "@/components/repository/mcp/components/add/community/McpCommunityRequestError"; diff --git a/frontend/app/[locale]/mcp-space/components/add/community/McpCommunityToolbar.tsx b/frontend/app/[locale]/mcp-space/components/add/community/McpCommunityToolbar.tsx index 461e930c79..3296196c4c 100644 --- a/frontend/app/[locale]/mcp-space/components/add/community/McpCommunityToolbar.tsx +++ b/frontend/app/[locale]/mcp-space/components/add/community/McpCommunityToolbar.tsx @@ -1,86 +1,4 @@ -import { Input, Select } from "antd"; -import { useTranslation } from "react-i18next"; -import { FILTER_ALL, McpTransportType } from "@/const/mcpTools"; -import type { McpTagStat, McpTransportFilter } from "@/types/mcpTools"; +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -interface McpCommunityToolbarProps { - search: string; - transport: McpTransportFilter; - tag: string; - tagStats: McpTagStat[]; - page: number; - resultCount: number; - onSearchChange: (value: string) => void; - onTransportChange: (value: McpTransportFilter) => void; - onTagChange: (value: string) => void; -} - -/** - * Community-browser toolbar. Search input takes ~2/3 of the row, the two - * filter selects share the remaining space and stay narrow on desktop. - */ -export default function McpCommunityToolbar({ - search, - transport, - tag, - tagStats, - page, - resultCount, - onSearchChange, - onTransportChange, - onTagChange, -}: McpCommunityToolbarProps) { - const { t } = useTranslation("common"); - - return ( -
-
- onSearchChange(event.target.value)} - placeholder={t("mcpTools.community.searchPlaceholder")} - allowClear - className="h-9 rounded-md border border-slate-200 text-sm lg:basis-2/3" - /> -
- ({ - value: item.tag, - label: `${item.tag} (${item.count})`, - })), - ]} - /> -
-
- - {t("mcpTools.community.pageResult", { page, count: resultCount })} - -
- ); -} +export * from "@/components/repository/mcp/components/add/community/McpCommunityToolbar"; +export { default } from "@/components/repository/mcp/components/add/community/McpCommunityToolbar"; diff --git a/frontend/app/[locale]/mcp-space/components/add/local/AddMcpServiceLocalSection.tsx b/frontend/app/[locale]/mcp-space/components/add/local/AddMcpServiceLocalSection.tsx index fade668544..f8c3a3bb82 100644 --- a/frontend/app/[locale]/mcp-space/components/add/local/AddMcpServiceLocalSection.tsx +++ b/frontend/app/[locale]/mcp-space/components/add/local/AddMcpServiceLocalSection.tsx @@ -1,762 +1,4 @@ // Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -import { useEffect, useState } from "react"; -import { - Button, - Card, - Form, - Input, - InputNumber, - Select, - Space, - Tabs, - Upload, -} from "antd"; -import type { UploadFile } from "antd"; -import { Container, Import, Unplug, Upload as UploadIcon } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import { McpDeploymentType, McpTransportType } from "@/const/mcpTools"; -import type { LocalAddMcpDraft } from "@/types/mcpTools"; -import { useMcpAddLocal } from "@/hooks/mcpTools/useMcpAddLocal"; -import { useMcpFormRules } from "@/hooks/mcpTools/useMcpFormRules"; -import { useGroupList } from "@/hooks/group/useGroupList"; -import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; -import { Can } from "@/components/permission/Can"; -import McpContainerLogsModal from "@/components/mcp/McpContainerLogsModal"; - -/** Maps the shared add-server tabs (mirroring the MCP config modal) to deployment types. */ -const DEPLOYMENT_TAB_ITEMS = [ - { - key: "remote", - type: McpDeploymentType.REMOTE_LINK, - labelKey: "mcpConfig.addServer.title", - Icon: Unplug, - }, - { - key: "container", - type: McpDeploymentType.CONTAINER, - labelKey: "mcpConfig.addContainer.title", - Icon: Container, - }, - { - key: "openapi", - type: McpDeploymentType.API, - labelKey: "mcpConfig.openApiToMcp.title", - Icon: Import, - }, - { - key: "upload", - type: McpDeploymentType.LOCAL_IMAGE, - labelKey: "mcpConfig.uploadImage.title", - Icon: UploadIcon, - }, -] as const; - -const DEPLOYMENT_TAB_KEY: Record = { - [McpDeploymentType.REMOTE_LINK]: "remote", - [McpDeploymentType.CONTAINER]: "container", - [McpDeploymentType.API]: "openapi", - [McpDeploymentType.LOCAL_IMAGE]: "upload", -}; - -const VALIDATION_FIELDS_BY_DEPLOYMENT: Record< - McpDeploymentType, - Array -> = { - [McpDeploymentType.REMOTE_LINK]: [ - "name", - "description", - "serverUrl", - "authorizationToken", - "customHeaders", - ], - [McpDeploymentType.CONTAINER]: [ - "name", - "description", - "containerConfigJson", - "authorizationToken", - ], - [McpDeploymentType.API]: [ - "name", - "description", - "serverUrl", - "customHeaders", - "openApiJson", - ], - [McpDeploymentType.LOCAL_IMAGE]: [ - "name", - "description", - "uploadImageFile", - "authorizationToken", - ], -}; - -const createInitialDraft = (): LocalAddMcpDraft => ({ - name: "", - description: "", - deploymentType: McpDeploymentType.REMOTE_LINK, - transportType: McpTransportType.URL, - serverUrl: "", - authorizationToken: "", - customHeaders: "", - openApiJson: "", - containerConfigJson: "", - containerPort: undefined, - uploadImageFile: null, - tags: [], - groupIds: [], - ingroupPermission: "READ_ONLY", -}); - -interface AddMcpServiceLocalSectionProps { - active: boolean; - enableUploadImage?: boolean; - onAdded: () => void; - onSubmittingChange?: (submitting: boolean) => void; -} - -export default function AddMcpServiceLocalSection({ - active, - enableUploadImage = false, - onAdded, - onSubmittingChange, -}: AddMcpServiceLocalSectionProps) { - const { t } = useTranslation("common"); - const rules = useMcpFormRules(); - const [form] = Form.useForm(); - const [draft, setDraft] = useState(() => - createInitialDraft() - ); - const [deploymentType, setDeploymentType] = useState( - McpDeploymentType.REMOTE_LINK - ); - const [deploymentStarted, setDeploymentStarted] = useState(false); - const [deployedContainerId, setDeployedContainerId] = useState( - null - ); - const [logsOpen, setLogsOpen] = useState(false); - const { user } = useAuthorizationContext(); - const tenantId = user?.tenantId || null; - const { data: groupData } = useGroupList(tenantId); - const groups = groupData?.groups || []; - const { submit, submitting } = useMcpAddLocal({ - onSuccess: () => { - setDraft(createInitialDraft()); - setDeploymentType(McpDeploymentType.REMOTE_LINK); - form.resetFields(); - onAdded(); - }, - onContainerStarted: (containerId) => setDeployedContainerId(containerId), - }); - - // Notify parent modal of submitting state to block close during submission - useEffect(() => { - onSubmittingChange?.(submitting); - }, [submitting, onSubmittingChange]); - - const patchDraft = (patch: Partial) => { - setDraft((prev) => ({ ...prev, ...patch })); - }; - - const deploymentTabItems = enableUploadImage - ? DEPLOYMENT_TAB_ITEMS - : DEPLOYMENT_TAB_ITEMS.filter( - (item) => item.type !== McpDeploymentType.LOCAL_IMAGE - ); - const activeTabKey = DEPLOYMENT_TAB_KEY[deploymentType]; - - const handleTabChange = (key: string) => { - const item = DEPLOYMENT_TAB_ITEMS.find((i) => i.key === key); - if (!item) return; - const nextType = item.type; - const nextTransport = - nextType === McpDeploymentType.CONTAINER || - nextType === McpDeploymentType.LOCAL_IMAGE - ? McpTransportType.CONTAINER - : McpTransportType.URL; - patchDraft({ - deploymentType: nextType, - transportType: nextTransport, - uploadImageFile: - nextType === McpDeploymentType.LOCAL_IMAGE - ? draft.uploadImageFile - : null, - groupIds: draft.groupIds, - ingroupPermission: "READ_ONLY" as "EDIT" | "READ_ONLY" | "PRIVATE", - }); - setDeploymentType(nextType); - form.setFieldValue("ingroup_permission", "READ_ONLY"); - form.setFieldValue("group_ids", []); - form.setFieldValue("transportType", nextTransport); - }; - - const uploadFileList: UploadFile[] = draft.uploadImageFile - ? [ - { - uid: "local-image", - name: draft.uploadImageFile.name, - status: "done", - originFileObj: draft.uploadImageFile as UploadFile["originFileObj"], - }, - ] - : []; - - const bindField = (key: K) => ({ - value: draft[key], - onChange: (eventOrValue: unknown) => { - const next = - eventOrValue && - typeof eventOrValue === "object" && - "target" in (eventOrValue as Record) - ? (eventOrValue as { target: { value: LocalAddMcpDraft[K] } }).target - .value - : (eventOrValue as LocalAddMcpDraft[K]); - patchDraft({ [key]: next } as Partial); - form.setFieldValue(key as string, next); - }, - }); - - const handlePermissionChange = (value: string) => { - const permission = value as "EDIT" | "READ_ONLY" | "PRIVATE"; - patchDraft({ ingroupPermission: permission }); - if (permission === "PRIVATE") { - patchDraft({ groupIds: [] }); - form.setFieldValue("group_ids", []); - } - }; - - const handleSubmit = async () => { - try { - await form.validateFields( - VALIDATION_FIELDS_BY_DEPLOYMENT[deploymentType] - ); - } catch { - return; - } - const isContainerDeployment = - deploymentType === McpDeploymentType.CONTAINER || - deploymentType === McpDeploymentType.LOCAL_IMAGE; - if (isContainerDeployment) { - setDeploymentStarted(true); - setDeployedContainerId(null); - } - await submit(draft); - }; - - const renderDescriptionInput = () => ( - - - - ); - - if (!active) return null; - - const isApi = deploymentType === McpDeploymentType.API; - const isLocalImage = deploymentType === McpDeploymentType.LOCAL_IMAGE; - const isGroupSelectDisabled = draft.ingroupPermission === "PRIVATE" || isApi; - - return ( -
-
- ({ - key, - label: ( - - - {t(labelKey)} - - ), - children: ( - - - {type === McpDeploymentType.REMOTE_LINK ? ( - <> -
- - - - - - -
- {renderDescriptionInput()} - - - -
- - - -
- - ) : null} - - {type === McpDeploymentType.CONTAINER ? ( - <> -
- -
- {t("mcpConfig.addContainer.configHint")} -
-
- - - -
-
- - {t("mcpConfig.addContainer.serviceName")}: - - - - - - {t("mcpConfig.addContainer.port")}: - - { - const next = value === null ? undefined : value; - patchDraft({ containerPort: next }); - form.setFieldValue("containerPort", next); - }} - min={1} - max={65535} - style={{ width: 120 }} - controls={false} - /> -
-
- {renderDescriptionInput()} - - ) : null} - - {type === McpDeploymentType.API ? ( - <> -
- - - - - - -
- {renderDescriptionInput()} - - - - - - - - {t("mcpConfig.openApiToMcp.form.apiJsonHint")} - - - ) : null} - - {type === McpDeploymentType.LOCAL_IMAGE ? ( - <> -
- -
- {t("mcpConfig.uploadImage.fileHint")} -
-
- { - const fileName = - value && - typeof value === "object" && - "name" in value - ? String(value.name || "") - : ""; - if (fileName && !fileName.endsWith(".tar")) { - return Promise.reject( - new Error( - t( - "mcpConfig.message.uploadImageInvalidFileType" - ) - ) - ); - } - return Promise.resolve(); - }, - }, - ]} - > - false} - accept=".tar" - maxCount={1} - fileList={uploadFileList} - onRemove={() => { - patchDraft({ uploadImageFile: null }); - form.setFieldValue("uploadImageFile", null); - }} - onChange={(info) => { - const file = - info.fileList[0]?.originFileObj ?? null; - patchDraft({ - uploadImageFile: file as File | null, - }); - form.setFieldValue("uploadImageFile", file); - }} - > - - - -
-
- { - const next = value === null ? undefined : value; - patchDraft({ containerPort: next }); - form.setFieldValue("containerPort", next); - }} - min={1} - max={65535} - style={{ width: 150 }} - controls={false} - /> - - - -
- {renderDescriptionInput()} -
- - - -
- - ) : null} - - - ), - }))} - /> - - -
- - {t("mcpTools.detail.groupPermissionUnsupported")} - - ) : undefined - } - > - - - -
-
- - -
-
- {deploymentStarted ? ( - - ) : null} -
- -
- - {deploymentStarted ? ( - setLogsOpen(false)} - containerId={deployedContainerId} - /> - ) : null} -
- ); -} +export * from "@/components/repository/mcp/components/add/local/AddMcpServiceLocalSection"; +export { default } from "@/components/repository/mcp/components/add/local/AddMcpServiceLocalSection"; diff --git a/frontend/app/[locale]/mcp-space/components/shared/ContainerPortField.tsx b/frontend/app/[locale]/mcp-space/components/shared/ContainerPortField.tsx index 81b3df8a86..e3de339c95 100644 --- a/frontend/app/[locale]/mcp-space/components/shared/ContainerPortField.tsx +++ b/frontend/app/[locale]/mcp-space/components/shared/ContainerPortField.tsx @@ -1,74 +1,4 @@ -import { Button, InputNumber } from "antd"; -import { LoadingOutlined } from "@ant-design/icons"; -import { useTranslation } from "react-i18next"; -import { useContainerPortAvailability } from "@/hooks/mcpTools/useContainerPortAvailability"; +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -interface ContainerPortFieldProps { - scope: string; - enabled?: boolean; - /** When false, hide the "Suggest Port" button; the user types the port directly. */ - showSuggestButton?: boolean; - containerPort: number | undefined; - setContainerPort: (value: number | undefined) => void; -} - -export default function ContainerPortField({ - scope, - enabled = true, - showSuggestButton = true, - containerPort, - setContainerPort, -}: ContainerPortFieldProps) { - const { t } = useTranslation("common"); - const { portCheckLoading, portAvailable, suggesting, suggestPort } = - useContainerPortAvailability({ - enabled, - containerPort, - setContainerPort, - }); - - return ( - - ); -} +export * from "@/components/repository/mcp/components/shared/ContainerPortField"; +export { default } from "@/components/repository/mcp/components/shared/ContainerPortField"; diff --git a/frontend/app/[locale]/mcp-space/components/shared/JsonPreviewModal.tsx b/frontend/app/[locale]/mcp-space/components/shared/JsonPreviewModal.tsx index f3c68dc4a7..7e2350e6b2 100644 --- a/frontend/app/[locale]/mcp-space/components/shared/JsonPreviewModal.tsx +++ b/frontend/app/[locale]/mcp-space/components/shared/JsonPreviewModal.tsx @@ -1,37 +1,4 @@ -import { Modal } from "antd"; - -interface JsonPreviewModalProps { - open: boolean; - title: string; - json: string; - onCancel: () => void; -} - -export default function JsonPreviewModal({ - open, - title, - json, - onCancel, -}: JsonPreviewModalProps) { - if (!open) return null; - return ( - -
-
-          {json}
-        
-
-
- ); -} +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +export * from "@/components/repository/mcp/components/shared/JsonPreviewModal"; +export { default } from "@/components/repository/mcp/components/shared/JsonPreviewModal"; diff --git a/frontend/app/[locale]/mcp-space/components/shared/StatusBadge.tsx b/frontend/app/[locale]/mcp-space/components/shared/StatusBadge.tsx index d8faded2ce..f8e3a3e74d 100644 --- a/frontend/app/[locale]/mcp-space/components/shared/StatusBadge.tsx +++ b/frontend/app/[locale]/mcp-space/components/shared/StatusBadge.tsx @@ -1,50 +1,4 @@ -import { useTranslation } from "react-i18next"; -import { McpServerStatus, McpServiceStatus } from "@/const/mcpTools"; +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -interface RegistryStatusBadgeProps { - status: string | undefined; - className?: string; -} - -/** - * Registry / community server status (active, deprecated, unknown) using - * `mcpTools.status.*` keys. - */ -export default function RegistryStatusBadge({ - status -}: RegistryStatusBadgeProps) { - const { t } = useTranslation("common"); - const normalized = String(status || "").toLowerCase(); - - let toneClass: string; - let textKey: string; - switch (normalized) { - case McpServiceStatus.ENABLED: - toneClass = "bg-emerald-100 text-emerald-700"; - textKey = "mcpTools.status.enabled"; - break; - case McpServiceStatus.DISABLED: - toneClass = "bg-slate-100 text-slate-600"; - textKey = "mcpTools.status.disabled"; - break; - case McpServerStatus.ACTIVE: - toneClass = "bg-emerald-100 text-emerald-700"; - textKey = "mcpTools.status.active"; - break; - case McpServerStatus.DEPRECATED: - toneClass = "bg-amber-100 text-amber-700"; - textKey = "mcpTools.status.deprecated"; - break; - default: - toneClass = "bg-slate-100 text-slate-600"; - textKey = "mcpTools.status.unknown"; - } - - return ( - - {t(textKey)} - - ); -} +export * from "@/components/repository/mcp/components/shared/StatusBadge"; +export { default } from "@/components/repository/mcp/components/shared/StatusBadge"; diff --git a/frontend/app/[locale]/mcp-space/components/shared/TagEditor.tsx b/frontend/app/[locale]/mcp-space/components/shared/TagEditor.tsx index 71bfda322f..18c7f1e1d2 100644 --- a/frontend/app/[locale]/mcp-space/components/shared/TagEditor.tsx +++ b/frontend/app/[locale]/mcp-space/components/shared/TagEditor.tsx @@ -1,100 +1,4 @@ -import { useEffect, useRef, useState } from "react"; -import { Input, Tag } from "antd"; -import { PlusOutlined } from "@ant-design/icons"; -import type { InputRef } from "antd"; -import { useTranslation } from "react-i18next"; +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -interface TagEditorProps { - title?: string; - tags: string[]; - tagInput?: string; - onTagInputChange?: (value: string) => void; - onAddTag: (value?: string) => void; - onRemoveTag: (index: number) => void; - removeAriaKey?: string; - placeholderKey?: string; - loading?: boolean; - titleClassName?: string; -} - -export default function TagEditor({ - title, - tags, - tagInput, - onTagInputChange, - onAddTag, - onRemoveTag, - removeAriaKey = "mcpTools.addModal.removeTagAria", - placeholderKey = "mcpTools.addModal.tagInputPlaceholder", - loading = false, - titleClassName = "mb-1 block text-sm font-normal text-slate-500", -}: TagEditorProps) { - const { t } = useTranslation("common"); - const isControlled = tagInput !== undefined; - const [internalValue, setInternalValue] = useState(""); - const value = isControlled ? (tagInput ?? "") : internalValue; - const setValue = (next: string) => { - if (isControlled) onTagInputChange?.(next); - else setInternalValue(next); - }; - - const [editing, setEditing] = useState(false); - const inputRef = useRef(null); - useEffect(() => { - if (editing) inputRef.current?.focus(); - }, [editing]); - - const commit = () => { - if (loading) return; - const next = value.trim(); - if (next) onAddTag(next); - setValue(""); - setEditing(false); - }; - - return ( -
- {title ?

{title}

: null} -
- {tags.map((tag, index) => ( - { - event.preventDefault(); - onRemoveTag(index); - }} - aria-label={t(removeAriaKey, { tag })} - className="m-0" - > - {tag} - - ))} - {editing ? ( - setValue(event.target.value)} - onPressEnter={commit} - onBlur={commit} - placeholder={t(placeholderKey)} - className="w-32" - /> - ) : ( - !loading && setEditing(true)} - className="m-0 cursor-pointer border-dashed bg-transparent" - > - {loading ? t("mcpTools.detail.saving") : t("common.add")} - - )} -
-
- ); -} +export * from "@/components/repository/mcp/components/shared/TagEditor"; +export { default } from "@/components/repository/mcp/components/shared/TagEditor"; diff --git a/frontend/app/[locale]/mcp-space/components/shared/TransportIcon.tsx b/frontend/app/[locale]/mcp-space/components/shared/TransportIcon.tsx index 142cb06f47..ddc1194e9a 100644 --- a/frontend/app/[locale]/mcp-space/components/shared/TransportIcon.tsx +++ b/frontend/app/[locale]/mcp-space/components/shared/TransportIcon.tsx @@ -1,114 +1,4 @@ -import { - ApiOutlined, - CloudOutlined, - CodeOutlined, - ContainerOutlined, - DatabaseOutlined, - DesktopOutlined, - GlobalOutlined, - LinkOutlined, - RocketOutlined, - ThunderboltOutlined, -} from "@ant-design/icons"; -import { McpDeploymentType, McpTransportType } from "@/const/mcpTools"; +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -interface TransportVisual { - Icon: typeof LinkOutlined; - className: string; -} - -const DEPLOYMENT_VISUALS: Record = { - [McpDeploymentType.REMOTE_LINK]: { - Icon: LinkOutlined, - className: "bg-blue-50 text-blue-600", - }, - [McpDeploymentType.CONTAINER]: { - Icon: ContainerOutlined, - className: "bg-blue-50 text-blue-600", - }, - [McpDeploymentType.API]: { - Icon: ApiOutlined, - className: "bg-blue-50 text-blue-600", - }, - [McpDeploymentType.LOCAL_IMAGE]: { - Icon: CloudOutlined, - className: "bg-blue-50 text-blue-600", - }, -}; - -const ICON_POOL: Array = [ - LinkOutlined, - GlobalOutlined, - ThunderboltOutlined, - RocketOutlined, - DatabaseOutlined, - CodeOutlined, - DesktopOutlined, - CloudOutlined, - ApiOutlined, - ContainerOutlined, -]; - -const COLOR_POOL: string[] = [ - "bg-blue-50 text-blue-600", - "bg-sky-50 text-sky-600", - "bg-indigo-50 text-indigo-600", - "bg-blue-100 text-blue-700", - "bg-sky-100 text-sky-700", - "bg-indigo-100 text-indigo-700", - "bg-blue-50 text-blue-600", - "bg-sky-50 text-sky-600", -]; - -const DEFAULT_VISUAL: TransportVisual = - DEPLOYMENT_VISUALS[McpDeploymentType.REMOTE_LINK]; - -function hashString(value: string): number { - let hash = 0; - for (let i = 0; i < value.length; i += 1) { - hash = (hash << 5) - hash + value.charCodeAt(i); - hash |= 0; - } - return Math.abs(hash); -} - -interface TransportIconProps { - transportType: string; - deploymentType?: string; - label?: string; - className?: string; - seed?: string; -} - -export default function TransportIcon({ - transportType, - deploymentType, - label, - className, - seed, -}: TransportIconProps) { - const baseVisual = - (deploymentType && DEPLOYMENT_VISUALS[deploymentType]) || - DEFAULT_VISUAL; - - let Icon = baseVisual.Icon; - let iconClassName = baseVisual.className; - - if (seed) { - const seedHash = hashString(seed); - Icon = ICON_POOL[seedHash % ICON_POOL.length]; - iconClassName = COLOR_POOL[seedHash % COLOR_POOL.length]; - } - - return ( - - - - ); -} +export * from "@/components/repository/mcp/components/shared/TransportIcon"; +export { default } from "@/components/repository/mcp/components/shared/TransportIcon"; diff --git a/frontend/app/[locale]/mcp-space/my-mcp.tsx b/frontend/app/[locale]/mcp-space/my-mcp.tsx index cb7aa5b840..2d17b90916 100644 --- a/frontend/app/[locale]/mcp-space/my-mcp.tsx +++ b/frontend/app/[locale]/mcp-space/my-mcp.tsx @@ -2,836 +2,4 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { useQueryClient } from "@tanstack/react-query"; -import { App, Button, Popover, Spin } from "antd"; -import { useTranslation } from "react-i18next"; -import { Tag } from "lucide-react"; -import { MCP_SERVERS_QUERY_KEY } from "@/hooks/mcp/useMcpServerList"; -import { useMcpServicesList } from "@/hooks/mcpTools/useMcpServicesList"; -import { useMyCommunityMcp } from "@/hooks/mcpTools/useMyCommunityMcp"; -import { useMcpServiceToggle } from "@/hooks/mcpTools/useMcpServiceToggle"; -import { useTagLibraries, useTagDefinitions } from "@/hooks/useTagManagement"; -import { checkMcpServerHealth } from "@/services/mcpService"; -import { - cancelCommunityMcpReview, - deleteCommunityMcpTool, - deleteMcpToolService, - publishCommunityMcpTool, - updateCommunityMcpTool, -} from "@/services/mcpToolsService"; -import { tagManagementApi } from "@/services/tagManagementService"; -import { getTagSearchPredicates } from "@/lib/systemTagLabels"; -import { matchesNameOrTag, resolveDeploymentType } from "@/lib/mcpTools"; -import type { - CommunityMcpCard, - McpContainerConfigPayload, - McpServiceItem, -} from "@/types/mcpTools"; -import type { TagResourcePredicate } from "@/types/tagManagement"; -import { - FILTER_ALL, - McpDeploymentType, - McpServiceStatus, - MCP_TOOLS_QUERY_KEYS, - McpTransportType, -} from "@/const/mcpTools"; -import TagFilterControls from "@/components/tag/TagFilterControls"; -import { REPOSITORY_PAGE_SIZE } from "@/const/resource"; -import ResourceCardGrid from "@/components/resource/ResourceCardGrid"; -import AddMcpServiceCard from "./components/AddMcpServiceCard"; -import McpToolsSearchFilterBar from "./components/McpToolsSearchFilterBar"; -import MineMcpServiceCard, { - type MineMcpCardItem, - type McpConnectionStatus, -} from "./components/MineMcpServiceCard"; -import MineApplyListingModal from "./components/MineApplyListingModal"; -import MineMcpReviewStatusModal from "./components/MineMcpReviewStatusModal"; -import { getDeploymentTypeLabelKey } from "@/lib/mcpTools"; - -export function MyMcp({ - localList, - myPublished, - actions, - reviewDeepLink, - onReviewDeepLinkConsumed, - onAdd, - onEditLocal, - onEditCommunity, - onToggled, - tagRefreshKeys, -}: { - localList: ReturnType; - myPublished: ReturnType; - actions: React.ReactNode; - reviewDeepLink: { marketId: number; sourceMcpId: number } | null; - onReviewDeepLinkConsumed: () => void; - onAdd: () => void; - onEditLocal: (service: McpServiceItem) => void; - onEditCommunity: (service: CommunityMcpCard) => void; - onToggled: (mcpId: number) => Promise; - tagRefreshKeys?: Record; -}) { - const { t } = useTranslation("common"); - const { message, modal } = App.useApp(); - const toggle = useMcpServiceToggle(); - const queryClient = useQueryClient(); - const [search, setSearch] = useState(""); - const [deploymentType, setDeploymentType] = - useState(FILTER_ALL); - const tag = FILTER_ALL; - const { data: mineTagLibraries } = useTagLibraries(); - const mineDefaultLibrary = - mineTagLibraries?.find((lib) => lib.bucket_key === "default_resource") ?? - null; - const { data: mineTagDefinitions } = useTagDefinitions( - mineDefaultLibrary?.bucket_id ?? null - ); - const [tagPredicates, setTagPredicates] = useState( - [] - ); - const [matchedTagIds, setMatchedTagIds] = useState | null>(null); - const [matchedSearchTagIds, setMatchedSearchTagIds] = - useState | null>(null); - const [page, setPage] = useState(1); - const [publishingKey, setPublishingKey] = useState(null); - const [unpublishingKey, setUnpublishingKey] = useState(null); - const [refreshingMineKey, setRefreshingMineKey] = useState( - null - ); - const [connectionStatuses, setConnectionStatuses] = useState< - Record - >({}); - const [reviewProgressItem, setReviewProgressItem] = useState<{ - item: MineMcpCardItem; - onlineService?: CommunityMcpCard; - } | null>(null); - const [applyListingItem, setApplyListingItem] = useState<{ - item: MineMcpCardItem; - onlineService?: CommunityMcpCard; - } | null>(null); - const deepLinkHandledRef = useRef(null); - const items = useMemo(() => { - return getDeduplicatedMineItems(localList.services, myPublished.items); - }, [localList.services, myPublished.items]); - - const onlineServiceByCommunityId = useMemo(() => { - const services = new Map(); - for (const service of myPublished.items) { - if (service.communityId) services.set(service.communityId, service); - } - return services; - }, [myPublished.items]); - - const onlineServiceBySourceMcpId = useMemo(() => { - const services = new Map(); - for (const item of myPublished.items) { - if (item.sourceMcpId != null) services.set(item.sourceMcpId, item); - } - return services; - }, [myPublished.items]); - - useEffect(() => { - /* eslint-disable react-hooks/set-state-in-effect -- The deep link opens the matching review item after data loads. */ - if (!reviewDeepLink) { - deepLinkHandledRef.current = null; - return; - } - if (localList.loading || myPublished.loading) { - return; - } - - const deepLinkKey = `${reviewDeepLink.marketId}:${reviewDeepLink.sourceMcpId}`; - if (deepLinkHandledRef.current === deepLinkKey) { - return; - } - - const onlineService = - myPublished.items.find( - (service) => - service.marketId === reviewDeepLink.marketId || - service.communityId === reviewDeepLink.marketId - ) || onlineServiceBySourceMcpId.get(reviewDeepLink.sourceMcpId); - - const localService = localList.services.find( - (service) => service.mcpId === reviewDeepLink.sourceMcpId - ); - - if (localService) { - const item: MineMcpCardItem = { kind: "local", service: localService }; - setReviewProgressItem({ - item, - onlineService: onlineService || undefined, - }); - deepLinkHandledRef.current = deepLinkKey; - onReviewDeepLinkConsumed(); - return; - } - - if (onlineService) { - setReviewProgressItem({ - item: { kind: "community", service: onlineService }, - onlineService, - }); - deepLinkHandledRef.current = deepLinkKey; - onReviewDeepLinkConsumed(); - return; - } - - deepLinkHandledRef.current = deepLinkKey; - message.error(t("notifications.deepLink.mcpNotFound")); - onReviewDeepLinkConsumed(); - /* eslint-enable react-hooks/set-state-in-effect */ - }, [ - localList.loading, - localList.services, - message, - myPublished.items, - myPublished.loading, - onReviewDeepLinkConsumed, - onlineServiceBySourceMcpId, - reviewDeepLink, - t, - ]); - - const categoryStats = useMemo( - () => - getDeploymentCategoryStats( - items.map((item) => item.service), - t - ), - [items, t] - ); - - const searchTagPredicates = useMemo( - () => getTagSearchPredicates(mineTagDefinitions, search, t), - [mineTagDefinitions, search, t] - ); - - useEffect(() => { - /* eslint-disable react-hooks/set-state-in-effect -- Reset matches when the selected filters change. */ - if (tagPredicates.length === 0) { - setMatchedTagIds(null); - return; - } - const localItems = items - .filter((item) => item.kind === "local") - .map((item) => String(item.service.mcpId)) - .filter(Boolean); - if (localItems.length === 0) { - setMatchedTagIds(new Set()); - return; - } - let cancelled = false; - tagManagementApi - .filterResourceIds("mcp_service", localItems, tagPredicates) - .then((result) => { - if (cancelled) return; - setMatchedTagIds(new Set(result.matched_resource_ids ?? [])); - }) - .catch(() => { - if (cancelled) return; - setMatchedTagIds(new Set()); - }); - return () => { - cancelled = true; - }; - /* eslint-enable react-hooks/set-state-in-effect */ - }, [tagPredicates, items]); - - useEffect(() => { - /* eslint-disable react-hooks/set-state-in-effect -- Reset matches when the search predicates change. */ - if (searchTagPredicates.length === 0) { - setMatchedSearchTagIds(null); - return; - } - const localItems = items - .filter((item) => item.kind === "local") - .map((item) => String(item.service.mcpId)) - .filter(Boolean); - if (localItems.length === 0) { - setMatchedSearchTagIds(new Set()); - return; - } - let cancelled = false; - Promise.all( - searchTagPredicates.map((predicate) => - tagManagementApi.filterResourceIds("mcp_service", localItems, [ - predicate, - ]) - ) - ) - .then((results) => { - if (cancelled) return; - setMatchedSearchTagIds( - new Set( - results.flatMap((result) => result.matched_resource_ids ?? []) - ) - ); - }) - .catch(() => { - if (cancelled) return; - setMatchedSearchTagIds(new Set()); - }); - return () => { - cancelled = true; - }; - /* eslint-enable react-hooks/set-state-in-effect */ - }, [items, searchTagPredicates]); - const filteredItems = useMemo(() => { - return items.filter((item) => { - const service = item.service; - const matchesStructuredTag = - item.kind === "local" && - matchedSearchTagIds?.has(String(item.service.mcpId)); - if (!matchesNameOrTag(service, search) && !matchesStructuredTag) - return false; - if (tag !== FILTER_ALL && !(service.tags || []).includes(tag)) - return false; - if ( - deploymentType !== FILTER_ALL && - resolveDeploymentType(service) !== deploymentType - ) - return false; - if (matchedTagIds !== null) { - if (item.kind !== "local") return false; - if (!matchedTagIds.has(String(item.service.mcpId))) return false; - } - return true; - }); - }, [items, search, tag, deploymentType, matchedTagIds, matchedSearchTagIds]); - - useEffect(() => { - /* eslint-disable react-hooks/set-state-in-effect -- Filter changes restart pagination. */ - setPage(1); - /* eslint-enable react-hooks/set-state-in-effect */ - }, [search, tag, deploymentType, tagPredicates]); - - const itemsPerPage = REPOSITORY_PAGE_SIZE - 1; - const totalPages = Math.max( - 1, - Math.ceil(filteredItems.length / itemsPerPage) - ); - useEffect(() => { - /* eslint-disable react-hooks/set-state-in-effect -- Filtering or deleting items can reduce the last page. */ - if (page > totalPages) setPage(totalPages); - /* eslint-enable react-hooks/set-state-in-effect */ - }, [page, totalPages]); - - const loading = localList.loading || myPublished.loading; - - const handleToggle = async (service: McpServiceItem) => { - await toggle.toggle(service); - await onToggled(service.mcpId); - }; - - const refreshMineData = async () => { - // Refetch the review query immediately so the admin tab badge updates - // without requiring a full page reload. - await queryClient.refetchQueries({ - queryKey: MCP_TOOLS_QUERY_KEYS.communityReview, - type: "all", - }); - await Promise.all([localList.refetch(), myPublished.refetch()]); - }; - - const handleSubmitVersionUpdate = ( - item: MineMcpCardItem, - onlineService?: CommunityMcpCard - ) => { - setApplyListingItem({ item, onlineService }); - }; - - const doSubmitVersionUpdate = async ( - item: MineMcpCardItem, - onlineService: CommunityMcpCard | undefined, - content?: string, - sharedFields?: Record - ) => { - const key = getMineItemKey(item); - setPublishingKey(key); - try { - if (item.kind === "community") { - const service = item.service; - if (!service.marketId) return; - await updateCommunityMcpTool({ - market_id: service.marketId, - name: service.name.trim(), - description: (service.description || "").trim(), - version: (service.version || "").trim(), - tags: service.tags || [], - registry_json: service.registryJson, - shared_fields: sharedFields, - content, - }); - } else if (onlineService?.marketId) { - const service = item.service; - const configJson = toMcpContainerConfigPayload(service.configJson); - await updateCommunityMcpTool({ - market_id: onlineService.marketId, - name: service.name.trim(), - description: (service.description || "").trim(), - version: (service.version || "").trim(), - tags: service.tags || [], - registry_json: service.registryJson || onlineService.registryJson, - mcp_server: configJson ? undefined : service.serverUrl, - transport_type: configJson - ? McpTransportType.CONTAINER - : McpTransportType.URL, - config_json: configJson, - shared_fields: sharedFields, - content, - }); - } else if (item.kind === "local") { - const service = item.service; - const configJson = toMcpContainerConfigPayload(service.configJson); - await publishCommunityMcpTool({ - mcp_id: service.mcpId, - name: service.name.trim(), - description: service.description, - version: (service.version || "").trim(), - tags: service.tags || [], - mcp_server: configJson ? undefined : service.serverUrl, - config_json: configJson, - shared_fields: sharedFields, - content, - }); - } - const isInitialPublish = - item.kind === "local" && !onlineService?.marketId; - message.success( - isInitialPublish - ? t("mcpTools.mine.publishApplySuccess") - : t("mcpTools.mine.submitVersionUpdateSuccess") - ); - // Optimistically update local cache to show pending status - updateLocalReviewStatus(item, "pending"); - } catch { - message.error(t("mcpTools.mine.publishApplyFailed")); - return; - } finally { - setPublishingKey(null); - } - // Refresh caches after successful submission; never fail the submission - // when a cache refresh has a transient error. - try { - await refreshMineData(); - } catch { - // cache refresh errors are non-fatal - } - setPublishingKey(null); - }; - - const updateLocalReviewStatus = ( - item: MineMcpCardItem, - status: "pending" | "approved" | "rejected" - ) => { - if (item.kind !== "local") return; - queryClient.setQueryData( - [...MCP_TOOLS_QUERY_KEYS.services], - (old: McpServiceItem[] | undefined) => { - if (!old) return old; - return old.map((s) => - s.mcpId === item.service.mcpId ? { ...s, reviewStatus: status } : s - ); - } - ); - }; - - const handleUnpublishOnline = ( - item: MineMcpCardItem, - onlineService: CommunityMcpCard - ) => { - if (!onlineService.communityId) return; - const isPendingReview = onlineService.reviewStatus === "pending"; - modal.confirm({ - title: isPendingReview - ? t("mcpTools.mine.reviewModal.confirmCancelApplyTitle") - : t("mcpTools.mine.unpublishOnlineVersionTitle"), - content: isPendingReview - ? t("repository.listingStatus.cancelApply") - : t("mcpTools.mine.unpublishOnlineVersionDescription", { - name: onlineService.name || item.service.name, - }), - okText: isPendingReview - ? t("repository.listingStatus.cancelApply") - : t("mcpTools.mine.unpublishOnlineVersion"), - cancelText: t("common.cancel"), - okButtonProps: { danger: true }, - centered: true, - onOk: async () => { - const key = getMineItemKey(item); - setUnpublishingKey(key); - try { - await deleteCommunityMcpTool(onlineService.communityId!); - message.success( - isPendingReview - ? t("repository.mine.cancelApplySuccess") - : t("mcpTools.mine.unpublishOnlineVersionSuccess") - ); - await refreshMineData(); - } catch { - message.error( - isPendingReview - ? t("repository.mine.cancelApplyError") - : t("mcpTools.mine.unpublishOnlineVersionFailed") - ); - } finally { - setUnpublishingKey(null); - } - }, - }); - }; - - const handleDelete = (item: MineMcpCardItem) => { - modal.confirm({ - title: t("mcpTools.mine.deleteConfirmTitle"), - content: t("mcpTools.mine.deleteConfirmDescription", { - name: item.service.name, - }), - okText: t("common.delete"), - cancelText: t("common.cancel"), - okButtonProps: { danger: true }, - centered: true, - onOk: async () => { - try { - if (item.kind === "local") { - await deleteMcpToolService(item.service.mcpId); - } else if (item.service.communityId) { - await deleteCommunityMcpTool(item.service.communityId); - } - message.success(t("repository.mine.deleteSuccess")); - await refreshMineData(); - // Force-refresh all caches the agent config page relies on - await Promise.all([ - queryClient.invalidateQueries({ queryKey: MCP_SERVERS_QUERY_KEY }), - queryClient.invalidateQueries({ queryKey: ["tools"] }), - queryClient.invalidateQueries({ queryKey: ["agents"] }), - queryClient.refetchQueries({ - queryKey: MCP_SERVERS_QUERY_KEY, - type: "all", - }), - ]); - } catch { - message.error(t("repository.mine.deleteFailed")); - } - }, - }); - }; - - const handleHealthCheck = async (item: MineMcpCardItem) => { - const mcpId = - item.kind === "local" ? item.service.mcpId : item.service.sourceMcpId; - if (!mcpId) return; - const key = getMineItemKey(item); - setRefreshingMineKey(key); - try { - const result = await checkMcpServerHealth(mcpId); - setConnectionStatuses((current) => ({ - ...current, - [key]: result.success ? "success" : "failed", - })); - if (!result.success) { - // If MCP is enabled and health check fails, auto-disable it - if ( - item.kind === "local" && - item.service.enabled === McpServiceStatus.ENABLED - ) { - await toggle.toggle(item.service); - } - } - await refreshMineData(); - } catch { - setConnectionStatuses((current) => ({ ...current, [key]: "failed" })); - } finally { - setRefreshingMineKey(null); - } - }; - - const handleCancelApply = async ( - item: MineMcpCardItem, - onlineService?: CommunityMcpCard - ) => { - const communityRecord = - item.kind === "community" ? item.service : onlineService; - const reviewId = communityRecord?.reviewId; - if (!reviewId) return; - try { - await cancelCommunityMcpReview(reviewId); - message.success(t("repository.mine.cancelApplySuccess")); - setReviewProgressItem(null); - await refreshMineData(); - } catch { - message.error(t("repository.mine.cancelApplyError")); - } - }; - - const handleTakeDown = async ( - item: MineMcpCardItem, - onlineService: CommunityMcpCard - ) => { - handleUnpublishOnline(item, onlineService); - }; - - return ( -
- - - {tagPredicates.length > 0 ? ( - - ) : null} -
- } - > - - - } - onSearchChange={setSearch} - onDeploymentTypeChange={setDeploymentType} - /> - -
- {loading ? ( - - - - ) : ( - } - paginateItems - showToolbar={false} - emptyState={null} - renderItem={(item) => { - const key = getMineItemKey(item); - const onlineService = - item.kind === "local" - ? resolveOnlineService( - item.service, - onlineServiceByCommunityId, - onlineServiceBySourceMcpId - ) - : item.service; - return ( - - setReviewProgressItem({ item, onlineService: os }) - } - onHealthCheck={handleHealthCheck} - healthChecking={refreshingMineKey === getMineItemKey(item)} - connectionStatus={connectionStatuses[key] ?? "unchecked"} - tagRefreshKey={ - item.kind === "local" - ? tagRefreshKeys?.[item.service.mcpId] - : undefined - } - /> - ); - }} - /> - )} -
- - setReviewProgressItem(null)} - onCancelApply={handleCancelApply} - onTakeDown={handleTakeDown} - /> - - setApplyListingItem(null)} - onConfirm={async (content, sharedFields) => { - if (!applyListingItem) return; - await doSubmitVersionUpdate( - applyListingItem.item, - applyListingItem.onlineService, - content, - sharedFields - ); - }} - /> -
- ); -} - -export type DeploymentFilter = McpDeploymentType | typeof FILTER_ALL; - -type DeploymentCountable = { - transportType: CommunityMcpCard["transportType"]; - deploymentType?: McpDeploymentType; - configJson?: Record; - serverUrl?: string; -}; - -const deploymentCategories = [ - McpDeploymentType.REMOTE_LINK, - McpDeploymentType.CONTAINER, - McpDeploymentType.API, - McpDeploymentType.LOCAL_IMAGE, -]; - -export function getDeploymentCategoryStats( - items: DeploymentCountable[], - t: (key: string) => string -): Array<{ value: DeploymentFilter; label: string; count: number }> { - const hasLocalImage = items.some( - (item) => resolveDeploymentType(item) === McpDeploymentType.LOCAL_IMAGE - ); - return [ - { - value: FILTER_ALL, - label: t("mcpTools.deploymentType.all"), - count: items.length, - }, - ...deploymentCategories - .filter((dt) => dt !== McpDeploymentType.LOCAL_IMAGE || hasLocalImage) - .map((deploymentType) => ({ - value: deploymentType, - label: t(getDeploymentTypeLabelKey(deploymentType)), - count: items.filter( - (item) => resolveDeploymentType(item) === deploymentType - ).length, - })), - ]; -} - -export function getDeduplicatedMineItems( - localServices: McpServiceItem[], - publishedServices: CommunityMcpCard[] -): MineMcpCardItem[] { - // Only show local MCPs that belong to the current user or are shared via groups - const myLocalServices = localServices.filter( - (s) => s.permission === "EDIT" || s.groupIds - ); - const linkedCommunityIds = new Set(); - const localNames = new Set(); - - for (const service of myLocalServices) { - if (service.communityId) linkedCommunityIds.add(service.communityId); - localNames.add(normalizeMcpName(service.name)); - } - - const visiblePublishedServices = publishedServices.filter((service) => { - // Published-by-me items (have sourceMcpId) are hidden from "我的" tab. - // They are managed via the repository tab. This prevents them from - // reappearing after the local copy is deleted. - if (service.sourceMcpId != null) return false; - if (service.communityId && linkedCommunityIds.has(service.communityId)) { - return false; - } - return !localNames.has(normalizeMcpName(service.name)); - }); - - return [ - ...myLocalServices.map((service) => ({ - kind: "local" as const, - service, - })), - ...visiblePublishedServices.map((service) => ({ - kind: "community" as const, - service, - })), - ]; -} - -function normalizeMcpName(name: string): string { - return name.trim().toLowerCase(); -} - -export function getMineItemKey(item: MineMcpCardItem): string { - return item.kind === "local" - ? `local-${item.service.mcpId}` - : `community-${item.service.communityId || item.service.name}`; -} - -export function toMcpContainerConfigPayload( - value?: Record -): McpContainerConfigPayload | undefined { - if (!value || typeof value.mcpServers !== "object" || !value.mcpServers) { - return undefined; - } - return value as unknown as McpContainerConfigPayload; -} - -export function resolveOnlineService( - service: McpServiceItem, - serviceByCommunityId: Map, - serviceBySourceMcpId: Map -): CommunityMcpCard | undefined { - const reviewService = serviceBySourceMcpId.get(service.mcpId); - if (reviewService) return reviewService; - if (service.communityId) { - const marketService = serviceByCommunityId.get(service.communityId); - if ( - marketService?.sourceMcpId == null || - marketService.sourceMcpId === service.mcpId - ) { - return marketService; - } - } - return undefined; -} - -export function PlaceholderBox({ children }: { children: React.ReactNode }) { - return ( -
- {children} -
- ); -} +export * from "@/components/repository/mcp/my-mcp"; diff --git a/frontend/app/[locale]/mcp-space/review-center.tsx b/frontend/app/[locale]/mcp-space/review-center.tsx index 000b0c1710..9846f51a83 100644 --- a/frontend/app/[locale]/mcp-space/review-center.tsx +++ b/frontend/app/[locale]/mcp-space/review-center.tsx @@ -2,300 +2,4 @@ "use client"; -import { useState } from "react"; -import { App, Button, Empty, Spin } from "antd"; -import { useUserDisplayName } from "@/hooks/auth/useUserDisplayName"; -import { useTranslation } from "react-i18next"; -import { CheckCircle, Clock, Eye, XCircle } from "lucide-react"; -import { - approveCommunityMcpTool, - rejectCommunityMcpTool, -} from "@/services/mcpToolsService"; -import type { CommunityMcpCard } from "@/types/mcpTools"; -import { - formatRegistryDate, - getDeploymentTypeLabelKey, - resolveDeploymentType, -} from "@/lib/mcpTools"; -import { useMcpCommunityReview } from "@/hooks/mcpTools/useMcpCommunityReview"; -import McpToolsPagination from "./components/McpToolsPagination"; -import McpRepositoryReviewConfirmModal, { - type McpRepositoryReviewAction, -} from "./components/McpRepositoryReviewConfirmModal"; -import TransportIcon from "./components/shared/TransportIcon"; -import { PlaceholderBox } from "./my-mcp"; - -export function ReviewCenter({ - browser, - onSelect, - onReviewed, -}: { - browser: ReturnType; - onSelect: (service: CommunityMcpCard) => void; - onReviewed: () => Promise; -}) { - const { t } = useTranslation("common"); - const { message } = App.useApp(); - const [reviewingId, setReviewingId] = useState(null); - const [confirmAction, setConfirmAction] = - useState(null); - const [confirmService, setConfirmService] = useState( - null - ); - - const openReviewConfirm = ( - service: CommunityMcpCard, - action: McpRepositoryReviewAction - ) => { - setConfirmService(service); - setConfirmAction(action); - }; - - const handleReviewConfirm = async (content?: string) => { - if (!confirmService?.reviewId || !confirmAction) return; - setReviewingId(confirmService.reviewId); - try { - if (confirmAction === "approve") { - await approveCommunityMcpTool(confirmService.reviewId, content); - message.success( - t("repository.review.approveSuccess", { name: confirmService.name }) - ); - } else { - await rejectCommunityMcpTool(confirmService.reviewId, content); - message.success( - t("repository.review.rejectSuccess", { name: confirmService.name }) - ); - } - setConfirmAction(null); - setConfirmService(null); - await onReviewed(); - } catch { - message.error(t("repository.review.actionFailed")); - throw new Error("Review action failed"); - } finally { - setReviewingId(null); - } - }; - - return ( -
- {browser.loading ? ( - - - - ) : browser.services.length === 0 ? ( - - - - ) : ( -
- - - - - - - - - - - - - {browser.services.map((service) => ( - onSelect(service)} - onApprove={() => openReviewConfirm(service, "approve")} - onReject={() => openReviewConfirm(service, "reject")} - /> - ))} - -
- {t("repository.review.column.name")} - - {t("repository.review.column.deploymentType")} - - {t("repository.review.column.submitter")} - - {t("repository.review.column.listingNote")} - - {t("repository.review.column.status")} - - {t("repository.review.column.actions")} -
-
- )} - - - - { - setConfirmAction(null); - setConfirmService(null); - }} - onConfirm={handleReviewConfirm} - /> -
- ); -} - -function ReviewTableRow({ - service, - reviewing, - onSelect, - onApprove, - onReject, -}: { - service: CommunityMcpCard; - reviewing: boolean; - onSelect: () => void; - onApprove: () => void; - onReject: () => void; -}) { - const { t } = useTranslation("common"); - const displayName = useUserDisplayName(); - const deploymentType = resolveDeploymentType(service); - const deploymentLabel = t(getDeploymentTypeLabelKey(deploymentType)); - const reviewStatus = service.reviewStatus || "pending"; - const isPending = reviewStatus === "pending"; - const author = displayName( - service.authorCasUsername, - service.authorDisplayName || service.authorName || "-" - ); - const submitDate = formatRegistryDate(service.createdAt || ""); - const listingNote = service.content?.trim() || "—"; - - const statusBadge = (() => { - if (reviewStatus === "approved") { - return ( - - - {t("repository.review.status.approved")} - - ); - } - if (reviewStatus === "rejected") { - return ( - - - {t("repository.review.status.rejected")} - - ); - } - return ( - - - {t("repository.review.status.pending")} - - ); - })(); - - return ( - - {/* MCP Service */} - -
- -
-
- {service.name} -
-
-
- - - {/* Deployment Type */} - - - {deploymentLabel} - - - - {/* Submitter */} - -
{author}
-
{submitDate}
- - - {/* Listing note */} - - - {listingNote} - - - - {/* Status */} - {statusBadge} - - {/* Actions */} - - {isPending ? ( -
- - - -
- ) : ( - - )} - - - ); -} +export * from "@/components/repository/mcp/review-center"; diff --git a/frontend/app/[locale]/resource-space/page.tsx b/frontend/app/[locale]/resource-space/page.tsx new file mode 100644 index 0000000000..98cdc6d67e --- /dev/null +++ b/frontend/app/[locale]/resource-space/page.tsx @@ -0,0 +1,9 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import ResourceRepositoryPage from "@/components/repository/ResourceRepositoryPage"; + +export default function Page() { + return ; +} diff --git a/frontend/app/[locale]/skill-space/components/CreateNewSkillCard.tsx b/frontend/app/[locale]/skill-space/components/CreateNewSkillCard.tsx index e80b455bbe..be5b0003c5 100644 --- a/frontend/app/[locale]/skill-space/components/CreateNewSkillCard.tsx +++ b/frontend/app/[locale]/skill-space/components/CreateNewSkillCard.tsx @@ -1,19 +1,5 @@ -"use client"; - -import { useTranslation } from "react-i18next"; +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -import CreateResourceCard from "@/components/resource/CreateResourceCard"; - -interface CreateNewSkillCardProps { - onClick: () => void; -} +"use client"; -export function CreateNewSkillCard({ onClick }: CreateNewSkillCardProps) { - const { t } = useTranslation("common"); - return ( - - ); -} +export * from "@/components/repository/skill/components/CreateNewSkillCard"; diff --git a/frontend/app/[locale]/skill-space/components/MineApplyListingModal.tsx b/frontend/app/[locale]/skill-space/components/MineApplyListingModal.tsx index 7a6e0115a2..b6fd2f56f9 100644 --- a/frontend/app/[locale]/skill-space/components/MineApplyListingModal.tsx +++ b/frontend/app/[locale]/skill-space/components/MineApplyListingModal.tsx @@ -1,101 +1,5 @@ -"use client"; - -import { useEffect, useRef, useState } from "react"; -import { Input, Modal } from "antd"; -import { useTranslation } from "react-i18next"; - -import type { - MyEditableSkillItem, - SkillRepositoryListingCreatePayload, -} from "@/types/skillRepository"; - -interface MineApplyListingModalProps { - open: boolean; - skill: MyEditableSkillItem | null; - loading?: boolean; - onClose: () => void; - onConfirm: (payload: SkillRepositoryListingCreatePayload) => Promise; -} - -export function MineApplyListingModal({ - open, - skill, - loading = false, - onClose, - onConfirm, -}: MineApplyListingModalProps) { - const { t } = useTranslation("common"); - const [listingContent, setListingContent] = useState(""); - const [submitting, setSubmitting] = useState(false); - const submittingRef = useRef(false); +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. - useEffect(() => { - if (!open) { - setListingContent(""); - setSubmitting(false); - submittingRef.current = false; - } - }, [open]); - - if (!skill) { - return null; - } - - const title = skill.name?.trim() || t("skillRepository.common.untitled"); - const isBusy = loading || submitting; - - const handleOk = async () => { - if (submittingRef.current || loading) { - return; - } - submittingRef.current = true; - setSubmitting(true); - try { - await onConfirm({ - icon: "skill", - tags: skill.tags ?? [], - content: listingContent.trim() || undefined, - }); - onClose(); - } finally { - submittingRef.current = false; - setSubmitting(false); - } - }; +"use client"; - return ( - -
-

- {t("skillRepository.mine.confirmApplyContent")} -

-
- - setListingContent(event.target.value)} - placeholder={t("repository.mine.applyModal.contentPlaceholder")} - rows={4} - disabled={isBusy} - /> -
-
-
- ); -} +export * from "@/components/repository/skill/components/MineApplyListingModal"; diff --git a/frontend/app/[locale]/skill-space/components/MineSkillsView.tsx b/frontend/app/[locale]/skill-space/components/MineSkillsView.tsx index 3bb6dfefcb..3b84cdf3a0 100644 --- a/frontend/app/[locale]/skill-space/components/MineSkillsView.tsx +++ b/frontend/app/[locale]/skill-space/components/MineSkillsView.tsx @@ -2,591 +2,4 @@ "use client"; -import { useEffect, useRef, useState } from "react"; -import { App, Button, Dropdown, Input, Popover } from "antd"; -import type { MenuProps } from "antd"; -import { useTranslation } from "react-i18next"; -import { - Bot, - ClipboardCheck, - Clock, - Eye, - MoreHorizontal, - Pencil, - Search, - Tag, - Trash2, - Upload, -} from "lucide-react"; - -import { CreateNewSkillCard } from "./CreateNewSkillCard"; -import { REPOSITORY_PAGE_SIZE } from "@/const/resource"; -import ResourceCardGrid from "@/components/resource/ResourceCardGrid"; -import ResourceCard from "@/components/resource/ResourceCard"; -import { MineApplyListingModal } from "./MineApplyListingModal"; -import { SkillReviewStatusModal } from "./SkillReviewStatusModal"; -import { AsyncContent, FilterButton } from "./SkillRepositoryControls"; -import { - formatRepositoryDate, - getSkillRepositoryStatusLabel, - pickReviewDisplayRepositoryInfo, -} from "./skillRepositoryShared"; -import type { - MyEditableSkillItem, - MyEditableSkillListItem, - MySkillRepositoryInfoItem, - MineOwnershipFilter, - NewSkillPaddingItem, - SkillRepositoryListingCreatePayload, - SkillRepositoryListingStatus, -} from "@/types/skillRepository"; -import TagFilterControls from "@/components/tag/TagFilterControls"; -import type { - TagDefinition, - TagResourcePredicate, -} from "@/types/tagManagement"; - -const MINE_OWNERSHIP_FILTERS: MineOwnershipFilter[] = [ - "all", - "created", - "others", -]; - -export function isNewSkillPaddingItem( - item: MyEditableSkillListItem -): item is NewSkillPaddingItem { - return "new_skill_padding" in item && item.new_skill_padding === true; -} - -export type SkillReviewDeepLinkTarget = { - skillRepositoryId: number; - skillId: number; -}; - -function findRepositoryInfoById( - repositoryInfo: MySkillRepositoryInfoItem[], - skillRepositoryId: number -): MySkillRepositoryInfoItem | null { - return ( - repositoryInfo.find( - (item) => item.skill_repository_id === skillRepositoryId - ) ?? null - ); -} - -export function MineSkillsView({ - skills, - counts, - ownership, - onOwnershipChange, - searchQuery, - onSearchChange, - tagDefinitions, - tagPredicates, - onTagPredicatesChange, - isLoading, - isError, - isFetching, - page, - total, - onPageChange, - onRetry, - onCreateSkill, - onEditSkill, - onViewSkill, - onDeleteSkill, - onApplyListing, - isUpdatingStatus, - onSetNotShared, - reviewDeepLink = null, - deepLinkFallbackSkill = null, - deepLinkFallbackLoading = false, - onReviewDeepLinkConsumed, -}: { - skills: MyEditableSkillListItem[]; - counts: { all: number; created: number; others: number }; - ownership: MineOwnershipFilter; - onOwnershipChange: (ownership: MineOwnershipFilter) => void; - searchQuery: string; - onSearchChange: (value: string) => void; - tagDefinitions: TagDefinition[]; - tagPredicates: TagResourcePredicate[]; - onTagPredicatesChange: (predicates: TagResourcePredicate[]) => void; - isLoading: boolean; - isError: boolean; - isFetching: boolean; - page: number; - total: number; - onPageChange: (page: number) => void; - onRetry: () => void; - onCreateSkill: () => void; - onEditSkill: (skill: MyEditableSkillItem) => void; - onViewSkill: (skill: MyEditableSkillItem) => void; - onDeleteSkill: (skill: MyEditableSkillItem) => Promise; - onApplyListing: ( - skill: MyEditableSkillItem, - payload: SkillRepositoryListingCreatePayload - ) => Promise; - isUpdatingStatus: boolean; - onSetNotShared: (repositoryInfo: MySkillRepositoryInfoItem) => Promise; - reviewDeepLink?: SkillReviewDeepLinkTarget | null; - deepLinkFallbackSkill?: MyEditableSkillItem | null; - deepLinkFallbackLoading?: boolean; - onReviewDeepLinkConsumed?: () => void; -}) { - const { t } = useTranslation("common"); - const { message, modal } = App.useApp(); - const [reviewModalOpen, setReviewModalOpen] = useState(false); - const [reviewModalSkill, setReviewModalSkill] = - useState(null); - const [reviewModalInfo, setReviewModalInfo] = - useState(null); - const [applyModalOpen, setApplyModalOpen] = useState(false); - const [applyModalSkill, setApplyModalSkill] = - useState(null); - const [applySubmitting, setApplySubmitting] = useState(false); - const consumedDeepLinkRef = useRef(null); - const ownershipLabelKey: Record = { - all: "repository.mine.filter.all", - created: "repository.mine.filter.created", - others: "repository.mine.filter.others", - }; - - const openReviewModal = ( - skill: MyEditableSkillItem, - repositoryInfo?: MySkillRepositoryInfoItem | null - ) => { - const info = - repositoryInfo ?? - pickReviewDisplayRepositoryInfo(skill.repository_info ?? []); - if (!info) { - return; - } - setReviewModalSkill(skill); - setReviewModalInfo(info); - setReviewModalOpen(true); - }; - - const closeReviewModal = () => { - setReviewModalOpen(false); - setReviewModalSkill(null); - setReviewModalInfo(null); - }; - - const handleSetNotShared = async () => { - if (!reviewModalInfo) return; - - try { - await onSetNotShared(reviewModalInfo); - closeReviewModal(); - } catch (error) { - message.error( - error instanceof Error - ? error.message - : t("skillRepository.common.statusUpdateFailed") - ); - throw error; - } - }; - - useEffect(() => { - if (!reviewDeepLink) { - consumedDeepLinkRef.current = null; - return; - } - - if (consumedDeepLinkRef.current === reviewDeepLink.skillRepositoryId) { - return; - } - - if (isLoading && deepLinkFallbackLoading) { - return; - } - - const skillFromList = skills.find( - (item): item is MyEditableSkillItem => - !isNewSkillPaddingItem(item) && item.skill_id === reviewDeepLink.skillId - ); - const skill = skillFromList ?? deepLinkFallbackSkill; - - if (!skill) { - if (isLoading || deepLinkFallbackLoading) { - return; - } - message.error(t("notifications.deepLink.skillNotFound")); - consumedDeepLinkRef.current = reviewDeepLink.skillRepositoryId; - onReviewDeepLinkConsumed?.(); - return; - } - - const repositoryInfo = findRepositoryInfoById( - skill.repository_info ?? [], - reviewDeepLink.skillRepositoryId - ); - - if (!repositoryInfo) { - message.error(t("notifications.deepLink.skillNotFound")); - consumedDeepLinkRef.current = reviewDeepLink.skillRepositoryId; - onReviewDeepLinkConsumed?.(); - return; - } - - // eslint-disable-next-line react-hooks/set-state-in-effect -- Resolve the review deep link once its skill data is available. - openReviewModal(skill, repositoryInfo); - consumedDeepLinkRef.current = reviewDeepLink.skillRepositoryId; - onReviewDeepLinkConsumed?.(); - }, [ - deepLinkFallbackLoading, - deepLinkFallbackSkill, - isLoading, - message, - onReviewDeepLinkConsumed, - reviewDeepLink, - skills, - t, - ]); - - const handleEnableSkill = (skill: MyEditableSkillItem) => { - setApplyModalSkill(skill); - setApplyModalOpen(true); - }; - - const handleConfirmApply = async ( - payload: SkillRepositoryListingCreatePayload - ) => { - if (!applyModalSkill) { - return; - } - setApplySubmitting(true); - try { - await onApplyListing(applyModalSkill, payload); - } finally { - setApplySubmitting(false); - } - }; - - const handleDeleteSkill = (skill: MyEditableSkillItem) => { - const title = skill.name?.trim() || t("skillRepository.common.untitled"); - modal.confirm({ - title: t("skillRepository.mine.deleteTitle"), - content: t("skillRepository.mine.deleteContent", { name: title }), - okText: t("common.delete"), - cancelText: t("common.cancel"), - okButtonProps: { danger: true }, - onOk: async () => { - await onDeleteSkill(skill); - }, - }); - }; - - return ( -
-
-
- onSearchChange(event.target.value)} - placeholder={t("skillRepository.searchPlaceholder")} - prefix={} - className="rounded-xl" - allowClear - /> -
- - - {tagPredicates.length > 0 ? ( - - ) : null} -
- } - > - - -
- -
-
- {MINE_OWNERSHIP_FILTERS.map((filter) => ( - onOwnershipChange(filter)} - > - {t(ownershipLabelKey[filter])} - {counts[filter]} - - ))} -
-
- -
- - <> - - isNewSkillPaddingItem(skill) ? ( - - ) : ( - onEditSkill(skill)} - onView={() => onViewSkill(skill)} - onDelete={() => handleDeleteSkill(skill)} - onApplyListing={() => handleEnableSkill(skill)} - onViewReview={() => openReviewModal(skill)} - /> - ) - } - /> - - -
- - - { - setApplyModalOpen(false); - setApplyModalSkill(null); - }} - onConfirm={handleConfirmApply} - /> - - ); -} - -const MINE_SKILL_STATUS_CLASS: Record = { - not_shared: - "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300", - pending_review: - "bg-orange-50 text-orange-700 dark:bg-orange-500/10 dark:text-orange-300", - rejected: "bg-red-50 text-red-700 dark:bg-red-500/10 dark:text-red-300", - shared: - "bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300", -}; - -function getMineSkillMenuItems({ - canApplyListing, - canEdit, - canPublish, - hasRepositoryInfo, - t, - onApplyListing, - onViewReview, - onDelete, -}: { - canApplyListing: boolean; - canEdit: boolean; - canPublish: boolean; - hasRepositoryInfo: boolean; - t: (key: string) => string; - onApplyListing: () => void; - onViewReview: () => void; - onDelete: () => void; -}): MenuProps["items"] { - const items: MenuProps["items"] = []; - if (canApplyListing) { - items.push({ - key: "apply-listing", - label: t("skillRepository.mine.button.apply"), - icon: , - onClick: onApplyListing, - }); - } - if (canPublish && hasRepositoryInfo) { - items.push({ - key: "review", - label: t("skillRepository.mine.viewReviewProgress"), - icon: , - onClick: onViewReview, - }); - } - if (canEdit) { - items.push({ - key: "delete", - label: t("common.delete"), - icon: , - danger: true, - onClick: onDelete, - }); - } - return items; -} - -function MineSkillCard({ - skill, - onEdit, - onView, - onDelete, - onApplyListing, - onViewReview, -}: { - skill: MyEditableSkillItem; - onEdit: () => void; - onView: () => void; - onDelete: () => void; - onApplyListing: () => void; - onViewReview: () => void; -}) { - const { t } = useTranslation("common"); - const latestRepository = pickReviewDisplayRepositoryInfo( - skill.repository_info ?? [] - ); - const hasRepositoryInfo = latestRepository != null; - const repositoryStatus = latestRepository?.status ?? "not_shared"; - const canEdit = - skill.permission !== "READ_ONLY" && skill.permission !== "PRIVATE"; - const canPublish = skill.can_publish === true; - const updatedAt = formatRepositoryDate(skill.updated_at ?? skill.update_time); - const tags = skill.tags?.filter((tag) => tag.trim()) ?? []; - const canApplyListing = canPublish && !hasRepositoryInfo; - const menuItems = getMineSkillMenuItems({ - canApplyListing, - canEdit, - canPublish, - hasRepositoryInfo, - t, - onApplyListing, - onViewReview, - onDelete, - }); - - return ( - - - - } - fixedHeaderLayout - description={ - skill.description || t("skillRepository.common.noDescription") - } - tags={ - tags.length > 0 ? ( - <> - {tags.map((tag) => ( - - {tag} - - ))} - - ) : undefined - } - meta={ - updatedAt ? ( - - - {updatedAt} - - ) : undefined - } - footerLayout="inline" - headerActions={ - canEdit || canApplyListing || hasRepositoryInfo ? ( -
-
- {canEdit || canApplyListing ? ( - -
-
- {hasRepositoryInfo ? ( - - {getSkillRepositoryStatusLabel(t, repositoryStatus)} - - ) : null} -
-
- ) : undefined - } - footer={ -
- {canEdit ? ( - - ) : ( - - )} -
- } - /> - ); -} +export * from "@/components/repository/skill/components/MineSkillsView"; diff --git a/frontend/app/[locale]/skill-space/components/RepositoryView.tsx b/frontend/app/[locale]/skill-space/components/RepositoryView.tsx index 1faca85f52..99f88b67f5 100644 --- a/frontend/app/[locale]/skill-space/components/RepositoryView.tsx +++ b/frontend/app/[locale]/skill-space/components/RepositoryView.tsx @@ -2,131 +2,4 @@ "use client"; -import { Button, Input } from "antd"; -import { Copy, Search } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import TagFilterPopover from "@/components/tag/TagFilterPopover"; -import { REPOSITORY_PAGE_SIZE } from "@/const/resource"; -import ResourceCardGrid from "@/components/resource/ResourceCardGrid"; - -import { SkillRepositoryCard } from "./SkillRepositoryCard"; -import { AsyncContent } from "./SkillRepositoryControls"; -import type { SkillRepositoryListingItem } from "@/types/skillRepository"; -import type { - TagDefinition, - TagResourcePredicate, -} from "@/types/tagManagement"; - -export function RepositoryView({ - searchQuery, - onSearchChange, - tagDefinitions, - tagPredicates, - onTagPredicatesChange, - listings, - isLoading, - isError, - isFetching, - page, - total, - onPageChange, - onRetry, - onInstall, - onDetailClick, - showAdminMenu, - onTakeDown, - installingRepositoryId, - takingDownRepositoryId, -}: { - searchQuery: string; - onSearchChange: (value: string) => void; - tagDefinitions: TagDefinition[]; - tagPredicates: TagResourcePredicate[]; - onTagPredicatesChange: (value: TagResourcePredicate[]) => void; - listings: SkillRepositoryListingItem[]; - isLoading: boolean; - isError: boolean; - isFetching: boolean; - page: number; - total: number; - onPageChange: (page: number) => void; - onRetry: () => void; - onInstall: (listing: SkillRepositoryListingItem) => void; - onDetailClick: (listing: SkillRepositoryListingItem) => void; - showAdminMenu: boolean; - onTakeDown: (listing: SkillRepositoryListingItem) => void; - installingRepositoryId: number | null; - takingDownRepositoryId: number | null; -}) { - const { t } = useTranslation("common"); - return ( -
-
-
- onSearchChange(event.target.value)} - placeholder={t("skillRepository.searchPlaceholder")} - prefix={} - className="rounded-xl" - /> -
- -
- -
- - <> - ( - onDetailClick(listing)} - showAdminMenu={showAdminMenu} - isTakingDown={ - takingDownRepositoryId === listing.skill_repository_id - } - onTakeDown={() => onTakeDown(listing)} - action={ - - } - /> - )} - /> - - -
-
- ); -} +export * from "@/components/repository/skill/components/RepositoryView"; diff --git a/frontend/app/[locale]/skill-space/components/ReviewSkillList.tsx b/frontend/app/[locale]/skill-space/components/ReviewSkillList.tsx index 47ce8ca4d9..a3119583de 100644 --- a/frontend/app/[locale]/skill-space/components/ReviewSkillList.tsx +++ b/frontend/app/[locale]/skill-space/components/ReviewSkillList.tsx @@ -2,192 +2,4 @@ "use client"; -import { Button, Tag } from "antd"; -import { Bot, Check, Eye, X } from "lucide-react"; -import { useUserDisplayName } from "@/hooks/auth/useUserDisplayName"; -import { useTranslation } from "react-i18next"; - -import { AsyncContent, PaginationBar } from "./SkillRepositoryControls"; -import type { - SkillRepositoryListingItem, - SkillRepositoryListingStatus, -} from "@/types/skillRepository"; - -const GRID_COLS = "grid-cols-[minmax(0,2fr)_160px_100px_minmax(0,1.5fr)_260px]"; - -const REVIEW_STATUS_LABEL_KEYS: Record = { - not_shared: "repository.review.status.notShared", - pending_review: "repository.review.status.pending", - rejected: "repository.review.status.rejected", - shared: "repository.review.status.approved", -}; - -const REVIEW_STATUS_COLORS: Record = { - not_shared: "default", - pending_review: "processing", - rejected: "error", - shared: "success", -}; - -function getSubmitterDisplay( - submittedBy: string | null | undefined, - t: (key: string) => string -) { - return submittedBy?.trim() || t("repository.review.unknownSubmitter"); -} - -export function ReviewSkillList({ - listings, - isLoading, - isError, - isFetching, - page, - pageSize, - total, - onPageChange, - onRetry, - updatingRepositoryId, - onDetailClick, - onApprove, - onReject, -}: { - listings: SkillRepositoryListingItem[]; - isLoading: boolean; - isError: boolean; - isFetching: boolean; - page: number; - pageSize: number; - total: number; - onPageChange: (page: number) => void; - onRetry: () => void; - updatingRepositoryId: number | null; - onDetailClick: (listing: SkillRepositoryListingItem) => void; - onApprove: (listing: SkillRepositoryListingItem) => void; - onReject: (listing: SkillRepositoryListingItem) => void; -}) { - const { t } = useTranslation("common"); - const displayName = useUserDisplayName(); - return ( -
- - <> -
-
-
- {t("repository.review.column.name")} - {t("repository.review.column.submitter")} - {t("repository.review.column.status")} - {t("repository.review.column.listingNote")} - {t("repository.review.column.actions")} -
- -
    - {listings.map((listing) => { - const isUpdating = - updatingRepositoryId === listing.skill_repository_id; - const isPendingReview = listing.status === "pending_review"; - const listingNote = listing.content?.trim() || "—"; - - return ( -
  • -
    -
    - -
    -
    -

    - {listing.name || - t("skillRepository.common.untitled")} -

    - {listing.description ? ( -

    - {listing.description} -

    - ) : null} -
    -
    - -
    - {displayName( - listing.submitted_by_cas_username, - getSubmitterDisplay(listing.submitted_by, t) - )} -
    - -
    - - {t(REVIEW_STATUS_LABEL_KEYS[listing.status])} - -
    - -
    - {listingNote} -
    - -
    - - {isPendingReview ? ( - <> - - - - ) : null} -
    -
  • - ); - })} -
-
-
- - -
-
- ); -} +export * from "@/components/repository/skill/components/ReviewSkillList"; diff --git a/frontend/app/[locale]/skill-space/components/SkillRepositoryCard.tsx b/frontend/app/[locale]/skill-space/components/SkillRepositoryCard.tsx index 5fb5ac9ca6..9f18b77984 100644 --- a/frontend/app/[locale]/skill-space/components/SkillRepositoryCard.tsx +++ b/frontend/app/[locale]/skill-space/components/SkillRepositoryCard.tsx @@ -2,137 +2,4 @@ "use client"; -import type { ReactNode } from "react"; -import type { MenuProps } from "antd"; -import { Button, Dropdown, Tag } from "antd"; -import { Bot, Download, MoreHorizontal, PackageX } from "lucide-react"; -import { useUserDisplayName } from "@/hooks/auth/useUserDisplayName"; -import { useTranslation } from "react-i18next"; - -import ResourceCard from "@/components/resource/ResourceCard"; -import type { - SkillRepositoryListingItem, - SkillRepositoryListingStatus, -} from "@/types/skillRepository"; -import { - getSkillRepositoryStatusLabel, - STATUS_COLORS, -} from "./skillRepositoryShared"; - -export function StatusTag({ - status, -}: { - status: SkillRepositoryListingStatus; -}) { - const { t } = useTranslation("common"); - return ( - - {getSkillRepositoryStatusLabel(t, status)} - - ); -} - -export function SkillRepositoryCard({ - listing, - onDetailClick, - action, - showAdminMenu = false, - isTakingDown = false, - onTakeDown, -}: { - listing: SkillRepositoryListingItem; - onDetailClick?: () => void; - action?: ReactNode; - showAdminMenu?: boolean; - isTakingDown?: boolean; - onTakeDown?: () => void; -}) { - const { t } = useTranslation("common"); - const displayName = useUserDisplayName(); - const tags = listing.tags?.filter((tag) => tag.trim()) ?? []; - const author = listing.author?.trim() - ? displayName(listing.author_cas_username, listing.author.trim()) - : displayName( - listing.submitted_by_cas_username, - listing.submitted_by?.trim() || "" - ); - const menuItems: MenuProps["items"] = - showAdminMenu && onTakeDown != null - ? [ - { - key: "takeDown", - label: t("skillRepository.action.status.notShared"), - icon: , - danger: true, - disabled: isTakingDown, - onClick: onTakeDown, - }, - ] - : []; - - return ( - - - - } - description={ - listing.description || t("skillRepository.common.noDescription") - } - tags={ - tags.length > 0 ? ( - <> - {tags.map((tag) => ( - - {tag} - - ))} - - ) : ( - - ) - } - headerActions={ - - - {(listing.downloads ?? 0).toLocaleString()} - - } - actions={ - menuItems.length > 0 ? ( - - - - ); - } - - if (isEmpty) { - return ( -
- -
- ); - } +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. - return ( -
{children}
- ); -} - -export function PaginationBar({ - page, - pageSize, - total, - onPageChange, -}: { - page: number; - pageSize: number; - total: number; - onPageChange: (page: number) => void; -}) { - const { t } = useTranslation("common"); - const totalPages = total > 0 ? Math.ceil(total / pageSize) : 0; - if (totalPages <= 1) return null; - - return ( -
- - {Array.from({ length: totalPages }, (_, index) => index + 1).map( - (pageNumber) => ( - - ) - )} - -
- ); -} - -export function FilterButton({ - active, - onClick, - children, -}: { - active: boolean; - onClick: () => void; - children: ReactNode; -}) { - return ( - - ); -} - -export function CountBadge({ - count, - strong = false, -}: { - count: number; - strong?: boolean; -}) { - if (!strong) { - return ( - - {count} - - ); - } - - if (count <= 0) return null; +"use client"; - return ( - - {count} - - ); -} +export * from "@/components/repository/skill/components/SkillRepositoryControls"; diff --git a/frontend/app/[locale]/skill-space/components/SkillRepositoryReviewConfirmModal.tsx b/frontend/app/[locale]/skill-space/components/SkillRepositoryReviewConfirmModal.tsx index fbc1143480..f1ac3ae5a5 100644 --- a/frontend/app/[locale]/skill-space/components/SkillRepositoryReviewConfirmModal.tsx +++ b/frontend/app/[locale]/skill-space/components/SkillRepositoryReviewConfirmModal.tsx @@ -1,96 +1,5 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { Input, Modal } from "antd"; -import { useTranslation } from "react-i18next"; - -import type { SkillRepositoryListingItem } from "@/types/skillRepository"; - -export type SkillRepositoryReviewAction = "approve" | "reject"; - -interface SkillRepositoryReviewConfirmModalProps { - open: boolean; - action: SkillRepositoryReviewAction | null; - listing: SkillRepositoryListingItem | null; - loading?: boolean; - onClose: () => void; - onConfirm: (content?: string) => Promise; -} +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -export function SkillRepositoryReviewConfirmModal({ - open, - action, - listing, - loading = false, - onClose, - onConfirm, -}: SkillRepositoryReviewConfirmModalProps) { - const { t } = useTranslation("common"); - const [reviewOpinion, setReviewOpinion] = useState(""); - - useEffect(() => { - if (!open) { - setReviewOpinion(""); - } - }, [open]); - - if (!action || !listing) { - return null; - } - - const isApprove = action === "approve"; - const title = - listing.name?.trim() || t("skillRepository.common.untitled"); - - const handleOk = async () => { - const trimmed = reviewOpinion.trim(); - await onConfirm(trimmed || undefined); - }; +"use client"; - return ( - -
-

- {isApprove - ? t("repository.review.confirmApproveContent", { name: title }) - : t("repository.review.confirmRejectContent", { name: title })} -

-
- - setReviewOpinion(event.target.value)} - placeholder={t("repository.review.reviewOpinionPlaceholder")} - rows={4} - disabled={loading} - /> -
-
-
- ); -} +export * from "@/components/repository/skill/components/SkillRepositoryReviewConfirmModal"; diff --git a/frontend/app/[locale]/skill-space/components/SkillReviewStatusModal.tsx b/frontend/app/[locale]/skill-space/components/SkillReviewStatusModal.tsx index ee904f5d33..b101693940 100644 --- a/frontend/app/[locale]/skill-space/components/SkillReviewStatusModal.tsx +++ b/frontend/app/[locale]/skill-space/components/SkillReviewStatusModal.tsx @@ -1,168 +1,5 @@ -"use client"; - -import { Button, Modal } from "antd"; -import { CheckCircle2, Clock, PackageX, Store, XCircle } from "lucide-react"; -import { useTranslation } from "react-i18next"; - -import { - formatRepositoryDate, - isCancelableRepositoryStatus, - isTakeDownableRepositoryStatus, -} from "./skillRepositoryShared"; -import type { - MyEditableSkillItem, - MySkillRepositoryInfoItem, -} from "@/types/skillRepository"; - -export function SkillReviewStatusModal({ - open, - skill, - repositoryInfo, - isUpdatingStatus, - onClose, - onSetNotShared, -}: { - open: boolean; - skill: MyEditableSkillItem | null; - repositoryInfo: MySkillRepositoryInfoItem | null; - isUpdatingStatus: boolean; - onClose: () => void; - onSetNotShared: () => Promise; -}) { - const { t } = useTranslation("common"); - if (!skill || !repositoryInfo) return null; - - const title = skill.name?.trim() || `Skill #${skill.skill_id}`; - const isPending = repositoryInfo.status === "pending_review"; - const isRejected = repositoryInfo.status === "rejected"; - const canCancelApply = isCancelableRepositoryStatus(repositoryInfo.status); - const canTakeDown = isTakeDownableRepositoryStatus(repositoryInfo.status); - const submittedAt = formatRepositoryDate(repositoryInfo.create_time); - const listingContent = repositoryInfo.content?.trim() ?? ""; +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. - const statusConfig = isPending - ? { - icon: Clock, - label: t("repository.listingStatus.pendingLabel"), - description: t("repository.listingStatus.pendingDescription"), - tone: "border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200", - iconClass: "text-amber-600 dark:text-amber-300", - } - : isRejected - ? { - icon: XCircle, - label: t("repository.listingStatus.rejectedLabel"), - description: t("repository.listingStatus.rejectedDescription"), - tone: "border-red-200 bg-red-50 text-red-800 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200", - iconClass: "text-red-600 dark:text-red-300", - } - : { - icon: CheckCircle2, - label: t("repository.listingStatus.listedLabel"), - description: t("repository.listingStatus.listedDescription"), - tone: "border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-200", - iconClass: "text-emerald-600 dark:text-emerald-300", - }; - - const StatusIcon = statusConfig.icon; - - const confirmSetNotShared = () => { - Modal.confirm({ - title: canTakeDown - ? t("repository.listingStatus.confirmTakeDownTitle") - : t("repository.listingStatus.confirmCancelApplyTitle"), - content: canTakeDown - ? t("repository.listingStatus.confirmTakeDownContent", { - name: title, - }) - : t("repository.listingStatus.confirmCancelApplyContent", { - name: title, - }), - okText: canTakeDown - ? t("repository.listingStatus.takeDown") - : t("repository.listingStatus.cancelApply"), - cancelText: t("common.cancel"), - okButtonProps: { danger: true }, - onOk: onSetNotShared, - }); - }; - - return ( - - - {t("repository.listingStatus.title")} -
- } - footer={ -
- - {canCancelApply || canTakeDown ? ( - - ) : null} -
- } - > -

{title}

- -
- -
-

{statusConfig.label}

-

- {statusConfig.description} -

- {listingContent ? ( -

- {isPending - ? t("repository.listingStatus.pendingNote", { - content: listingContent, - }) - : t("repository.listingStatus.reviewOpinion", { - content: listingContent, - })} -

- ) : null} -
-
+"use client"; - {submittedAt ? ( -
-
- {t("repository.listingStatus.submittedAt")} - - {submittedAt} - -
-
- ) : null} - - ); -} +export * from "@/components/repository/skill/components/SkillReviewStatusModal"; diff --git a/frontend/app/[locale]/skill-space/components/skillRepositoryShared.ts b/frontend/app/[locale]/skill-space/components/skillRepositoryShared.ts index 2c1cb062f5..7f933d6b09 100644 --- a/frontend/app/[locale]/skill-space/components/skillRepositoryShared.ts +++ b/frontend/app/[locale]/skill-space/components/skillRepositoryShared.ts @@ -1,93 +1,3 @@ -import type { - MySkillRepositoryInfoItem, - SkillRepositoryListingStatus, -} from "@/types/skillRepository"; +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -type Translate = (key: string, options?: Record) => string; - -export const STATUS_LABEL_KEYS: Record = { - not_shared: "repository.status.notListed", - pending_review: "repository.status.reviewing", - rejected: "repository.status.rejected", - shared: "repository.status.listed", -}; - -export const STATUS_COLORS: Record = { - not_shared: "default", - pending_review: "processing", - rejected: "error", - shared: "success", -}; - -export function getSkillRepositoryStatusLabel( - t: Translate, - status: SkillRepositoryListingStatus -): string { - return t(STATUS_LABEL_KEYS[status]); -} - -export function parseRepositoryTime(value?: string | null): number { - if (!value) return 0; - const timestamp = Date.parse(value); - return Number.isNaN(timestamp) ? 0 : timestamp; -} - -export function formatRepositoryDate(value?: string | null): string | null { - const timestamp = parseRepositoryTime(value); - if (!timestamp) return null; - return new Date(timestamp).toISOString().slice(0, 10); -} - -export function getSkillSourceLabel( - source: string | null | undefined, - t: Translate -): string { - if (source === "custom") { - return t("skillRepository.source.custom"); - } - if (source === "repository") { - return t("skillRepository.source.repository"); - } - return source || "-"; -} - -export function pickLatestRepositoryInfo( - items: MySkillRepositoryInfoItem[] -): MySkillRepositoryInfoItem | null { - if (!items.length) return null; - return [...items].sort( - (a, b) => - parseRepositoryTime(b.create_time) - parseRepositoryTime(a.create_time) - )[0]; -} - -export function pickReviewDisplayRepositoryInfo( - items: MySkillRepositoryInfoItem[] -): MySkillRepositoryInfoItem | null { - const pending = pickLatestRepositoryInfo( - items.filter((item) => item.status === "pending_review") - ); - if (pending) return pending; - - const rejected = pickLatestRepositoryInfo( - items.filter((item) => item.status === "rejected") - ); - if (rejected) return rejected; - - const shared = pickLatestRepositoryInfo( - items.filter((item) => item.status === "shared") - ); - return shared; -} - -export function isCancelableRepositoryStatus( - status: MySkillRepositoryInfoItem["status"] -): boolean { - return status === "pending_review" || status === "rejected"; -} - -export function isTakeDownableRepositoryStatus( - status: MySkillRepositoryInfoItem["status"] -): boolean { - return status === "shared"; -} +export * from "@/components/repository/skill/components/skillRepositoryShared"; diff --git a/frontend/app/[locale]/skill-space/my-skill.tsx b/frontend/app/[locale]/skill-space/my-skill.tsx index 70febad2c3..c624c47d54 100644 --- a/frontend/app/[locale]/skill-space/my-skill.tsx +++ b/frontend/app/[locale]/skill-space/my-skill.tsx @@ -2,337 +2,4 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { App } from "antd"; -import dynamic from "next/dynamic"; -import { useParams, useRouter, useSearchParams } from "next/navigation"; -import { useQueryClient } from "@tanstack/react-query"; -import { useTranslation } from "react-i18next"; -import { useTagDefinitions, useTagLibraries } from "@/hooks/useTagManagement"; -import type { TagResourcePredicate } from "@/types/tagManagement"; -import { - invalidateSkillRepositoryCaches, - SKILLS_LIST_QUERY_KEY, - useCreateSkillRepositoryListing, - useMyEditableSkills, - useUpdateSkillRepositoryStatus, -} from "@/hooks/skillRepository/useSkillRepositoryListings"; -import { parseSkillReviewDeepLinkParams } from "@/lib/notificationNavigation"; -import { REPOSITORY_PAGE_SIZE } from "@/const/resource"; -import { ApiError } from "@/services/api"; -import { deleteSkillByName } from "@/services/skillService"; -import { fetchMyEditableSkills } from "@/services/skillRepositoryService"; -import type { - MineOwnershipFilter, - MyEditableSkillItem, - MySkillRepositoryInfoItem, -} from "@/types/skillRepository"; -import type { Skill } from "@/types/agentConfig"; -import { - isNewSkillPaddingItem, - MineSkillsView, -} from "./components/MineSkillsView"; - -const SkillBuildModal = dynamic( - () => import("../agents/components/capability/SkillBuildModal"), - { ssr: false } -); -const SkillDetailModal = dynamic( - () => import("../agents/components/capability/SkillDetailModal"), - { ssr: false } -); -const SEARCH_DEBOUNCE_MS = 300; - -export function MySkill({ active }: { active: boolean }) { - const { t } = useTranslation("common"); - const { message } = App.useApp(); - const pageSize = REPOSITORY_PAGE_SIZE; - const queryClient = useQueryClient(); - const searchParams = useSearchParams(); - const router = useRouter(); - const params = useParams<{ locale: string }>(); - const locale = params.locale || "en"; - const [minePage, setMinePage] = useState(1); - const [mineOwnership, setMineOwnership] = - useState("all"); - const [mineSearch, setMineSearch] = useState(""); - const [mineTagPredicates, setMineTagPredicates] = useState< - TagResourcePredicate[] - >([]); - const [debouncedMineSearch, setDebouncedMineSearch] = useState(""); - const [skillBuildOpen, setSkillBuildOpen] = useState(false); - const [skillBuildLoaded, setSkillBuildLoaded] = useState(false); - const [editingSkill, setEditingSkill] = useState( - null - ); - useEffect(() => { - if (!active) return; - const id = Number(searchParams.get("edit_skill_id")); - if (!Number.isInteger(id) || id <= 0) return; - let cancelled = false; - void (async () => { - try { - for (let page = 1; ; page += 1) { - const result = await fetchMyEditableSkills({ - ownership: "all", - page, - page_size: 100, - new_skill_padding: false, - }); - if (cancelled) return; - const skill = result.items.find( - (item): item is MyEditableSkillItem => - "skill_id" in item && item.skill_id === id - ); - if (skill) { - setEditingSkill(skill); - setSkillBuildLoaded(true); - setSkillBuildOpen(true); - return; - } - if (page >= result.pagination.total_pages) { - message.warning(t("ui.localization.skillEditDenied")); - return; - } - } - } catch { - if (!cancelled) message.error(t("ui.localization.skillLoadFailed")); - } - })(); - return () => { - cancelled = true; - }; - }, [active, searchParams, message, t]); - const [skillDetailLoaded, setSkillDetailLoaded] = useState(false); - const [viewingSkill, setViewingSkill] = useState( - null - ); - const { data: tagLibraries } = useTagLibraries(); - const defaultTagLibrary = - tagLibraries?.find( - (library) => library.bucket_key === "default_resource" - ) ?? null; - const { data: mineTagDefinitions } = useTagDefinitions( - defaultTagLibrary?.bucket_id ?? null - ); - - useEffect(() => { - const timer = window.setTimeout( - () => setDebouncedMineSearch(mineSearch), - SEARCH_DEBOUNCE_MS - ); - return () => window.clearTimeout(timer); - }, [mineSearch]); - - const reviewDeepLink = useMemo( - () => parseSkillReviewDeepLinkParams(searchParams), - [searchParams] - ); - const handleReviewDeepLinkConsumed = useCallback(() => { - router.replace(`/${locale}/skill-space?tab=mine`); - }, [locale, router]); - const mineParams = useMemo( - () => ({ - ownership: mineOwnership, - page: minePage, - page_size: pageSize, - ...(debouncedMineSearch.trim() - ? { search: debouncedMineSearch.trim() } - : {}), - ...(mineTagPredicates.length > 0 - ? { tag_predicates: mineTagPredicates } - : {}), - ...(mineOwnership === "all" && - !debouncedMineSearch.trim() && - mineTagPredicates.length === 0 - ? { new_skill_padding: true } - : {}), - }), - [debouncedMineSearch, mineOwnership, minePage, mineTagPredicates, pageSize] - ); - const { - data: mineData, - isLoading: isMineLoading, - isError: isMineError, - isFetching: isMineFetching, - refetch: refetchMine, - } = useMyEditableSkills(mineParams, active); - const { data: deepLinkMineData, isLoading: isDeepLinkMineLoading } = - useMyEditableSkills( - { ownership: "all", page: 1, page_size: 100, new_skill_padding: false }, - active && reviewDeepLink != null - ); - const createListingMutation = useCreateSkillRepositoryListing(); - const updateStatusMutation = useUpdateSkillRepositoryStatus(); - const mineItems = mineData?.items ?? []; - const mineTotal = mineData?.pagination?.total ?? 0; - const mineCounts = mineData?.counts ?? { all: 0, created: 0, others: 0 }; - const deepLinkFallbackSkill = useMemo(() => { - if (!reviewDeepLink) return null; - const items = deepLinkMineData?.items ?? []; - return ( - items.find( - (item): item is MyEditableSkillItem => - !isNewSkillPaddingItem(item) && - item.skill_id === reviewDeepLink.skillId - ) ?? null - ); - }, [deepLinkMineData?.items, reviewDeepLink]); - - useEffect(() => { - if (active) refetchMine().catch(() => {}); - }, [active, refetchMine]); - - useEffect(() => { - const total = mineData?.pagination?.total; - if (total == null) return; - const totalPages = Math.max(1, Math.ceil(total / pageSize)); - if (minePage > totalPages) { - /* eslint-disable react-hooks/set-state-in-effect -- Clamp after server pagination changes. */ - setMinePage(totalPages); - /* eslint-enable react-hooks/set-state-in-effect */ - } - }, [mineData?.pagination?.total, minePage, pageSize]); - const handleSetNotShared = async ( - repositoryInfo: MySkillRepositoryInfoItem - ) => { - const wasShared = repositoryInfo.status === "shared"; - await updateStatusMutation.mutateAsync({ - skillRepositoryId: repositoryInfo.skill_repository_id, - status: "not_shared", - }); - message.success( - wasShared - ? t("repository.mine.takeDownSuccess") - : t("repository.mine.cancelApplySuccess") - ); - }; - - const refreshSkillCaches = async () => { - await Promise.all([ - invalidateSkillRepositoryCaches(queryClient), - queryClient.invalidateQueries({ queryKey: [SKILLS_LIST_QUERY_KEY] }), - ]); - }; - - const handleSkillBuildSuccess = async () => { - await refreshSkillCaches().catch(() => {}); - setEditingSkill(null); - }; - return ( - <> - {active ? ( - { - setMineOwnership(ownership); - setMinePage(1); - }} - searchQuery={mineSearch} - onSearchChange={(value) => { - setMineSearch(value); - setMinePage(1); - }} - tagDefinitions={mineTagDefinitions ?? []} - tagPredicates={mineTagPredicates} - onTagPredicatesChange={(value) => { - setMineTagPredicates(value); - setMinePage(1); - }} - isLoading={isMineLoading} - isError={isMineError} - isFetching={isMineFetching} - page={minePage} - total={mineTotal} - onPageChange={setMinePage} - onRetry={() => refetchMine()} - onCreateSkill={() => { - setEditingSkill(null); - setSkillBuildLoaded(true); - setSkillBuildOpen(true); - }} - onEditSkill={(skill) => { - setEditingSkill(skill); - setSkillBuildLoaded(true); - setSkillBuildOpen(true); - }} - onViewSkill={(skill) => { - setSkillDetailLoaded(true); - setViewingSkill(skill); - }} - onDeleteSkill={async (skill) => { - const name = skill.name?.trim(); - if (!name) { - message.error(t("skillRepository.delete.emptyName")); - return; - } - const result = await deleteSkillByName(name); - if (!result.success) { - message.error( - result.message || t("repository.mine.deleteFailed") - ); - throw new Error(result.message || "Delete skill failed"); - } - message.success(t("repository.mine.deleteSuccess")); - await refreshSkillCaches(); - }} - onApplyListing={async (skill, payload) => { - try { - await createListingMutation.mutateAsync({ - skillId: skill.skill_id, - payload, - }); - message.success(t("repository.mine.applySuccess")); - } catch (error) { - if (error instanceof ApiError && Number(error.code) === 403) { - message.error(t("skillRepository.mine.applyForbidden")); - return; - } - message.error( - error instanceof Error - ? error.message - : t("repository.mine.applyError") - ); - } - }} - isUpdatingStatus={updateStatusMutation.isPending} - onSetNotShared={handleSetNotShared} - reviewDeepLink={reviewDeepLink} - deepLinkFallbackSkill={deepLinkFallbackSkill} - deepLinkFallbackLoading={isDeepLinkMineLoading} - onReviewDeepLinkConsumed={handleReviewDeepLinkConsumed} - /> - ) : null} - {skillBuildLoaded ? ( - { - setSkillBuildOpen(false); - setEditingSkill(null); - }} - onSuccess={handleSkillBuildSuccess} - /> - ) : null} - {skillDetailLoaded ? ( - setViewingSkill(null)} - /> - ) : null} - - ); -} +export * from "@/components/repository/skill/my-skill"; diff --git a/frontend/app/[locale]/skill-space/review-center.tsx b/frontend/app/[locale]/skill-space/review-center.tsx index 812dd32d29..1c7215ece5 100644 --- a/frontend/app/[locale]/skill-space/review-center.tsx +++ b/frontend/app/[locale]/skill-space/review-center.tsx @@ -1,181 +1,5 @@ -"use client"; - -import { useEffect, useMemo, useState } from "react"; -import { App } from "antd"; -import { useTranslation } from "react-i18next"; -import { - useSkillRepositoryListingDetail, - useSkillRepositoryListings, - useUpdateSkillRepositoryStatus, -} from "@/hooks/skillRepository/useSkillRepositoryListings"; -import type { - SkillRepositoryListingItem, - SkillRepositoryListingStatus, -} from "@/types/skillRepository"; -import { SkillDetail } from "@/components/skill/skill-detail"; -import { ReviewSkillList } from "./components/ReviewSkillList"; -import { - SkillRepositoryReviewConfirmModal, - type SkillRepositoryReviewAction, -} from "./components/SkillRepositoryReviewConfirmModal"; -import { getSkillRepositoryStatusLabel } from "./components/skillRepositoryShared"; - -const REVIEW_PAGE_SIZE = 10; -const STATUS_ACTION_LABEL_KEYS: Partial< - Record -> = { - not_shared: "skillRepository.action.status.notShared", - shared: "skillRepository.action.status.shared", - rejected: "skillRepository.action.status.rejected", -}; - -export function ReviewCenter({ active }: { active: boolean }) { - const { t } = useTranslation("common"); - const { message } = App.useApp(); - const [reviewPage, setReviewPage] = useState(1); - const [detailRepositoryId, setDetailRepositoryId] = useState( - null - ); - const [reviewListing, setReviewListing] = - useState(null); - const [reviewAction, setReviewAction] = - useState(null); - const reviewParams = useMemo( - () => ({ - status: "pending_review" as const, - page: reviewPage, - page_size: REVIEW_PAGE_SIZE, - sort_by_update_time: true, - }), - [reviewPage] - ); - const { - data: reviewData, - isLoading: isReviewLoading, - isError: isReviewError, - isFetching: isReviewFetching, - refetch: refetchReview, - } = useSkillRepositoryListings(reviewParams, active); - const updateStatusMutation = useUpdateSkillRepositoryStatus(); - const { - data: detailData, - isLoading: isDetailLoading, - isError: isDetailError, - isFetching: isDetailFetching, - refetch: refetchDetail, - } = useSkillRepositoryListingDetail( - detailRepositoryId, - detailRepositoryId != null - ); - const reviewItems = reviewData?.items ?? []; - const reviewTotal = reviewData?.pagination?.total ?? 0; - const updatingRepositoryId = updateStatusMutation.isPending - ? (updateStatusMutation.variables?.skillRepositoryId ?? null) - : null; +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. - useEffect(() => { - if (active) refetchReview().catch(() => {}); - }, [active, refetchReview]); - - useEffect(() => { - const total = reviewData?.pagination?.total; - if (total == null) return; - const totalPages = Math.max(1, Math.ceil(total / REVIEW_PAGE_SIZE)); - if (reviewPage > totalPages) { - /* eslint-disable react-hooks/set-state-in-effect -- Clamp after server pagination changes. */ - setReviewPage(totalPages); - /* eslint-enable react-hooks/set-state-in-effect */ - } - }, [reviewData?.pagination?.total, reviewPage]); - - const openDetail = (listing: SkillRepositoryListingItem) => { - setDetailRepositoryId(listing.skill_repository_id); - }; - - const handleUpdateStatus = async ( - listing: SkillRepositoryListingItem, - status: SkillRepositoryListingStatus, - content?: string - ) => { - try { - await updateStatusMutation.mutateAsync({ - skillRepositoryId: listing.skill_repository_id, - status, - content, - }); - message.success( - t("skillRepository.action.success", { - action: STATUS_ACTION_LABEL_KEYS[status] - ? t(STATUS_ACTION_LABEL_KEYS[status]) - : getSkillRepositoryStatusLabel(t, status), - }) - ); - } catch (error) { - message.error( - error instanceof Error - ? error.message - : t("skillRepository.common.statusUpdateFailed") - ); - } - }; - const openReviewConfirmModal = ( - listing: SkillRepositoryListingItem, - action: SkillRepositoryReviewAction - ) => { - setReviewListing(listing); - setReviewAction(action); - }; +"use client"; - const closeReviewConfirmModal = () => { - setReviewListing(null); - setReviewAction(null); - }; - return ( - <> - {active ? ( - refetchReview()} - updatingRepositoryId={updatingRepositoryId} - onDetailClick={openDetail} - onApprove={(listing) => openReviewConfirmModal(listing, "approve")} - onReject={(listing) => openReviewConfirmModal(listing, "reject")} - /> - ) : null} - setDetailRepositoryId(null)} - onRetry={() => refetchDetail()} - /> - { - if (!reviewListing || !reviewAction) { - return; - } - await handleUpdateStatus( - reviewListing, - reviewAction === "approve" ? "shared" : "rejected", - content - ); - closeReviewConfirmModal(); - }} - /> - - ); -} +export * from "@/components/repository/skill/review-center"; diff --git a/frontend/app/[locale]/skill-space/skill-space.tsx b/frontend/app/[locale]/skill-space/skill-space.tsx index 3e2f4b9d94..ba71206cf8 100644 --- a/frontend/app/[locale]/skill-space/skill-space.tsx +++ b/frontend/app/[locale]/skill-space/skill-space.tsx @@ -2,321 +2,4 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; -import { App, Input, Modal } from "antd"; -import { useTranslation } from "react-i18next"; -import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; -import { USER_ROLES } from "@/const/auth"; -import { useTagDefinitions, useTagLibraries } from "@/hooks/useTagManagement"; -import type { TagResourcePredicate } from "@/types/tagManagement"; -import { - useInstallSkillFromRepository, - useSkillRepositoryListingDetail, - useSkillRepositoryListings, - useUpdateSkillRepositoryStatus, -} from "@/hooks/skillRepository/useSkillRepositoryListings"; -import type { - SkillRepositoryListingItem, - SkillRepositoryListingStatus, -} from "@/types/skillRepository"; -import { SkillDetail } from "@/components/skill/skill-detail"; -import { REPOSITORY_PAGE_SIZE } from "@/const/resource"; -import { RepositoryView } from "./components/RepositoryView"; -import { getSkillRepositoryStatusLabel } from "./components/skillRepositoryShared"; - -const SEARCH_DEBOUNCE_MS = 300; -const STATUS_ACTION_LABEL_KEYS: Partial< - Record -> = { - not_shared: "skillRepository.action.status.notShared", - shared: "skillRepository.action.status.shared", - rejected: "skillRepository.action.status.rejected", -}; - -export function SkillSpace({ active }: { active: boolean }) { - const { t } = useTranslation("common"); - const { message, modal } = App.useApp(); - const { user } = useAuthorizationContext(); - const isAdmin = user?.role === USER_ROLES.ADMIN; - const [repositoryPage, setRepositoryPage] = useState(1); - const [repositorySearch, setRepositorySearch] = useState(""); - const [repositoryTagPredicates, setRepositoryTagPredicates] = useState< - TagResourcePredicate[] - >([]); - const [debouncedRepositorySearch, setDebouncedRepositorySearch] = - useState(""); - const [detailRepositoryId, setDetailRepositoryId] = useState( - null - ); - const [copyListing, setCopyListing] = - useState(null); - const [copyTargetName, setCopyTargetName] = useState(""); - const [copyNameError, setCopyNameError] = useState(null); - const { data: tagLibraries } = useTagLibraries(); - const defaultTagLibrary = - tagLibraries?.find( - (library) => library.bucket_key === "default_resource" - ) ?? null; - const { data: tagDefinitions } = useTagDefinitions( - defaultTagLibrary?.bucket_id ?? null - ); - - useEffect(() => { - const timer = window.setTimeout( - () => setDebouncedRepositorySearch(repositorySearch), - SEARCH_DEBOUNCE_MS - ); - return () => window.clearTimeout(timer); - }, [repositorySearch]); - - const repositoryParams = useMemo( - () => ({ - status: "shared" as const, - page: repositoryPage, - page_size: REPOSITORY_PAGE_SIZE, - ...(debouncedRepositorySearch.trim() - ? { search: debouncedRepositorySearch.trim() } - : {}), - ...(repositoryTagPredicates.length > 0 - ? { tag_predicates: repositoryTagPredicates } - : {}), - }), - [debouncedRepositorySearch, repositoryPage, repositoryTagPredicates] - ); - const { - data: repositoryData, - isLoading: isRepositoryLoading, - isError: isRepositoryError, - isFetching: isRepositoryFetching, - refetch: refetchRepository, - } = useSkillRepositoryListings(repositoryParams, active); - const installMutation = useInstallSkillFromRepository(); - const updateStatusMutation = useUpdateSkillRepositoryStatus(); - const { - data: detailData, - isLoading: isDetailLoading, - isError: isDetailError, - isFetching: isDetailFetching, - refetch: refetchDetail, - } = useSkillRepositoryListingDetail( - detailRepositoryId, - detailRepositoryId != null - ); - const repositoryItems = repositoryData?.items ?? []; - const repositoryTotal = repositoryData?.pagination?.total ?? 0; - const updatingRepositoryId = updateStatusMutation.isPending - ? (updateStatusMutation.variables?.skillRepositoryId ?? null) - : null; - const installingRepositoryId = installMutation.isPending - ? (installMutation.variables?.skillRepositoryId ?? null) - : null; - - useEffect(() => { - if (active) refetchRepository().catch(() => {}); - }, [active, refetchRepository]); - - useEffect(() => { - const total = repositoryData?.pagination?.total; - if (total == null) return; - const totalPages = Math.max(1, Math.ceil(total / REPOSITORY_PAGE_SIZE)); - if (repositoryPage > totalPages) { - /* eslint-disable react-hooks/set-state-in-effect -- Clamp after server pagination changes. */ - setRepositoryPage(totalPages); - /* eslint-enable react-hooks/set-state-in-effect */ - } - }, [repositoryData?.pagination?.total, repositoryPage]); - const getDuplicateSkillNames = (error: unknown): string[] | null => { - const detail = - error instanceof Error && "detail" in error - ? (error as { detail?: unknown }).detail - : null; - if ( - typeof detail !== "object" || - detail == null || - !("type" in detail) || - (detail as { type?: unknown }).type !== "skill_duplicate" - ) { - return null; - } - const duplicates = (detail as { duplicate_skills?: unknown }) - .duplicate_skills; - return Array.isArray(duplicates) - ? duplicates.filter((name): name is string => typeof name === "string") - : []; - }; - - const openDetail = (listing: SkillRepositoryListingItem) => { - setDetailRepositoryId(listing.skill_repository_id); - }; - - const handleInstall = (listing: SkillRepositoryListingItem) => { - const baseName = listing.name?.trim() || "Skill"; - setCopyListing(listing); - setCopyTargetName( - t("skillRepository.copy.defaultName", { name: baseName }) - ); - setCopyNameError(null); - }; - - const handleConfirmInstall = async () => { - if (!copyListing) { - return; - } - const targetName = copyTargetName.trim(); - if (!targetName) { - setCopyNameError(t("skillRepository.copy.nameRequired")); - return; - } - try { - setCopyNameError(null); - const result = await installMutation.mutateAsync({ - skillRepositoryId: copyListing.skill_repository_id, - targetName, - }); - message.success( - result.name - ? t("skillRepository.copy.successWithName", { name: result.name }) - : t("skillRepository.copy.success") - ); - setCopyListing(null); - setCopyTargetName(""); - } catch (error) { - const duplicateNames = getDuplicateSkillNames(error); - if (duplicateNames) { - setCopyNameError( - duplicateNames.length > 0 - ? t("skillRepository.copy.duplicateWithNames", { - names: duplicateNames.join("、"), - }) - : t("skillRepository.copy.duplicate") - ); - return; - } - message.error( - error instanceof Error - ? error.message - : t("skillRepository.copy.failed") - ); - } - }; - - const handleUpdateStatus = async ( - listing: SkillRepositoryListingItem, - status: SkillRepositoryListingStatus, - content?: string - ) => { - try { - await updateStatusMutation.mutateAsync({ - skillRepositoryId: listing.skill_repository_id, - status, - content, - }); - message.success( - t("skillRepository.action.success", { - action: STATUS_ACTION_LABEL_KEYS[status] - ? t(STATUS_ACTION_LABEL_KEYS[status]) - : getSkillRepositoryStatusLabel(t, status), - }) - ); - } catch (error) { - message.error( - error instanceof Error - ? error.message - : t("skillRepository.common.statusUpdateFailed") - ); - } - }; - const confirmTakeDown = (listing: SkillRepositoryListingItem) => { - modal.confirm({ - title: t("skillRepository.action.confirmTakeDown"), - content: listing.name, - okText: t("skillRepository.action.status.notShared"), - cancelText: t("common.cancel"), - okButtonProps: { danger: true }, - onOk: () => handleUpdateStatus(listing, "not_shared"), - }); - }; - return ( - <> - {active ? ( - { - setRepositorySearch(value); - setRepositoryPage(1); - }} - tagDefinitions={tagDefinitions ?? []} - tagPredicates={repositoryTagPredicates} - onTagPredicatesChange={(value) => { - setRepositoryTagPredicates(value); - setRepositoryPage(1); - }} - listings={repositoryItems} - isLoading={isRepositoryLoading} - isError={isRepositoryError} - isFetching={isRepositoryFetching} - page={repositoryPage} - total={repositoryTotal} - onPageChange={setRepositoryPage} - onRetry={() => refetchRepository()} - onInstall={handleInstall} - onDetailClick={openDetail} - showAdminMenu={isAdmin} - onTakeDown={confirmTakeDown} - installingRepositoryId={installingRepositoryId} - takingDownRepositoryId={updatingRepositoryId} - /> - ) : null} - setDetailRepositoryId(null)} - onRetry={() => refetchDetail()} - /> - { - setCopyListing(null); - setCopyTargetName(""); - setCopyNameError(null); - }} - > -
- - { - setCopyTargetName(event.target.value); - if (copyNameError) { - setCopyNameError(null); - } - }} - onPressEnter={handleConfirmInstall} - /> - {copyNameError ? ( -

{copyNameError}

- ) : ( -

- {t("skillRepository.copy.renameHint")} -

- )} -
-
- - ); -} +export * from "@/components/repository/skill/skill-space"; diff --git a/frontend/components/agent/agent-detail.tsx b/frontend/components/agent/agent-detail.tsx index a0c9cca325..da6518a31a 100644 --- a/frontend/components/agent/agent-detail.tsx +++ b/frontend/components/agent/agent-detail.tsx @@ -2,11 +2,14 @@ "use client"; -import { Button, Spin } from "antd"; +import { Button, Empty, Spin } from "antd"; +import Image from "next/image"; import { BookOpen, Bot, Calendar, + ChevronDown, + ChevronUp, Cpu, Download, Pencil, @@ -15,11 +18,15 @@ import { UserRound, Wrench, } from "lucide-react"; -import type { ReactNode } from "react"; +import { useId, useState, type ReactNode } from "react"; import { useUserDisplayName } from "@/hooks/auth/useUserDisplayName"; import { useTranslation } from "react-i18next"; +import { StandardButton } from "@/components/common/StandardButton"; import ResourceDetail from "@/components/resource/ResourceDetail"; +import { useRepositoryPresentation } from "@/hooks/useRepositoryPresentation"; import type { MyAgentDetailView } from "@/lib/myAgentDetail"; +import { MODEL_PROVIDER_LOGOS } from "@/const/modelConfig"; +import { formatModelTokenLabel } from "@/lib/modelTokenLabel"; import type { AgentRepositoryListingStatus } from "@/types/agentRepository"; interface AgentDetailProps { @@ -28,6 +35,7 @@ interface AgentDetailProps { onEdit?: () => void; detail: MyAgentDetailView | null | undefined; agentIcon?: ReactNode; + footerActions?: ReactNode; published: boolean; status?: AgentRepositoryListingStatus; showRepositoryInfo?: boolean; @@ -82,12 +90,303 @@ function DetailSection({ ); } +function RepositoryAgentDetailContent({ + detail, + agentIcon, + author, +}: { + detail: MyAgentDetailView; + agentIcon: ReactNode; + author: string; +}) { + const { t, i18n } = useTranslation("common"); + const [expandedSections, setExpandedSections] = useState< + Record + >({}); + const sectionId = useId(); + const repository = detail.repositoryInfo; + const models = repository?.modelNames?.length + ? repository.modelNames + : detail.modelName + ? [detail.modelName] + : []; + const empty = ( + + ); + const sections = [ + { + title: t("agentRepository.mine.detail.model"), + items: models, + Icon: Cpu, + isModel: true, + }, + { + title: t("agentRepository.mine.detail.knowledgeBases"), + items: detail.knowledgeBases, + Icon: BookOpen, + iconColor: "bg-[#EEE7FF] text-[#765CEB]", + knowledge: repository?.knowledgeDetails, + }, + { + title: t("agentRepository.mine.detail.skills"), + items: detail.skills, + Icon: Sparkles, + iconColor: "bg-[#E6F2FF] text-[#4285D7]", + tags: repository?.skillTags, + }, + { + title: t("agentRepository.mine.detail.tools"), + items: detail.tools, + Icon: Wrench, + iconColor: "bg-[#E6F2FF] text-[#4285D7]", + tags: repository?.toolTags, + }, + { + title: t("resourceCenter.subAgents"), + items: detail.subAgents, + Icon: Bot, + iconColor: "bg-[#E7F0FF] text-[#5878E8]", + tags: repository?.subAgentTags, + }, + ]; + + return ( +
+
+
+
{agentIcon}
+
+

+ {detail.title || t("agentRepository.card.untitled")} +

+ {detail.tags.length > 0 ? ( +
    + {detail.tags.map((tag) => ( +
  • + {tag} +
  • + ))} +
+ ) : null} +
+
+ {detail.tags.length === 0 ? ( +
+

+ {t("agentRepository.mine.detail.tags")} +

+ {empty} +
+ ) : null} +
+
+
+ {repository ? ( +
+ + {t("resourceCenter.agentCardCopies", { + count: repository.downloads, + formattedCount: new Intl.NumberFormat( + i18n?.language || undefined + ).format(repository.downloads), + })} + + {repository.updatedAt ? ( + + + {t("agentConfig.list.updatedAt", { + date: formatCreatedAt(repository.updatedAt), + })} + + ) : null} +
+ ) : null} + {detail.description ? ( +

+ {detail.description} +

+ ) : ( +
+

{t("common.description")}

+ {empty} +
+ )} +
+
+

+ {t("agentRepository.mine.detail.author")} +

+ {author ?

{author}

: empty} +
+
+

{t("resourceCenter.publishedAt")}

+

+ {repository?.publishedAt + ? formatCreatedAt(repository.publishedAt) + : t("resourceCenter.notRecorded")} +

+
+ {sections.map( + ( + { title, items, Icon, isModel, iconColor, knowledge, tags }, + index + ) => { + const id = `${sectionId}-${index}`; + const expanded = Boolean(expandedSections[id]); + const canExpand = items.length > 4; + const visibleItems = + canExpand && !expanded ? items.slice(0, 4) : items; + return ( +
+

{title}

+ {items.length > 0 ? ( +
    + {visibleItems.map((item) => { + const model = isModel + ? repository?.models?.find( + (candidate) => + ( + candidate.displayName || candidate.name + ).trim() === item + ) + : undefined; + const provider = String( + model?.modelFactory || model?.source || "" + ).toLowerCase(); + const logo = + MODEL_PROVIDER_LOGOS[provider] ?? + (isModel && /^deepseek(?:[- /]|$)/i.test(item) + ? MODEL_PROVIDER_LOGOS.deepseek + : undefined); + const badges = model + ? [ + formatModelTokenLabel( + model.contextWindowTokens ?? model.maxInputTokens + ), + model.type === "llm" && + t("agent.highFidelity.modelCapability.text"), + model.reasoningCapability?.status === "supported" && + t("agent.highFidelity.modelCapability.reasoning"), + model.supportToolCalls === true && + t("agent.highFidelity.modelCapability.tools"), + ].filter((label): label is string => Boolean(label)) + : (tags?.get(item) ?? []); + const base = knowledge?.get(item); + return ( +
  • + {logo ? ( + + ) : ( + + )} +
    + {item} + {base ? ( + + {typeof base.updatedAt === "string" && + base.updatedAt ? ( + + {t("knowledgeBase.tag.updatedAt", { + date: formatCreatedAt(base.updatedAt), + })} + + ) : null} + {Number.isFinite(base.documentCount) ? ( + + {t("resourcePicker.knowledge.documents", { + count: base.documentCount, + })} + + ) : null} + {Number.isFinite(base.chunkCount) ? ( + + {t("resourcePicker.knowledge.chunks", { + count: base.chunkCount, + })} + + ) : null} + + ) : null} + {badges.map((badge) => ( + + {badge} + + ))} +
    +
  • + ); + })} +
+ ) : ( + empty + )} + {canExpand ? ( + + ) : null} +
+ ); + } + )} +
+
+ ); +} + export function AgentDetail({ open, onClose, onEdit, detail, agentIcon, + footerActions, published, status, showRepositoryInfo = false, @@ -97,6 +396,7 @@ export function AgentDetail({ onRetry, }: AgentDetailProps) { const { t } = useTranslation("common"); + const presentation = useRepositoryPresentation(); const displayName = useUserDisplayName(); return ( @@ -107,8 +407,26 @@ export function AgentDetail({ className="agent-detail-modal [&_.ant-modal-close]:!z-20 [&_.ant-modal-content]:!overflow-hidden [&_.ant-modal-content]:!rounded-2xl [&_.ant-modal-content]:!p-0" bodyStyle={{ padding: 0 }} > -
-
+
+
{isLoading ? (
@@ -124,6 +442,15 @@ export function AgentDetail({ ) : null}
+ ) : detail && presentation && showRepositoryInfo ? ( + ) : detail ? ( <>
@@ -260,8 +587,24 @@ export function AgentDetail({ ) : null}
-
- +
+ {presentation && showRepositoryInfo ? ( + + {t("common.close")} + + ) : ( + + )} + {footerActions} {onEdit && detail && !isError ? ( ]} + footer={[ + , + ]} > - -
-          {logs || (containerId ? t("mcpConfig.containerLogs.empty") : "")}
-        
-
+ ); } diff --git a/frontend/components/mcp/mcp-detail.tsx b/frontend/components/mcp/mcp-detail.tsx index e8bc6fb53e..130d094f7f 100644 --- a/frontend/components/mcp/mcp-detail.tsx +++ b/frontend/components/mcp/mcp-detail.tsx @@ -6,6 +6,7 @@ import { useMemo, useState, type ReactNode } from "react"; import { App, Button } from "antd"; import { Download, + ChevronDown, Server, Tag as TagIcon, UserRound, @@ -13,6 +14,8 @@ import { } from "lucide-react"; import { useUserDisplayName } from "@/hooks/auth/useUserDisplayName"; import { useTranslation } from "react-i18next"; +import { useRepositoryPresentation } from "@/hooks/useRepositoryPresentation"; +import { StandardButton } from "@/components/common/StandardButton"; import ResourceDetail from "@/components/resource/ResourceDetail"; import McpToolListModal from "@/components/mcp/McpToolListModal"; import { @@ -45,6 +48,7 @@ export function McpDetail({ onInstall, }: McpDetailProps) { const { t } = useTranslation("common"); + const presentation = useRepositoryPresentation(); const displayName = useUserDisplayName(); const { message } = App.useApp(); const [toolsOpen, setToolsOpen] = useState(false); @@ -95,69 +99,55 @@ export function McpDetail({ className="mcp-detail-modal [&_.ant-modal-close]:!z-20 [&_.ant-modal-content]:!overflow-hidden [&_.ant-modal-content]:!rounded-2xl [&_.ant-modal-content]:!p-0" bodyStyle={{ padding: 0 }} > -
-
-
- {icon} -
-

- {service.name} -

-
- - {deploymentLabel} - + {presentation?.resourceType === "mcp" ? ( +
+
+
+ {icon} +
+
+

+ {service.name} +

+ + {deploymentLabel} + +
+
+ {tags.map((label, index) => ( + + {label} + + ))} +
-
- -

- {service.description || t("mcpTools.detail.noDescription")} -

- -
- - - {t("mcpTools.repository.downloadCount")} - - {downloadCount.toLocaleString()} -
- -
-
- - - {t("mcpTools.repository.sourceLabel")} +
+ + {t("resourceCenter.author")}:{author} - - {author} + + {t("resourceCenter.publishedAt")}: + {service.publishedAt?.slice(0, 10) || + t("resourceCenter.notRecorded")} -
-
- - - {t("mcpTools.deploymentType.label")} - - - {deploymentLabel} + + {t("agentRepository.detail.downloads", { + count: downloadCount, + })}
-
- -
-
-

- - {t("mcpTools.detail.tools")} + +
+

+ {service.description || t("mcpTools.detail.noDescription")} +

+
+

+ {t("mcpTools.detail.tools")}{" "}

- {snapshotTools.length > 0 ? ( -
- {snapshotTools.map((tool) => ( - - {tool.name} - - ))} -
- ) : null} +
+ {snapshotTools.map((tool) => ( +
+ + + + {tool.name} + + + {tool.description} + + + + {tool.description && ( +

+ {tool.description} +

+ )} +
+ ))} +
+
+
+ + {t("common.close")} + + onInstall(service)} + > + {t( + installed + ? "mcpTools.repository.installed" + : "mcpTools.repository.install" + )} + +
+

+ ) : ( +
+
+
+ {icon} +
+

+ {service.name} +

+
+ + {deploymentLabel} + +
+
+
+ +

+ {service.description || t("mcpTools.detail.noDescription")} +

+ +
+ + + {t("mcpTools.repository.downloadCount")} + + {downloadCount.toLocaleString()} +
- {tags.length > 0 ? ( +
+
+ + + {t("mcpTools.repository.sourceLabel")} + + + {author} + +
+
+ + + {t("mcpTools.deploymentType.label")} + + + {deploymentLabel} + +
+
+ +

- - {t("agentRepository.mine.detail.tags")} - - {tags.length} - + {t("mcpTools.detail.tools")} +

-
- {tags.map((tag) => ( - - {tag} - - ))} -
+ {snapshotTools.length > 0 ? ( +
+ {snapshotTools.map((tool) => ( + + {tool.name} + + ))} +
+ ) : null}
- ) : null} + + {tags.length > 0 ? ( +
+

+ + {t("agentRepository.mine.detail.tags")} + + {tags.length} + +

+
+ {tags.map((tag) => ( + + {tag} + + ))} +
+
+ ) : null} +
-
-
- - +
+ + +
-
+ )} (); const locale = params.locale || "en"; + const { canAccessRoute } = useAuthorizationContext(); const [dropdownOpen, setDropdownOpen] = useState(false); const changeOpen = (open: boolean) => { setDropdownOpen(open); @@ -55,16 +60,23 @@ export function NotificationBell({ ? t("notifications.bell.unread", { count: unreadCount }) : t("notifications.bell.label"); - const handleItemClick = async (item: NotificationItem) => { - if (!isRepositoryReviewNotification(item)) { - return; + const getAccessibleLink = (item: NotificationItem) => { + const unified = + pathname?.includes("/resource-space") || + item.resource_type === "resource_review"; + if (!unified && !isRepositoryReviewNotification(item)) { + return null; } - const deepLink = buildRepositoryReviewDeepLink(locale, item); - if (!deepLink) { - return; - } + const deepLink = unified + ? buildUnifiedRepositoryReviewDeepLink(locale, item) + : buildRepositoryReviewDeepLink(locale, item); + if (!deepLink) return null; + const section = withoutBasePath(deepLink).split("?")[0].split("/")[2]; + return section && canAccessRoute(`/${section}`) ? deepLink : null; + }; + const handleItemClick = async (item: NotificationItem, deepLink: string) => { changeOpen(false); if (onMarkRead) { await onMarkRead(item.receiver_id); @@ -105,7 +117,8 @@ export function NotificationBell({ ) : (
    {items.map((item) => { - const isClickable = isRepositoryReviewNotification(item); + const deepLink = getAccessibleLink(item); + const isClickable = deepLink !== null; return (
  • { - void handleItemClick(item); - }} + onClick={ + deepLink + ? () => void handleItemClick(item, deepLink) + : undefined + } onKeyDown={(event) => { - if (!isClickable) { + if (!deepLink) { return; } if (event.key === "Enter" || event.key === " ") { event.preventDefault(); - void handleItemClick(item); + void handleItemClick(item, deepLink); } }} role={isClickable ? "button" : undefined} diff --git a/frontend/components/navigation/SideNavigation.tsx b/frontend/components/navigation/SideNavigation.tsx index d5f0d85424..f9d50efa28 100644 --- a/frontend/components/navigation/SideNavigation.tsx +++ b/frontend/components/navigation/SideNavigation.tsx @@ -139,7 +139,7 @@ const ROUTE_CONFIG: RouteConfig[] = [ labelKey: "sidebar.resourceSpace", order: 9, parentKey: null, - isSection: true, + isSection: false, }, { path: "/agent-space", diff --git a/frontend/components/repository/RepositoryDisplayControls.tsx b/frontend/components/repository/RepositoryDisplayControls.tsx new file mode 100644 index 0000000000..179f203884 --- /dev/null +++ b/frontend/components/repository/RepositoryDisplayControls.tsx @@ -0,0 +1,92 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { Select } from "antd"; +import { LayoutGrid, List } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { useRepositoryPresentation } from "@/hooks/useRepositoryPresentation"; + +/** Keep repository display controls on the same row as search and filters. */ +export default function RepositoryDisplayControls({ + showSort = true, +}: { + showSort?: boolean; +}) { + const presentation = useRepositoryPresentation(); + const { t } = useTranslation("common"); + if (!presentation) return null; + return ( +
    + {showSort && presentation.query?.get("view") !== "mine" && ( + presentation?.updateQuery?.({ sort, page: "1" })} + /> + ); +} diff --git a/frontend/components/repository/ResourcePublishedAt.tsx b/frontend/components/repository/ResourcePublishedAt.tsx new file mode 100644 index 0000000000..9e23e28f60 --- /dev/null +++ b/frontend/components/repository/ResourcePublishedAt.tsx @@ -0,0 +1,26 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useTranslation } from "react-i18next"; +import { useRepositoryPresentation } from "@/hooks/useRepositoryPresentation"; + +export default function ResourcePublishedAt({ + value, + className = "block text-xs text-muted-foreground", +}: { + value?: string | null; + className?: string; +}) { + const presentation = useRepositoryPresentation(); + const { t } = useTranslation("common"); + if (!presentation) return null; + return ( + + {t("resourceCenter.publishedAt")}:{" "} + {value + ? new Date(value).toLocaleString() + : t("resourceCenter.notRecorded")} + + ); +} diff --git a/frontend/components/repository/ResourceRepositoryPage.tsx b/frontend/components/repository/ResourceRepositoryPage.tsx new file mode 100644 index 0000000000..1be338675c --- /dev/null +++ b/frontend/components/repository/ResourceRepositoryPage.tsx @@ -0,0 +1,238 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + useParams, + usePathname, + useRouter, + useSearchParams, +} from "next/navigation"; +import { ConfigProvider, Result, Segmented, Spin, Tabs } from "antd"; +import antdEnUs from "antd/locale/en_US"; +import antdZhCn from "antd/locale/zh_CN"; +import { Bot, Puzzle, ShieldCheck, Sparkles } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { RepositoryPresentationContext } from "@/hooks/useRepositoryPresentation"; +import { useQueryClient } from "@tanstack/react-query"; +import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; +import { + RESOURCE_REPOSITORY_PERMISSION_ROUTE, + RESOURCE_REPOSITORY_TABS, +} from "@/const/resourceRepository"; +import { + buildResourceRepositoryHref, + parseResourceRepositoryTarget, +} from "@/lib/resourceRepositoryRoutes"; +import type { + ResourceRepositoryTarget, + ResourceRepositoryView, +} from "@/types/resourceRepository"; +import { AgentSpace } from "./agent/agent-space"; +import { SkillSpace } from "./skill/skill-space"; +import { MySkill } from "./skill/my-skill"; +import { McpSpace, useMcpSpaceController } from "./mcp/agent-space"; +import { MyMcp } from "./mcp/my-mcp"; +import ResourceReviewCenter from "./ResourceReviewCenter"; +import "./resource-space.css"; + +function McpContent({ + view, + sortByPublished, +}: { + view: ResourceRepositoryView; + sortByPublished: boolean; +}) { + const controller = useMcpSpaceController(sortByPublished); + return ( + <> + {view === "mine" ? ( + + ) : ( + + )} + {controller.dialogs} + + ); +} + +export default function ResourceRepositoryPage() { + const { t, i18n } = useTranslation("common"); + const { locale } = useParams<{ locale: string }>(); + const router = useRouter(); + const pathname = usePathname(); + const params = useSearchParams(); + const pendingChanges = useRef | null>(null); + const updateQuery = useCallback( + (changes: Record, replace = false) => { + const scheduled = pendingChanges.current !== null; + pendingChanges.current = { ...pendingChanges.current, ...changes }; + if (scheduled) return; + queueMicrotask(() => { + const current = new URL(window.location.href); + const next = current.searchParams; + const previous = next.toString(); + Object.entries(pendingChanges.current || {}).forEach(([key, value]) => + value === null ? next.delete(key) : next.set(key, value) + ); + pendingChanges.current = null; + const query = next.toString(); + if (query === previous) return; + window.history[replace ? "replaceState" : "pushState"]( + null, + "", + `${pathname}${query ? `?${query}` : ""}${current.hash}` + ); + }); + }, + [pathname] + ); + const presentation = useMemo( + () => ({ + layout: + params.get("layout") === "list" + ? ("list" as const) + : ("cards" as const), + resourceType: params.get("tab") || "agent", + query: params, + updateQuery, + }), + [params, updateQuery] + ); + + const { user, isAuthzReady, canAccessRoute } = useAuthorizationContext(); + const queryClient = useQueryClient(); + const identity = `${user?.id}:${user?.tenantId}:${user?.role}`; + const [preparedIdentity, setPreparedIdentity] = useState(null); + useEffect(() => { + queryClient.removeQueries({ + predicate: ({ queryKey }) => + /repository|myEditable|mcp|skills|resourceReview/i.test( + String(queryKey[0]) + ), + }); + // eslint-disable-next-line react-hooks/set-state-in-effect -- Mount children only after identity caches have been cleared. + setPreparedIdentity(identity); + }, [identity, queryClient]); + if (!isAuthzReady || preparedIdentity !== identity) + return ( +
    + +
    + ); + if (!canAccessRoute(RESOURCE_REPOSITORY_PERMISSION_ROUTE)) + return ; + const parsed = parseResourceRepositoryTarget(params); + if (!parsed.valid) + return ; + const target = parsed.target; + const view = + target.tab === "agent" ? "repository" : (target.view ?? "repository"); + const navigate = (next: ResourceRepositoryTarget) => + router.push(buildResourceRepositoryHref(locale || "en", next)); + const icons = { + agent: , + mcp: , + skill: , + review: , + }; + return ( + +
    +

    + {t("resourceCenter.title")} +

    + + navigate({ + tab: tab as ResourceRepositoryTarget["tab"], + view: "repository", + }) + } + items={RESOURCE_REPOSITORY_TABS.map((tab) => ({ + key: tab, + label: ( + + {icons[tab]} + {t(`resourceCenter.tab.${tab}`)} + + ), + }))} + tabBarExtraContent={ + target.tab === "mcp" || target.tab === "skill" ? ( + + navigate({ + tab: target.tab, + view: value as ResourceRepositoryView, + }) + } + options={[ + { value: "repository", label: t("resourceCenter.all") }, + { value: "mine", label: t("resourceCenter.mine") }, + ]} + /> + ) : undefined + } + /> + +
    + {target.tab === "agent" && ( + + )} + {target.tab === "skill" && + (view === "mine" ? ( + + ) : ( + + ))} + {target.tab === "mcp" && ( + + )} + {target.tab === "review" && ( + + )} +
    +
    + {target.tab !== "review" && target.repositoryId && ( + + )} +
    +
    + ); +} diff --git a/frontend/components/repository/ResourceReviewCenter.tsx b/frontend/components/repository/ResourceReviewCenter.tsx new file mode 100644 index 0000000000..53632ca350 --- /dev/null +++ b/frontend/components/repository/ResourceReviewCenter.tsx @@ -0,0 +1,574 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useEffect, useId, useState } from "react"; +import { + Alert, + App, + Button, + Descriptions, + Empty, + Input, + Modal, + Select, + Space, + Spin, + Tag, +} from "antd"; +import { StandardTable } from "@/components/common/StandardTable"; +import { StandardButton } from "@/components/common/StandardButton"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useTranslation } from "react-i18next"; +import { useUserDisplayName } from "@/hooks/auth/useUserDisplayName"; +import { useResourceReviews } from "@/hooks/resourceReview/useResourceReviews"; +import type { + ResourceRepositoryTarget, + ResourceType, +} from "@/types/resourceRepository"; +import type { + ResourceReview, + ResourceReviewQuery, + ReviewDecision, + ReviewStatus, +} from "@/types/resourceReview"; + +const dateText = (value: string | null) => + value ? new Date(value).toLocaleString() : "—"; + +export default function ResourceReviewCenter({ + target, + detailOnly = false, +}: { + target: ResourceRepositoryTarget; + detailOnly?: boolean; +}) { + const { t } = useTranslation("common"); + const opinionId = useId(); + const { message } = App.useApp(); + const displayName = useUserDisplayName(); + const params = useSearchParams(); + const pathname = usePathname(); + const router = useRouter(); + const filterType = params.get("review_type") || target.resourceType || "all"; + const filterStatus = params.get("status") || "all"; + const page = Math.max(1, Number(params.get("page")) || 1); + const query: ResourceReviewQuery = { + type: ["agent", "skill", "mcp"].includes(filterType) + ? (filterType as ResourceType) + : "all", + status: ["pending_review", "approved", "rejected"].includes(filterStatus) + ? (filterStatus as ReviewStatus) + : "all", + search: params.get("search") || "", + page, + page_size: [10, 20, 50, 100].includes(Number(params.get("page_size"))) + ? Number(params.get("page_size")) + : 10, + }; + const { list, detail, decision } = useResourceReviews( + query, + target.resourceType, + target.reviewId, + detailOnly + ); + const [opinion, setOpinion] = useState(""); + const [action, setAction] = useState<{ + row: ResourceReview; + decision: ReviewDecision; + } | null>(null); + const [failed, setFailed] = useState(false); + const [reconciled, setReconciled] = useState(false); + const updateUrl = ( + changes: Record, + replace = false + ) => { + const next = new URLSearchParams(params.toString()); + Object.entries(changes).forEach(([key, value]) => + value === null ? next.delete(key) : next.set(key, value) + ); + router[replace ? "replace" : "push"](`${pathname}?${next.toString()}`); + }; + const filter = (key: string, value: string) => + updateUrl({ + [key]: value, + page: "1", + review_id: null, + resource_type: null, + }); + const total = list.data?.pagination.total ?? 0; + useEffect(() => { + if ( + list.isSuccess && + !list.isFetching && + page > Math.max(1, list.data.pagination.total_pages) + ) { + const next = new URLSearchParams(params.toString()); + next.set("page", String(Math.max(1, list.data.pagination.total_pages))); + router.replace(`${pathname}?${next.toString()}`); + } + }, [ + list.isSuccess, + list.isFetching, + list.data, + page, + params, + pathname, + router, + ]); + const open = (row: ResourceReview) => + updateUrl({ + resource_type: null, + review_id: row.review_id, + }); + const begin = (row: ResourceReview, value: ReviewDecision) => { + open(row); + setAction({ row, decision: value }); + setOpinion(""); + setFailed(false); + setReconciled(false); + }; + const latest = detail.data; + const stale = + !!action && + !!latest && + (latest.review_id !== action.row.review_id || + latest.status !== "pending_review"); + const submit = async () => { + if (!action || stale || (failed && !reconciled)) return; + try { + await decision.mutateAsync({ + type: action.row.resource_type, + id: action.row.review_id, + decision: action.decision, + reason: opinion, + }); + setAction(null); + message.success(t("resourceCenter.review.success")); + } catch { + setFailed(true); + setReconciled(false); + const current = await detail.refetch(); + setReconciled(current.isSuccess); + } + }; + const typeOptions = ["all", "agent", "mcp", "skill"].map((value) => ({ + value, + label: t(`resourceCenter.${value === "all" ? "allTypes" : `tab.${value}`}`), + })); + const statusOptions = ["all", "pending_review", "approved", "rejected"].map( + (value) => ({ value, label: t(`resourceCenter.status.${value}`) }) + ); + const actions = (row: ResourceReview, inline = false) => ( + + {row.allowed_actions.map((value) => ( + + ))} + + ); + return ( +
    + {!detailOnly && ( + <> +
    + filter("search", value.trim())} + className="w-full sm:!w-[594px]" + /> + filter("status", value)} + className="!w-full sm:!w-[296px]" + /> +
    + {list.isError && ( + list.refetch()}> + {t("resourceCenter.retry")} + + } + /> + )} + + className="resource-review-table resource-scroll-table" + size="middle" + scroll={{ x: 1500, y: "100%" }} + rowKey={(row) => row.review_id} + loading={list.isFetching} + dataSource={list.data?.items || []} + locale={{ + emptyText: , + }} + columns={[ + { + title: t("resourceCenter.name"), + dataIndex: "name", + width: 300, + render: (_, row) => ( +
    + + {row.version_label && ( + + {row.version_label} + + )} +
    + ), + }, + { + title: t("resourceCenter.type"), + dataIndex: "resource_type", + width: 180, + render: (value) => t(`resourceCenter.tab.${value}`), + }, + { + title: t("resourceCenter.statusLabel"), + dataIndex: "status", + width: 180, + render: (value) => ( + + {t(`resourceCenter.status.${value}`)} + + ), + }, + { + title: t("resourceCenter.submitter"), + width: 180, + ellipsis: true, + render: (_, row) => + displayName( + row.submitter.cas_username, + row.submitter.display_name || "—" + ), + }, + { + title: t("resourceCenter.submittedAt"), + dataIndex: "submitted_at", + width: 180, + render: dateText, + }, + { + title: t("resourceCenter.submission"), + dataIndex: "submit_description", + ellipsis: true, + render: (value) => value || "—", + }, + { + title: t("resourceCenter.actions"), + width: 240, + fixed: "right", + render: (_, row) => actions(row, true), + }, + ]} + pagination={{ + current: page, + pageSize: query.page_size, + total, + showSizeChanger: true, + showQuickJumper: total > query.page_size, + pageSizeOptions: [10, 20, 50, 100], + showTotal: (count) => + t("resourceCenter.totalPages", { + count: Math.ceil(count / query.page_size), + }), + onChange: (nextPage, size) => + updateUrl({ + page: String(size !== query.page_size ? 1 : nextPage), + page_size: String(size), + }), + }} + /> + + )} + updateUrl({ review_id: null, resource_type: null })} + footer={latest ? actions(latest) : null} + destroyOnHidden + > + {detail.isLoading ? ( + + ) : detail.isError ? ( + + ) : ( + latest && ( +
    + + {latest.resource_detail.readme && ( +
    + {latest.resource_detail.readme} +
    + )} + {latest.resource_detail.files_unavailable && ( + + )} + {(latest.resource_detail.file_count || 0) > + (latest.resource_detail.file_manifest?.length || 0) && ( +

    + {t("resourceCenter.filePreviewLimit", { + shown: latest.resource_detail.file_manifest?.length || 0, + total: latest.resource_detail.file_count, + })} +

    + )} + {latest.resource_detail.file_manifest?.map((file) => ( +
    + {file.path} · {file.size_bytes} B +
    + ))} + {latest.resource_detail.agents?.map((agent, index) => ( +
    +

    {agent.name}

    +

    {agent.description}

    +

    {agent.prompt}

    +

    {agent.tools?.join(", ")}

    +
    + ))} + {latest.resource_type === "mcp" && ( + <> +

    + {latest.resource_detail.transport_type} ·{" "} + {latest.resource_detail.server_url} +

    + {Object.entries( + latest.resource_detail.shared_fields || {} + ).map(([name, shared]) => ( +
    + {name}:{" "} + {t( + shared + ? "resourceCenter.sharedField" + : "resourceCenter.privateField" + )} +
    + ))} + {latest.resource_detail.sensitive_fields?.map((field) => ( +
    + {field.name}:{" "} + {t( + field.configured + ? "resourceCenter.masked" + : "resourceCenter.notConfigured" + )} +
    + ))} + + )} +
    + ) + )} +
    + + {t( + `resourceCenter.review.${action?.decision === "reject" ? "confirmReject" : "confirmApprove"}` + )} + + } + width={768} + classNames={{ + container: + "!rounded-lg !bg-white !px-6 !py-5 !shadow-[0_16px_48px_0_rgba(0,0,0,0.16)]", + body: "!my-4", + }} + centered + onCancel={() => !decision.isPending && setAction(null)} + footer={ +
    + setAction(null)} + > + {t("common.cancel")} + + 1000 + } + > + {t("common.confirm")} + +
    + } + > +

    + {t( + action?.decision === "reject" + ? "repository.review.confirmRejectContent" + : "repository.review.confirmApproveContent", + { name: action?.row.name } + )} +

    + {failed && ( + { + setAction( + (current) => current && { ...current, row: latest } + ); + setFailed(false); + }} + > + {t("resourceCenter.review.reconfirm")} + + ) : !reconciled ? ( + + ) : undefined + } + /> + )} + +
    + setOpinion(event.target.value)} + className="!h-[98px] !min-h-[98px] !rounded !border-[#c9c9c9] !bg-white !px-3 !pt-[5px] !pb-6 placeholder:!text-[#AEAEAE]" + placeholder={t("resourceCenter.review.reason")} + /> + + {Array.from(opinion.trim()).length}/1000 + +
    +
    +
    + ); +} diff --git a/frontend/components/repository/ResourceReviewLink.tsx b/frontend/components/repository/ResourceReviewLink.tsx new file mode 100644 index 0000000000..93ec98921e --- /dev/null +++ b/frontend/components/repository/ResourceReviewLink.tsx @@ -0,0 +1,33 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { useTranslation } from "react-i18next"; +import { buildResourceRepositoryHref } from "@/lib/resourceRepositoryRoutes"; + +export default function ResourceReviewLink({ + review, +}: { + review?: { + review_id: string; + status: "pending_review" | "approved" | "rejected"; + } | null; +}) { + const { locale } = useParams<{ locale: string }>(); + const { t } = useTranslation("common"); + if (!review) return null; + return ( + event.stopPropagation()} + className="pointer-events-auto relative z-10 inline-flex shrink-0 rounded px-1.5 py-0.5 text-xs text-blue-600 hover:underline" + > + {t(`resourceCenter.status.${review.status}`)} + + ); +} diff --git a/frontend/components/repository/agent/agent-space.tsx b/frontend/components/repository/agent/agent-space.tsx new file mode 100644 index 0000000000..90c60e8312 --- /dev/null +++ b/frontend/components/repository/agent/agent-space.tsx @@ -0,0 +1,650 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import RepositoryDisplayControls, { + AgentRepositorySort, +} from "@/components/repository/RepositoryDisplayControls"; +import { StandardButton } from "@/components/common/StandardButton"; +import { useRepositoryPresentation } from "@/hooks/useRepositoryPresentation"; +import { useRepositoryTagPredicates } from "@/hooks/useRepositoryTagPredicates"; + +import { + useRepositoryPageSize, + useRepositoryQueryState, +} from "@/hooks/useRepositoryQueryState"; +import ResourcePublishedAt from "@/components/repository/ResourcePublishedAt"; +import { useMemo, useRef, useState } from "react"; +import type { MenuProps } from "antd"; +import { + App, + Button, + Dropdown, + Empty, + Input, + Modal, + Select, + Space, + Spin, +} from "antd"; +import { + Bot, + ChevronDown, + Copy, + Download, + MoreHorizontal, + PackageX, + Search, + ShieldCheck, +} from "lucide-react"; +import { useUserDisplayName } from "@/hooks/auth/useUserDisplayName"; +import { useTranslation } from "react-i18next"; +import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; +import { USER_ROLES } from "@/const/auth"; +import { useTagLibraries, useTagDefinitions } from "@/hooks/useTagManagement"; +import { getTagSearchPredicates } from "@/lib/systemTagLabels"; + +import ResourceCardGrid from "@/components/resource/ResourceCardGrid"; +import { AgentCardTags } from "./components/AgentCardTags"; +import ResourceCard from "@/components/resource/ResourceCard"; +import { AgentDetail } from "@/components/agent/agent-detail"; +import TagFilterPopover from "@/components/tag/TagFilterPopover"; +import { getAgentRepositoryTagLabel } from "@/lib/agentRepositoryLabels"; +import type { TagResourcePredicate } from "@/types/tagManagement"; +import type { AgentRepositoryListingItem } from "@/types/agentRepository"; +import { AgentRepositoryCopyDialog } from "./components/AgentRepositoryCopyDialog"; +import { RepositoryAgentIcon } from "./components/RepositoryAgentIcon"; +import { + useAgentRepositoryListings, + useUpdateAgentRepositoryStatus, +} from "@/hooks/agentRepository/useAgentRepositoryListings"; +import { useRepositoryAgentDetail } from "@/hooks/agentRepository/useRepositoryAgentDetail"; + +type RepositorySortOrder = "default" | "latest"; + +export function AgentSpace({ + active, + enhanced = false, + sortByPublished = false, +}: { + active: boolean; + enhanced?: boolean; + sortByPublished?: boolean; +}) { + const { t } = useTranslation("common"); + const presentation = useRepositoryPresentation(); + const sortByDownloads = presentation?.query?.get("sort") === "downloads"; + const displayName = useUserDisplayName(); + const { message } = App.useApp(); + const { user } = useAuthorizationContext(); + const showAdminMenu = user?.role === USER_ROLES.ADMIN; + const [searchQuery, setSearchQuery] = useRepositoryQueryState("search", ""); + const composingSearch = useRef(false); + const [searchDraft, setSearchDraft] = useState(null); + const [tagPredicates, setTagPredicates] = useRepositoryTagPredicates(); + const [sortOrder, setSortOrder] = useState("default"); + const [page, setPage] = useRepositoryQueryState("page", 1); + const [pageSize, setPageSize] = useRepositoryPageSize(); + const { data: tagLibraries } = useTagLibraries(); + const defaultTagLibrary = + tagLibraries?.find( + (library) => library.bucket_key === "default_resource" + ) ?? null; + const { data: tagDefinitions } = useTagDefinitions( + defaultTagLibrary?.bucket_id ?? null + ); + const searchTagPredicates = useMemo( + () => getTagSearchPredicates(tagDefinitions, searchQuery, t), + [tagDefinitions, searchQuery, t] + ); + const listingParams = useMemo( + () => ({ + status: "shared" as const, + page, + page_size: pageSize, + ...(searchQuery.trim() ? { search: searchQuery.trim() } : {}), + ...(searchTagPredicates.length > 0 + ? { search_tag_predicates: searchTagPredicates } + : {}), + ...(tagPredicates.length > 0 ? { tag_predicates: tagPredicates } : {}), + ...(sortByDownloads ? { sort_by_downloads: true } : {}), + ...(sortOrder === "latest" || sortByPublished + ? enhanced + ? { sort_by_published_time: true } + : { sort_by_update_time: true } + : {}), + }), + [ + enhanced, + page, + pageSize, + searchQuery, + searchTagPredicates, + tagPredicates, + sortOrder, + sortByPublished, + sortByDownloads, + ] + ); + const { data, isLoading, isError, isFetching, isPlaceholderData, refetch } = + useAgentRepositoryListings(listingParams, active, Boolean(presentation)); + const updateStatusMutation = useUpdateAgentRepositoryStatus(); + const listings = data?.items ?? []; + const total = data?.pagination?.total ?? 0; + const updatingRepositoryId = updateStatusMutation.isPending + ? (updateStatusMutation.variables?.agentRepositoryId ?? null) + : null; + const onSearchChange = (value: string) => { + if (value === searchQuery) return; + setSearchQuery(value); + if (!presentation?.updateQuery) setPage(1); + }; + const onTagPredicatesChange = (value: TagResourcePredicate[]) => { + setTagPredicates(value); + setPage(1); + }; + const onSortOrderChange = (value: RepositorySortOrder) => { + setSortOrder(value); + setPage(1); + }; + const onTakeDown = (listing: AgentRepositoryListingItem) => + updateStatusMutation.mutateAsync({ + agentRepositoryId: listing.agent_repository_id, + status: "not_shared", + }); + const [copyListing, setCopyListing] = + useState(null); + const [detailListing, setDetailListing] = + useState(null); + const { + detail, + repositoryDetail, + isLoading: isDetailLoading, + isError: isDetailError, + isFetching: isDetailFetching, + retry: refetchDetail, + } = useRepositoryAgentDetail(detailListing, active); + const confirmTakeDown = (listing: AgentRepositoryListingItem) => { + const title = + listing.display_name?.trim() || + listing.name?.trim() || + t("agentRepository.card.untitled"); + + Modal.confirm({ + title: t("repository.listingStatus.confirmTakeDownTitle"), + content: t("repository.listingStatus.confirmTakeDownContent", { + name: title, + }), + okText: t("repository.listingStatus.takeDown"), + cancelText: t("common.cancel"), + okButtonProps: { danger: true }, + onOk: async () => { + try { + await onTakeDown(listing); + message.success(t("repository.mine.takeDownSuccess")); + } catch { + message.error(t("repository.mine.takeDownError")); + throw new Error("Take down failed"); + } + }, + }); + }; + + const renderListing = (listing: AgentRepositoryListingItem) => { + const title = + listing.display_name?.trim() || + listing.name?.trim() || + t("agentRepository.card.untitled"); + const author = displayName( + listing.author_cas_username, + listing.author?.trim() || "" + ); + const tags = listing.tags?.filter((tag) => tag.trim()) ?? []; + const toolCount = listing.tool_count ?? 0; + const downloads = listing.downloads ?? 0; + const isTakingDown = updatingRepositoryId === listing.agent_repository_id; + const isOfficialListing = listing.is_official === true; + const isMainAgent = listing.is_main_agent !== false; + const showBadge = + isOfficialListing || Boolean(listing.version_label) || isMainAgent; + // Official templates are managed from the super-admin resource page. + // The ordinary repository status menu must not expose take-down actions + // for official listings to tenant administrators. + const canManageListing = showAdminMenu && !listing.is_official; + const showSplitAction = + canManageListing && presentation?.layout === "cards"; + const templateButtonClassName = + "!box-border !h-[32px] !min-w-[88px] !max-w-[200px] !rounded !border-[#c9c9c9] !bg-white !px-[16px] !py-[5px] !font-['HarmonyOS_Sans_SC',sans-serif] !text-[14px] !font-normal !leading-[20px] !tracking-normal !text-[#191919]"; + const menuItems: MenuProps["items"] = canManageListing + ? [ + { + key: "takeDown", + label: t("repository.listingStatus.takeDown"), + icon: , + danger: true, + disabled: isTakingDown, + onClick: () => confirmTakeDown(listing), + }, + ] + : []; + + return ( + setDetailListing(listing)} + icon={ +
    + +
    + } + statusRow={ + presentation ? ( + + ) : undefined + } + descriptionLabel={ + presentation ? t("resourceCenter.details") : undefined + } + description={ + listing.description?.trim() || t("agentRepository.card.noDescription") + } + descriptionLines={presentation ? 1 : undefined} + badge={ + showBadge && !presentation ? ( + <> + {isOfficialListing ? ( + + + {t("agentRepository.card.official")} + + ) : null} + {isMainAgent ? ( + + + {t("agentRepository.card.mainAgent")} + + ) : null} + {listing.version_label ? ( + + + + {t("agentRepository.mine.currentVersion", { + version: listing.version_label, + })} + + + ) : null} + + ) : undefined + } + tags={ + presentation || tags.length > 0 || toolCount > 0 ? ( +
    + {presentation && ( + + {t("resourceCenter.tags")} + + )} + {presentation && tags.length === 0 && ( + — + )} + {tags.length > 0 ? ( + getAgentRepositoryTagLabel(tag, t))} + /> + ) : null} + {toolCount > 0 ? ( + + {t("agentRepository.card.toolCount", { count: toolCount })} + + ) : null} +
    + ) : undefined + } + footerLayout="inline" + meta={ + presentation ? ( +
    + + + + {t( + isMainAgent + ? "agentRepository.card.mainAgent" + : "space.detail.tabs.subAgents" + )} + + + + {t("resourceCenter.agentCardCopies", { + count: downloads, + formattedCount: downloads.toLocaleString(), + })} + + + + {t("resourceCenter.publishedVersion")} + + + {listing.version_label || "—"} + + +
    + ) : author ? ( + + {author} + + ) : undefined + } + headerActions={ +
    + {!presentation && ( + + + {downloads.toLocaleString()} + + )} + {canManageListing && presentation?.layout === "list" ? ( + + ) : canManageListing && !showSplitAction ? ( + +
    + } + footer={ + showSplitAction ? ( + + + + + ) + } + /> + ); + }; + + return ( +
    +
    +
    + { + composingSearch.current = true; + setSearchDraft(event.currentTarget.value); + }} + onCompositionEnd={(event) => { + composingSearch.current = false; + onSearchChange(event.currentTarget.value); + setSearchDraft(null); + }} + onChange={(event) => { + if (composingSearch.current) { + setSearchDraft(event.target.value); + } else { + onSearchChange(event.target.value); + } + }} + placeholder={t("agentRepository.page.searchPlaceholder")} + prefix={} + className={ + presentation + ? "h-8 gap-1 rounded border-[#C9C9C9] bg-white px-3 py-[5px] text-sm leading-[22px] [&_input::placeholder]:text-[#AEAEAE]" + : "rounded-xl" + } + allowClear + /> +
    + + {!enhanced && ( + + value={sortOrder} + onChange={onSortOrderChange} + className="w-44 shrink-0" + aria-label={t("agentRepository.sort.label")} + options={[ + { value: "default", label: t("agentRepository.sort.default") }, + { value: "latest", label: t("agentRepository.sort.latest") }, + ]} + /> + )} + {presentation && } + +
    +
    + {isLoading ? ( +
    + +
    + ) : isError ? ( +
    +

    + {t("agentRepository.page.loadError")} +

    + +
    + ) : ( + + } + renderItem={renderListing} + /> + )} +
    + setDetailListing(null)} + detail={detail} + agentIcon={ + detailListing ? ( + + ) : undefined + } + published + status={repositoryDetail?.status} + showRepositoryInfo + isLoading={isDetailLoading} + isError={isDetailError} + isFetching={isDetailFetching} + onRetry={refetchDetail} + footerActions={ + presentation && detailListing ? ( + <> + {showAdminMenu && !detailListing.is_official ? ( + confirmTakeDown(detailListing)} + loading={ + updatingRepositoryId === detailListing.agent_repository_id + } + disabled={isDetailLoading || isDetailError} + > + {t("repository.listingStatus.takeDown")} + + ) : null} + { + setCopyListing(detailListing); + setDetailListing(null); + }} + > + {t("resourceCenter.createFromTemplate")} + + + ) : undefined + } + /> + { + if (!open) setCopyListing(null); + }} + /> +
    + ); +} diff --git a/frontend/components/repository/agent/components/AgentCardTags.tsx b/frontend/components/repository/agent/components/AgentCardTags.tsx new file mode 100644 index 0000000000..020515d5bc --- /dev/null +++ b/frontend/components/repository/agent/components/AgentCardTags.tsx @@ -0,0 +1,123 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useLayoutEffect, useRef, useState } from "react"; +import { Popover } from "antd"; +import { useRepositoryPresentation } from "@/hooks/useRepositoryPresentation"; + +const legacyChipClassName = + "block max-w-[120px] shrink-0 truncate rounded-md bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-700 dark:bg-slate-800 dark:text-slate-200"; + +export function AgentCardTags({ + labels, + maxVisible, +}: { + labels: string[]; + maxVisible?: number; +}) { + const presentation = useRepositoryPresentation(); + const list = presentation?.layout === "list"; + const limit = maxVisible ?? (presentation ? (list ? 2 : 3) : labels.length); + const chipClassName = presentation + ? `block shrink-0 truncate rounded bg-[rgba(25,25,25,0.05)] px-2 font-['HarmonyOS_Sans_SC',sans-serif] text-xs font-normal leading-5 tracking-normal text-[#393939] ${list ? "max-w-16" : "max-w-24"}` + : legacyChipClassName; + const rowRef = useRef(null); + const measureRef = useRef(null); + const [visibleCount, setVisibleCount] = useState(labels.length); + const labelKey = JSON.stringify(labels); + + useLayoutEffect(() => { + const row = rowRef.current; + const measure = measureRef.current; + if (!row || !measure) return; + const update = () => { + if (row.clientWidth === 0) return; + const chips = Array.from(measure.children) as HTMLElement[]; + const widths = chips + .slice(0, -1) + .map((chip) => chip.getBoundingClientRect().width); + const gap = list ? 4 : 8; + const fullWidth = + widths.reduce((sum, width) => sum + width, 0) + + Math.max(0, widths.length - 1) * gap; + if (widths.length <= limit && fullWidth <= row.clientWidth) { + setVisibleCount(widths.length); + return; + } + const moreWidth = chips.at(-1)?.getBoundingClientRect().width ?? 0; + let usedWidth = moreWidth; + let count = 0; + for (const width of widths.slice(0, limit)) { + if (usedWidth + gap + width > row.clientWidth) break; + usedWidth += gap + width; + count += 1; + } + setVisibleCount(count); + }; + update(); + const observer = new ResizeObserver(update); + observer.observe(row); + observer.observe(measure); + return () => observer.disconnect(); + }, [labelKey, limit, list]); + + if (labels.length === 0) return null; + const count = Math.min(visibleCount, labels.length, limit); + const hiddenCount = labels.length - count; + + return ( +
    event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + > + + {labels.slice(0, count).map((label, index) => ( + + {label} + + ))} + {hiddenCount > 0 ? ( + event.stopPropagation()} + > + {labels.map((label, index) => ( + + {label} + + ))} +
    + } + > + + + ) : null} +
    + ); +} diff --git a/frontend/components/repository/agent/components/AgentRepositoryCopyDialog.tsx b/frontend/components/repository/agent/components/AgentRepositoryCopyDialog.tsx new file mode 100644 index 0000000000..b3a7a791e4 --- /dev/null +++ b/frontend/components/repository/agent/components/AgentRepositoryCopyDialog.tsx @@ -0,0 +1,743 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { App, Button, Modal, Radio, Select, Space, Spin, Tag } from "antd"; +import { + AlertCircle, + CheckCircle2, + ChevronDown, + ChevronRight, + Copy, + Cpu, + Database, + ExternalLink, + Plug, + RefreshCw, + Sparkles, + Wrench, + X, +} from "lucide-react"; +import { useParams, useRouter } from "next/navigation"; +import { useTranslation } from "react-i18next"; +import { useModelList } from "@/hooks/model/useModelList"; +import { + useImportAgentFromRepository, + useRepositoryImportPrecheck, +} from "@/hooks/agentRepository/useAgentRepositoryListings"; +import { + getRepositoryRequirementActivatePath, + getRepositoryRequirementReasonLabel, + getRepositoryRequirementTypeLabel, + getRepositoryRequirementTypeOrder, +} from "@/lib/agentRepositoryLabels"; +import log from "@/lib/logger"; +import type { + AgentRepositoryListingItem, + RepositoryImportRequirementItem, + RepositoryImportRequirementType, +} from "@/types/agentRepository"; + +const TYPE_ICON: Record = { + model: Cpu, + knowledge_base: Database, + mcp: Plug, + skill: Sparkles, + tool: Wrench, +}; + +const SYSTEM_TENANT_ID = "system"; + +interface AgentRepositoryCopyDialogProps { + listing: AgentRepositoryListingItem | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess?: () => void; +} + +function groupByType(items: RepositoryImportRequirementItem[]) { + const order = getRepositoryRequirementTypeOrder(); + return order + .map((type) => ({ + type, + items: items.filter((item) => item.type === type), + })) + .filter((group) => group.items.length > 0); +} + +export function AgentRepositoryCopyDialog({ + listing, + open, + onOpenChange, + onSuccess, +}: AgentRepositoryCopyDialogProps) { + const { t } = useTranslation("common"); + const { message } = App.useApp(); + const router = useRouter(); + const params = useParams<{ locale: string }>(); + const locale = params.locale || "zh"; + + const [warningDismissed, setWarningDismissed] = useState(false); + const [abnormalOpen, setAbnormalOpen] = useState(true); + const [availableOpen, setAvailableOpen] = useState(true); + const [skillResolutionActions, setSkillResolutionActions] = useState< + Record + >({}); + const [knowledgeResolutionActions, setKnowledgeResolutionActions] = useState< + Record + >({}); + const [selectedModelId, setSelectedModelId] = useState(); + const [selectedEmbeddingModelId, setSelectedEmbeddingModelId] = + useState(); + + const agentRepositoryId = listing?.agent_repository_id ?? null; + const listingTitle = + listing?.display_name?.trim() || + listing?.name?.trim() || + t("agentRepository.card.untitled"); + const isOfficialListing = + listing?.is_official === true || + listing?.publisher_tenant_id === SYSTEM_TENANT_ID; + + const { + data: precheck, + isLoading, + isError, + isFetching, + refetch, + } = useRepositoryImportPrecheck(agentRepositoryId, open); + + const importMutation = useImportAgentFromRepository(); + const { availableLlmModels, models: tenantModels } = useModelList({ + enabled: open, + }); + + const abnormalItems = useMemo( + () => + precheck?.items.filter( + (item) => + !item.available || + (item.type === "knowledge_base" && item.resolution_required) + ) ?? [], + [precheck] + ); + const availableItems = useMemo( + () => + precheck?.items.filter( + (item) => + item.available && + !(item.type === "knowledge_base" && item.resolution_required) + ) ?? [], + [precheck] + ); + + const skillConflictItems = useMemo( + () => + precheck?.items.filter( + (item) => + item.type === "skill" && item.reason_code === "skill_duplicate" + ) ?? [], + [precheck] + ); + const hasSkillConflicts = skillConflictItems.length > 0; + const officialKnowledgeItems = useMemo( + () => + precheck?.items.filter((item) => item.type === "knowledge_base") ?? [], + [precheck] + ); + const hasOfficialKnowledge = + isOfficialListing && officialKnowledgeItems.length > 0; + const hasExistingOfficialKnowledge = Boolean( + hasOfficialKnowledge && + officialKnowledgeItems.every((item) => item.resolution_required === true) + ); + const officialKnowledgeConflictItems = useMemo( + () => officialKnowledgeItems.filter((item) => item.resolution_required), + [officialKnowledgeItems] + ); + const hasOfficialKnowledgeToCreate = Boolean( + hasOfficialKnowledge && + (officialKnowledgeItems.some((item) => !item.resolution_required) || + officialKnowledgeConflictItems.some( + (item) => knowledgeResolutionActions[item.name] === "create_new" + )) + ); + const availableEmbeddingModels = useMemo( + () => + tenantModels.filter( + (model) => + (model.type === "embedding" || model.type === "multi_embedding") && + model.connect_status === "available" + ), + [tenantModels] + ); + const officialEmbeddingModelMissing = Boolean( + isOfficialListing && + hasOfficialKnowledge && + hasOfficialKnowledgeToCreate && + availableEmbeddingModels.length === 0 + ); + + useEffect(() => { + if (!open || !isOfficialListing) return; + // eslint-disable-next-line react-hooks/set-state-in-effect -- Preserve the existing modal reset when its external open state changes. + setSelectedModelId((current) => current ?? availableLlmModels[0]?.id); + if (hasOfficialKnowledgeToCreate) { + setSelectedEmbeddingModelId( + (current) => current ?? availableEmbeddingModels[0]?.id + ); + } else if (hasExistingOfficialKnowledge) { + setSelectedEmbeddingModelId(undefined); + } + }, [ + open, + isOfficialListing, + hasOfficialKnowledge, + hasExistingOfficialKnowledge, + hasOfficialKnowledgeToCreate, + availableLlmModels, + availableEmbeddingModels, + ]); + + const percent = precheck?.percent ?? 0; + const hasAbnormal = precheck?.has_abnormal ?? false; + + const handleOpenActivate = (type: RepositoryImportRequirementType) => { + const path = getRepositoryRequirementActivatePath(type); + if (!path) { + return; + } + router.push(`/${locale}${path}`); + }; + + const handleCopy = async () => { + if (!agentRepositoryId) { + return; + } + + const skillResolutions = hasSkillConflicts + ? skillConflictItems.map((item) => ({ + skill_name: item.name, + action: (skillResolutionActions[item.name] ?? "rename") as + "rename" | "use_existing", + ...(skillResolutionActions[item.name] !== "use_existing" + ? { new_name: item.suggested_new_name || `${item.name} 副本` } + : {}), + })) + : undefined; + const knowledgeBaseResolutions = + officialKnowledgeConflictItems.length > 0 + ? officialKnowledgeConflictItems.map((item) => ({ + knowledge_name: item.name, + action: knowledgeResolutionActions[item.name] ?? "reuse", + })) + : undefined; + + if ( + isOfficialListing && + (!selectedModelId || + (hasOfficialKnowledge && + hasOfficialKnowledgeToCreate && + !selectedEmbeddingModelId)) + ) { + message.error( + officialEmbeddingModelMissing + ? t("ui.localization.embeddingRequiredToCopy") + : t("ui.localization.chooseRequiredModels") + ); + return; + } + + try { + await importMutation.mutateAsync({ + agentRepositoryId, + skillResolutions, + modelOptions: isOfficialListing + ? { + modelIds: selectedModelId + ? { [listing.name]: selectedModelId } + : undefined, + embeddingModelIds: + hasOfficialKnowledge && + hasOfficialKnowledgeToCreate && + selectedEmbeddingModelId + ? { [listing.name]: selectedEmbeddingModelId } + : undefined, + knowledgeBaseResolutions, + } + : undefined, + }); + message.success( + t("agentRepository.copy.success", { name: listingTitle }) + ); + onOpenChange(false); + onSuccess?.(); + } catch (error) { + const err = error as Error & { + status?: number; + detail?: { type?: string; duplicate_skills?: string[] } | string; + }; + const detail = + typeof err.detail === "object" && err.detail !== null + ? err.detail + : null; + if ( + err.status === 409 && + detail?.type === "skill_duplicate" && + Array.isArray(detail.duplicate_skills) + ) { + message.error( + t("agentRepository.copy.skillDuplicate", { + names: detail.duplicate_skills.join(", "), + }) + ); + return; + } + log.error("Failed to import agent from repository:", error); + message.error(t("agentRepository.copy.failed")); + } + }; + + const handleClose = () => { + onOpenChange(false); + setWarningDismissed(false); + setAbnormalOpen(true); + setAvailableOpen(true); + setSkillResolutionActions({}); + setKnowledgeResolutionActions({}); + setSelectedModelId(undefined); + setSelectedEmbeddingModelId(undefined); + }; + + return ( + + + +
+ } + styles={{ + body: { maxHeight: "70vh", overflowY: "auto", paddingTop: 8 }, + }} + > + {isLoading ? ( +
+ +
+ ) : isError ? ( +
+

+ {t("agentRepository.copy.loadError")} +

+ +
+ ) : precheck ? ( +
+
+
+ + {t("agentRepository.copy.configList")} + + + {t("agentRepository.copy.percent", { percent })} + +
+ +
+ + {hasAbnormal && !warningDismissed ? ( +
+ +

+ {t("agentRepository.copy.warning")} +

+ +
+ ) : null} + +
+
+
+
+
+ + {t("agentRepository.copy.availableCount", { + count: availableItems.length, + })} + + + {t("agentRepository.copy.pendingCount")}{" "} + + {abnormalItems.length} + + +
+
+ + {hasSkillConflicts ? ( +
+

+ {t( + "agentRepository.copy.skillDuplicate.title", + "Skill Name Conflict Detected" + )} +

+

+ {t( + "agentRepository.copy.skillDuplicate.message", + "Choose how to handle each conflicting skill:" + )} +

+
+ {skillConflictItems.map((item) => ( +
+
+ {item.name} +
+ { + setSkillResolutionActions((prev) => ({ + ...prev, + [item.name]: event.target.value, + })); + }} + > + + + {t( + "agentRepository.copy.skillDuplicate.rename", + "Install as new skill" + )} + + {t( + "agentRepository.copy.skillDuplicate.renameTarget", + { + name: + item.suggested_new_name || + `${item.name} 副本`, + defaultValue: `New name: ${item.suggested_new_name || `${item.name} 副本`}`, + } + )} + + + + {t( + "agentRepository.copy.skillDuplicate.useExisting", + "Use existing local skill" + )} + + + +
+ ))} +
+
+ ) : null} + + {isOfficialListing && officialKnowledgeConflictItems.length > 0 ? ( +
+

+ {t("ui.localization.knowledgeConflictTitle")} +

+

+ {t("ui.localization.knowledgeConflictHelp")} +

+
+ {officialKnowledgeConflictItems.map((item) => ( +
+
+ {item.name} +
+ { + setKnowledgeResolutionActions((prev) => ({ + ...prev, + [item.name]: event.target.value, + })); + }} + > + + + {t("ui.localization.reuseKnowledge")} + + + {t("ui.localization.createKnowledge")} + + {t("ui.localization.newName")} + {item.name} {t("ui.localization.copySuffix")} + + + + +
+ ))} +
+
+ ) : null} + + {isOfficialListing ? ( +
+

+ {t("ui.localization.installationModels")} +

+
+ + ({ + value: model.id, + label: model.displayName || model.name, + }))} + /> +
+ ) : null} + {officialEmbeddingModelMissing ? ( +

+ {t("ui.localization.embeddingUnavailable")} +

+ ) : null} +
+ ) : null} + + {hasAbnormal ? ( +
+ + {abnormalOpen + ? groupByType(abnormalItems).map((group) => ( + + handleOpenActivate( + group.type as RepositoryImportRequirementType + ) + } + /> + )) + : null} +
+ ) : null} + + {availableItems.length > 0 ? ( +
+ + {availableOpen + ? groupByType(availableItems).map((group) => ( + + )) + : null} +
+ ) : null} +
+ ) : null} + + ); +} + +function RequirementTypeGroup({ + type, + items, + status, + t, + onActivate, +}: { + type: RepositoryImportRequirementType; + items: RepositoryImportRequirementItem[]; + status: "abnormal" | "available"; + t: ReturnType["t"]; + onActivate?: () => void; +}) { + const Icon = TYPE_ICON[type]; + const abnormal = status === "abnormal"; + const typeLabel = getRepositoryRequirementTypeLabel(type, t); + const activatePath = getRepositoryRequirementActivatePath(type); + const hasKnowledgeConflict = + type === "knowledge_base" && + items.some((item) => item.resolution_required === true); + const hasNameConflict = + (type === "knowledge_base" && hasKnowledgeConflict) || + (type === "skill" && + items.some((item) => item.reason_code === "skill_duplicate")); + const reasonLabel = + hasNameConflict && type === "knowledge_base" + ? t( + "agentRepository.copy.reason.kb_duplicate", + "Knowledge base name conflict" + ) + : hasNameConflict && type === "skill" + ? t( + "agentRepository.copy.reason.skill_duplicate", + "Skill name conflict" + ) + : getRepositoryRequirementReasonLabel(items[0]?.reason_code, t); + + return ( +
+
+
+ + {typeLabel} +
+ {abnormal ? ( + activatePath && !hasNameConflict ? ( + + ) : ( + + + {reasonLabel || t("agentRepository.copy.unavailable")} + + ) + ) : ( + + + {t("agentRepository.copy.activated")} + + )} +
+ +
    + {items.map((item) => ( +
  • + +
    +

    + {item.name} +

    + {item.description ? ( +

    + {item.description} +

    + ) : null} +
    +
  • + ))} +
+
+ ); +} diff --git a/frontend/components/repository/agent/components/AgentRepositoryReviewConfirmModal.tsx b/frontend/components/repository/agent/components/AgentRepositoryReviewConfirmModal.tsx new file mode 100644 index 0000000000..067496700e --- /dev/null +++ b/frontend/components/repository/agent/components/AgentRepositoryReviewConfirmModal.tsx @@ -0,0 +1,101 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useEffect, useState } from "react"; +import { Input, Modal } from "antd"; +import { useTranslation } from "react-i18next"; +import type { AgentRepositoryListingItem } from "@/types/agentRepository"; + +export type AgentRepositoryReviewAction = "approve" | "reject"; + +interface AgentRepositoryReviewConfirmModalProps { + open: boolean; + action: AgentRepositoryReviewAction | null; + listing: AgentRepositoryListingItem | null; + loading?: boolean; + onClose: () => void; + onConfirm: (content?: string) => Promise; +} + +function getListingTitle(listing: AgentRepositoryListingItem) { + return listing.display_name?.trim() || listing.name?.trim() || ""; +} + +export function AgentRepositoryReviewConfirmModal({ + open, + action, + listing, + loading = false, + onClose, + onConfirm, +}: AgentRepositoryReviewConfirmModalProps) { + const { t } = useTranslation("common"); + const [reviewOpinion, setReviewOpinion] = useState(""); + + useEffect(() => { + if (!open) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- Preserve the existing modal reset when its external open state changes. + setReviewOpinion(""); + } + }, [open]); + + if (!action || !listing) { + return null; + } + + const isApprove = action === "approve"; + const title = getListingTitle(listing) || t("agentRepository.card.untitled"); + + const handleOk = async () => { + const trimmed = reviewOpinion.trim(); + await onConfirm(trimmed || undefined); + }; + + return ( + +
+

+ {isApprove + ? t("repository.review.confirmApproveContent", { name: title }) + : t("repository.review.confirmRejectContent", { name: title })} +

+
+ + setReviewOpinion(event.target.value)} + placeholder={t("repository.review.reviewOpinionPlaceholder")} + rows={4} + disabled={loading} + /> +
+
+
+ ); +} diff --git a/frontend/components/repository/agent/components/AgentUsageGuideModal.tsx b/frontend/components/repository/agent/components/AgentUsageGuideModal.tsx new file mode 100644 index 0000000000..64c0922cc8 --- /dev/null +++ b/frontend/components/repository/agent/components/AgentUsageGuideModal.tsx @@ -0,0 +1,270 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Alert, App, Button, Modal, Spin, Tabs, Typography } from "antd"; +import { Copy, ExternalLink } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import A2AServerSettingsPanel from "@/app/agents/components/a2a/A2AServerSettingsPanel"; +import { + buildAgentShareUrl, + buildNorthboundDocsUrl, + buildNorthboundCurl, + buildNorthboundRunUrl, + buildUserApiKeyPath, + getAgentUsageGuideAccess, + getA2AGuideState, +} from "@/lib/agentUsageGuide"; +import { a2aClientService } from "@/services/a2aService"; +import { fetchPublishedAgentList } from "@/services/agentConfigService"; +import { configService } from "@/services/configService"; +import type { Agent } from "@/types/agentConfig"; +import type { MyEditableAgentItem } from "@/types/agentRepository"; + +interface AgentUsageGuideModalProps { + agent: MyEditableAgentItem | null; + locale: string; + open: boolean; + canAccessApiKeys?: boolean; + onClose: () => void; +} + +export function AgentUsageGuideModal({ + agent, + locale, + open, + canAccessApiKeys = true, + onClose, +}: AgentUsageGuideModalProps) { + const { t } = useTranslation("common"); + const { message } = App.useApp(); + const [activeTab, setActiveTab] = useState("share"); + const agentId = agent?.agent_id; + const agentName = agent?.name?.trim() || "agent"; + const { canOpen } = getAgentUsageGuideAccess({ + currentVersionNo: agent?.current_version_no, + }); + + useEffect(() => { + if (open) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- Preserve the existing modal reset when its external open state changes. + setActiveTab("share"); + } + }, [agentId, open]); + + const frontendConfigQuery = useQuery({ + queryKey: ["frontend-config"], + queryFn: () => configService.fetchRuntimeFrontendConfig(), + enabled: open && activeTab === "northbound", + }); + const a2aQuery = useQuery({ + queryKey: ["a2aServerSettings", agentId], + queryFn: () => a2aClientService.getServerSettings(agentId!), + enabled: open && activeTab === "a2a" && agentId != null, + }); + const publishedAgentsQuery = useQuery({ + queryKey: ["publishedAgentsList"], + queryFn: fetchPublishedAgentList, + enabled: open && activeTab === "northbound" && agentId != null, + }); + const publishedAgent = publishedAgentsQuery.data?.data?.find( + (candidate: Agent) => candidate.id === String(agentId) + ); + + const shareUrl = useMemo(() => { + if (!open || !canOpen || agentId == null || typeof window === "undefined") { + return ""; + } + return buildAgentShareUrl(window.location.origin, locale, agentId); + }, [agentId, canOpen, locale, open]); + const northboundUrl = buildNorthboundRunUrl( + frontendConfigQuery.data?.northboundBaseUrl, + typeof window === "undefined" ? undefined : window.location.origin + ); + const northboundCurl = buildNorthboundCurl( + publishedAgent?.name?.trim() || agentName, + northboundUrl + ); + const a2aGuideState = getA2AGuideState({ + isLoading: a2aQuery.isLoading, + isError: a2aQuery.isError, + isEnabled: Boolean( + a2aQuery.data?.success && a2aQuery.data.data?.is_enabled + ), + }); + const copy = async (value: string) => { + try { + await navigator.clipboard.writeText(value); + message.success(t("common.copied")); + } catch { + message.error(t("agentUsageGuide.copyFailed")); + } + }; + + return ( + +
+ + +
+ + {shareUrl} + +
+ + +
+
+
+ ) : ( + + ), + }, + { + key: "northbound", + label: t("agentUsageGuide.tabs.northbound"), + children: ( +
+
    +
  1. + {canAccessApiKeys ? ( + + {t("agentUsageGuide.northbound.stepKey")} + + ) : ( + t("agentUsageGuide.northbound.stepKey") + )} +
  2. +
  3. {t("agentUsageGuide.northbound.stepCall")}
  4. +
  5. {t("agentUsageGuide.northbound.stepResult")}
  6. +
+ {frontendConfigQuery.isLoading ? ( + + ) : frontendConfigQuery.isError ? ( + + ) : ( +
+ +
+                        {northboundCurl}
+                      
+
+ )} + + +
+ ), + }, + { + key: "a2a", + label: t("agentUsageGuide.tabs.a2a"), + children: + a2aGuideState === "loading" ? ( + + ) : a2aGuideState === "error" ? ( + + ) : a2aGuideState === "enabled" ? ( + + ) : ( + + ), + }, + ]} + /> +
+ + ); +} diff --git a/frontend/components/repository/agent/components/CreateNewAgentCard.tsx b/frontend/components/repository/agent/components/CreateNewAgentCard.tsx new file mode 100644 index 0000000000..ffc7b0ebe5 --- /dev/null +++ b/frontend/components/repository/agent/components/CreateNewAgentCard.tsx @@ -0,0 +1,21 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useTranslation } from "react-i18next"; + +import CreateResourceCard from "@/components/resource/CreateResourceCard"; + +interface CreateNewAgentCardProps { + onClick: () => void; +} + +export function CreateNewAgentCard({ onClick }: CreateNewAgentCardProps) { + const { t } = useTranslation("common"); + return ( + + ); +} diff --git a/frontend/components/repository/agent/components/MineApplyListingModal.tsx b/frontend/components/repository/agent/components/MineApplyListingModal.tsx new file mode 100644 index 0000000000..80625c9234 --- /dev/null +++ b/frontend/components/repository/agent/components/MineApplyListingModal.tsx @@ -0,0 +1,448 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { createElement, useEffect, useMemo, useState } from "react"; +import { + App, + Avatar, + Button, + Input, + Modal, + Spin, + Upload as AntdUpload, +} from "antd"; +import type { UploadProps } from "antd"; +import { Share2, Upload as UploadIcon } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { API_ENDPOINTS } from "@/services/api"; +import { fetchWithAuth } from "@/lib/auth"; +import { useAgentRepositoryListings } from "@/hooks/agentRepository/useAgentRepositoryListings"; +import { + getAgentRepositoryTagLabel, + resolveAgentRepositoryTagForSubmit, +} from "@/lib/agentRepositoryLabels"; +import { getAgentIcon } from "@/lib/chat/agentIconUtils"; +import { withBasePath } from "@/lib/basePath"; +import { + useTagAssignments, + useTagDefinitions, + useTagLibraries, +} from "@/hooks/useTagManagement"; +import ResourceTagAssignmentModal from "@/components/tag/ResourceTagAssignmentModal"; +import { + buildApplyListingFormPrefill, + getListingTagsFromAssignments, + pickApplyListingPrefillSource, +} from "@/lib/agentRepositoryMine"; +import type { + AgentRepositoryListingCreatePayload, + MyEditableAgentItem, +} from "@/types/agentRepository"; +import type { TagAssignmentValue } from "@/types/tagManagement"; + +const MAX_TAGS = 5; +const MAX_TAG_LENGTH = 20; + +interface MineApplyListingModalProps { + open: boolean; + agent: MyEditableAgentItem | null; + isSubmitting?: boolean; + onClose: () => void; + onSubmit: (payload: AgentRepositoryListingCreatePayload) => Promise; +} + +export function MineApplyListingModal({ + open, + agent, + isSubmitting = false, + onClose, + onSubmit, +}: MineApplyListingModalProps) { + const { t } = useTranslation("common"); + const { message } = App.useApp(); + + const { data: tagLibraries } = useTagLibraries(); + const defaultResourceLibrary = useMemo( + () => + (tagLibraries ?? []).find( + (library) => library.bucket_key === "default_resource" + ) ?? null, + [tagLibraries] + ); + const { data: tagDefinitions } = useTagDefinitions( + defaultResourceLibrary?.bucket_id ?? null + ); + const agentCategory = useMemo( + () => + (tagDefinitions ?? []).find( + (definition) => definition.definition_key === "agent_category" + ) ?? null, + [tagDefinitions] + ); + const categoryValues = agentCategory?.values ?? []; + + const [iconUrl, setIconUrl] = useState(null); + const [iconPreviewUrl, setIconPreviewUrl] = useState(null); + const [iconLoadError, setIconLoadError] = useState(false); + const [selectedIconFile, setSelectedIconFile] = useState(null); + const [uploadingIcon, setUploadingIcon] = useState(false); + const [listingContent, setListingContent] = useState(""); + const [formInitialized, setFormInitialized] = useState(false); + const [tagEditorOpen, setTagEditorOpen] = useState(false); + const [savedAssignments, setSavedAssignments] = useState< + TagAssignmentValue[] | null + >(null); + + const agentId = agent?.agent_id; + const agentTagAssignments = useTagAssignments( + "agent", + agentId == null ? null : String(agentId) + ); + const { + data: listingsData, + isSuccess: isListingsSuccess, + isFetching: isListingsFetching, + } = useAgentRepositoryListings( + agentId != null + ? { agent_id: agentId, page: 1, page_size: 100 } + : undefined, + open && agentId != null + ); + + const assignmentValues = + savedAssignments ?? agentTagAssignments.data?.assignments ?? []; + + const categoryAssignmentValueIds = useMemo( + () => + new Set( + assignmentValues + .filter( + (assignment) => + assignment.definition_id === agentCategory?.definition_id + ) + .map((assignment) => assignment.value_id) + ), + [agentCategory?.definition_id, assignmentValues] + ); + + const selectedListingTags = useMemo( + () => getListingTagsFromAssignments(assignmentValues, agentCategory), + [assignmentValues, agentCategory] + ); + + const legacyCategorySelection = useMemo(() => { + if (!agentCategory || categoryAssignmentValueIds.size > 0) return {}; + const source = pickApplyListingPrefillSource( + listingsData?.items ?? [], + agent?.version_label + ); + const prefill = buildApplyListingFormPrefill(source, { maxTags: MAX_TAGS }); + if (!prefill) return {}; + const legacyTags = new Set( + prefill.tags.map((tag) => tag.trim().toLocaleLowerCase()) + ); + const valueIds = categoryValues + .filter((value) => + [ + value.normalized_value, + value.display_value, + getAgentRepositoryTagLabel(value.normalized_value, t), + ].some((candidate) => + legacyTags.has(candidate.trim().toLocaleLowerCase()) + ) + ) + .map((value) => value.value_id); + return valueIds.length > 0 + ? { [agentCategory.definition_id]: valueIds } + : {}; + }, [ + agent?.version_label, + agentCategory, + categoryAssignmentValueIds.size, + categoryValues, + listingsData?.items, + t, + ]); + + useEffect(() => { + if (!open) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- Preserve the existing modal reset when its external open state changes. + setFormInitialized(false); + setTagEditorOpen(false); + setSavedAssignments(null); + setIconPreviewUrl(null); + setIconLoadError(false); + setSelectedIconFile(null); + return; + } + + if (!agent || !isListingsSuccess || formInitialized) { + return; + } + + const source = pickApplyListingPrefillSource( + listingsData?.items ?? [], + agent.version_label + ); + const prefill = buildApplyListingFormPrefill(source, { + maxTags: MAX_TAGS, + }); + + if (!prefill) { + setIconUrl(null); + setIconPreviewUrl(null); + setIconLoadError(false); + setSelectedIconFile(null); + setListingContent(""); + setFormInitialized(true); + return; + } + + setIconUrl(prefill.icon_url); + setIconPreviewUrl(null); + setIconLoadError(false); + setSelectedIconFile(null); + + setListingContent(""); + setFormInitialized(true); + }, [open, agent, isListingsSuccess, listingsData, formInitialized]); + + const title = agent?.name?.trim() || t("agentRepository.card.untitled"); + + useEffect(() => { + return () => { + if (iconPreviewUrl) URL.revokeObjectURL(iconPreviewUrl); + }; + }, [iconPreviewUrl]); + + const handleIconUpload = (file: File) => { + if (file.size > 2 * 1024 * 1024) { + message.error(t("agentRepository.mine.applyModal.iconTooLarge")); + return false; + } + setSelectedIconFile(file); + setIconPreviewUrl(URL.createObjectURL(file)); + setIconLoadError(false); + return false; + }; + + const uploadProps: UploadProps = { + accept: "image/png,image/jpeg,image/gif,image/webp", + showUploadList: false, + disabled: uploadingIcon || isSubmitting, + beforeUpload: handleIconUpload, + }; + const defaultIcon = createElement(getAgentIcon({ agent_id: agentId ?? 0 }), { + size: 28, + }); + const selectedIconSource = iconPreviewUrl ?? iconUrl; + const iconSource = + selectedIconSource && !iconLoadError + ? withBasePath(selectedIconSource) + : undefined; + + const handleSubmit = async () => { + if (uploadingIcon) { + return; + } + + if (selectedListingTags.length > MAX_TAGS) { + message.warning( + t("agentRepository.mine.applyModal.validation.tagsMax", { + count: MAX_TAGS, + }) + ); + return; + } + const tags = selectedListingTags.map((tag) => + resolveAgentRepositoryTagForSubmit(tag, t) + ); + if (tags.some((tag) => tag.length > MAX_TAG_LENGTH)) { + message.warning( + t("agentRepository.mine.applyModal.validation.tagLength", { + count: MAX_TAG_LENGTH, + }) + ); + return; + } + + try { + let submittedIconUrl = iconUrl; + if (selectedIconFile && agentId != null && agent) { + setUploadingIcon(true); + try { + const formData = new FormData(); + formData.append("file", selectedIconFile); + const response = await fetchWithAuth( + API_ENDPOINTS.agentRepository.icon( + agentId, + agent.current_version_no ?? 0 + ), + { method: "POST", body: formData } + ); + if (!response.ok) throw new Error("Icon upload failed"); + const data = (await response.json()) as { icon_url: string }; + submittedIconUrl = data.icon_url; + setIconUrl(submittedIconUrl); + setSelectedIconFile(null); + } catch { + message.error(t("agentRepository.mine.applyModal.iconUploadFailed")); + return; + } + } + await onSubmit({ + icon_url: submittedIconUrl, + tags, + content: listingContent.trim(), + }); + } catch (error) { + message.error(error instanceof Error ? error.message : String(error)); + } finally { + setUploadingIcon(false); + } + }; + + return ( + <> + + + {t("agentRepository.mine.applyModal.title")} + + } + footer={ +
+ + +
+ } + > +

+ {t("agentRepository.mine.applyModal.agentName", { name: title })} +

+ + +
+
+
+ {t("agent.icon")} +
+
+ +
{ + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + event.currentTarget.click(); + } + }} + > + { + setIconLoadError(true); + return false; + }} + className={`border-2 border-dashed border-gray-300 ${iconSource ? "" : "!bg-primary/10 !text-primary"}`} + /> +
+ {uploadingIcon ? ( + + ) : ( + + )} +
+
+
+
+ {t("agent.iconHint")} +
+ {(iconUrl || selectedIconFile) && ( + + )} +
+
+ +
+

+ {t("agentRepository.mine.applyModal.tags")} +

+
+ + {selectedListingTags.length > 0 ? ( + + {selectedListingTags + .map((tag) => getAgentRepositoryTagLabel(tag, t)) + .join(" · ")} + + ) : null} +
+

+ {t("agentRepository.mine.applyModal.tagsHint", { + count: MAX_TAGS, + })} +

+
+ +
+

+ {t("repository.mine.applyModal.content")} +

+ setListingContent(event.target.value)} + rows={4} + placeholder={t("repository.mine.applyModal.contentPlaceholder")} + /> +
+
+
+
+ setTagEditorOpen(false)} + resourceType="agent" + resourceId={String(agentId ?? "")} + definitions={tagDefinitions ?? []} + canEdit={agent?.permission !== "READ_ONLY"} + initialSelection={legacyCategorySelection} + onSaved={(assignment) => setSavedAssignments(assignment.assignments)} + /> + + ); +} diff --git a/frontend/components/repository/agent/components/MineReviewStatusModal.tsx b/frontend/components/repository/agent/components/MineReviewStatusModal.tsx new file mode 100644 index 0000000000..2725da718d --- /dev/null +++ b/frontend/components/repository/agent/components/MineReviewStatusModal.tsx @@ -0,0 +1,209 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { Button, Modal } from "antd"; +import { CheckCircle2, Clock, PackageX, Store, XCircle } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { + formatMineDate, + formatRepositoryVersionLabel, + isCancelableRepositoryStatus, + isTakeDownableRepositoryStatus, +} from "@/lib/agentRepositoryMine"; +import type { + MyAgentRepositoryInfoItem, + MyEditableAgentItem, +} from "@/types/agentRepository"; + +interface MineReviewStatusModalProps { + open: boolean; + agent: MyEditableAgentItem | null; + repositoryInfo: MyAgentRepositoryInfoItem | null; + mode: "review" | "reviewUpdate"; + isUpdatingStatus?: boolean; + onClose: () => void; + onSetNotShared: () => Promise; +} + +export function MineReviewStatusModal({ + open, + agent, + repositoryInfo, + mode, + isUpdatingStatus = false, + onClose, + onSetNotShared, +}: MineReviewStatusModalProps) { + const { t } = useTranslation("common"); + + if (!agent || !repositoryInfo) { + return null; + } + + const title = agent.name?.trim() || t("agentRepository.card.untitled"); + const isPending = repositoryInfo.status === "pending_review"; + const isRejected = repositoryInfo.status === "rejected"; + const canCancelApply = isCancelableRepositoryStatus(repositoryInfo.status); + const canTakeDown = isTakeDownableRepositoryStatus(repositoryInfo.status); + const versionLabel = formatRepositoryVersionLabel(repositoryInfo); + const submittedAt = formatMineDate(repositoryInfo.create_time); + const listingContent = repositoryInfo.content?.trim() ?? ""; + + const statusConfig = isPending + ? { + icon: Clock, + label: t("repository.listingStatus.pendingLabel"), + description: t("repository.listingStatus.pendingDescription"), + tone: "border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200", + iconClass: "text-amber-600 dark:text-amber-300", + } + : isRejected + ? { + icon: XCircle, + label: t("repository.listingStatus.rejectedLabel"), + description: t("repository.listingStatus.rejectedDescription"), + tone: "border-red-200 bg-red-50 text-red-800 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200", + iconClass: "text-red-600 dark:text-red-300", + } + : { + icon: CheckCircle2, + label: t("repository.listingStatus.listedLabel"), + description: t("repository.listingStatus.listedDescription"), + tone: "border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-200", + iconClass: "text-emerald-600 dark:text-emerald-300", + }; + + const StatusIcon = statusConfig.icon; + const modalTitle = + mode === "reviewUpdate" + ? t("agentRepository.mine.reviewModal.reviewUpdateTitle") + : t("repository.listingStatus.title"); + + const confirmCancelApply = () => { + Modal.confirm({ + title: t("repository.listingStatus.confirmCancelApplyTitle"), + content: t("repository.listingStatus.confirmCancelApplyContent", { + name: title, + }), + okText: t("repository.listingStatus.cancelApply"), + cancelText: t("common.cancel"), + okButtonProps: { danger: true }, + onOk: async () => { + try { + await onSetNotShared(); + } catch { + throw new Error("Cancel listing request failed"); + } + }, + }); + }; + + const confirmTakeDown = () => { + Modal.confirm({ + title: t("repository.listingStatus.confirmTakeDownTitle"), + content: t("repository.listingStatus.confirmTakeDownContent", { + name: title, + }), + okText: t("repository.listingStatus.takeDown"), + cancelText: t("common.cancel"), + okButtonProps: { danger: true }, + onOk: async () => { + try { + await onSetNotShared(); + } catch { + throw new Error("Take down failed"); + } + }, + }); + }; + + return ( + + + {canCancelApply ? ( + + ) : null} + {canTakeDown ? ( + + ) : null} +
+ } + title={ + + + {modalTitle} + + } + centered + destroyOnHidden + > +

+ {t("agentRepository.mine.reviewModal.agentName", { name: title })} +

+ +
+ +
+

{statusConfig.label}

+

+ {statusConfig.description} +

+ {listingContent ? ( +

+ {isPending + ? t("repository.listingStatus.pendingNote", { + content: listingContent, + }) + : t("repository.listingStatus.reviewOpinion", { + content: listingContent, + })} +

+ ) : null} +
+
+ +
+
+ {t("agentRepository.mine.reviewModal.version")} + + {versionLabel} + +
+ {submittedAt ? ( +
+ {t("repository.listingStatus.submittedAt")} + + {submittedAt} + +
+ ) : null} +
+ + ); +} diff --git a/frontend/components/repository/agent/components/MyAgentCard.tsx b/frontend/components/repository/agent/components/MyAgentCard.tsx new file mode 100644 index 0000000000..eb6f53dee4 --- /dev/null +++ b/frontend/components/repository/agent/components/MyAgentCard.tsx @@ -0,0 +1,335 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import ResourceReviewLink from "@/components/repository/ResourceReviewLink"; +import { useRepositoryPresentation } from "@/hooks/useRepositoryPresentation"; +import { Button, Dropdown, Tooltip } from "antd"; +import type { MenuProps } from "antd"; +import { + Bot, + ClipboardCheck, + Clock, + Eye, + LineChart, + MoreHorizontal, + Pencil, + Share2, + Trash2, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { getAgentUsageGuideAccess } from "@/lib/agentUsageGuide"; +import { getAgentRepositoryTagLabel } from "@/lib/agentRepositoryLabels"; +import { getUnavailableReasonLabels } from "@/lib/agentLabelMapper"; +import { + formatMineDate, + getMineCardRepositoryStatusBadge, + type MineCardMenuAction, +} from "@/lib/agentRepositoryMine"; +import type { MyEditableAgentItem } from "@/types/agentRepository"; +import ResourceCard from "@/components/resource/ResourceCard"; +import { MyAgentIcon } from "./MyAgentIcon"; +import { AgentCardTags } from "./AgentCardTags"; + +interface MyAgentCardProps { + agent: MyEditableAgentItem; + onEdit: () => void; + onView: () => void; + onApplyListing: () => void; + onViewReview: ( + agent: MyEditableAgentItem, + mode: "review" | "reviewUpdate" + ) => void; + onDelete: () => void; + onEvaluate: () => void; + onUsageGuide: () => void; + highlighted?: boolean; + guideMenuOpen?: boolean; + onGuideMenuOpenChange?: (open: boolean) => void; + isApplying?: boolean; + isDeleting?: boolean; +} + +const MENU_ACTION_I18N: Record = { + apply: "agentRepository.mine.menu.apply", + review: "agentRepository.mine.menu.review", + reviewUpdate: "agentRepository.mine.menu.reviewUpdate", +}; + +export function MyAgentCard({ + agent, + onEdit, + onView, + onApplyListing, + onViewReview, + onDelete, + onEvaluate, + onUsageGuide, + highlighted = false, + guideMenuOpen, + onGuideMenuOpenChange, + isApplying = false, + isDeleting = false, +}: MyAgentCardProps) { + const { t } = useTranslation("common"); + const presentation = useRepositoryPresentation(); + + const title = agent.name?.trim() || t("agentRepository.card.untitled"); + const description = + agent.description?.trim() || t("agentRepository.card.noDescription"); + const tags = agent.tags?.filter((tag) => tag.trim()) ?? []; + const unavailableReasonLabels = getUnavailableReasonLabels( + agent.unavailable_reasons ?? [], + t + ); + const { canOpen: published } = getAgentUsageGuideAccess({ + currentVersionNo: agent.current_version_no, + permission: agent.permission, + }); + const footerDate = formatMineDate(agent.version_create_time); + const versionLabel = agent.version_label; + const isMainAgent = agent.is_main_agent !== false; + const canEdit = agent.permission !== "READ_ONLY"; + const canView = (agent.current_version_no ?? 0) > 0; + const canEvaluate = canView; + const menuActions: Array<"apply" | "review" | "reviewUpdate"> = + canEdit && canView && agent.latest_review?.status !== "pending_review" + ? ["apply"] + : []; + const repositoryBadge = getMineCardRepositoryStatusBadge( + agent.repository_info.filter((item) => item.status === "shared") + ); + + const menuItems: MenuProps["items"] = menuActions.map((action) => { + const icon = + action === "apply" ? ( + + ) : ( + + ); + + return { + key: action, + label: t(MENU_ACTION_I18N[action]), + icon, + disabled: + action === "apply" && + (isApplying || agent.latest_review?.status === "pending_review"), + onClick: () => { + if (action === "apply") { + onApplyListing(); + return; + } + onViewReview( + agent, + action === "reviewUpdate" ? "reviewUpdate" : "review" + ); + }, + }; + }); + + if (canEvaluate) { + menuItems.push({ + key: "evaluate", + icon: , + label: t("agentRepository.mine.evaluate"), + onClick: onEvaluate, + }); + } + + if (menuItems.length > 0 && canEdit) { + menuItems.push({ type: "divider" }); + } + + if (published) { + menuItems.push({ + key: "usageGuide", + icon: , + label: t("agentRepository.mine.menu.usageGuide"), + className: guideMenuOpen ? "font-semibold" : undefined, + onClick: onUsageGuide, + }); + } + + if (canEdit) { + menuItems.push({ + key: "delete", + danger: true, + icon: , + label: t("common.delete"), + disabled: isDeleting, + onClick: onDelete, + }); + } + + return ( + + {isMainAgent ? ( + + + {t("agentRepository.card.mainAgent")} + + ) : null} + {versionLabel != null ? ( + + + + {t("agentRepository.mine.currentVersion", { + version: versionLabel, + })} + + + ) : null} + + ) : undefined + } + icon={} + description={description} + descriptionLines={2} + tags={ + tags.length > 0 ? ( + getAgentRepositoryTagLabel(tag, t))} + /> + ) : undefined + } + statusRow={ +
+ + {published + ? t("agentRepository.mine.lifecycle.published") + : t("agentRepository.mine.lifecycle.draft")} + + +
+ {repositoryBadge ? ( + + {t(repositoryBadge.labelKey)} + {repositoryBadge.versionLabel + ? ` · ${repositoryBadge.versionLabel}` + : null} + + ) : null} +
+
+ } + headerActions={ + menuItems.length > 0 || agent.is_available === false ? ( +
+
+ {menuItems.length > 0 ? ( + +
+ {agent.is_available === false ? ( +
+ 0 + ? unavailableReasonLabels.join(", ") + : t("agentSelector.agentUnavailable") + } + > + + {t("mcpConfig.status.unavailable")} + + +
+ ) : null} +
+ ) : undefined + } + fixedHeaderLayout + footerLayout="inline" + meta={ + footerDate ? ( + + + {footerDate} + + ) : undefined + } + footer={ + canEdit ? ( + + ) : ( + + ) + } + /> + ); +} diff --git a/frontend/components/repository/agent/components/MyAgentIcon.tsx b/frontend/components/repository/agent/components/MyAgentIcon.tsx new file mode 100644 index 0000000000..cb800cbe3a --- /dev/null +++ b/frontend/components/repository/agent/components/MyAgentIcon.tsx @@ -0,0 +1,47 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { Avatar } from "antd"; +import { createElement } from "react"; +import { + getAgentIcon, + getAgentUploadedIconId, + getAgentUploadedIconRevision, +} from "@/lib/chat/agentIconUtils"; +import { API_ENDPOINTS } from "@/services/api"; +import type { MyEditableAgentItem } from "@/types/agentRepository"; + +interface MyAgentIconProps { + agent: Pick; + size: number; + iconSize: number; +} + +export function MyAgentIcon({ agent, size, iconSize }: MyAgentIconProps) { + const fallbackIcon = createElement( + getAgentIcon({ agent_id: agent.agent_id }), + { + size: iconSize, + "aria-hidden": true, + } + ); + const uploadedIconId = getAgentUploadedIconId(agent); + + return ( + + ); +} diff --git a/frontend/components/repository/agent/components/RepositoryAgentIcon.tsx b/frontend/components/repository/agent/components/RepositoryAgentIcon.tsx new file mode 100644 index 0000000000..f71c50f269 --- /dev/null +++ b/frontend/components/repository/agent/components/RepositoryAgentIcon.tsx @@ -0,0 +1,37 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { Avatar } from "antd"; +import { createElement } from "react"; +import { getAgentIcon } from "@/lib/chat/agentIconUtils"; +import { withBasePath } from "@/lib/basePath"; + +interface RepositoryAgentIconProps { + agentId?: number | null; + iconUrl?: string | null; + size: number; + iconSize: number; + className?: string; +} + +export function RepositoryAgentIcon({ + agentId, + iconUrl, + size, + iconSize, + className, +}: RepositoryAgentIconProps) { + const DefaultIcon = getAgentIcon({ agent_id: agentId ?? 0 }); + return ( + + ); +} diff --git a/frontend/components/repository/agent/components/ReviewAgentList.tsx b/frontend/components/repository/agent/components/ReviewAgentList.tsx new file mode 100644 index 0000000000..08fba3d2c3 --- /dev/null +++ b/frontend/components/repository/agent/components/ReviewAgentList.tsx @@ -0,0 +1,159 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { Button } from "antd"; +import { Check, Eye, X } from "lucide-react"; +import { useUserDisplayName } from "@/hooks/auth/useUserDisplayName"; +import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; +import type { AgentRepositoryListingItem } from "@/types/agentRepository"; +import { RepositoryAgentIcon } from "./RepositoryAgentIcon"; + +const GRID_COLS = "grid-cols-[minmax(0,2fr)_120px_160px_minmax(0,1.5fr)_280px]"; + +interface ReviewAgentListProps { + listings: AgentRepositoryListingItem[]; + currentUserEmail?: string | null; + updatingRepositoryId: number | null; + onDetailClick: (listing: AgentRepositoryListingItem) => void; + onApprove: (listing: AgentRepositoryListingItem) => void; + onReject: (listing: AgentRepositoryListingItem) => void; +} + +function getListingTitle(listing: AgentRepositoryListingItem, t: TFunction) { + return ( + listing.display_name?.trim() || + listing.name?.trim() || + t("agentRepository.card.untitled") + ); +} + +function getSubmitterDisplay( + submittedBy: string | null | undefined, + currentUserEmail: string | null | undefined, + t: TFunction +) { + const trimmed = submittedBy?.trim(); + if (!trimmed) { + return t("repository.review.unknownSubmitter"); + } + if ( + currentUserEmail && + trimmed.toLowerCase() === currentUserEmail.toLowerCase() + ) { + return t("repository.review.me"); + } + return trimmed; +} + +export function ReviewAgentList({ + listings, + currentUserEmail, + updatingRepositoryId, + onDetailClick, + onApprove, + onReject, +}: ReviewAgentListProps) { + const { t } = useTranslation("common"); + const displayName = useUserDisplayName(); + + return ( +
+
+
+ {t("repository.review.column.name")} + {t("repository.review.column.version")} + {t("repository.review.column.submitter")} + {t("repository.review.column.listingNote")} + {t("repository.review.column.actions")} +
+ +
    + {listings.map((listing) => { + const title = getListingTitle(listing, t); + const isUpdating = + updatingRepositoryId === listing.agent_repository_id; + const versionLabel = + listing.version_label?.trim() || t("repository.review.noVersion"); + const submitter = displayName( + listing.submitted_by_cas_username, + getSubmitterDisplay(listing.submitted_by, currentUserEmail, t) + ); + const listingNote = listing.content?.trim() || "—"; + + return ( +
  • +
    +
    + +
    +

    + {title} +

    +
    + +
    + {versionLabel} +
    + +
    + {submitter} +
    + +
    + {listingNote} +
    + +
    + + + +
    +
  • + ); + })} +
+
+
+ ); +} diff --git a/frontend/components/repository/agent/my-agent.tsx b/frontend/components/repository/agent/my-agent.tsx new file mode 100644 index 0000000000..f52b37645d --- /dev/null +++ b/frontend/components/repository/agent/my-agent.tsx @@ -0,0 +1,816 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import RepositoryDisplayControls from "@/components/repository/RepositoryDisplayControls"; +import { useRepositoryTagPredicates } from "@/hooks/useRepositoryTagPredicates"; + +import { useRepositoryQueryState } from "@/hooks/useRepositoryQueryState"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { App, Button, Empty, Input, Select, Spin } from "antd"; +import { Search } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import CreateAgentModal, { + type CreatedAgentResult, +} from "@/components/agent/CreateAgentModal"; +import { useConfirmModal } from "@/hooks/useConfirmModal"; +import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; +import { useAgentList } from "@/hooks/agent/useAgentList"; +import { useToolList } from "@/hooks/agent/useToolList"; +import { useSkillList } from "@/hooks/agent/useSkillList"; +import type { Agent } from "@/types/agentConfig"; +import type { AgentListFilters } from "@/services/agentConfigService"; +import { deleteAgent } from "@/services/agentConfigService"; +import { + AGENTS_LIST_QUERY_KEY, + invalidateAgentRepositoryCaches, + useCreateAgentRepositoryListing, + useMyEditableAgents, + useAgentRepositoryListings, + useUpdateAgentRepositoryStatus, +} from "@/hooks/agentRepository/useAgentRepositoryListings"; +import { useTagDefinitions, useTagLibraries } from "@/hooks/useTagManagement"; +import { getTagSearchPredicates } from "@/lib/systemTagLabels"; +import { parseReviewDeepLinkParams } from "@/lib/notificationNavigation"; +import { parseAgentUsageGuideParams } from "@/lib/agentUsageGuide"; +import log from "@/lib/logger"; +import { + getAgentUsageGuideOpenAction, + parseAgentUsageGuideTargetParams, + resolveAgentUsageGuideTarget, +} from "@/lib/agentUsageGuide"; +import { + isCancelableRepositoryStatus, + isTakeDownableRepositoryStatus, + findRepositoryInfoById, + pickReviewDisplayRepositoryInfo, + resolveReviewModalMode, + toMineRepositoryInfo, +} from "@/lib/agentRepositoryMine"; +import { + isNewAgentPaddingItem, + type AgentRepositoryListingCreatePayload, + type MyAgentRepositoryInfoItem, + type MyEditableAgentItem, +} from "@/types/agentRepository"; +import { MineApplyListingModal } from "./components/MineApplyListingModal"; +import { MineReviewStatusModal } from "./components/MineReviewStatusModal"; +import { AgentUsageGuideModal } from "./components/AgentUsageGuideModal"; +import { CreateNewAgentCard } from "./components/CreateNewAgentCard"; +import { MyAgentCard } from "./components/MyAgentCard"; +import { REPOSITORY_PAGE_SIZE } from "@/const/resource"; +import ResourceCardGrid from "@/components/resource/ResourceCardGrid"; +import TagFilterPopover from "@/components/tag/TagFilterPopover"; +import type { TagResourcePredicate } from "@/types/tagManagement"; +import { useAgentVersionDetail } from "@/hooks/agent/useAgentVersionDetail"; +import { mapMyAgentDetail } from "@/lib/myAgentDetail"; +import { AgentDetail } from "@/components/agent/agent-detail"; +import { MyAgentIcon } from "./components/MyAgentIcon"; + +type MineSortOrder = "default" | "latest"; + +export function MyAgent({ + active, + onTotalChange, +}: { + active: boolean; + onTotalChange?: (total: number) => void; +}) { + const { t } = useTranslation("common"); + const { message } = App.useApp(); + const { confirm } = useConfirmModal(); + const { user } = useAuthorizationContext(); + const router = useRouter(); + const searchParams = useSearchParams(); + const queryClient = useQueryClient(); + const params = useParams<{ locale: string }>(); + const locale = params.locale || "en"; + const [searchQuery, setSearchQuery] = useRepositoryQueryState("search", ""); + const [sortOrder, setSortOrder] = useState("default"); + const [tagPredicates, setTagPredicates] = useRepositoryTagPredicates(); + const [page, setPage] = useRepositoryQueryState("page", 1); + const { data: tagLibraries } = useTagLibraries(); + const defaultTagLibrary = + tagLibraries?.find( + (library) => library.bucket_key === "default_resource" + ) ?? null; + const { data: tagDefinitions } = useTagDefinitions( + defaultTagLibrary?.bucket_id ?? null + ); + const searchTagPredicates = useMemo( + () => getTagSearchPredicates(tagDefinitions, searchQuery, t), + [tagDefinitions, searchQuery, t] + ); + const pageSize = REPOSITORY_PAGE_SIZE - 1; + const listParams = useMemo( + (): AgentListFilters => ({ + tenantId: user?.tenantId ?? null, + enabled: active, + includeRepositoryInfo: true, + page, + pageSize, + search: searchQuery.trim() || undefined, + tagPredicates, + searchTagPredicates, + sortByUpdateTime: sortOrder === "latest" || undefined, + }), + [ + active, + user?.tenantId, + page, + pageSize, + searchQuery, + tagPredicates, + searchTagPredicates, + sortOrder, + ] + ); + const { + agents: listedAgents, + creatorCounts, + pagination, + isLoading, + isError, + isFetching, + refetch, + } = useAgentList(listParams); + const agents: MyEditableAgentItem[] = useMemo( + () => listedAgents.map(toMyAgentItem), + [listedAgents] + ); + const total = pagination?.total ?? 0; + useEffect(() => { + if (active && creatorCounts && tagPredicates.length === 0) { + onTotalChange?.(creatorCounts.all); + } + }, [active, creatorCounts, onTotalChange, tagPredicates.length]); + const reviewDeepLink = useMemo( + () => parseReviewDeepLinkParams(searchParams), + [searchParams] + ); + const usageGuideDeepLink = useMemo( + () => parseAgentUsageGuideParams(searchParams), + [searchParams] + ); + const usageGuideTarget = useMemo( + () => parseAgentUsageGuideTargetParams(searchParams), + [searchParams] + ); + const { data: deepLinkMineData, isLoading: deepLinkFallbackLoading } = + useMyEditableAgents( + { + ownership: "all", + agent_id: reviewDeepLink?.agentId ?? usageGuideTarget?.agentId, + page: 1, + page_size: 1, + new_agent_padding: false, + }, + active && (reviewDeepLink != null || usageGuideTarget != null) + ); + const deepLinkFallbackAgent = useMemo(() => { + const item = deepLinkMineData?.items?.[0]; + return item && !isNewAgentPaddingItem(item) ? item : null; + }, [deepLinkMineData]); + const onReviewDeepLinkConsumed = useCallback(() => { + router.replace( + searchParams.get("tab") === "agent" + ? `/${locale}/agents` + : `/${locale}/agent-space?tab=mine` + ); + }, [locale, router, searchParams]); + const onUsageGuideDeepLinkConsumed = useCallback(() => { + if (!usageGuideTarget) return; + router.replace( + searchParams.get("tab") === "agent" + ? `/${locale}/agents/${usageGuideTarget.agentId}` + : `/${locale}/agent-space?tab=mine&agent_id=${usageGuideTarget.agentId}` + ); + }, [locale, router, usageGuideTarget]); + const onSortOrderChange = (value: MineSortOrder) => { + setSortOrder(value); + setPage(1); + }; + const onSearchChange = (value: string) => { + setSearchQuery(value); + setPage(1); + }; + const onTagPredicatesChange = (value: TagResourcePredicate[]) => { + setTagPredicates(value); + setPage(1); + }; + const [createAgentModalVisible, setCreateAgentModalVisible] = useState(false); + const [reviewModalOpen, setReviewModalOpen] = useState(false); + const [reviewModalAgent, setReviewModalAgent] = + useState(null); + const [reviewModalInfo, setReviewModalInfo] = + useState(null); + const [reviewModalMode, setReviewModalMode] = useState< + "review" | "reviewUpdate" + >("review"); + const [applyingAgentId, setApplyingAgentId] = useState(null); + const [detailTarget, setDetailTarget] = useState<{ + agentId: number; + versionNo: number; + agent: MyEditableAgentItem; + } | null>(null); + const { + data: versionDetail, + isLoading: isDetailLoading, + isError: isDetailError, + isFetching: isDetailFetching, + refetch: refetchDetail, + } = useAgentVersionDetail( + detailTarget?.agentId ?? null, + detailTarget?.versionNo ?? null, + active && detailTarget != null + ); + const { tools: availableTools } = useToolList({ + enabled: active && detailTarget != null, + }); + const { skills: availableSkills } = useSkillList({ + enabled: active && detailTarget != null, + }); + const { + data: detailListings, + isLoading: isDetailListingsLoading, + isError: isDetailListingsError, + isFetching: isDetailListingsFetching, + refetch: refetchDetailListings, + } = useAgentRepositoryListings( + detailTarget + ? { agent_id: detailTarget.agentId, page: 1, page_size: 100 } + : undefined, + active && detailTarget != null + ); + const detail = useMemo(() => { + if (!detailTarget) return null; + if (!versionDetail) return undefined; + const repositoryInfo = pickReviewDisplayRepositoryInfo( + toMineRepositoryInfo(detailListings?.items ?? []) + ); + return { + ...mapMyAgentDetail(versionDetail, detailTarget.agent, { + tools: availableTools, + skills: availableSkills, + }), + status: repositoryInfo?.status, + }; + }, [ + availableSkills, + availableTools, + detailTarget, + detailListings, + versionDetail, + ]); + const [applyModalOpen, setApplyModalOpen] = useState(false); + const [applyModalAgent, setApplyModalAgent] = + useState(null); + const [usageGuideAgent, setUsageGuideAgent] = + useState(null); + const [guidedMenuAgentId, setGuidedMenuAgentId] = useState( + null + ); + const consumedDeepLinkRef = useRef(null); + const consumedUsageGuideRef = useRef(null); + + const createListingMutation = useCreateAgentRepositoryListing(); + const updateStatusMutation = useUpdateAgentRepositoryStatus(); + const deleteAgentMutation = useMutation({ + mutationFn: (agentId: number) => deleteAgent(agentId), + }); + + const normalizedQuery = searchQuery.trim().toLowerCase(); + const usageGuideTargetState = useMemo( + () => + usageGuideTarget + ? resolveAgentUsageGuideTarget({ + agentId: usageGuideTarget.agentId, + agents, + fallbackAgent: deepLinkFallbackAgent, + isListLoading: isLoading, + isFallbackLoading: deepLinkFallbackLoading, + isActive: active, + getAgentId: (agent) => + isNewAgentPaddingItem(agent) ? null : agent.agent_id, + }) + : null, + [ + agents, + deepLinkFallbackAgent, + deepLinkFallbackLoading, + isLoading, + usageGuideTarget, + ] + ); + const displayedAgents = useMemo(() => { + if (usageGuideTargetState?.state !== "found") { + return agents; + } + const targetAgent = usageGuideTargetState.agent; + if ( + isNewAgentPaddingItem(targetAgent) || + agents.some( + (agent) => + !isNewAgentPaddingItem(agent) && + agent.agent_id === targetAgent.agent_id + ) + ) { + return agents; + } + return [targetAgent, ...agents]; + }, [agents, usageGuideTargetState]); + const highlightedAgentId = + usageGuideTargetState?.state === "found" && + !isNewAgentPaddingItem(usageGuideTargetState.agent) + ? usageGuideTargetState.agent.agent_id + : null; + + const handleCreateAgent = () => { + setCreateAgentModalVisible(true); + }; + + const handleAgentCreated = async ({ agentId }: CreatedAgentResult) => { + setCreateAgentModalVisible(false); + await Promise.all([ + invalidateAgentRepositoryCaches(queryClient), + queryClient.invalidateQueries({ queryKey: [AGENTS_LIST_QUERY_KEY] }), + ]); + router.push(`/${locale}/agents/${agentId}?onboarding=1`); + }; + + const handleEdit = ( + agentId: number, + permission?: MyEditableAgentItem["permission"] + ) => { + if (permission === "READ_ONLY") { + return; + } + router.push(`/${locale}/agents/${agentId}?from=agent-space&tab=mine`); + }; + + const handleDeleteAgent = (agent: MyEditableAgentItem) => { + const name = agent.name?.trim() || t("agentRepository.card.untitled"); + confirm({ + title: t("businessLogic.config.modal.deleteTitle"), + content: t("businessLogic.config.modal.deleteContent", { name }), + onOk: async () => { + try { + const result = await deleteAgentMutation.mutateAsync(agent.agent_id); + if (!result.success) { + throw new Error(result.message || "delete failed"); + } + message.success( + t("businessLogic.config.error.agentDeleteSuccess", { name }) + ); + await Promise.all([ + invalidateAgentRepositoryCaches(queryClient), + queryClient.invalidateQueries({ + queryKey: [AGENTS_LIST_QUERY_KEY], + }), + ]); + } catch (error) { + log.error("Failed to delete agent:", error); + message.error(t("businessLogic.config.error.agentDeleteFailed")); + throw error; + } + }, + }); + }; + + const handleEvaluate = (agent: MyEditableAgentItem) => { + const versionNo = agent.current_version_no ?? 0; + if (versionNo <= 0) { + return; + } + router.push( + `/${locale}/evaluation?agent_ids=${encodeURIComponent(JSON.stringify([agent.agent_id]))}` + ); + }; + + const closeReviewModal = () => { + setReviewModalOpen(false); + setReviewModalAgent(null); + setReviewModalInfo(null); + }; + + const handleApplyListing = (agent: MyEditableAgentItem) => { + const versionNo = agent.current_version_no ?? 0; + if (versionNo <= 0) { + return; + } + setApplyModalAgent(agent); + setApplyModalOpen(true); + }; + + const closeApplyModal = () => { + setApplyModalOpen(false); + setApplyModalAgent(null); + }; + + const handleSubmitApplyListing = async ( + payload: AgentRepositoryListingCreatePayload + ) => { + if (!applyModalAgent) { + return; + } + + const versionNo = applyModalAgent.current_version_no ?? 0; + if (versionNo <= 0) { + return; + } + + setApplyingAgentId(applyModalAgent.agent_id); + try { + await createListingMutation.mutateAsync({ + agentId: applyModalAgent.agent_id, + versionNo, + payload, + }); + message.success(t("repository.mine.applySuccess")); + closeApplyModal(); + } catch { + message.error(t("repository.mine.applyError")); + } finally { + setApplyingAgentId(null); + } + }; + + const handleViewReview = ( + agent: MyEditableAgentItem, + mode: "review" | "reviewUpdate" + ) => { + const repositoryInfo = pickReviewDisplayRepositoryInfo( + agent.repository_info ?? [] + ); + if (!repositoryInfo) { + return; + } + openReviewModal(agent, repositoryInfo, mode); + }; + + const openReviewModal = ( + agent: MyEditableAgentItem, + repositoryInfo: MyAgentRepositoryInfoItem, + mode: "review" | "reviewUpdate" + ) => { + setReviewModalAgent(agent); + setReviewModalInfo(repositoryInfo); + setReviewModalMode(mode); + setReviewModalOpen(true); + }; + + useEffect(() => { + if (!active) return; + if (!reviewDeepLink) { + consumedDeepLinkRef.current = null; + return; + } + + if (consumedDeepLinkRef.current === reviewDeepLink.agentRepositoryId) { + return; + } + + const listStillLoading = isLoading; + const fallbackStillLoading = deepLinkFallbackLoading; + if (listStillLoading && fallbackStillLoading) { + return; + } + + const agentFromList = agents.find( + (item) => item.agent_id === reviewDeepLink.agentId + ); + const agent = deepLinkFallbackAgent ?? agentFromList; + + if (!agent) { + if (listStillLoading || fallbackStillLoading) { + return; + } + message.error(t("notifications.deepLink.agentNotFound")); + consumedDeepLinkRef.current = reviewDeepLink.agentRepositoryId; + onReviewDeepLinkConsumed?.(); + return; + } + + const repositoryInfo = findRepositoryInfoById( + agent.repository_info ?? [], + reviewDeepLink.agentRepositoryId + ); + + if (!repositoryInfo) { + message.error(t("notifications.deepLink.agentNotFound")); + consumedDeepLinkRef.current = reviewDeepLink.agentRepositoryId; + onReviewDeepLinkConsumed?.(); + return; + } + + openReviewModal( + agent, + repositoryInfo, + resolveReviewModalMode(agent, repositoryInfo) + ); + consumedDeepLinkRef.current = reviewDeepLink.agentRepositoryId; + onReviewDeepLinkConsumed?.(); + }, [ + active, + agents, + deepLinkFallbackAgent, + deepLinkFallbackLoading, + isLoading, + message, + onReviewDeepLinkConsumed, + reviewDeepLink, + t, + ]); + + useEffect(() => { + if (!usageGuideDeepLink) { + consumedUsageGuideRef.current = null; + return; + } + + if (!usageGuideTargetState) { + return; + } + const openAction = getAgentUsageGuideOpenAction({ + agentId: usageGuideDeepLink.agentId, + consumedAgentId: consumedUsageGuideRef.current, + target: usageGuideTargetState, + }); + if (openAction.action === "ignore" || openAction.action === "wait") { + return; + } + if (openAction.action === "missing") { + message.error(t("notifications.usageGuide.agentNotFound")); + consumedUsageGuideRef.current = usageGuideDeepLink.agentId; + onUsageGuideDeepLinkConsumed?.(); + return; + } + + if (isNewAgentPaddingItem(openAction.agent)) { + return; + } + setGuidedMenuAgentId(openAction.agent.agent_id); + consumedUsageGuideRef.current = usageGuideDeepLink.agentId; + onUsageGuideDeepLinkConsumed?.(); + }, [ + onUsageGuideDeepLinkConsumed, + t, + usageGuideDeepLink, + usageGuideTargetState, + ]); + + const closeUsageGuide = () => { + setUsageGuideAgent(null); + }; + + const handleSetNotShared = async () => { + if (!reviewModalInfo) { + return; + } + + const canUpdate = + isCancelableRepositoryStatus(reviewModalInfo.status) || + isTakeDownableRepositoryStatus(reviewModalInfo.status); + if (!canUpdate) { + return; + } + + const wasShared = reviewModalInfo.status === "shared"; + + try { + await updateStatusMutation.mutateAsync({ + agentRepositoryId: reviewModalInfo.agent_repository_id, + status: "not_shared", + }); + message.success( + wasShared + ? t("repository.mine.takeDownSuccess") + : t("repository.mine.cancelApplySuccess") + ); + closeReviewModal(); + } catch { + message.error( + wasShared + ? t("repository.mine.takeDownError") + : t("repository.mine.cancelApplyError") + ); + throw new Error("Update repository status failed"); + } + }; + + const hasActiveFilter = + normalizedQuery.length > 0 || tagPredicates.length > 0; + const showFilteredEmpty = + !isLoading && !isError && agents.length === 0 && hasActiveFilter; + return ( +
+
+
+ onSearchChange(e.target.value)} + placeholder={t("agentRepository.mine.searchPlaceholder")} + prefix={} + className="rounded-xl" + allowClear + /> +
+ + + value={sortOrder} + onChange={onSortOrderChange} + className="w-44 shrink-0" + aria-label={t("agentRepository.sort.label")} + options={[ + { value: "default", label: t("agentRepository.sort.default") }, + { value: "latest", label: t("agentRepository.sort.latest") }, + ]} + /> + +
+ +
+ {isLoading ? ( +
+ +
+ ) : isError ? ( +
+

+ {t("agentRepository.mine.loadError")} +

+ +
+ ) : ( + <> + + setDetailTarget({ + agentId: agent.agent_id, + versionNo: agent.current_version_no ?? 0, + agent, + }) + } + items={displayedAgents} + pageSize={REPOSITORY_PAGE_SIZE} + page={page} + total={total} + onPageChange={setPage} + paginateItems={false} + showToolbar={false} + emptyState={ + showFilteredEmpty ? ( + + ) : undefined + } + showCreateCard + createCard={} + renderItem={(agent) => ( + handleEdit(agent.agent_id, agent.permission)} + onView={() => + setDetailTarget({ + agentId: agent.agent_id, + versionNo: agent.current_version_no ?? 0, + agent, + }) + } + onApplyListing={() => handleApplyListing(agent)} + onViewReview={handleViewReview} + onDelete={() => handleDeleteAgent(agent)} + onEvaluate={() => handleEvaluate(agent)} + onUsageGuide={() => { + setGuidedMenuAgentId(null); + setUsageGuideAgent(agent); + }} + highlighted={highlightedAgentId === agent.agent_id} + guideMenuOpen={ + guidedMenuAgentId === agent.agent_id ? true : undefined + } + onGuideMenuOpenChange={(open) => { + if (!open && guidedMenuAgentId === agent.agent_id) { + setGuidedMenuAgentId(null); + } + }} + isApplying={ + applyingAgentId === agent.agent_id && + createListingMutation.isPending + } + isDeleting={ + deleteAgentMutation.isPending && + deleteAgentMutation.variables === agent.agent_id + } + /> + )} + /> + {showFilteredEmpty ? ( + + ) : null} + + )} +
+ + + + + + + + setCreateAgentModalVisible(false)} + onCreated={handleAgentCreated} + /> + setDetailTarget(null)} + onEdit={ + detailTarget?.agent.permission === "READ_ONLY" + ? undefined + : detailTarget + ? () => + handleEdit( + detailTarget.agentId, + detailTarget.agent.permission + ) + : undefined + } + detail={detail} + agentIcon={ + detailTarget ? ( + + ) : undefined + } + published={(detailTarget?.versionNo ?? 0) > 0} + status={detail?.status} + isLoading={isDetailLoading || isDetailListingsLoading} + isError={isDetailError || isDetailListingsError} + isFetching={isDetailFetching || isDetailListingsFetching} + onRetry={() => { + void refetchDetail(); + void refetchDetailListings(); + }} + /> +
+ ); +} + +function toMyAgentItem(agent: Agent): MyEditableAgentItem { + const currentVersionNo = agent.current_version_no ?? 0; + return { + agent_id: Number(agent.id), + icon_url: agent.icon_url, + is_available: agent.is_available, + unavailable_reasons: agent.unavailable_reasons, + name: agent.display_name || agent.name, + description: agent.description, + current_version_no: currentVersionNo, + version_label: + agent.version_label ?? + (currentVersionNo > 0 ? `V${currentVersionNo}` : null), + version_create_time: agent.version_create_time ?? null, + permission: agent.permission, + is_main_agent: agent.is_main_agent, + tags: agent.tags, + repository_info: agent.repository_info ?? [], + latest_review: agent.latest_review, + }; +} diff --git a/frontend/components/repository/agent/review-center.tsx b/frontend/components/repository/agent/review-center.tsx new file mode 100644 index 0000000000..56d1a6f5bc --- /dev/null +++ b/frontend/components/repository/agent/review-center.tsx @@ -0,0 +1,206 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useMemo, useState } from "react"; +import { App, Button, Empty, Spin } from "antd"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; +import { AgentDetail } from "@/components/agent/agent-detail"; +import { USER_ROLES } from "@/const/auth"; +import { + useAgentRepositoryListings, + useUpdateAgentRepositoryStatus, +} from "@/hooks/agentRepository/useAgentRepositoryListings"; +import { useRepositoryAgentDetail } from "@/hooks/agentRepository/useRepositoryAgentDetail"; + +import type { AgentRepositoryListingItem } from "@/types/agentRepository"; +import { ReviewAgentList } from "./components/ReviewAgentList"; +import { RepositoryAgentIcon } from "./components/RepositoryAgentIcon"; +import { + AgentRepositoryReviewConfirmModal, + type AgentRepositoryReviewAction, +} from "./components/AgentRepositoryReviewConfirmModal"; + +const REVIEW_PAGE_SIZE = 10; + +export function ReviewCenter({ active }: { active: boolean }) { + const { t } = useTranslation("common"); + const { message } = App.useApp(); + const { user } = useAuthorizationContext(); + const isAdmin = user?.role === USER_ROLES.ADMIN; + const currentUserEmail = user?.email; + const [page, setPage] = useState(1); + const pageSize = REVIEW_PAGE_SIZE; + const reviewListParams = useMemo( + () => ({ status: "pending_review" as const, page, page_size: pageSize }), + [page, pageSize] + ); + const { data, isLoading, isError, isFetching, refetch } = + useAgentRepositoryListings(reviewListParams, isAdmin && active); + const updateStatusMutation = useUpdateAgentRepositoryStatus(); + const listings = data?.items ?? []; + const total = data?.pagination?.total ?? 0; + const updatingRepositoryId = updateStatusMutation.isPending + ? (updateStatusMutation.variables?.agentRepositoryId ?? null) + : null; + const [detailListing, setDetailListing] = + useState(null); + const { + detail, + repositoryDetail, + isLoading: isDetailLoading, + isError: isDetailError, + isFetching: isDetailFetching, + retry: refetchDetail, + } = useRepositoryAgentDetail(detailListing, active); + const [reviewAction, setReviewAction] = + useState(null); + const [reviewListing, setReviewListing] = + useState(null); + const totalPages = total > 0 ? Math.ceil(total / pageSize) : 0; + const showPagination = !isLoading && !isError && totalPages > 1; + + const closeReviewModal = () => { + setReviewAction(null); + setReviewListing(null); + }; + const handleReviewConfirm = async (content?: string) => { + if (!reviewListing || !reviewAction) return; + const title = + reviewListing.display_name?.trim() || + reviewListing.name?.trim() || + t("agentRepository.card.untitled"); + const isApprove = reviewAction === "approve"; + try { + await updateStatusMutation.mutateAsync({ + agentRepositoryId: reviewListing.agent_repository_id, + status: isApprove ? "shared" : "rejected", + content, + }); + message.success( + isApprove + ? t("repository.review.approveSuccess", { name: title }) + : t("repository.review.rejectSuccess", { name: title }) + ); + closeReviewModal(); + } catch { + message.error( + isApprove + ? t("repository.review.approveError") + : t("repository.review.rejectError") + ); + throw new Error("Review action failed"); + } + }; + const isReviewModalLoading = + reviewListing != null && + updatingRepositoryId === reviewListing.agent_repository_id; + + return ( +
+ {isLoading ? ( +
+ +
+ ) : isError ? ( +
+

+ {t("repository.review.loadError")} +

+ +
+ ) : listings.length === 0 ? ( + + ) : ( + <> + setDetailListing(listing)} + onApprove={(listing) => { + setReviewListing(listing); + setReviewAction("approve"); + }} + onReject={(listing) => { + setReviewListing(listing); + setReviewAction("reject"); + }} + /> + + {showPagination ? ( +
+ + {Array.from({ length: totalPages }, (_, index) => index + 1).map( + (pageNumber) => ( + + ) + )} + +
+ ) : null} + + )} + setDetailListing(null)} + detail={detail} + agentIcon={ + detailListing ? ( + + ) : undefined + } + published + status={repositoryDetail?.status} + showRepositoryInfo + isLoading={isDetailLoading} + isError={isDetailError} + isFetching={isDetailFetching} + onRetry={refetchDetail} + /> +
+ ); +} diff --git a/frontend/components/repository/mcp/agent-space.tsx b/frontend/components/repository/mcp/agent-space.tsx new file mode 100644 index 0000000000..edb8659f6c --- /dev/null +++ b/frontend/components/repository/mcp/agent-space.tsx @@ -0,0 +1,447 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useRepositoryPresentation } from "@/hooks/useRepositoryPresentation"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; +import { App, Button, Empty, Spin } from "antd"; +import { useTranslation } from "react-i18next"; +import { parseMcpReviewDeepLinkParams } from "@/lib/notificationNavigation"; +import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; +import { USER_ROLES } from "@/const/auth"; +import { FILTER_ALL, McpToolsServicesTab } from "@/const/mcpTools"; +import { + filterByDeploymentType, + getDeploymentTypeLabelKey, + matchesNameOrTag, + resolveDeploymentType, +} from "@/lib/mcpTools"; +import { McpDetail } from "@/components/mcp/mcp-detail"; +import type { CommunityMcpCard, McpServiceItem } from "@/types/mcpTools"; +import { useMcpServicesList } from "@/hooks/mcpTools/useMcpServicesList"; +import { useMcpCommunityBrowser } from "@/hooks/mcpTools/useMcpCommunityBrowser"; +import { useMcpCommunityReview } from "@/hooks/mcpTools/useMcpCommunityReview"; +import { useMcpCommunityQuickAdd } from "@/hooks/mcpTools/useMcpCommunityQuickAdd"; +import { useMyCommunityMcp } from "@/hooks/mcpTools/useMyCommunityMcp"; +import { unpublishCommunityMcpTool } from "@/services/mcpToolsService"; +import RepositoryTagFilter from "@/components/tag/RepositoryTagFilter"; +import { useRepositoryPageSize } from "@/hooks/useRepositoryQueryState"; +import ResourceCardGrid from "@/components/resource/ResourceCardGrid"; +import McpCommunityRequestError from "./components/add/community/McpCommunityRequestError"; +import McpToolsSearchFilterBar from "./components/McpToolsSearchFilterBar"; +import RepositoryMcpCard from "./components/RepositoryMcpCard"; +import AddMcpServiceModal from "./components/add/AddMcpServiceModal"; +import CommunityQuickAddModal from "./components/add/community/CommunityQuickAddModal"; +import McpCommunityDetailModal from "./components/add/community/McpCommunityDetailModal"; +import McpServiceDetailModal from "./components/McpServiceDetailModal"; +import TransportIcon from "./components/shared/TransportIcon"; +import PublishedServiceDetailModal from "./components/PublishedServiceDetailModal"; +import { + type DeploymentFilter, + getDeduplicatedMineItems, + PlaceholderBox, +} from "./my-mcp"; + +export function McpSpace({ + browser, + localServices, + isAdmin, + actions, + onSelect, + onInstall, + onOffline, +}: { + browser: ReturnType; + localServices: McpServiceItem[]; + isAdmin: boolean; + actions: React.ReactNode; + onSelect: (service: CommunityMcpCard) => void; + onInstall: (service: CommunityMcpCard) => void; + onOffline: (service: CommunityMcpCard) => void; +}) { + const { t } = useTranslation("common"); + const presentation = useRepositoryPresentation(); + const scrollCards = presentation?.layout === "cards"; + const [pageSize, setPageSize] = useRepositoryPageSize(); + const [deploymentType] = useState(FILTER_ALL); + const filteredServices = useMemo(() => { + if (presentation) return browser.services; + return filterByDeploymentType(browser.services, deploymentType).filter( + (item) => matchesNameOrTag(item, browser.filters.search) + ); + }, [browser.services, browser.filters.search, deploymentType, presentation]); + const isInstalled = (service: CommunityMcpCard) => { + return localServices.some((localService) => { + if (localService.permission !== "EDIT") return false; + if ( + service.communityId && + localService.communityId === service.communityId + ) + return true; + return localService.name === service.name; + }); + }; + + return ( +
+ + browser.updateFilter("tag", value ?? FILTER_ALL) + } + /> + } + onSearchChange={(value) => browser.updateFilter("search", value)} + /> + +
+ {browser.loading ? ( + + + + ) : browser.error ? ( + + void browser.refetch()} /> + + ) : filteredServices.length === 0 ? ( + + + + ) : ( + ( + + )} + /> + )} +
+
+ ); +} + +export function useMcpSpaceController(sortByPublished = false) { + const { t } = useTranslation("common"); + const presentation = useRepositoryPresentation(); + const { message, modal } = App.useApp(); + const { user } = useAuthorizationContext(); + const router = useRouter(); + const params = useParams<{ locale: string }>(); + const locale = params.locale || "en"; + const searchParams = useSearchParams(); + const isAdmin = useMemo( + () => user?.role === USER_ROLES.ADMIN || user?.role === USER_ROLES.SU, + [user?.role] + ); + + const [tab, setTab] = useState( + McpToolsServicesTab.REPOSITORY + ); + const [showAddModal, setShowAddModal] = useState(false); + const [selectedLocal, setSelectedLocal] = useState( + null + ); + const [selectedRepository, setSelectedRepository] = + useState(null); + const [selectedReview, setSelectedReview] = useState( + null + ); + const [selectedPublished, setSelectedPublished] = + useState(null); + + const reviewDeepLink = useMemo( + () => parseMcpReviewDeepLinkParams(searchParams), + [searchParams] + ); + + useEffect(() => { + const tabParam = + searchParams.get("tab") === "mcp" + ? searchParams.get("view") || "repository" + : searchParams.get("tab"); + /* eslint-disable react-hooks/set-state-in-effect -- URL changes select the requested tab. */ + if (tabParam === McpToolsServicesTab.MINE) { + setTab(McpToolsServicesTab.MINE); + } else if (tabParam === McpToolsServicesTab.REVIEW && isAdmin) { + setTab(McpToolsServicesTab.REVIEW); + } else if (tabParam === McpToolsServicesTab.REPOSITORY) { + setTab(McpToolsServicesTab.REPOSITORY); + } + /* eslint-enable react-hooks/set-state-in-effect */ + }, [searchParams, isAdmin]); + + const handleReviewDeepLinkConsumed = useCallback(() => { + router.replace( + searchParams.get("tab") === "mcp" + ? `/${locale}/resource-space?tab=mcp&view=mine` + : `/${locale}/mcp-space?tab=mine` + ); + }, [locale, router, searchParams]); + + const localList = useMcpServicesList(); + const consumedLocalTarget = useRef(null); + useEffect(() => { + const value = searchParams.get("source_mcp_id"); + if ( + searchParams.get("tab") !== "mcp" || + !value || + value === consumedLocalTarget.current + ) + return; + const id = Number(value); + if (!Number.isSafeInteger(id) || id <= 0) return; + const service = localList.services.find((item) => item.mcpId === id); + if (service) { + consumedLocalTarget.current = value; + // eslint-disable-next-line react-hooks/set-state-in-effect -- External links open the requested local record. + setSelectedLocal(service); + } + }, [localList.services, searchParams]); + + const myPublished = useMyCommunityMcp( + tab === McpToolsServicesTab.MINE || Boolean(reviewDeepLink) + ); + const [repositoryPageSize] = useRepositoryPageSize(); + const repositoryBrowser = useMcpCommunityBrowser( + tab === McpToolsServicesTab.REPOSITORY, + repositoryPageSize, + sortByPublished + ); + const reviewBrowser = useMcpCommunityReview( + isAdmin && searchParams.get("tab") !== "mcp" + ); + const quickAdd = useMcpCommunityQuickAdd({ + onSuccess: () => setShowAddModal(false), + }); + const isRepositoryInstalled = useCallback( + (service: CommunityMcpCard) => { + return localList.services.some((localService) => { + if (localService.permission !== "EDIT") return false; + if ( + service.communityId && + localService.communityId === service.communityId + ) + return true; + return localService.name === service.name; + }); + }, + [localList.services] + ); + const detailMcpIdRef = useRef(null); + + useEffect(() => { + if (!isAdmin && tab === McpToolsServicesTab.REVIEW) { + /* eslint-disable react-hooks/set-state-in-effect -- Hide the admin tab when access changes. */ + setTab(McpToolsServicesTab.REPOSITORY); + /* eslint-enable react-hooks/set-state-in-effect */ + } + }, [isAdmin, tab]); + + const openAddModal = () => { + setShowAddModal(true); + }; + + const openLocalDetail = (service: McpServiceItem) => { + detailMcpIdRef.current = service.mcpId; + setSelectedLocal(service); + }; + + const closeLocalDetail = () => { + detailMcpIdRef.current = null; + setSelectedLocal(null); + }; + + const [mineTagRefreshKeys, setMineTagRefreshKeys] = useState< + Record + >({}); + + const handleToggled = async (mcpId: number) => { + const result = await localList.refetch(); + const updated = result.data?.find((s) => s.mcpId === mcpId); + if (updated && detailMcpIdRef.current === mcpId) { + setSelectedLocal(updated); + } + }; + + const handleTagsChanged = (mcpId: number) => { + setMineTagRefreshKeys((prev) => ({ + ...prev, + [mcpId]: (prev[mcpId] ?? 0) + 1, + })); + }; + + const handleRepositoryOffline = (service: CommunityMcpCard) => { + if (!service.communityId) return; + modal.confirm({ + title: t("mcpTools.mine.unpublishOnlineVersionTitle"), + content: t("mcpTools.mine.unpublishOnlineVersionDescription", { + name: service.name, + }), + okText: t("mcpTools.repository.offline"), + cancelText: t("common.cancel"), + okButtonProps: { danger: true }, + centered: true, + onOk: async () => { + try { + await unpublishCommunityMcpTool(service.communityId!); + message.success(t("mcpTools.mine.unpublishOnlineVersionSuccess")); + await Promise.all([ + repositoryBrowser.refetch(), + myPublished.refetch(), + localList.refetch(), + ]); + } catch { + message.error(t("mcpTools.mine.unpublishOnlineVersionFailed")); + } + }, + }); + }; + + const repositoryCount = repositoryBrowser.services.length; + const mineCount = getDeduplicatedMineItems( + localList.services, + myPublished.items + ).length; + const pendingReviewCount = reviewBrowser.services.filter( + (s) => (s.reviewStatus || "pending") === "pending" + ).length; + + const onReviewed = async () => { + await Promise.all([ + reviewBrowser.refetch(), + repositoryBrowser.refetch(), + myPublished.refetch(), + localList.refetch(), + ]); + }; + + const dialogs = ( + <> + {selectedLocal ? ( + + ) : null} + + {selectedRepository ? ( + + } + onClose={() => setSelectedRepository(null)} + onInstall={quickAdd.open} + /> + ) : null} + + {selectedReview ? ( + setSelectedReview(null)} + /> + ) : null} + + setSelectedPublished(null)} + /> + + {quickAdd.visible ? ( + + ) : null} + + setShowAddModal(false)} + /> + + ); + + return { + tab, + setTab, + isAdmin, + repositoryCount, + mineCount, + pendingReviewCount, + repositoryProps: { + browser: repositoryBrowser, + localServices: localList.services, + isAdmin, + actions: null, + onSelect: setSelectedRepository, + onInstall: quickAdd.open, + onOffline: handleRepositoryOffline, + }, + mineProps: { + localList, + myPublished, + actions: null, + reviewDeepLink, + onReviewDeepLinkConsumed: handleReviewDeepLinkConsumed, + onAdd: openAddModal, + onEditLocal: openLocalDetail, + onEditCommunity: setSelectedPublished, + onToggled: handleToggled, + tagRefreshKeys: mineTagRefreshKeys, + }, + reviewProps: { + browser: reviewBrowser, + onSelect: setSelectedReview, + onReviewed, + }, + dialogs, + }; +} diff --git a/frontend/components/repository/mcp/components/AddMcpServiceCard.tsx b/frontend/components/repository/mcp/components/AddMcpServiceCard.tsx new file mode 100644 index 0000000000..585925d354 --- /dev/null +++ b/frontend/components/repository/mcp/components/AddMcpServiceCard.tsx @@ -0,0 +1,19 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { useTranslation } from "react-i18next"; + +import CreateResourceCard from "@/components/resource/CreateResourceCard"; + +interface AddMcpServiceCardProps { + onClick: () => void; +} + +export default function AddMcpServiceCard({ onClick }: AddMcpServiceCardProps) { + const { t } = useTranslation("common"); + return ( + + ); +} diff --git a/frontend/components/repository/mcp/components/McpEditInspection.tsx b/frontend/components/repository/mcp/components/McpEditInspection.tsx new file mode 100644 index 0000000000..07f56ee795 --- /dev/null +++ b/frontend/components/repository/mcp/components/McpEditInspection.tsx @@ -0,0 +1,254 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { useEffect, useRef, useState } from "react"; +import { App, Button, Collapse, Empty, Spin } from "antd"; +import { ChevronDown, Copy, Wrench } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { McpContainerLogOutput } from "@/components/mcp/McpContainerLogsModal"; +import type { McpTool } from "@/types/agentConfig"; + +const codeStyle = + "mcp-edit-code m-0 overflow-auto bg-[#191919]/[0.03] px-5 py-4 font-mono text-xs leading-5 text-[#191919]"; +const previewStyle = + "@container overflow-hidden rounded border border-[#DFDFDF] bg-white"; +const previewHeaderStyle = + "flex h-8 items-center justify-between gap-2 border-b border-[#DFDFDF] px-3 text-sm leading-[22px] text-[#191919]"; + +function JsonPreview({ name, json }: { name: string; json: string }) { + const { t } = useTranslation("common"); + const { message } = App.useApp(); + + return ( +
+
+ + {name} + +
+
{json}
+
+ ); +} + +interface McpEditInspectionProps { + name: string; + tools: McpTool[]; + loadingTools: boolean; + onLoadTools: () => Promise; + serverJson?: string; + containerConfig?: string; + showLogs: boolean; + containerId?: string; + active: boolean; +} + +export default function McpEditInspection({ + name, + tools, + loadingTools, + onLoadTools, + serverJson, + containerConfig, + showLogs, + containerId, + active, +}: McpEditInspectionProps) { + const { t } = useTranslation("common"); + const [expanded, setExpanded] = useState([]); + const inspectionRef = useRef(null); + + useEffect(() => { + const inspection = inspectionRef.current; + if (!inspection) return; + + const updateOverflow = () => { + inspection + .querySelectorAll(".mcp-edit-code") + .forEach((preview) => { + preview.toggleAttribute( + "data-overflow-y", + preview.scrollHeight > preview.clientHeight + ); + }); + }; + const resizeObserver = new ResizeObserver(updateOverflow); + const mutationObserver = new MutationObserver(updateOverflow); + resizeObserver.observe(inspection); + mutationObserver.observe(inspection, { + childList: true, + characterData: true, + subtree: true, + }); + updateOverflow(); + + return () => { + resizeObserver.disconnect(); + mutationObserver.disconnect(); + }; + }, []); + + const sectionLabel = (key: string, label: string) => ( + + {label} + + + ); + + const sections = ( + { + if ( + keys.includes("tools") && + !expanded.includes("tools") && + !loadingTools + ) { + void onLoadTools(); + } + setExpanded(keys); + }} + items={[ + { + key: "tools", + showArrow: false, + label: sectionLabel("tools", t("mcpTools.detail.tools")), + children: loadingTools ? ( +
+ +
+ ) : tools.length ? ( + ( + + )} + className="flex flex-col gap-2" + classNames={{ + header: + "!min-h-8 !items-center !gap-2 !bg-transparent !px-3 !py-1 !shadow-none hover:!bg-transparent", + title: "!min-w-0", + body: "!px-3 !pt-0 !pb-2", + }} + items={tools.map((tool) => ({ + key: tool.name, + className: + "group/tool !rounded !border !border-[#D8D8D8] !bg-transparent", + label: ( + + + + + + {tool.name} + + + {tool.description} + + + ), + children: ( +
+ {tool.description || "—"} +
+ ), + }))} + /> + ) : ( + + ), + }, + ...(serverJson + ? [ + { + key: "server-json", + showArrow: false, + label: sectionLabel( + "server-json", + t("mcpTools.detail.fullServerJson") + ), + children: , + }, + ] + : []), + ...(showLogs + ? [ + { + key: "logs", + showArrow: false, + label: sectionLabel("logs", t("mcpConfig.containerLogs.title")), + children: containerId ? ( +
+
+ {containerId.slice(0, 12)} +
+ +
+ ) : ( + + ), + }, + ] + : []), + ...(containerConfig + ? [ + { + key: "container-config", + showArrow: false, + label: sectionLabel( + "container-config", + t("mcpTools.detail.containerConfig") + ), + children: , + }, + ] + : []), + ]} + /> + ); + + return
{sections}
; +} diff --git a/frontend/components/repository/mcp/components/McpRepositoryReviewConfirmModal.tsx b/frontend/components/repository/mcp/components/McpRepositoryReviewConfirmModal.tsx new file mode 100644 index 0000000000..cd8de918d9 --- /dev/null +++ b/frontend/components/repository/mcp/components/McpRepositoryReviewConfirmModal.tsx @@ -0,0 +1,98 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useEffect, useState } from "react"; +import { Input, Modal } from "antd"; +import { useTranslation } from "react-i18next"; + +import type { CommunityMcpCard } from "@/types/mcpTools"; + +export type McpRepositoryReviewAction = "approve" | "reject"; + +interface McpRepositoryReviewConfirmModalProps { + open: boolean; + action: McpRepositoryReviewAction | null; + service: CommunityMcpCard | null; + loading?: boolean; + onClose: () => void; + onConfirm: (content?: string) => Promise; +} + +export default function McpRepositoryReviewConfirmModal({ + open, + action, + service, + loading = false, + onClose, + onConfirm, +}: McpRepositoryReviewConfirmModalProps) { + const { t } = useTranslation("common"); + const [reviewOpinion, setReviewOpinion] = useState(""); + + useEffect(() => { + if (!open) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- Preserve the existing modal reset when its external open state changes. + setReviewOpinion(""); + } + }, [open]); + + if (!action || !service) { + return null; + } + + const isApprove = action === "approve"; + const title = service.name?.trim() || "-"; + + const handleOk = async () => { + const trimmed = reviewOpinion.trim(); + await onConfirm(trimmed || undefined); + }; + + return ( + +
+

+ {isApprove + ? t("repository.review.confirmApproveContent", { name: title }) + : t("repository.review.confirmRejectContent", { name: title })} +

+
+ + setReviewOpinion(event.target.value)} + placeholder={t("repository.review.reviewOpinionPlaceholder")} + rows={4} + disabled={loading} + /> +
+
+
+ ); +} diff --git a/frontend/components/repository/mcp/components/McpServiceCard.tsx b/frontend/components/repository/mcp/components/McpServiceCard.tsx new file mode 100644 index 0000000000..3eb1d04a4a --- /dev/null +++ b/frontend/components/repository/mcp/components/McpServiceCard.tsx @@ -0,0 +1,73 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { Tag } from "antd"; +import { useTranslation } from "react-i18next"; +import { + MCP_GRID_CARD_OUTER, + MCP_GRID_CARD_OUTER_STYLE, +} from "@/const/mcpTools"; +import type { McpServiceItem } from "@/types/mcpTools"; +import { getTransportLabelKey } from "@/lib/mcpTools"; +import StatusBadge from "./shared/StatusBadge"; +import TransportIcon from "./shared/TransportIcon"; + +interface McpServiceCardProps { + service: McpServiceItem; + onSelect: (service: McpServiceItem) => void; +} + +export default function McpServiceCard({ + service, + onSelect, +}: McpServiceCardProps) { + const { t } = useTranslation("common"); + const transportLabel = t(getTransportLabelKey(service.transportType)); + + return ( +
onSelect(service)} + className={MCP_GRID_CARD_OUTER} + style={MCP_GRID_CARD_OUTER_STYLE} + > +
+ +
+
+

+ {service.name} +

+ +
+
+ {transportLabel} +
+
+
+ +
+

+ {service.description || "-"} +

+
+ + {service.tags.length > 0 ? ( +
+ {service.tags.map((tag) => ( + + {tag} + + ))} +
+ ) : null} +
+ ); +} diff --git a/frontend/components/repository/mcp/components/McpServiceDetailModal.tsx b/frontend/components/repository/mcp/components/McpServiceDetailModal.tsx new file mode 100644 index 0000000000..56489b0cb5 --- /dev/null +++ b/frontend/components/repository/mcp/components/McpServiceDetailModal.tsx @@ -0,0 +1,955 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { useEffect, useMemo, useState } from "react"; +import { + Alert, + App, + Button, + ConfigProvider, + Form, + Input, + InputNumber, + Select, + Steps, +} from "antd"; +import { useTranslation } from "react-i18next"; +import { + McpDeploymentType, + McpServiceStatus, + MCP_EDIT_SERVICE_DRAWER_WIDTH, +} from "@/const/mcpTools"; +import type { McpServiceItem } from "@/types/mcpTools"; +import { resolveDeploymentType, toPrettyRegistryJson } from "@/lib/mcpTools"; +import { useMcpFormRules } from "@/hooks/mcpTools/useMcpFormRules"; +import { useMcpServiceDetail } from "@/hooks/mcpTools/useMcpServiceDetail"; +import { useGroupList } from "@/hooks/group/useGroupList"; +import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; +import { usePermission } from "@/hooks/permission/usePermission"; +import { Can } from "@/components/permission/Can"; +import { StandardDrawer } from "@/components/common/StandardDrawer"; +import ContainerPortField from "./shared/ContainerPortField"; +import McpGroupSelectionTable from "./shared/McpGroupSelectionTable"; +import ResourceTagAssignmentModal from "@/components/tag/ResourceTagAssignmentModal"; +import ResourceTagChips from "@/components/tag/ResourceTagChips"; +import TagDefinitionManagementModal from "@/components/tag/TagDefinitionManagementModal"; +import { useTagDefinitions, useTagLibraries } from "@/hooks/useTagManagement"; +import McpEditInspection from "./McpEditInspection"; +import PublishConfirmModal from "./PublishConfirmModal"; + +interface McpServiceDetailModalProps { + selectedService: McpServiceItem | null; + onClose: () => void; + onToggled?: (mcpId: number, next: McpServiceStatus) => void; + onTagsChanged?: (mcpId: number) => void; +} + +const DEPLOYMENT_OPTIONS = [ + { + value: McpDeploymentType.REMOTE_LINK, + descriptionKey: "mcpTools.addModal.mode.remoteDescription", + labelKey: "mcpConfig.addServer.title", + }, + { + value: McpDeploymentType.CONTAINER, + descriptionKey: "mcpTools.addModal.mode.containerDescription", + labelKey: "mcpTools.addModal.mode.container", + }, + { + value: McpDeploymentType.API, + descriptionKey: "mcpTools.addModal.mode.apiDescription", + labelKey: "mcpTools.addModal.mode.api", + }, + { + value: McpDeploymentType.LOCAL_IMAGE, + descriptionKey: "mcpTools.addModal.mode.uploadDescription", + labelKey: "mcpConfig.uploadImage.title", + }, +] as const; + +export default function McpServiceDetailModal({ + selectedService, + onClose, + onTagsChanged, +}: McpServiceDetailModalProps) { + const { modal } = App.useApp(); + const { t } = useTranslation("common"); + const rules = useMcpFormRules(); + const [form] = Form.useForm(); + const [publishConfirmOpen, setPublishConfirmOpen] = useState(false); + const [deploymentType, setDeploymentType] = useState( + McpDeploymentType.REMOTE_LINK + ); + const [assignOpen, setAssignOpen] = useState(false); + const [tagPreviewRefreshKey, setTagPreviewRefreshKey] = useState(0); + const [tagManagementOpen, setTagManagementOpen] = useState(false); + const { data: tagLibraries } = useTagLibraries(); + const defaultLibrary = + tagLibraries?.find( + (library) => library.bucket_key === "default_resource" + ) ?? null; + const { data: assignDefinitions, refresh: refreshAssignDefinitions } = + useTagDefinitions(defaultLibrary?.bucket_id ?? null); + const [containerPort, setContainerPort] = useState(); + + const detail = useMcpServiceDetail({ selectedService, onClose }); + const { user } = useAuthorizationContext(); + const { isReady, can } = usePermission(); + const canSelectGroups = isReady && can("group:read"); + const [step, setStep] = useState(0); + const showGroupStep = canSelectGroups && step === 1; + const tenantId = user?.tenantId || null; + const { data: groupData, isLoading: loadingGroups } = useGroupList(tenantId); + const groups = groupData?.groups || []; + const { draft, setDraft } = detail; + + const originalDeploymentType = useMemo( + () => + draft + ? resolveDeploymentType({ + transportType: draft.transportType, + deploymentType: draft.deploymentType, + configJson: draft.configJson, + serverUrl: draft.serverUrl, + source: draft.source, + }) + : McpDeploymentType.REMOTE_LINK, + [draft] + ); + + useEffect(() => { + if (!draft) return; + const nextDeploymentType = resolveDeploymentType({ + transportType: draft.transportType, + deploymentType: draft.deploymentType, + configJson: draft.configJson, + serverUrl: draft.serverUrl, + source: draft.source, + }); + // eslint-disable-next-line react-hooks/set-state-in-effect -- Preserve the existing modal reset when its external open state changes. + setDeploymentType(nextDeploymentType); + setContainerPort(draft.containerPort); + form.setFieldsValue({ + name: draft.name, + description: draft.description, + version: draft.version, + serverUrl: draft.serverUrl, + authorizationToken: draft.authorizationToken ?? "", + customHeaders: draft.customHeaders + ? JSON.stringify(draft.customHeaders, null, 2) + : "", + openApiJson: toPrettyRegistryJson(draft.configJson), + containerConfigJson: toPrettyRegistryJson(draft.configJson), + containerPort: draft.containerPort, + group_ids: draft.groupIds + ? draft.groupIds.split(",").map(Number).filter(Boolean) + : undefined, + ingroup_permission: draft.ingroupPermission || "READ_ONLY", + }); + }, [draft, form]); + + if (!selectedService || !draft) { + return null; + } + + const isRemoteLink = deploymentType === McpDeploymentType.REMOTE_LINK; + const isContainer = deploymentType === McpDeploymentType.CONTAINER; + const isApi = deploymentType === McpDeploymentType.API; + const isLocalImage = deploymentType === McpDeploymentType.LOCAL_IMAGE; + const isUnsupported = deploymentType !== originalDeploymentType; + const isReadOnly = selectedService?.permission === "READ_ONLY"; + + const handleSave = async () => { + if (isUnsupported || isReadOnly) return; + try { + await form.validateFields(); + } catch { + setStep(0); + return; + } + + const values = form.getFieldsValue(); + let parsedCustomHeaders: Record | undefined; + if (values.customHeaders?.trim()) { + try { + parsedCustomHeaders = JSON.parse(values.customHeaders.trim()); + } catch { + setStep(0); + modal.error({ + content: t("mcpConfig.message.invalidCustomHeadersJson"), + }); + return; + } + } + + let parsedConfigJson = draft.configJson; + if (isApi) { + try { + parsedConfigJson = JSON.parse(String(values.openApiJson || "").trim()); + } catch { + setStep(0); + modal.error({ + content: t("mcpConfig.openApiToMcp.message.invalidJson"), + }); + return; + } + } + if (isContainer) { + try { + parsedConfigJson = JSON.parse( + String(values.containerConfigJson || "").trim() + ); + } catch { + setStep(0); + modal.error({ + content: t("mcpTools.add.error.containerJsonInvalid"), + }); + return; + } + } + + const nextDraft = { + ...draft, + name: values.name ?? "", + description: values.description ?? "", + version: values.version ?? "", + serverUrl: values.serverUrl ?? "", + authorizationToken: values.authorizationToken ?? "", + customHeaders: parsedCustomHeaders, + configJson: parsedConfigJson, + }; + detail.setDraft(nextDraft); + const ok = await detail.save(nextDraft); + if (ok) onClose(); + }; + + return ( + <> + + + + {showGroupStep && ( + + )} + +
+ } + > +
+ {canSelectGroups && ( + + )} +
+ + {canSelectGroups && ( + + ); +} diff --git a/frontend/components/repository/mcp/components/McpToolsPagination.tsx b/frontend/components/repository/mcp/components/McpToolsPagination.tsx new file mode 100644 index 0000000000..66c984c497 --- /dev/null +++ b/frontend/components/repository/mcp/components/McpToolsPagination.tsx @@ -0,0 +1,106 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { Button } from "antd"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +interface OffsetPaginationProps { + mode: "offset"; + current: number; + pageSize: number; + total: number; + onChange: (page: number) => void; +} + +interface CursorPaginationProps { + mode: "cursor"; + page: number; + resultCount: number; + hasPrevPage: boolean; + hasNextPage: boolean; + onPrevPage: () => void; + onNextPage: () => void; +} + +type McpToolsPaginationProps = OffsetPaginationProps | CursorPaginationProps; + +export default function McpToolsPagination(props: McpToolsPaginationProps) { + const { t } = useTranslation("common"); + + if (props.mode === "offset") { + if (props.total <= props.pageSize) return null; + const totalPages = Math.ceil(props.total / props.pageSize); + return ( +
+ + {Array.from({ length: totalPages }, (_, index) => index + 1).map( + (pageNumber) => ( + + ) + )} + +
+ ); + } + + if (props.mode === "cursor") { + // Hide pagination when there is only one page + if (props.page === 1 && !props.hasPrevPage && !props.hasNextPage) + return null; + + return ( +
+ + {t("mcpTools.community.pageResult", { + page: props.page, + count: props.resultCount, + })} + + + +
+ ); + } +} diff --git a/frontend/components/repository/mcp/components/McpToolsSearchFilterBar.tsx b/frontend/components/repository/mcp/components/McpToolsSearchFilterBar.tsx new file mode 100644 index 0000000000..9eeb67688b --- /dev/null +++ b/frontend/components/repository/mcp/components/McpToolsSearchFilterBar.tsx @@ -0,0 +1,207 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import RepositoryDisplayControls from "@/components/repository/RepositoryDisplayControls"; +import { useRepositoryPresentation } from "@/hooks/useRepositoryPresentation"; +import { Input, Select } from "antd"; +import { Search } from "lucide-react"; +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { FILTER_ALL, McpDeploymentType } from "@/const/mcpTools"; + +type DeploymentFilter = McpDeploymentType | typeof FILTER_ALL; + +interface McpCategoryStat { + value: DeploymentFilter; + label: string; + count: number; +} + +interface FilterTab { + value: string; + label: string; + count: number; +} + +interface McpToolsSearchFilterBarProps { + search: string; + deploymentType?: DeploymentFilter; + searchPlaceholder?: string; + status?: string; + statusOptions?: Array<{ value: string; label: string }>; + categoryStats?: McpCategoryStat[]; + actions?: ReactNode; + searchActions?: ReactNode; + filterActions?: ReactNode; + onSearchChange: (value: string) => void; + onDeploymentTypeChange?: (value: DeploymentFilter) => void; + onStatusChange?: (value: string) => void; + /** Generic filter tabs replace categoryStats when provided */ + filterTabs?: FilterTab[]; + activeFilterTab?: string; + onFilterTabChange?: (value: string) => void; +} + +export default function McpToolsSearchFilterBar({ + search, + deploymentType, + searchPlaceholder, + status, + statusOptions, + categoryStats, + actions, + searchActions, + filterActions, + onSearchChange, + onDeploymentTypeChange, + onStatusChange, + filterTabs, + activeFilterTab, + onFilterTabChange, +}: McpToolsSearchFilterBarProps) { + const { t } = useTranslation("common"); + const presentation = useRepositoryPresentation(); + const measuredMine = + presentation?.resourceType === "mcp" && + presentation.query?.get("view") === "mine"; + const measuredRepository = + presentation?.resourceType === "mcp" && + presentation.query?.get("view") !== "mine"; + + const renderTab = ( + item: { value: string; label: string; count: number }, + selected: boolean, + onClick: () => void + ) => ( + + ); + + const filterTabsContent = filterTabs?.length + ? filterTabs.map((item) => + renderTab(item, activeFilterTab === item.value, () => + onFilterTabChange?.(item.value) + ) + ) + : !measuredMine && categoryStats?.length && onDeploymentTypeChange + ? categoryStats.map((item) => + renderTab(item, deploymentType === item.value, () => + onDeploymentTypeChange(item.value) + ) + ) + : null; + + const tabsContent = + filterTabsContent || filterActions ? ( +
+
{filterTabsContent}
+ {filterActions ? ( +
{filterActions}
+ ) : null} +
+ ) : null; + + return ( +
+
+
+ onSearchChange(event.target.value)} + placeholder={ + searchPlaceholder || t("mcpTools.page.searchNameTagPlaceholder") + } + allowClear + prefix={ + + } + className={ + measuredRepository + ? `h-8 min-w-0 max-w-full shrink-0 gap-1 rounded border-[#C9C9C9] bg-white px-3 py-[5px] text-sm leading-[22px] [&_input::placeholder]:text-[#AEAEAE] ${presentation.layout === "list" ? "!w-[349px]" : "!w-[440px]"}` + : measuredMine + ? "h-8 !min-w-[80px] max-w-[440px] flex-[1_1_440px] rounded-lg border-slate-200 bg-slate-50/60" + : "h-8 min-w-0 flex-1 rounded-lg border-slate-200 bg-slate-50/60" + } + /> + {searchActions ? ( +
+ {searchActions} +
+ ) : null} + {measuredMine && categoryStats?.length && onDeploymentTypeChange ? ( + + ) : null} +
+ {measuredMine ? ( +
+ {actions} + +
+ ) : ( + <> + {actions ? ( +
{actions}
+ ) : null} + + + )} +
+ {tabsContent} +
+ ); +} diff --git a/frontend/components/repository/mcp/components/MineApplyListingModal.tsx b/frontend/components/repository/mcp/components/MineApplyListingModal.tsx new file mode 100644 index 0000000000..102b162efc --- /dev/null +++ b/frontend/components/repository/mcp/components/MineApplyListingModal.tsx @@ -0,0 +1,156 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { App, Checkbox, Input, Modal } from "antd"; +import { useTranslation } from "react-i18next"; +import { McpTransportType } from "@/const/mcpTools"; + +import type { MineMcpCardItem } from "./MineMcpServiceCard"; + +interface MineApplyListingModalProps { + open: boolean; + item: MineMcpCardItem | null; + loading?: boolean; + onClose: () => void; + onConfirm: ( + content?: string, + sharedFields?: Record + ) => Promise; +} + +export default function MineApplyListingModal({ + open, + item, + loading = false, + onClose, + onConfirm, +}: MineApplyListingModalProps) { + const { t } = useTranslation("common"); + const { message } = App.useApp(); + const [listingContent, setListingContent] = useState(""); + const [sharedFields, setSharedFields] = useState>({}); + const [submitting, setSubmitting] = useState(false); + + const shareOptions = useMemo(() => { + if (!item) return []; + const service = item.service; + if (service.transportType === McpTransportType.CONTAINER) { + return service.configJson + ? [ + { + key: "containerConfigJson", + label: t("mcpTools.detail.containerConfig"), + }, + ] + : []; + } + return [ + service.serverUrl + ? { key: "serverUrl", label: t("mcpTools.detail.serverUrl") } + : null, + service.authorizationToken + ? { + key: "authorizationToken", + label: t("mcpConfig.editServer.authorizationToken"), + } + : null, + service.customHeaders && Object.keys(service.customHeaders).length > 0 + ? { key: "customHeaders", label: t("mcpTools.detail.customHeaders") } + : null, + ].filter( + (option): option is { key: string; label: string } => option !== null + ); + }, [item, t]); + + useEffect(() => { + if (!open) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- Preserve the existing modal reset when its external open state changes. + setListingContent(""); + setSharedFields({}); + setSubmitting(false); + return; + } + setSharedFields(item?.service.sharedFields ?? {}); + }, [item, open]); + + if (!item) { + return null; + } + + const title = item.service.name?.trim() || "-"; + const isBusy = loading || submitting; + + const handleOk = async () => { + if (!Object.values(sharedFields).some(Boolean)) { + message.warning(t("mcpTools.mine.sharedFieldsRequired")); + return; + } + setSubmitting(true); + try { + await onConfirm(listingContent.trim() || undefined, sharedFields); + onClose(); + } finally { + setSubmitting(false); + } + }; + + return ( + +
+

+ {t("repository.mine.confirmApplyTitle", { name: title })} +

+
+

+ {t("repository.mine.applyModal.sharedFields")} +

+
+ {shareOptions.map((option) => ( + { + setSharedFields((previous) => ({ + ...previous, + [option.key]: event.target.checked, + })); + }} + > + {option.label} + + ))} +
+
+
+ + setListingContent(event.target.value)} + placeholder={t("repository.mine.applyModal.contentPlaceholder")} + rows={4} + disabled={isBusy} + /> +
+
+
+ ); +} diff --git a/frontend/components/repository/mcp/components/MineMcpReviewStatusModal.tsx b/frontend/components/repository/mcp/components/MineMcpReviewStatusModal.tsx new file mode 100644 index 0000000000..3d6daeed28 --- /dev/null +++ b/frontend/components/repository/mcp/components/MineMcpReviewStatusModal.tsx @@ -0,0 +1,193 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { Button, Modal } from "antd"; +import { CheckCircle2, Clock, Store, XCircle } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { + isCancelableReviewStatus, + isTakeDownableReviewStatus, + formatMineDate, +} from "@/lib/mcpToolsMine"; +import type { CommunityMcpCard } from "@/types/mcpTools"; +import type { MineMcpCardItem } from "./MineMcpServiceCard"; + +interface MineMcpReviewStatusModalProps { + open: boolean; + item: MineMcpCardItem | null; + onlineService?: CommunityMcpCard; + isUpdatingStatus?: boolean; + onClose: () => void; + onCancelApply: ( + item: MineMcpCardItem, + onlineService?: CommunityMcpCard + ) => Promise; + onTakeDown: ( + item: MineMcpCardItem, + onlineService: CommunityMcpCard + ) => Promise; +} + +export default function MineMcpReviewStatusModal({ + open, + item, + onlineService, + isUpdatingStatus = false, + onClose, + onCancelApply, + onTakeDown, +}: MineMcpReviewStatusModalProps) { + const { t } = useTranslation("common"); + + if (!item) return null; + + const service = item.service; + const title = service.name?.trim() || "-"; + const communityRecord = + item.kind === "community" ? item.service : onlineService; + const reviewStatus = communityRecord?.reviewStatus || "pending"; + const isPending = reviewStatus === "pending"; + const isRejected = reviewStatus === "rejected"; + const canCancel = isCancelableReviewStatus(reviewStatus); + const canTakeDown = isTakeDownableReviewStatus(reviewStatus); + const submittedAt = formatMineDate(communityRecord?.createdAt); + const listingContent = communityRecord?.content?.trim() || ""; + + const statusConfig = isPending + ? { + icon: Clock, + label: t("repository.listingStatus.pendingLabel"), + description: t("repository.listingStatus.pendingDescription"), + tone: "border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200", + iconClass: "text-amber-600 dark:text-amber-300", + } + : isRejected + ? { + icon: XCircle, + label: t("repository.listingStatus.rejectedLabel"), + description: t("repository.listingStatus.rejectedDescription"), + tone: "border-red-200 bg-red-50 text-red-800 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200", + iconClass: "text-red-600 dark:text-red-300", + } + : { + icon: CheckCircle2, + label: t("repository.listingStatus.listedLabel"), + description: t("repository.listingStatus.listedDescription"), + tone: "border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-200", + iconClass: "text-emerald-600 dark:text-emerald-300", + }; + + const StatusIcon = statusConfig.icon; + + const confirmCancelApply = () => { + Modal.confirm({ + title: t("repository.listingStatus.confirmCancelApplyTitle"), + content: t("repository.listingStatus.confirmCancelApplyContent", { + name: title, + }), + okText: t("repository.listingStatus.cancelApply"), + cancelText: t("common.cancel"), + okButtonProps: { danger: true }, + centered: true, + onOk: async () => { + try { + await onCancelApply(item, onlineService); + } catch { + throw new Error("Cancel listing request failed"); + } + }, + }); + }; + + const confirmTakeDown = async () => { + try { + if (onlineService) await onTakeDown(item, onlineService); + } catch { + throw new Error("Take down failed"); + } + }; + + return ( + + + {canCancel ? ( + + ) : null} + {canTakeDown ? ( + + ) : null} +
+ } + title={ + + + {t("repository.listingStatus.title")} + + } + centered + destroyOnHidden + > +

+ {t("mcpTools.mine.reviewModal.serviceName", { name: title })} +

+ +
+ +
+

{statusConfig.label}

+

+ {statusConfig.description} +

+ {listingContent ? ( +

+ {isPending + ? t("repository.listingStatus.pendingNote", { + content: listingContent, + }) + : t("repository.listingStatus.reviewOpinion", { + content: listingContent, + })} +

+ ) : null} +
+
+ + {submittedAt ? ( +
+
+ {t("repository.listingStatus.submittedAt")} + + {submittedAt} + +
+
+ ) : null} + + ); +} diff --git a/frontend/components/repository/mcp/components/MineMcpServiceCard.tsx b/frontend/components/repository/mcp/components/MineMcpServiceCard.tsx new file mode 100644 index 0000000000..b28c311175 --- /dev/null +++ b/frontend/components/repository/mcp/components/MineMcpServiceCard.tsx @@ -0,0 +1,423 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import ResourceReviewLink from "@/components/repository/ResourceReviewLink"; +import { useRepositoryPresentation } from "@/hooks/useRepositoryPresentation"; +import { CheckCircleFilled } from "@ant-design/icons"; +import type { ReactNode } from "react"; +import { Button, Dropdown, type MenuProps } from "antd"; +import { Clock, MoreHorizontal, Puzzle } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { McpServiceStatus, McpDeploymentType } from "@/const/mcpTools"; +import type { CommunityMcpCard, McpServiceItem } from "@/types/mcpTools"; +import { + formatRegistryDate, + getDeploymentTypeLabelKey, + resolveDeploymentType, +} from "@/lib/mcpTools"; +import { getMineCardReviewBadge } from "@/lib/mcpToolsMine"; +import ResourceTagChips from "@/components/tag/ResourceTagChips"; +import ResourceCard from "@/components/resource/ResourceCard"; +import TransportIcon from "./shared/TransportIcon"; + +export type MineMcpCardItem = + | { kind: "local"; service: McpServiceItem } + | { kind: "community"; service: CommunityMcpCard }; + +export type McpConnectionStatus = "unchecked" | "success" | "failed"; + +interface MineMcpServiceCardProps { + item: MineMcpCardItem; + selection?: ReactNode; + onlineService?: CommunityMcpCard; + toggling?: boolean; + publishing?: boolean; + unpublishing?: boolean; + healthChecking?: boolean; + connectionStatus?: McpConnectionStatus; + tagRefreshKey?: string | number; + onEditLocal: (service: McpServiceItem) => void; + onEditCommunity: (service: CommunityMcpCard) => void; + onToggle: (service: McpServiceItem) => void; + onSubmitVersionUpdate: ( + item: MineMcpCardItem, + onlineService?: CommunityMcpCard + ) => void; + onUnpublishOnline: ( + item: MineMcpCardItem, + onlineService: CommunityMcpCard + ) => void; + onDelete: (item: MineMcpCardItem) => void; + onViewReviewProgress?: ( + item: MineMcpCardItem, + onlineService?: CommunityMcpCard + ) => void; + onHealthCheck?: (item: MineMcpCardItem) => void; +} + +export default function MineMcpServiceCard({ + item, + selection, + onlineService, + toggling, + publishing, + unpublishing, + healthChecking = false, + connectionStatus = "unchecked", + tagRefreshKey, + onEditLocal, + onEditCommunity, + onToggle, + onSubmitVersionUpdate, + onUnpublishOnline, + onDelete, + onViewReviewProgress, + onHealthCheck, +}: MineMcpServiceCardProps) { + const { t } = useTranslation("common"); + const presentation = useRepositoryPresentation(); + const repositoryCard = presentation?.layout === "cards"; + const service = item.service; + const tags = service.tags || []; + const deploymentType = resolveDeploymentType(service); + const deploymentLabel = t(getDeploymentTypeLabelKey(deploymentType)); + const isLocal = item.kind === "local"; + const localService = isLocal ? item.service : null; + const isEnabled = localService?.enabled === McpServiceStatus.ENABLED; + const localStatusLabel = !localService + ? t("market.install.mcp.notInstalled") + : t(isEnabled ? "mcpTools.mine.enabled" : "mcpTools.status.disabled"); + const reviewStatus = onlineService?.reviewStatus || service.reviewStatus; + const isPending = reviewStatus === "pending"; + const isAlreadyListed = isLocal + ? Boolean(localService?.isListedInRepository) || + localService?.latest_review?.status === "approved" || + reviewStatus === "approved" + : reviewStatus === "approved"; + const hasVersionUpdateTarget = + item.kind === "community" + ? Boolean(item.service.marketId) + : Boolean(onlineService?.marketId); + const hasOnlineRecord = isLocal + ? Boolean(onlineService) && onlineService?.reviewStatus === "approved" + : reviewStatus === "approved"; + const reviewBadge = + reviewStatus === "approved" + ? getMineCardReviewBadge(item, onlineService) + : null; + const createDate = formatRegistryDate( + item.kind === "local" + ? item.service.createTime || "" + : item.service.createdAt || "" + ); + const updatedDate = formatRegistryDate(service.updatedAt || ""); + const installCount = onlineService?.installCount ?? 0; + const iconTone = + deploymentType === McpDeploymentType.REMOTE_LINK + ? "bg-[#EEE0F7] text-[#743E9C]" + : deploymentType === McpDeploymentType.API + ? "bg-[#E0F2D2] text-[#316614]" + : "bg-[#D0D8FD]/50 text-[#1F55B5]"; + const toolCount = resolveToolCount(item); + + // Owned = user-created MCP can be published/updated; community-installed + // or registry-installed MCPs only permit deletion. + const isOwned = + item.kind === "community" || localService?.permission === "EDIT"; + + const actionItems: MenuProps["items"] = (() => { + const items: MenuProps["items"] = []; + if (onHealthCheck && localService && isOwned) { + items.push({ + key: "health-check", + label: t("mcpConfig.serverList.button.healthCheck"), + disabled: healthChecking, + onClick: () => onHealthCheck(item), + }); + } + + if (localService) { + items.push({ + key: "toggle", + label: t(isEnabled ? "mcpTools.mine.disable" : "mcpTools.mine.enable"), + disabled: toggling, + onClick: () => onToggle(localService), + }); + } + + if (!isOwned) { + return [ + ...items, + { + key: "delete", + label: t("common.delete"), + danger: true, + onClick: () => onDelete(item), + }, + ]; + } + + // Show "view review progress" for any submitted status (pending/approved/rejected) + if ( + !localService?.latest_review && + (reviewStatus === "pending" || + reviewStatus === "approved" || + reviewStatus === "rejected") + ) { + items.push({ + key: "view-review-progress", + label: t("mcpTools.mine.viewReviewProgress"), + onClick: () => onViewReviewProgress?.(item, onlineService), + }); + } + + const isPendingReview = + localService?.latest_review?.status === "pending_review" || isPending; + if ( + !isPendingReview && + (!isAlreadyListed || hasVersionUpdateTarget) && + (deploymentType === McpDeploymentType.REMOTE_LINK || + deploymentType === McpDeploymentType.CONTAINER) + ) { + // only remote link and container MCPs can be published to community + items.push({ + key: isAlreadyListed ? "submit-version-update" : "apply-for-listing", + label: isAlreadyListed + ? t("mcpTools.mine.submitVersionUpdate") + : t("repository.mine.applyForListing"), + disabled: publishing, + onClick: () => onSubmitVersionUpdate(item, onlineService), + }); + } + + if (hasOnlineRecord) { + items.push({ + key: "unpublish-online-version", + label: isPending + ? t("mcpTools.mine.reviewModal.cancelApply") + : t("mcpTools.mine.unpublishOnlineVersion"), + danger: true, + disabled: unpublishing, + onClick: () => { + if (onlineService) onUnpublishOnline(item, onlineService); + }, + }); + } + + items.push({ + key: "delete", + label: t("common.delete"), + danger: true, + onClick: () => onDelete(item), + }); + + return items; + })(); + + const handleEdit = () => { + if (item.kind === "local") onEditLocal(item.service); + else onEditCommunity(item.service); + }; + + return ( + + + {service.name} + + + {deploymentLabel} + + {reviewBadge && ( + + {t(reviewBadge.labelKey)} + + )} + + ) : ( + service.name + ) + } + subtitle={ + !repositoryCard && ( + + + ) + } + onClick={handleEdit} + footerLayout="inline" + icon={ + repositoryCard ? ( + + + + ) : ( + + ) + } + description={service.description || t("mcpTools.detail.noDescription")} + descriptionLines={repositoryCard ? 1 : undefined} + tagsPlacement={repositoryCard ? "header" : "body"} + tags={ + <> + {!repositoryCard && ( + + {deploymentLabel} + + )} + {tags.slice(0, 3).map((tag) => ( + + {tag} + + ))} + {tags.length > 3 ? ( + + +{tags.length - 3} + + ) : null} + {item.kind === "local" ? ( + + ) : null} + {!repositoryCard && ( + + {t("mcpTools.repository.toolCount", { count: toolCount })} + + )} + + } + meta={ + repositoryCard ? ( + + + + {localStatusLabel} + + + {t("agentRepository.detail.downloads", { count: installCount })} + + {updatedDate && ( + + + {t("agentConfig.list.updatedAt", { date: updatedDate })} + + )} + + ) : ( + + + {createDate} + + ) + } + headerActions={ + selection ?? ( +
+
+ +
+ {presentation?.layout !== "list" ? ( + + ) : null} + {!repositoryCard && presentation?.layout !== "list" && reviewBadge ? ( + + {t(reviewBadge.labelKey)} + + ) : null} +
+ ) + } + footer={ + repositoryCard && onHealthCheck && isOwned && connectionStatus !== "unchecked" ? ( + + {connectionStatus === "success" ? ( + + ) : ( + + )} + {t(`mcpTools.mine.connectionStatus.${connectionStatus}`)} + + ) : null + } + /> + ); +} + +function resolveToolCount(item: MineMcpCardItem): number { + if (item.kind === "local") { + // For local MCPs installed from the community market, tools come from registryJson + const registryTools = item.service.registryJson?.tools; + if (Array.isArray(registryTools)) return registryTools.length; + return item.service.tools?.length || 0; + } + const registryTools = item.service.registryJson?.tools; + if (Array.isArray(registryTools)) return registryTools.length; + if (item.service.packages?.length) return item.service.packages.length; + if (item.service.remotes?.length) return item.service.remotes.length; + return 0; +} diff --git a/frontend/components/repository/mcp/components/PublishConfirmModal.tsx b/frontend/components/repository/mcp/components/PublishConfirmModal.tsx new file mode 100644 index 0000000000..be17b29b29 --- /dev/null +++ b/frontend/components/repository/mcp/components/PublishConfirmModal.tsx @@ -0,0 +1,354 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useEffect, useState } from "react"; +import { Form, Input, Modal, Select, Spin } from "antd"; +import { useTranslation } from "react-i18next"; +import { McpTransportType } from "@/const/mcpTools"; +import type { McpServiceItem } from "@/types/mcpTools"; +import { useMcpFormRules } from "@/hooks/mcpTools/useMcpFormRules"; +import { useGroupList } from "@/hooks/group/useGroupList"; +import { Can } from "@/components/permission/Can"; +import TagChips from "@/components/tag/TagChips"; +import { useTagAssignments } from "@/hooks/useTagManagement"; + +export interface PublishOverride { + name: string; + description: string; + version: string; + tags: string[]; + /** Remote server URL; only used when publishing a URL-type MCP. */ + serverUrl: string; + /** Container config JSON text; only used when publishing a container MCP. */ + containerConfigJson?: string; + /** Group IDs that can access this MCP. */ + groupIds?: number[]; + /** Permission level: EDIT, READ_ONLY, PRIVATE. */ + ingroupPermission?: "EDIT" | "READ_ONLY" | "PRIVATE"; + /** Field-level sharing flags. */ + sharedFields?: Record; +} + +interface PublishConfirmModalProps { + open: boolean; + source: McpServiceItem | null; + publishing: boolean; + tenantId: string | null; + onCancel: () => void; + onConfirm: (override: PublishOverride) => Promise | void; +} + +/** + * Confirmation step for "publish to community". Owns its own draft so the + * source service is never mutated; only the published copy reflects edits. + */ +export default function PublishConfirmModal({ + open, + source, + publishing, + tenantId, + onCancel, + onConfirm, +}: PublishConfirmModalProps) { + const { t } = useTranslation("common"); + const rules = useMcpFormRules(); + const [form] = Form.useForm(); + const [draft, setDraft] = useState({ + name: "", + description: "", + version: "", + tags: [], + serverUrl: "", + containerConfigJson: "", + groupIds: [], + ingroupPermission: "READ_ONLY", + sharedFields: {}, + }); + + const { data: groupData } = useGroupList(tenantId); + const groups = groupData?.groups || []; + const assignmentState = useTagAssignments( + "mcp_service", + open && source ? String(source.mcpId) : null + ); + const inheritedAssignments = assignmentState.data?.assignments ?? []; + const inheritedTags = assignmentState.data + ? inheritedAssignments.map((assignment) => assignment.display_value) + : (source?.tags ?? []); + + useEffect(() => { + if (!open || !source) return; + const containerConfigJson = + source.transportType === McpTransportType.CONTAINER + ? JSON.stringify(source.configJson ?? {}, null, 2) + : ""; + const next: PublishOverride = { + name: source.name, + description: source.description, + version: source.version || "", + tags: source.tags || [], + serverUrl: source.serverUrl || "", + containerConfigJson, + groupIds: [], + ingroupPermission: "READ_ONLY", + sharedFields: {}, + }; + // eslint-disable-next-line react-hooks/set-state-in-effect -- Preserve the existing modal reset when its external open state changes. + setDraft(next); + form.setFieldsValue(next); + }, [open, source, form]); + + const patch = (partial: Partial) => { + setDraft((prev) => ({ ...prev, ...partial })); + }; + + const handlePermissionChange = (value: string) => { + const permission = value as "EDIT" | "READ_ONLY" | "PRIVATE"; + patch({ ingroupPermission: permission }); + if (permission === "PRIVATE") { + patch({ groupIds: [] }); + form.setFieldsValue({ group_ids: [] }); + } + }; + + const handleOk = async () => { + if (!source) return; + try { + await form.validateFields(); + } catch { + return; + } + + const isPrivate = draft.ingroupPermission === "PRIVATE"; + await onConfirm({ + name: draft.name.trim(), + description: draft.description, + version: draft.version.trim(), + tags: inheritedTags, + serverUrl: + source?.transportType !== McpTransportType.CONTAINER + ? draft.serverUrl.trim() + : "", + containerConfigJson: + source?.transportType === McpTransportType.CONTAINER + ? (draft.containerConfigJson?.trim() ?? "") + : undefined, + groupIds: isPrivate ? [] : draft.groupIds, + ingroupPermission: draft.ingroupPermission, + sharedFields: draft.sharedFields, + }); + }; + + const isGroupSelectDisabled = draft.ingroupPermission === "PRIVATE"; + + return ( + +

+ {t("mcpTools.publish.confirmHint")} +

+ + + { + patch({ name: event.target.value }); + form.setFieldValue("name", event.target.value); + }} + className="rounded-md" + /> + + + + { + patch({ description: event.target.value }); + form.setFieldValue("description", event.target.value); + }} + autoSize={{ minRows: 2, maxRows: 12 }} + className="rounded-md" + /> + + + + { + patch({ version: event.target.value }); + form.setFieldValue("version", event.target.value); + }} + placeholder="1.0.0" + className="rounded-md" + /> + + + {source?.transportType !== McpTransportType.CONTAINER ? ( +
+ + { + patch({ serverUrl: event.target.value }); + form.setFieldValue("serverUrl", event.target.value); + }} + className="w-full rounded-md" + /> + + +
+ ) : null} + + {source?.transportType === McpTransportType.CONTAINER ? ( +
+ + { + patch({ containerConfigJson: event.target.value }); + form.setFieldValue("containerConfigJson", event.target.value); + }} + rows={6} + className="mt-2 w-full rounded-md font-mono text-sm" + placeholder={t("mcpTools.addModal.containerConfigPlaceholder")} + /> + + +
+ ) : null} + + + {assignmentState.loading ? ( + + ) : inheritedAssignments.length > 0 ? ( + + ) : ( + — + )} +

+ {t("mcpTools.publish.tagsInheritedHint")} +

+
+ + + + ({ + label: group.group_name, + value: group.group_id, + }) + )} + disabled={isGroupSelectDisabled} + onChange={(values: number[]) => patch({ groupIds: values })} + className="rounded-md" + /> + + + +
+ ); +} diff --git a/frontend/components/repository/mcp/components/PublishedServiceCard.tsx b/frontend/components/repository/mcp/components/PublishedServiceCard.tsx new file mode 100644 index 0000000000..8770afe7d4 --- /dev/null +++ b/frontend/components/repository/mcp/components/PublishedServiceCard.tsx @@ -0,0 +1,78 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { Tag } from "antd"; +import { useTranslation } from "react-i18next"; +import { + MCP_GRID_CARD_OUTER, + MCP_GRID_CARD_OUTER_STYLE, +} from "@/const/mcpTools"; +import type { CommunityMcpCard } from "@/types/mcpTools"; +import { getTransportLabelKey } from "@/lib/mcpTools"; +import TransportIcon from "./shared/TransportIcon"; + +interface PublishedServiceCardProps { + service: CommunityMcpCard; + onSelect: (service: CommunityMcpCard) => void; +} + +export default function PublishedServiceCard({ + service, + onSelect, +}: PublishedServiceCardProps) { + const { t } = useTranslation("common"); + const version = (service.version || "").trim(); + const tags = service.tags || []; + const transportLabel = t(getTransportLabelKey(service.transportType)); + + return ( +
onSelect(service)} + className={MCP_GRID_CARD_OUTER} + style={MCP_GRID_CARD_OUTER_STYLE} + > +
+ +
+
+

+ {service.name} +

+ {version ? ( + + v{version} + + ) : null} +
+
+ {transportLabel} +
+
+
+ +
+

+ {service.description || "-"} +

+
+ + {tags.length > 0 ? ( +
+ {tags.map((tag) => ( + + {tag} + + ))} +
+ ) : null} +
+ ); +} diff --git a/frontend/components/repository/mcp/components/PublishedServiceDetailModal.tsx b/frontend/components/repository/mcp/components/PublishedServiceDetailModal.tsx new file mode 100644 index 0000000000..49748eb010 --- /dev/null +++ b/frontend/components/repository/mcp/components/PublishedServiceDetailModal.tsx @@ -0,0 +1,606 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { useEffect, useState } from "react"; +import { App, Button, Form, Input, Modal, Select } from "antd"; +import { useTranslation } from "react-i18next"; +import { + Globe, + GitFork, + Link, + Zap, + Wrench, + Calendar, + Activity, + Server, + Tag as TagIcon, + Pencil, + Save, + X, + FileText, + Trash2, +} from "lucide-react"; +import { + MCP_TOOLS_MODAL_WRAP_CLASS, + mcpToolsModalChromeStyles, +} from "@/const/mcpTools"; +import type { CommunityMcpCard } from "@/types/mcpTools"; +import { useMcpFormRules } from "@/hooks/mcpTools/useMcpFormRules"; +import { usePublishedServiceDetailEdit } from "@/hooks/mcpTools/usePublishedServiceDetailEdit"; +import { + extractRegistryLinks, + formatRegistryDate, + getTransportLabelKey, + toPrettyRegistryJson, +} from "@/lib/mcpTools"; +import TransportIcon from "./shared/TransportIcon"; +import JsonPreviewModal from "./shared/JsonPreviewModal"; +import { useGroupList } from "@/hooks/group/useGroupList"; +import { Can } from "@/components/permission/Can"; +import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; + +interface PublishedServiceDetailModalProps { + open: boolean; + service: CommunityMcpCard | null; + onClose: () => void; +} + +/** + * Editable detail modal for the "my published" tab. Mirrors the layout of + * {@link McpServiceDetailModal} with a rich header, sectioned content, + * and inline edit mode for name/description. Published tags are read-only. + */ +export default function PublishedServiceDetailModal({ + open, + service, + onClose, +}: PublishedServiceDetailModalProps) { + const { t } = useTranslation("common"); + const { modal } = App.useApp(); + const rules = useMcpFormRules(); + const [form] = Form.useForm(); + const [isEditing, setIsEditing] = useState(false); + const edit = usePublishedServiceDetailEdit(service, open); + const { draft, saving, deleting, updateDraft } = edit; + const [showServerJsonModal, setShowServerJsonModal] = useState(false); + const [showConfigJsonModal, setShowConfigJsonModal] = useState(false); + const { user } = useAuthorizationContext(); + const tenantId = user?.tenantId || null; + const { data: groupData } = useGroupList(tenantId); + const groups = groupData?.groups || []; + + const { websiteUrl, repositoryUrl } = extractRegistryLinks( + (service?.registryJson || undefined) as Record | undefined + ); + const serverJsonPretty = toPrettyRegistryJson( + (service?.registryJson || undefined) as Record | undefined + ); + const configJsonPretty = toPrettyRegistryJson( + (service?.configJson || undefined) as Record | undefined + ); + const hasServerJson = Boolean( + service?.registryJson && Object.keys(service.registryJson).length > 0 + ); + const hasConfigJson = Boolean( + service?.configJson && Object.keys(service.configJson).length > 0 + ); + + useEffect(() => { + if (!open) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- Preserve the existing modal reset when its external open state changes. + setShowServerJsonModal(false); + setShowConfigJsonModal(false); + setIsEditing(false); + } + }, [open]); + + useEffect(() => { + if (!open || !draft) return; + form.setFieldsValue({ + name: draft.name, + description: draft.description, + version: draft.version, + ingroup_permission: draft.ingroupPermission ?? "READ_ONLY", + group_ids: draft.groupIds ? draft.groupIds.split(",").map(Number) : [], + }); + }, [open, draft, form]); + + const handleStartEdit = () => { + if (!draft) return; + form.setFieldsValue({ + name: draft.name, + description: draft.description, + version: draft.version, + ingroup_permission: draft.ingroupPermission ?? "READ_ONLY", + group_ids: draft.groupIds ? draft.groupIds.split(",").map(Number) : [], + }); + setIsEditing(true); + }; + + const handleCancelEdit = () => { + if (!draft) return; + form.setFieldsValue({ + name: draft.name, + description: draft.description, + version: draft.version, + }); + setIsEditing(false); + }; + + const handlePermissionChange = (value: string) => { + const permission = value as "EDIT" | "READ_ONLY" | "PRIVATE"; + updateDraft({ ingroupPermission: permission }); + if (permission === "PRIVATE") { + updateDraft({ groupIds: "" }); + form.setFieldsValue({ group_ids: [] }); + } + }; + + const handleSave = async () => { + try { + await form.validateFields(); + } catch { + return; + } + // Sync form values to draft before saving + const values = form.getFieldsValue(); + edit.updateDraft({ + name: values.name ?? "", + description: values.description ?? "", + version: values.version ?? "", + }); + const ok = await edit.save(); + if (ok) { + setIsEditing(false); + onClose(); + } + }; + + const handleDelete = () => { + if (!service?.communityId) return; + modal.confirm({ + title: t("mcpTools.community.mine.unpublishTitle"), + centered: true, + content: ( +

{service.name}

+ ), + okText: t("mcpTools.community.mine.unpublishConfirm"), + cancelText: t("common.cancel"), + okButtonProps: { danger: true }, + onOk: async () => { + if (typeof service.communityId !== "number") return; + const ok = await edit.remove(service.communityId); + if (ok) onClose(); + }, + }); + }; + + if (!service || !draft) return null; + + return ( + <> + { + setIsEditing(false); + onClose(); + }} + wrapClassName={`${MCP_TOOLS_MODAL_WRAP_CLASS} h-[calc(100dvh-80px)]`} + styles={mcpToolsModalChromeStyles()} + > +
+ {/* Header */} +
+
+
+
+ {isEditing ? ( +
+ + + +
+ + v + + + + + + + +
+
+ ) : ( + <> +
+ +
+

+ {draft.name} +

+
+
+

+ + v{draft.version || "1.0.0"} + + {draft.description || + t("mcpTools.detail.noDescription")} +

+ + )} +
+
+ + {/* Edit Action Buttons */} +
+ {isEditing ? ( + <> + + + + ) : ( + <> + +
+
+
+ + {/* Content */} +
+ {/* Service Info Section */} +
+

+ + {t("mcpTools.detail.serviceStatus")} +

+
+ } + label={t("mcpTools.detail.serverType")} + value={t(getTransportLabelKey(service.transportType))} + /> + } + label={t("mcpTools.detail.createdAt")} + value={formatRegistryDate(service.createdAt)} + /> + {service.updatedAt ? ( + } + label={t("mcpTools.detail.updatedAt")} + value={formatRegistryDate(service.updatedAt)} + /> + ) : null} +
+
+ + {/* Server URL Section */} + {!service.configJson && ( +
+

+ + {t("mcpTools.detail.serverUrl")} +

+
+ {service.serverUrl || "-"} +
+
+ )} + + {/* Links Section */} + {(websiteUrl || repositoryUrl) && ( +
+

+ + {t("mcpTools.detail.links")} +

+
+ {websiteUrl && ( + } + label={t("mcpTools.detail.website")} + href={websiteUrl} + /> + )} + {repositoryUrl && ( + } + label={t("mcpTools.detail.repository")} + href={repositoryUrl} + /> + )} +
+
+ )} + + {/* Tools Section */} + {(hasServerJson || hasConfigJson) && ( +
+

+ + {t("mcpTools.detail.tools")} +

+
+ {hasServerJson && ( + + )} + {hasConfigJson && ( + + )} +
+
+ )} + + {/* Tags Section */} +
+

+ + {t("mcpTools.detail.tags")} +

+
+ {(draft.tags ?? []).length > 0 ? ( + (draft.tags ?? []).map((tag) => ( + + {tag} + + )) + ) : ( + — + )} +
+
+ + {/* Permissions Section */} +
+

+ + {t("tenantResources.knowledgeBase.permission")} +

+ {isEditing ? ( +
+ + + ({ + label: g.group_name, + value: g.group_id, + }) + )} + disabled={draft.ingroupPermission === "PRIVATE"} + onChange={(values: number[]) => + updateDraft({ groupIds: values.join(",") }) + } + /> + + +
+ ) : ( +
+ {draft.ingroupPermission === "PRIVATE" ? ( + + {t("knowledgeBase.ingroup.permission.PRIVATE")} + + ) : draft.groupIds ? ( + + {t("knowledgeBase.ingroup.permission.READ_ONLY")} + + ) : ( + + {t("knowledgeBase.ingroup.permission.READ_ONLY")} + + )} +
+ )} +
+
+
+
+ + setShowServerJsonModal(false)} + /> + + setShowConfigJsonModal(false)} + /> + + ); +} + +interface InfoRowProps { + icon: React.ReactNode; + label: string; + value: string; + customValue?: React.ReactNode; + valueClass?: string; +} + +function InfoRow({ + icon, + label, + value, + customValue, + valueClass, +}: InfoRowProps) { + return ( +
+
+ {icon} + {label} +
+ {customValue ? ( + {customValue} + ) : ( + + {value} + + )} +
+ ); +} + +interface LinkRowProps { + icon: React.ReactNode; + label: string; + href: string; +} + +function LinkRow({ icon, label, href }: LinkRowProps) { + return ( + + ); +} diff --git a/frontend/components/repository/mcp/components/RepositoryMcpCard.tsx b/frontend/components/repository/mcp/components/RepositoryMcpCard.tsx new file mode 100644 index 0000000000..a82ee7681e --- /dev/null +++ b/frontend/components/repository/mcp/components/RepositoryMcpCard.tsx @@ -0,0 +1,298 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { useRepositoryPresentation } from "@/hooks/useRepositoryPresentation"; +import ResourcePublishedAt from "@/components/repository/ResourcePublishedAt"; +import { Button, Dropdown, Space, type MenuProps } from "antd"; +import { + ChevronDown, + Download, + MoreHorizontal, + Puzzle, + Trash2, +} from "lucide-react"; +import { AgentCardTags } from "@/components/repository/agent/components/AgentCardTags"; +import { StandardButton } from "@/components/common/StandardButton"; +import { McpDeploymentType } from "@/const/mcpTools"; +import { useUserDisplayName } from "@/hooks/auth/useUserDisplayName"; +import { useTranslation } from "react-i18next"; + +import ResourceCard from "@/components/resource/ResourceCard"; +import { + getDeploymentTypeLabelKey, + resolveDeploymentType, +} from "@/lib/mcpTools"; +import type { CommunityMcpCard } from "@/types/mcpTools"; +import TransportIcon from "./shared/TransportIcon"; + +interface RepositoryMcpCardProps { + service: CommunityMcpCard; + isAdmin: boolean; + installed: boolean; + onInstall: (service: CommunityMcpCard) => void; + onSelect: (service: CommunityMcpCard) => void; + onOffline: (service: CommunityMcpCard) => void; +} + +export default function RepositoryMcpCard({ + service, + isAdmin, + installed, + onInstall, + onSelect, + onOffline, +}: RepositoryMcpCardProps) { + const { t } = useTranslation("common"); + const presentation = useRepositoryPresentation(); + const repositoryCard = presentation?.layout === "cards"; + const repositoryList = presentation?.layout === "list"; + const displayName = useUserDisplayName(); + const tags = service.tags || []; + const deploymentType = resolveDeploymentType(service); + const deploymentLabel = t(getDeploymentTypeLabelKey(deploymentType)); + const installCount = Number(service.installCount || 0); + const toolCount = resolveToolCount(service); + const author = displayName( + service.authorCasUsername, + service.authorDisplayName || + service.authorName || + t("mcpTools.repository.authorFallback") + ); + const actionItems: MenuProps["items"] = isAdmin + ? [ + { + key: "offline", + label: t("mcpTools.repository.offline"), + icon: , + danger: true, + onClick: () => onOffline(service), + }, + ] + : []; + const installLabel = t( + installed ? "mcpTools.repository.installed" : "mcpTools.repository.install" + ); + const installButton = ( + onInstall(service)} + > + {installLabel} + + ); + const iconTone = + deploymentType === McpDeploymentType.REMOTE_LINK + ? "bg-[#EEE0F7] text-[#743E9C]" + : deploymentType === McpDeploymentType.API + ? "bg-[#E0F2D2] text-[#346522]" + : "bg-[rgba(208,216,253,0.5)] text-[#1F55B5]"; + + return ( + + + {service.name} + + + {deploymentLabel} + +
+ ) : ( + service.name + ) + } + footerLayout="inline" + icon={ + repositoryCard ? ( + + + + ) : ( + + ) + } + descriptionLines={repositoryCard ? 1 : undefined} + description={service.description || t("mcpTools.detail.noDescription")} + tagsPlacement={presentation ? "header" : "body"} + tags={ + repositoryCard ? ( + + ) : ( + <> + {!presentation && ( + + {deploymentLabel} + + )} + {tags.slice(0, 3).map((tag) => ( + + {tag} + + ))} + {tags.length > 3 ? ( + + +{tags.length - 3} + + ) : null} + + {t("mcpTools.repository.toolCount", { count: toolCount })} + + + ) + } + meta={ + repositoryCard ? ( + <> + + {t("resourceCenter.author")}:{author} + + + {t("agentRepository.detail.downloads", { count: installCount })} + + + ) : ( + <> + {" "} + { + + {author} + + }{" "} + + {presentation && ( + + {t("mcpTools.repository.downloadCount", { + count: installCount, + })} + + )} + + ) + } + headerActions={ + <> + {!presentation && ( + + + {installCount} + + )} + {!presentation && actionItems.length > 0 ? ( + + + {isAdmin && ( + + )} +
+ ) : repositoryCard ? ( + actionItems.length > 0 ? ( + + {installButton} + + } + /> + + + ) : ( + installButton + ) + ) : ( + + ) + } + onClick={() => onSelect(service)} + /> + ); +} + +function resolveToolCount(service: CommunityMcpCard): number { + const registryTools = service.registryJson?.tools; + if (Array.isArray(registryTools)) return registryTools.length; + const toolNames = service.registryJson?._toolNames; + if (Array.isArray(toolNames)) return toolNames.length; + if (service.packages?.length) return service.packages.length; + if (service.remotes?.length) return service.remotes.length; + return 0; +} diff --git a/frontend/components/repository/mcp/components/add/AddMcpServiceModal.tsx b/frontend/components/repository/mcp/components/add/AddMcpServiceModal.tsx new file mode 100644 index 0000000000..643b40c1f0 --- /dev/null +++ b/frontend/components/repository/mcp/components/add/AddMcpServiceModal.tsx @@ -0,0 +1,72 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { useCallback, useRef, useState } from "react"; +import { Drawer } from "antd"; +import { useTranslation } from "react-i18next"; +import { StandardCloseButton } from "@/components/common/StandardCloseButton"; +import AddMcpServiceLocalSection from "./local/AddMcpServiceLocalSection"; +import { useMcpServerList } from "@/hooks/mcp/useMcpServerList"; + +interface AddMcpServiceModalProps { + open: boolean; + onClose: () => void; +} + +export default function AddMcpServiceModal({ + open, + onClose, +}: AddMcpServiceModalProps) { + const { t } = useTranslation("common"); + const { enableUploadImage } = useMcpServerList({ enabled: open }); + const submittingRef = useRef(false); + const [submitting, setSubmittingState] = useState(false); + + const handleClose = useCallback(() => { + if (submittingRef.current) return; + onClose(); + }, [onClose]); + + const setSubmitting = useCallback((v: boolean) => { + submittingRef.current = v; + setSubmittingState(v); + }, []); + + if (!open) return null; + + return ( + + + {t("mcpTools.addModal.title")} + + {!submitting && ( + + )} +
+ } + closable={false} + onClose={handleClose} + mask={{ closable: !submitting }} + classNames={{ + section: "!p-6 !shadow-[-2px_0_12px_0_rgba(0,0,0,0.08)]", + header: "!shrink-0 !border-0 !p-0", + body: "!flex !min-h-0 !flex-col !overflow-hidden !p-0", + }} + > + + + ); +} diff --git a/frontend/components/repository/mcp/components/add/community/AddMcpServiceCommunitySection.tsx b/frontend/components/repository/mcp/components/add/community/AddMcpServiceCommunitySection.tsx new file mode 100644 index 0000000000..6a9f20c970 --- /dev/null +++ b/frontend/components/repository/mcp/components/add/community/AddMcpServiceCommunitySection.tsx @@ -0,0 +1,71 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { useState } from "react"; +import type { CommunityMcpCard } from "@/types/mcpTools"; +import { useMcpCommunityBrowser } from "@/hooks/mcpTools/useMcpCommunityBrowser"; +import { useMcpCommunityQuickAdd } from "@/hooks/mcpTools/useMcpCommunityQuickAdd"; +import McpCommunityToolbar from "./McpCommunityToolbar"; +import McpCommunityCardList from "./McpCommunityCardList"; +import McpCommunityDetailModal from "./McpCommunityDetailModal"; +import CommunityQuickAddModal from "./CommunityQuickAddModal"; + +interface AddMcpServiceCommunitySectionProps { + active: boolean; + onAdded: () => void; +} + +export default function AddMcpServiceCommunitySection({ + active, + onAdded, +}: AddMcpServiceCommunitySectionProps) { + const [selected, setSelected] = useState(null); + const browser = useMcpCommunityBrowser(active); + const quickAdd = useMcpCommunityQuickAdd({ onSuccess: onAdded }); + + if (!active) return null; + + return ( + <> +
+ browser.updateFilter("search", value)} + onTransportChange={(value) => + browser.updateFilter("transport", value) + } + onTagChange={(value) => browser.updateFilter("tag", value)} + /> + + void browser.refetch()} + services={browser.services} + hasPrevPage={browser.hasPrevPage} + hasNextPage={browser.hasNextPage} + onPrevPage={browser.prevPage} + onNextPage={browser.nextPage} + onSelect={setSelected} + onQuickAdd={quickAdd.open} + /> +
+ + {selected ? ( + setSelected(null)} + onQuickAdd={quickAdd.open} + /> + ) : null} + + {quickAdd.visible ? ( + + ) : null} + + ); +} diff --git a/frontend/components/repository/mcp/components/add/community/CommunityQuickAddModal.tsx b/frontend/components/repository/mcp/components/add/community/CommunityQuickAddModal.tsx new file mode 100644 index 0000000000..f1881ecbbd --- /dev/null +++ b/frontend/components/repository/mcp/components/add/community/CommunityQuickAddModal.tsx @@ -0,0 +1,238 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { Button, Input, Modal, Tag } from "antd"; +import { useTranslation } from "react-i18next"; +import { useState } from "react"; +import { + MCP_TOOLS_MODAL_WRAP_CLASS, + McpTransportType, + mcpToolsModalChromeStyles, +} from "@/const/mcpTools"; +import { + getDeploymentTypeLabelKey, + resolveDeploymentType, +} from "@/lib/mcpTools"; +import TransportIcon from "../../shared/TransportIcon"; +import McpContainerLogsModal from "@/components/mcp/McpContainerLogsModal"; +import type { useMcpCommunityQuickAdd } from "@/hooks/mcpTools/useMcpCommunityQuickAdd"; + +interface CommunityQuickAddModalProps { + controller: ReturnType; + mode?: "community" | "repository"; +} + +export default function CommunityQuickAddModal({ + controller, + mode = "community", +}: CommunityQuickAddModalProps) { + const { t } = useTranslation("common"); + const { + source, + draft, + submitting, + updateDraft, + close, + confirm, + nameError, + deploymentStarted, + containerId, + } = controller; + const [logsOpen, setLogsOpen] = useState(false); + + if (!source || !draft) return null; + + const deploymentType = resolveDeploymentType(source); + const deploymentLabel = t(getDeploymentTypeLabelKey(deploymentType)); + const isContainer = draft.transportType === McpTransportType.CONTAINER; + const isRepositoryInstall = mode === "repository"; + + return ( + +
+ {deploymentStarted ? ( + + ) : null} +
+
+ + +
+
+ } + > +
+ {/* Header */} +
+
+ +
+

+ {t( + isRepositoryInstall + ? "mcpTools.repository.installConfirmTitle" + : "mcpTools.community.quickAddConfirmTitle", + { name: source.name } + )} +

+
+
+
+ + {/* Content */} +
+ {/* Editable Name */} +
+ + updateDraft({ name: e.target.value })} + autoComplete="off" + status={nameError ? "error" : undefined} + /> +

+ {t("mcpTools.addModal.renameBeforeInstallHint")} +

+ {nameError ? ( +

{nameError}

+ ) : null} +
+ + {/* Description */} + {draft.description && ( +
+ +

+ {draft.description} +

+
+ )} + + {/* Server URL or Container Port */} + {isContainer ? ( +
+ + { + const val = e.target.value; + const num = parseInt(val, 10); + if ( + val === "" || + (Number.isFinite(num) && num >= 1 && num <= 65535) + ) { + updateDraft({ + containerPort: val === "" ? undefined : num, + }); + } + }} + type="number" + min={1} + max={65535} + autoComplete="off" + /> +
+ +
+                  {draft.containerConfigJson ||
+                    t("mcpTools.detail.noDescription")}
+                
+
+
+ ) : ( +
+ +

+ {draft.serverUrl || "-"} +

+
+ )} + + {/* Authorization Token */} + {!isContainer && ( +
+ + + updateDraft({ authorizationToken: e.target.value }) + } + placeholder={t( + "mcpConfig.editServer.authorizationTokenPlaceholder" + )} + autoComplete="new-password" + /> +
+ )} + + {/* Tags */} + {draft.tags && draft.tags.length > 0 && ( +
+ +
+ + {deploymentLabel} + + {draft.tags.map((tag) => ( + + {tag} + + ))} +
+
+ )} +
+
+ {deploymentStarted ? ( + setLogsOpen(false)} + containerId={containerId} + /> + ) : null} + + ); +} diff --git a/frontend/components/repository/mcp/components/add/community/McpCommunityCard.tsx b/frontend/components/repository/mcp/components/add/community/McpCommunityCard.tsx new file mode 100644 index 0000000000..462196b9fd --- /dev/null +++ b/frontend/components/repository/mcp/components/add/community/McpCommunityCard.tsx @@ -0,0 +1,89 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { Button, Tag } from "antd"; +import { useTranslation } from "react-i18next"; +import { + MCP_GRID_CARD_OUTER, + MCP_GRID_CARD_OUTER_STYLE, +} from "@/const/mcpTools"; +import { + formatRegistryDate, + formatRegistryVersion, + getTransportLabelKey, +} from "@/lib/mcpTools"; +import type { CommunityMcpCard } from "@/types/mcpTools"; +import RegistryStatusBadge from "../../shared/StatusBadge"; + +interface McpCommunityCardProps { + service: CommunityMcpCard; + onSelect: (service: CommunityMcpCard) => void; + onQuickAdd: (service: CommunityMcpCard) => void; +} + +export default function McpCommunityCard({ + service, + onSelect, + onQuickAdd, +}: McpCommunityCardProps) { + const { t } = useTranslation("common"); + const transportLabel = t(getTransportLabelKey(service.transportType)); + const tags = service.tags || []; + + return ( +
onSelect(service)} + className={MCP_GRID_CARD_OUTER} + style={MCP_GRID_CARD_OUTER_STYLE} + > +
+

+ {service.name} +

+ +
+ +
+ + {formatRegistryVersion(service.version || "")} + + + {formatRegistryDate(service.createdAt || "")} + +
+ +
+

+ {service.description || "-"} +

+
+ +
+ {transportLabel} + {tags.map((tag) => ( + + {tag} + + ))} +
+ +
+ +
+
+ ); +} diff --git a/frontend/components/repository/mcp/components/add/community/McpCommunityCardList.tsx b/frontend/components/repository/mcp/components/add/community/McpCommunityCardList.tsx new file mode 100644 index 0000000000..16daac21a1 --- /dev/null +++ b/frontend/components/repository/mcp/components/add/community/McpCommunityCardList.tsx @@ -0,0 +1,79 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { Button } from "antd"; +import { useTranslation } from "react-i18next"; +import type { CommunityMcpCard } from "@/types/mcpTools"; +import McpCommunityCard from "./McpCommunityCard"; +import McpCommunityRequestError from "./McpCommunityRequestError"; + +interface McpCommunityCardListProps { + loading: boolean; + error: Error | null; + onRetry: () => void; + services: CommunityMcpCard[]; + hasPrevPage: boolean; + hasNextPage: boolean; + onPrevPage: () => void; + onNextPage: () => void; + onSelect: (service: CommunityMcpCard) => void; + onQuickAdd: (service: CommunityMcpCard) => void; +} + +export default function McpCommunityCardList({ + loading, + error, + onRetry, + services, + hasPrevPage, + hasNextPage, + onPrevPage, + onNextPage, + onSelect, + onQuickAdd, +}: McpCommunityCardListProps) { + const { t } = useTranslation("common"); + + if (loading) { + return ( +
+ {t("mcpTools.community.loading")} +
+ ); + } + + if (error) { + return ; + } + + if (services.length === 0) { + return ( +
+ {t("mcpTools.community.empty")} +
+ ); + } + + return ( +
+
+ {services.map((service, index) => ( + + ))} +
+ +
+ + +
+
+ ); +} diff --git a/frontend/components/repository/mcp/components/add/community/McpCommunityDetailModal.tsx b/frontend/components/repository/mcp/components/add/community/McpCommunityDetailModal.tsx new file mode 100644 index 0000000000..a463f8be2d --- /dev/null +++ b/frontend/components/repository/mcp/components/add/community/McpCommunityDetailModal.tsx @@ -0,0 +1,437 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { useState } from "react"; +import { Button, Modal } from "antd"; +import { useTranslation } from "react-i18next"; +import { + Zap, + Globe, + GitFork, + Link, + Wrench, + Calendar, + Server, + Tag as TagIcon, + FileText, +} from "lucide-react"; +import { + MCP_TOOLS_MODAL_WRAP_CLASS, + mcpToolsModalChromeStyles, +} from "@/const/mcpTools"; +import { + extractRegistryLinks, + formatRegistryDate, + formatRegistryVersion, + getTransportLabelKey, + toPrettyRegistryJson, +} from "@/lib/mcpTools"; +import type { CommunityMcpCard } from "@/types/mcpTools"; +import JsonPreviewModal from "../../shared/JsonPreviewModal"; +import TransportIcon from "../../shared/TransportIcon"; + +interface McpCommunityDetailModalProps { + service: CommunityMcpCard; + onClose: () => void; + onQuickAdd?: (service: CommunityMcpCard) => void; +} + +export default function McpCommunityDetailModal({ + service, + onClose, + onQuickAdd, +}: McpCommunityDetailModalProps) { + const { t } = useTranslation("common"); + const [showServerJsonModal, setShowServerJsonModal] = useState(false); + const [showConfigJsonModal, setShowConfigJsonModal] = useState(false); + const { websiteUrl, repositoryUrl } = extractRegistryLinks( + service.registryJson as Record + ); + const serverJsonPretty = toPrettyRegistryJson( + service.registryJson as Record + ); + const configJsonPretty = toPrettyRegistryJson( + (service.configJson || undefined) as Record | undefined + ); + const hasServerJson = Boolean( + service.registryJson && Object.keys(service.registryJson).length > 0 + ); + const hasConfigJson = Boolean( + service.configJson && Object.keys(service.configJson).length > 0 + ); + const serverTypeText = t(getTransportLabelKey(service.transportType)); + const sourceText = t("mcpTools.source.community"); + + return ( + <> + +
+ {/* Header */} +
+
+
+
+ +
+

+ {service.name} +

+
+
+

+ {service.description || t("mcpTools.detail.noDescription")} +

+
+
+
+ + {/* Content */} +
+ {/* Service Info Section */} +
+

+ + {t("mcpTools.detail.serviceStatus")} +

+
+ } + label={t("mcpTools.detail.serverType")} + value={serverTypeText} + /> + } + label={t("repository.listingStatus.submittedAt")} + value={formatRegistryDate(service.createdAt)} + /> +
+
+ + {/* Service Config Section */} + {(!service.configJson || + service.authorizationToken || + service.customHeaders || + (service.configJson && + typeof service.configJson === "object" && + "mcpServers" in service.configJson)) && ( +
+

+ + {t("mcpTools.detail.serviceConfigTitle")} +

+
+ {!service.configJson ? ( +
+ +
+
+ {service.serverUrl || "-"} +
+ + {service.sharedFields?.serverUrl + ? t("mcpTools.detail.shared") + : t("mcpTools.detail.notShared")} + +
+
+ ) : null} + + {service.configJson && + typeof service.configJson === "object" && + "mcpServers" in service.configJson ? ( +
+ +
+ + + {service.sharedFields?.containerConfigJson + ? t("mcpTools.detail.shared") + : t("mcpTools.detail.notShared")} + +
+
+ ) : null} + + {service.configJson && + typeof service.configJson === "object" && + "mcpServers" in service.configJson ? ( +
+ +
+ + + {service.sharedFields?.containerConfigJson + ? t("ui.localization.shared") + : t("ui.localization.notShared")} + +
+
+ ) : null} + + {service.authorizationToken ? ( +
+ +
+
+ {service.authorizationToken} +
+ + {service.sharedFields?.authorizationToken + ? t("mcpTools.detail.shared") + : t("mcpTools.detail.notShared")} + +
+
+ ) : null} + + {service.customHeaders ? ( +
+ +
+
+ {typeof service.customHeaders === "string" + ? service.customHeaders + : JSON.stringify(service.customHeaders, null, 2)} +
+ + {service.sharedFields?.customHeaders + ? t("mcpTools.detail.shared") + : t("mcpTools.detail.notShared")} + +
+
+ ) : null} +
+
+ )} + + {/* Links Section */} + {(websiteUrl || repositoryUrl) && ( +
+

+ + {t("mcpTools.detail.links")} +

+
+ {websiteUrl && ( + } + label={t("mcpTools.detail.website")} + href={websiteUrl} + /> + )} + {repositoryUrl && ( + } + label={t("mcpTools.detail.repository")} + href={repositoryUrl} + /> + )} +
+
+ )} + + {/* Tools Section */} + {hasServerJson && ( +
+

+ + {t("mcpTools.detail.tools")} +

+
+ {hasServerJson && ( + + )} +
+
+ )} + + {/* Tags Section */} + {(service.tags || []).length > 0 && ( +
+

+ + {t("mcpTools.detail.tags")} +

+
+ {(service.tags || []).map((tag) => ( + + {tag} + + ))} +
+
+ )} +
+ + {onQuickAdd ? ( +
+ +
+ ) : null} +
+
+ + setShowServerJsonModal(false)} + /> + + setShowConfigJsonModal(false)} + /> + + ); +} + +interface InfoRowProps { + icon: React.ReactNode; + label: string; + value?: string; + customValue?: React.ReactNode; +} + +function InfoRow({ icon, label, value, customValue }: InfoRowProps) { + return ( +
+
+ {icon} + {label} +
+ {customValue ? ( + customValue + ) : ( + {value} + )} +
+ ); +} + +interface LinkRowProps { + icon: React.ReactNode; + label: string; + href: string; +} + +function LinkRow({ icon, label, href }: LinkRowProps) { + return ( + + ); +} diff --git a/frontend/components/repository/mcp/components/add/community/McpCommunityRequestError.tsx b/frontend/components/repository/mcp/components/add/community/McpCommunityRequestError.tsx new file mode 100644 index 0000000000..8c5084cc80 --- /dev/null +++ b/frontend/components/repository/mcp/components/add/community/McpCommunityRequestError.tsx @@ -0,0 +1,20 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { Button } from "antd"; +import { useTranslation } from "react-i18next"; + +export default function McpCommunityRequestError({ + onRetry, +}: { + onRetry: () => void; +}) { + const { t } = useTranslation("common"); + return ( +
+

{t("mcpTools.community.requestFailed")}

+ +
+ ); +} diff --git a/frontend/components/repository/mcp/components/add/community/McpCommunityToolbar.tsx b/frontend/components/repository/mcp/components/add/community/McpCommunityToolbar.tsx new file mode 100644 index 0000000000..d6b3f453df --- /dev/null +++ b/frontend/components/repository/mcp/components/add/community/McpCommunityToolbar.tsx @@ -0,0 +1,88 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { Input, Select } from "antd"; +import { useTranslation } from "react-i18next"; +import { FILTER_ALL, McpTransportType } from "@/const/mcpTools"; +import type { McpTagStat, McpTransportFilter } from "@/types/mcpTools"; + +interface McpCommunityToolbarProps { + search: string; + transport: McpTransportFilter; + tag: string; + tagStats: McpTagStat[]; + page: number; + resultCount: number; + onSearchChange: (value: string) => void; + onTransportChange: (value: McpTransportFilter) => void; + onTagChange: (value: string) => void; +} + +/** + * Community-browser toolbar. Search input takes ~2/3 of the row, the two + * filter selects share the remaining space and stay narrow on desktop. + */ +export default function McpCommunityToolbar({ + search, + transport, + tag, + tagStats, + page, + resultCount, + onSearchChange, + onTransportChange, + onTagChange, +}: McpCommunityToolbarProps) { + const { t } = useTranslation("common"); + + return ( +
+
+ onSearchChange(event.target.value)} + placeholder={t("mcpTools.community.searchPlaceholder")} + allowClear + className="h-9 rounded-md border border-slate-200 text-sm lg:basis-2/3" + /> +
+ ({ + value: item.tag, + label: `${item.tag} (${item.count})`, + })), + ]} + /> +
+
+ + {t("mcpTools.community.pageResult", { page, count: resultCount })} + +
+ ); +} diff --git a/frontend/components/repository/mcp/components/add/local/AddMcpServiceLocalSection.tsx b/frontend/components/repository/mcp/components/add/local/AddMcpServiceLocalSection.tsx new file mode 100644 index 0000000000..11112ddb72 --- /dev/null +++ b/frontend/components/repository/mcp/components/add/local/AddMcpServiceLocalSection.tsx @@ -0,0 +1,589 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { useEffect, useState } from "react"; +import { + Button, + ConfigProvider, + Form, + Input, + InputNumber, + Select, + Radio, + Steps, + Upload, +} from "antd"; +import type { UploadFile } from "antd"; +import { Upload as UploadIcon } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { McpDeploymentType, McpTransportType } from "@/const/mcpTools"; +import type { LocalAddMcpDraft } from "@/types/mcpTools"; +import { useMcpAddLocal } from "@/hooks/mcpTools/useMcpAddLocal"; +import { useMcpFormRules } from "@/hooks/mcpTools/useMcpFormRules"; +import { useGroupList } from "@/hooks/group/useGroupList"; +import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; +import { StandardButton } from "@/components/common/StandardButton"; +import McpGroupSelectionTable from "../../shared/McpGroupSelectionTable"; +import { usePermission } from "@/hooks/permission/usePermission"; +import { Can } from "@/components/permission/Can"; +import McpContainerLogsModal from "@/components/mcp/McpContainerLogsModal"; + +/** Maps the creation options to deployment types. */ +const DEPLOYMENT_TAB_ITEMS = [ + { + key: "remote", + type: McpDeploymentType.REMOTE_LINK, + labelKey: "mcpConfig.addServer.title", + descriptionKey: "mcpTools.addModal.mode.remoteDescription", + }, + { + key: "container", + type: McpDeploymentType.CONTAINER, + labelKey: "mcpTools.addModal.mode.container", + descriptionKey: "mcpTools.addModal.mode.containerDescription", + }, + { + key: "openapi", + type: McpDeploymentType.API, + labelKey: "mcpTools.addModal.mode.api", + descriptionKey: "mcpTools.addModal.mode.apiDescription", + }, + { + key: "upload", + type: McpDeploymentType.LOCAL_IMAGE, + labelKey: "mcpConfig.uploadImage.title", + descriptionKey: "mcpTools.addModal.mode.uploadDescription", + }, +] as const; + +const DEPLOYMENT_TAB_KEY: Record = { + [McpDeploymentType.REMOTE_LINK]: "remote", + [McpDeploymentType.CONTAINER]: "container", + [McpDeploymentType.API]: "openapi", + [McpDeploymentType.LOCAL_IMAGE]: "upload", +}; + +const VALIDATION_FIELDS_BY_DEPLOYMENT: Record< + McpDeploymentType, + Array +> = { + [McpDeploymentType.REMOTE_LINK]: [ + "name", + "description", + "serverUrl", + "authorizationToken", + "customHeaders", + ], + [McpDeploymentType.CONTAINER]: [ + "name", + "description", + "containerConfigJson", + "authorizationToken", + ], + [McpDeploymentType.API]: [ + "name", + "description", + "serverUrl", + "customHeaders", + "openApiJson", + ], + [McpDeploymentType.LOCAL_IMAGE]: [ + "name", + "description", + "uploadImageFile", + "authorizationToken", + ], +}; + +const createInitialDraft = (): LocalAddMcpDraft => ({ + name: "", + description: "", + deploymentType: McpDeploymentType.REMOTE_LINK, + transportType: McpTransportType.URL, + serverUrl: "", + authorizationToken: "", + customHeaders: "", + openApiJson: "", + containerConfigJson: "", + containerPort: undefined, + uploadImageFile: null, + tags: [], + groupIds: [], + ingroupPermission: "READ_ONLY", +}); + +interface AddMcpServiceLocalSectionProps { + active: boolean; + enableUploadImage?: boolean; + onAdded: () => void; + onClose: () => void; + onSubmittingChange?: (submitting: boolean) => void; +} + +export default function AddMcpServiceLocalSection({ + active, + enableUploadImage = false, + onAdded, + onClose, + onSubmittingChange, +}: AddMcpServiceLocalSectionProps) { + const { t } = useTranslation("common"); + const rules = useMcpFormRules(); + const [form] = Form.useForm(); + const [draft, setDraft] = useState(() => + createInitialDraft() + ); + const [deploymentType, setDeploymentType] = useState( + McpDeploymentType.REMOTE_LINK + ); + const [deploymentStarted, setDeploymentStarted] = useState(false); + const [deployedContainerId, setDeployedContainerId] = useState( + null + ); + const [logsOpen, setLogsOpen] = useState(false); + const [step, setStep] = useState(0); + const { isReady, can } = usePermission(); + const canSelectGroups = isReady && can("group:read"); + const { user } = useAuthorizationContext(); + const tenantId = user?.tenantId || null; + const { data: groupData, isLoading: loadingGroups } = useGroupList(tenantId); + const groups = groupData?.groups || []; + const { submit, submitting } = useMcpAddLocal({ + onSuccess: () => { + setDraft(createInitialDraft()); + setDeploymentType(McpDeploymentType.REMOTE_LINK); + form.resetFields(); + onAdded(); + }, + onContainerStarted: (containerId) => setDeployedContainerId(containerId), + }); + + // Notify parent modal of submitting state to block close during submission + useEffect(() => { + onSubmittingChange?.(submitting); + }, [submitting, onSubmittingChange]); + + const patchDraft = (patch: Partial) => { + setDraft((prev) => ({ ...prev, ...patch })); + }; + + const deploymentTabItems = enableUploadImage + ? DEPLOYMENT_TAB_ITEMS + : DEPLOYMENT_TAB_ITEMS.filter( + (item) => item.type !== McpDeploymentType.LOCAL_IMAGE + ); + const activeTabKey = DEPLOYMENT_TAB_KEY[deploymentType]; + + const handleTabChange = (key: string) => { + const item = DEPLOYMENT_TAB_ITEMS.find((i) => i.key === key); + if (!item) return; + const nextType = item.type; + const nextTransport = + nextType === McpDeploymentType.CONTAINER || + nextType === McpDeploymentType.LOCAL_IMAGE + ? McpTransportType.CONTAINER + : McpTransportType.URL; + patchDraft({ + deploymentType: nextType, + transportType: nextTransport, + uploadImageFile: + nextType === McpDeploymentType.LOCAL_IMAGE + ? draft.uploadImageFile + : null, + groupIds: draft.groupIds, + ingroupPermission: "READ_ONLY" as "EDIT" | "READ_ONLY" | "PRIVATE", + }); + setDeploymentType(nextType); + form.setFieldValue("transportType", nextTransport); + }; + + const uploadFileList: UploadFile[] = draft.uploadImageFile + ? [ + { + uid: "local-image", + name: draft.uploadImageFile.name, + status: "done", + originFileObj: draft.uploadImageFile as UploadFile["originFileObj"], + }, + ] + : []; + + const bindField = (key: K) => ({ + value: draft[key], + onChange: (eventOrValue: unknown) => { + const next = + eventOrValue && + typeof eventOrValue === "object" && + "target" in (eventOrValue as Record) + ? (eventOrValue as { target: { value: LocalAddMcpDraft[K] } }).target + .value + : (eventOrValue as LocalAddMcpDraft[K]); + patchDraft({ [key]: next } as Partial); + form.setFieldValue(key as string, next); + }, + }); + + const handlePermissionChange = (value: string) => { + const permission = value as "EDIT" | "READ_ONLY" | "PRIVATE"; + patchDraft({ ingroupPermission: permission }); + if (permission === "PRIVATE") { + patchDraft({ groupIds: [] }); + } + }; + + const validateConfiguration = async () => { + try { + await form.validateFields( + VALIDATION_FIELDS_BY_DEPLOYMENT[deploymentType] + ); + } catch { + return false; + } + return true; + }; + + const handleSubmit = async () => { + if (!(await validateConfiguration())) return; + const isContainerDeployment = + deploymentType === McpDeploymentType.CONTAINER || + deploymentType === McpDeploymentType.LOCAL_IMAGE; + if (isContainerDeployment) { + setDeploymentStarted(true); + setDeployedContainerId(null); + } + await submit(draft); + }; + + if (!active) return null; + + const isApi = deploymentType === McpDeploymentType.API; + const isContainer = deploymentType === McpDeploymentType.CONTAINER; + const isLocalImage = deploymentType === McpDeploymentType.LOCAL_IMAGE; + const isRemote = deploymentType === McpDeploymentType.REMOTE_LINK; + const isGroupSelectDisabled = draft.ingroupPermission === "PRIVATE" || isApi; + const urlPlaceholder = isApi + ? "mcpConfig.openapiService.form.serverUrlPlaceholder" + : "mcpConfig.addServer.urlPlaceholder"; + const jsonField = isApi ? "openApiJson" : "containerConfigJson"; + const showGroupStep = canSelectGroups && step === 1; + + return ( + +
+ {canSelectGroups && ( + + )} +
+
+ + {canSelectGroups && ( + +
+ ); +} diff --git a/frontend/components/repository/mcp/components/shared/TransportIcon.tsx b/frontend/components/repository/mcp/components/shared/TransportIcon.tsx new file mode 100644 index 0000000000..ae3d2513f3 --- /dev/null +++ b/frontend/components/repository/mcp/components/shared/TransportIcon.tsx @@ -0,0 +1,115 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { + ApiOutlined, + CloudOutlined, + CodeOutlined, + ContainerOutlined, + DatabaseOutlined, + DesktopOutlined, + GlobalOutlined, + LinkOutlined, + RocketOutlined, + ThunderboltOutlined, +} from "@ant-design/icons"; +import { McpDeploymentType, McpTransportType } from "@/const/mcpTools"; + +interface TransportVisual { + Icon: typeof LinkOutlined; + className: string; +} + +const DEPLOYMENT_VISUALS: Record = { + [McpDeploymentType.REMOTE_LINK]: { + Icon: LinkOutlined, + className: "bg-blue-50 text-blue-600", + }, + [McpDeploymentType.CONTAINER]: { + Icon: ContainerOutlined, + className: "bg-blue-50 text-blue-600", + }, + [McpDeploymentType.API]: { + Icon: ApiOutlined, + className: "bg-blue-50 text-blue-600", + }, + [McpDeploymentType.LOCAL_IMAGE]: { + Icon: CloudOutlined, + className: "bg-blue-50 text-blue-600", + }, +}; + +const ICON_POOL: Array = [ + LinkOutlined, + GlobalOutlined, + ThunderboltOutlined, + RocketOutlined, + DatabaseOutlined, + CodeOutlined, + DesktopOutlined, + CloudOutlined, + ApiOutlined, + ContainerOutlined, +]; + +const COLOR_POOL: string[] = [ + "bg-blue-50 text-blue-600", + "bg-sky-50 text-sky-600", + "bg-indigo-50 text-indigo-600", + "bg-blue-100 text-blue-700", + "bg-sky-100 text-sky-700", + "bg-indigo-100 text-indigo-700", + "bg-blue-50 text-blue-600", + "bg-sky-50 text-sky-600", +]; + +const DEFAULT_VISUAL: TransportVisual = + DEPLOYMENT_VISUALS[McpDeploymentType.REMOTE_LINK]; + +function hashString(value: string): number { + let hash = 0; + for (let i = 0; i < value.length; i += 1) { + hash = (hash << 5) - hash + value.charCodeAt(i); + hash |= 0; + } + return Math.abs(hash); +} + +interface TransportIconProps { + transportType: string; + deploymentType?: string; + label?: string; + className?: string; + seed?: string; +} + +export default function TransportIcon({ + transportType, + deploymentType, + label, + className, + seed, +}: TransportIconProps) { + const baseVisual = + (deploymentType && DEPLOYMENT_VISUALS[deploymentType]) || DEFAULT_VISUAL; + + let Icon = baseVisual.Icon; + let iconClassName = baseVisual.className; + + if (seed) { + const seedHash = hashString(seed); + Icon = ICON_POOL[seedHash % ICON_POOL.length]; + iconClassName = COLOR_POOL[seedHash % COLOR_POOL.length]; + } + + return ( + + + + ); +} diff --git a/frontend/components/repository/mcp/my-mcp.tsx b/frontend/components/repository/mcp/my-mcp.tsx new file mode 100644 index 0000000000..b85a51c223 --- /dev/null +++ b/frontend/components/repository/mcp/my-mcp.tsx @@ -0,0 +1,982 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useRepositoryTagPredicates } from "@/hooks/useRepositoryTagPredicates"; + +import { useRepositoryPresentation } from "@/hooks/useRepositoryPresentation"; +import { + useRepositoryPageSize, + useRepositoryQueryState, +} from "@/hooks/useRepositoryQueryState"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { App, Button, Spin } from "antd"; +import { useTranslation } from "react-i18next"; +import { ChevronUp } from "lucide-react"; +import { MCP_SERVERS_QUERY_KEY } from "@/hooks/mcp/useMcpServerList"; +import { useMcpServicesList } from "@/hooks/mcpTools/useMcpServicesList"; +import { useMyCommunityMcp } from "@/hooks/mcpTools/useMyCommunityMcp"; +import { useMcpServiceToggle } from "@/hooks/mcpTools/useMcpServiceToggle"; +import { useTagLibraries, useTagDefinitions } from "@/hooks/useTagManagement"; +import { checkMcpServerHealth } from "@/services/mcpService"; +import { + cancelCommunityMcpReview, + batchDeleteMcpTools, + deleteCommunityMcpTool, + unpublishCommunityMcpTool, + deleteMcpToolService, + publishCommunityMcpTool, + updateCommunityMcpTool, +} from "@/services/mcpToolsService"; +import { tagManagementApi } from "@/services/tagManagementService"; +import { getTagSearchPredicates } from "@/lib/systemTagLabels"; +import { matchesNameOrTag, resolveDeploymentType } from "@/lib/mcpTools"; +import type { + CommunityMcpCard, + McpContainerConfigPayload, + McpServiceItem, +} from "@/types/mcpTools"; +import type { TagResourcePredicate } from "@/types/tagManagement"; +import { + FILTER_ALL, + McpDeploymentType, + McpServiceStatus, + MCP_TOOLS_QUERY_KEYS, + McpTransportType, +} from "@/const/mcpTools"; +import TagFilterPopover from "@/components/tag/TagFilterPopover"; +import ResourceCardGrid from "@/components/resource/ResourceCardGrid"; +import ResourceReviewLink from "@/components/repository/ResourceReviewLink"; +import AddMcpServiceCard from "./components/AddMcpServiceCard"; +import McpToolsSearchFilterBar from "./components/McpToolsSearchFilterBar"; +import MineMcpServiceCard, { + type MineMcpCardItem, + type McpConnectionStatus, +} from "./components/MineMcpServiceCard"; +import MineApplyListingModal from "./components/MineApplyListingModal"; +import MineMcpReviewStatusModal from "./components/MineMcpReviewStatusModal"; +import { getDeploymentTypeLabelKey } from "@/lib/mcpTools"; + +export function MyMcp({ + localList, + myPublished, + actions, + reviewDeepLink, + onReviewDeepLinkConsumed, + onAdd, + onEditLocal, + onEditCommunity, + onToggled, + tagRefreshKeys, +}: { + localList: ReturnType; + myPublished: ReturnType; + actions: React.ReactNode; + reviewDeepLink: { marketId: number; sourceMcpId: number } | null; + onReviewDeepLinkConsumed: () => void; + onAdd: () => void; + onEditLocal: (service: McpServiceItem) => void; + onEditCommunity: (service: CommunityMcpCard) => void; + onToggled: (mcpId: number) => Promise; + tagRefreshKeys?: Record; +}) { + const { t } = useTranslation("common"); + const { message, modal } = App.useApp(); + const toggle = useMcpServiceToggle(); + const queryClient = useQueryClient(); + const presentation = useRepositoryPresentation(); + const cardLayout = presentation?.layout === "cards"; + const [batchMode, setBatchMode] = useState(false); + const [search, setSearch] = useRepositoryQueryState("search", ""); + const [deploymentType, setDeploymentType] = + useRepositoryQueryState("deployment", FILTER_ALL, [ + FILTER_ALL, + ...Object.values(McpDeploymentType), + ]); + const tag = FILTER_ALL; + const { data: mineTagLibraries } = useTagLibraries(); + const mineDefaultLibrary = + mineTagLibraries?.find((lib) => lib.bucket_key === "default_resource") ?? + null; + const { data: mineTagDefinitions } = useTagDefinitions( + mineDefaultLibrary?.bucket_id ?? null + ); + const [tagPredicates, setTagPredicates] = useRepositoryTagPredicates(); + const [matchedTagIds, setMatchedTagIds] = useState | null>(null); + const [matchedSearchTagIds, setMatchedSearchTagIds] = + useState | null>(null); + const [pageSize, setPageSize] = useRepositoryPageSize(); + const [page, setPage] = useRepositoryQueryState("page", 1); + const [publishingKey, setPublishingKey] = useState(null); + const [unpublishingKey, setUnpublishingKey] = useState(null); + const [refreshingMineKey, setRefreshingMineKey] = useState( + null + ); + const [connectionStatuses, setConnectionStatuses] = useState< + Record + >({}); + const [reviewProgressItem, setReviewProgressItem] = useState<{ + item: MineMcpCardItem; + onlineService?: CommunityMcpCard; + } | null>(null); + const [applyListingItem, setApplyListingItem] = useState<{ + item: MineMcpCardItem; + onlineService?: CommunityMcpCard; + } | null>(null); + const deepLinkHandledRef = useRef(null); + const items = useMemo(() => { + return getDeduplicatedMineItems(localList.services, myPublished.items); + }, [localList.services, myPublished.items]); + + const onlineServiceByCommunityId = useMemo(() => { + const services = new Map(); + for (const service of myPublished.items) { + if (service.communityId) services.set(service.communityId, service); + } + return services; + }, [myPublished.items]); + + const onlineServiceBySourceMcpId = useMemo(() => { + const services = new Map(); + for (const item of myPublished.items) { + if (item.sourceMcpId != null) services.set(item.sourceMcpId, item); + } + return services; + }, [myPublished.items]); + + useEffect(() => { + /* eslint-disable react-hooks/set-state-in-effect -- The deep link opens the matching review item after data loads. */ + if (!reviewDeepLink) { + deepLinkHandledRef.current = null; + return; + } + if (localList.loading || myPublished.loading) { + return; + } + + const deepLinkKey = `${reviewDeepLink.marketId}:${reviewDeepLink.sourceMcpId}`; + if (deepLinkHandledRef.current === deepLinkKey) { + return; + } + + const onlineService = + myPublished.items.find( + (service) => + service.marketId === reviewDeepLink.marketId || + service.communityId === reviewDeepLink.marketId + ) || onlineServiceBySourceMcpId.get(reviewDeepLink.sourceMcpId); + + const localService = localList.services.find( + (service) => service.mcpId === reviewDeepLink.sourceMcpId + ); + + if (localService) { + const item: MineMcpCardItem = { kind: "local", service: localService }; + setReviewProgressItem({ + item, + onlineService: onlineService || undefined, + }); + deepLinkHandledRef.current = deepLinkKey; + onReviewDeepLinkConsumed(); + return; + } + + if (onlineService) { + setReviewProgressItem({ + item: { kind: "community", service: onlineService }, + onlineService, + }); + deepLinkHandledRef.current = deepLinkKey; + onReviewDeepLinkConsumed(); + return; + } + + deepLinkHandledRef.current = deepLinkKey; + message.error(t("notifications.deepLink.mcpNotFound")); + onReviewDeepLinkConsumed(); + /* eslint-enable react-hooks/set-state-in-effect */ + }, [ + localList.loading, + localList.services, + message, + myPublished.items, + myPublished.loading, + onReviewDeepLinkConsumed, + onlineServiceBySourceMcpId, + reviewDeepLink, + t, + ]); + + const categoryStats = useMemo( + () => + getDeploymentCategoryStats( + items.map((item) => item.service), + t + ), + [items, t] + ); + + const searchTagPredicates = useMemo( + () => getTagSearchPredicates(mineTagDefinitions, search, t), + [mineTagDefinitions, search, t] + ); + + useEffect(() => { + /* eslint-disable react-hooks/set-state-in-effect -- Reset matches when the selected filters change. */ + if (tagPredicates.length === 0) { + setMatchedTagIds(null); + return; + } + const localItems = items + .filter((item) => item.kind === "local") + .map((item) => String(item.service.mcpId)) + .filter(Boolean); + if (localItems.length === 0) { + setMatchedTagIds(new Set()); + return; + } + let cancelled = false; + tagManagementApi + .filterResourceIds("mcp_service", localItems, tagPredicates) + .then((result) => { + if (cancelled) return; + setMatchedTagIds(new Set(result.matched_resource_ids ?? [])); + }) + .catch(() => { + if (cancelled) return; + setMatchedTagIds(new Set()); + }); + return () => { + cancelled = true; + }; + /* eslint-enable react-hooks/set-state-in-effect */ + }, [tagPredicates, items]); + + useEffect(() => { + /* eslint-disable react-hooks/set-state-in-effect -- Reset matches when the search predicates change. */ + if (searchTagPredicates.length === 0) { + setMatchedSearchTagIds(null); + return; + } + const localItems = items + .filter((item) => item.kind === "local") + .map((item) => String(item.service.mcpId)) + .filter(Boolean); + if (localItems.length === 0) { + setMatchedSearchTagIds(new Set()); + return; + } + let cancelled = false; + Promise.all( + searchTagPredicates.map((predicate) => + tagManagementApi.filterResourceIds("mcp_service", localItems, [ + predicate, + ]) + ) + ) + .then((results) => { + if (cancelled) return; + setMatchedSearchTagIds( + new Set( + results.flatMap((result) => result.matched_resource_ids ?? []) + ) + ); + }) + .catch(() => { + if (cancelled) return; + setMatchedSearchTagIds(new Set()); + }); + return () => { + cancelled = true; + }; + /* eslint-enable react-hooks/set-state-in-effect */ + }, [items, searchTagPredicates]); + const filteredItems = useMemo(() => { + return items.filter((item) => { + const service = item.service; + const matchesStructuredTag = + item.kind === "local" && + matchedSearchTagIds?.has(String(item.service.mcpId)); + if (!matchesNameOrTag(service, search) && !matchesStructuredTag) + return false; + if (tag !== FILTER_ALL && !(service.tags || []).includes(tag)) + return false; + if ( + deploymentType !== FILTER_ALL && + resolveDeploymentType(service) !== deploymentType + ) + return false; + if (matchedTagIds !== null) { + if (item.kind !== "local") return false; + if (!matchedTagIds.has(String(item.service.mcpId))) return false; + } + return true; + }); + }, [items, search, tag, deploymentType, matchedTagIds, matchedSearchTagIds]); + + useEffect(() => { + /* eslint-disable react-hooks/set-state-in-effect -- Filter changes restart pagination. */ + if (!presentation) setPage(1); + /* eslint-enable react-hooks/set-state-in-effect */ + }, [search, tag, deploymentType, tagPredicates]); + + const itemsPerPage = presentation ? pageSize : pageSize - 1; + const totalPages = Math.max( + 1, + Math.ceil(filteredItems.length / itemsPerPage) + ); + useEffect(() => { + /* eslint-disable react-hooks/set-state-in-effect -- Filtering or deleting items can reduce the last page. */ + if (!localList.loading && !myPublished.loading && page > totalPages) + setPage(totalPages); + /* eslint-enable react-hooks/set-state-in-effect */ + }, [page, totalPages, localList.loading, myPublished.loading, setPage]); + + const loading = localList.loading || myPublished.loading; + + const handleToggle = async (service: McpServiceItem) => { + await toggle.toggle(service); + await onToggled(service.mcpId); + }; + + const refreshMineData = async () => { + // Refetch the review query immediately so the admin tab badge updates + // without requiring a full page reload. + await queryClient.refetchQueries({ + queryKey: MCP_TOOLS_QUERY_KEYS.communityReview, + type: "all", + }); + await Promise.all([localList.refetch(), myPublished.refetch()]); + }; + + const handleSubmitVersionUpdate = ( + item: MineMcpCardItem, + onlineService?: CommunityMcpCard + ) => { + setApplyListingItem({ item, onlineService }); + }; + + const doSubmitVersionUpdate = async ( + item: MineMcpCardItem, + onlineService: CommunityMcpCard | undefined, + content?: string, + sharedFields?: Record + ) => { + const key = getMineItemKey(item); + setPublishingKey(key); + try { + if (item.kind === "community") { + const service = item.service; + if (!service.marketId) return; + await updateCommunityMcpTool({ + market_id: service.marketId, + name: service.name.trim(), + description: (service.description || "").trim(), + version: (service.version || "").trim(), + tags: service.tags || [], + registry_json: service.registryJson, + shared_fields: sharedFields, + content, + }); + } else if (onlineService?.marketId) { + const service = item.service; + const configJson = toMcpContainerConfigPayload(service.configJson); + await updateCommunityMcpTool({ + market_id: onlineService.marketId, + name: service.name.trim(), + description: (service.description || "").trim(), + version: (service.version || "").trim(), + tags: service.tags || [], + registry_json: service.registryJson || onlineService.registryJson, + mcp_server: configJson ? undefined : service.serverUrl, + transport_type: configJson + ? McpTransportType.CONTAINER + : McpTransportType.URL, + config_json: configJson, + shared_fields: sharedFields, + content, + }); + } else if (item.kind === "local") { + const service = item.service; + const configJson = toMcpContainerConfigPayload(service.configJson); + await publishCommunityMcpTool({ + mcp_id: service.mcpId, + name: service.name.trim(), + description: service.description, + version: (service.version || "").trim(), + tags: service.tags || [], + mcp_server: configJson ? undefined : service.serverUrl, + config_json: configJson, + shared_fields: sharedFields, + content, + }); + } + const isInitialPublish = + item.kind === "local" && !onlineService?.marketId; + message.success( + isInitialPublish + ? t("mcpTools.mine.publishApplySuccess") + : t("mcpTools.mine.submitVersionUpdateSuccess") + ); + } catch { + message.error(t("mcpTools.mine.publishApplyFailed")); + return; + } finally { + setPublishingKey(null); + } + // Refresh caches after successful submission; never fail the submission + // when a cache refresh has a transient error. + try { + await refreshMineData(); + } catch { + // cache refresh errors are non-fatal + } + setPublishingKey(null); + }; + + const handleUnpublishOnline = ( + item: MineMcpCardItem, + onlineService: CommunityMcpCard + ) => { + if (!onlineService.communityId) return; + const isPendingReview = onlineService.reviewStatus === "pending"; + modal.confirm({ + title: isPendingReview + ? t("mcpTools.mine.reviewModal.confirmCancelApplyTitle") + : t("mcpTools.mine.unpublishOnlineVersionTitle"), + content: isPendingReview + ? t("repository.listingStatus.cancelApply") + : t("mcpTools.mine.unpublishOnlineVersionDescription", { + name: onlineService.name || item.service.name, + }), + okText: isPendingReview + ? t("repository.listingStatus.cancelApply") + : t("mcpTools.mine.unpublishOnlineVersion"), + cancelText: t("common.cancel"), + okButtonProps: { danger: true }, + centered: true, + onOk: async () => { + const key = getMineItemKey(item); + setUnpublishingKey(key); + try { + await unpublishCommunityMcpTool(onlineService.communityId!); + message.success( + isPendingReview + ? t("repository.mine.cancelApplySuccess") + : t("mcpTools.mine.unpublishOnlineVersionSuccess") + ); + await refreshMineData(); + } catch { + message.error( + isPendingReview + ? t("repository.mine.cancelApplyError") + : t("mcpTools.mine.unpublishOnlineVersionFailed") + ); + } finally { + setUnpublishingKey(null); + } + }, + }); + }; + + const refreshAfterDelete = async () => { + await refreshMineData(); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: MCP_SERVERS_QUERY_KEY }), + queryClient.invalidateQueries({ queryKey: ["tools"] }), + queryClient.invalidateQueries({ queryKey: ["agents"] }), + queryClient.refetchQueries({ + queryKey: MCP_SERVERS_QUERY_KEY, + type: "all", + }), + ]); + }; + + const deleteSelectedItems = async (items: MineMcpCardItem[]) => { + const targets = items.map((item) => { + const id = + item.kind === "local" ? item.service.mcpId : item.service.communityId; + if (!id) throw new Error(t("repository.mine.deleteFailed")); + return { kind: item.kind, id }; + }); + const results = await batchDeleteMcpTools(targets); + const byTarget = new Map( + results.map((result) => [`${result.kind}:${result.id}`, result]) + ); + const failures = new Map(); + targets.forEach(({ kind, id }, index) => { + const result = byTarget.get(`${kind}:${id}`); + if (!result?.success) { + failures.set( + getMineItemKey(items[index]), + result?.message || t("repository.mine.deleteFailed") + ); + } + }); + if (failures.size < items.length) { + await Promise.allSettled([refreshAfterDelete()]); + } + return failures; + }; + + const handleDelete = (item: MineMcpCardItem) => { + modal.confirm({ + title: t("mcpTools.mine.deleteConfirmTitle"), + content: t("mcpTools.mine.deleteConfirmDescription", { + name: item.service.name, + }), + okText: t("common.delete"), + cancelText: t("common.cancel"), + okButtonProps: { danger: true }, + centered: true, + onOk: async () => { + try { + if (item.kind === "local") { + await deleteMcpToolService(item.service.mcpId); + } else { + const marketId = item.service.communityId; + if (!marketId) throw new Error(t("repository.mine.deleteFailed")); + await deleteCommunityMcpTool(marketId); + } + await refreshAfterDelete(); + message.success(t("repository.mine.deleteSuccess")); + } catch { + message.error(t("repository.mine.deleteFailed")); + } + }, + }); + }; + + const handleHealthCheck = async (item: MineMcpCardItem) => { + const mcpId = + item.kind === "local" ? item.service.mcpId : item.service.sourceMcpId; + if (!mcpId) return; + const key = getMineItemKey(item); + setRefreshingMineKey(key); + try { + const result = await checkMcpServerHealth(mcpId); + setConnectionStatuses((current) => ({ + ...current, + [key]: result.success ? "success" : "failed", + })); + if (result.success) { + message.success( + result.message || t("mcpService.message.healthCheckSuccess") + ); + } else { + message.error( + result.message || t("mcpService.message.healthCheckFailed") + ); + } + if (!result.success) { + // If MCP is enabled and health check fails, auto-disable it + if ( + item.kind === "local" && + item.service.enabled === McpServiceStatus.ENABLED + ) { + await toggle.toggle(item.service); + } + } + await refreshMineData(); + } catch { + setConnectionStatuses((current) => ({ ...current, [key]: "failed" })); + message.error(t("mcpService.message.healthCheckFailed")); + } finally { + setRefreshingMineKey(null); + } + }; + + const handleCancelApply = async ( + item: MineMcpCardItem, + onlineService?: CommunityMcpCard + ) => { + const communityRecord = + item.kind === "community" ? item.service : onlineService; + const reviewId = communityRecord?.reviewId; + if (!reviewId) return; + try { + await cancelCommunityMcpReview(reviewId); + message.success(t("repository.mine.cancelApplySuccess")); + setReviewProgressItem(null); + await refreshMineData(); + } catch { + message.error(t("repository.mine.cancelApplyError")); + } + }; + + const handleTakeDown = async ( + item: MineMcpCardItem, + onlineService: CommunityMcpCard + ) => { + handleUnpublishOnline(item, onlineService); + }; + + const renderBatchToolbar = (batchControls?: React.ReactNode) => ( +
+ + {actions} + {batchControls} + + {!batchMode && ( + + )} + + ) : ( + actions + ) + } + searchActions={ + + } + onSearchChange={setSearch} + onDeploymentTypeChange={setDeploymentType} + /> +
+ ); + + return ( +
+ {loading && renderBatchToolbar()} +
+ {loading ? ( + + + + ) : ( + + item.kind === "local" + ? onEditLocal(item.service) + : onEditCommunity(item.service) + } + renderListStatus={(item) => { + const localService = item.kind === "local" ? item.service : null; + const onlineService = localService + ? resolveOnlineService( + localService, + onlineServiceByCommunityId, + onlineServiceBySourceMcpId + ) + : item.service; + const reviewStatus = + onlineService?.reviewStatus || + item.service.reviewStatus || + (localService?.isListedInRepository || + (item.kind === "community" && item.service.status === "active") + ? "approved" + : undefined); + const isEnabled = + localService?.enabled === McpServiceStatus.ENABLED; + const listingState = + reviewStatus === "approved" + ? { + label: t("mcpTools.mine.status.listed"), + className: "bg-emerald-50 text-emerald-700", + } + : reviewStatus === "pending" + ? { + label: t("mcpTools.mine.status.reviewing"), + className: "bg-orange-50 text-orange-700", + } + : reviewStatus === "rejected" + ? { + label: t("mcpTools.mine.status.rejected"), + className: "bg-red-50 text-red-700", + } + : { + label: t("repository.status.notListed"), + className: "bg-slate-100 text-slate-600", + }; + const latestReview = localService?.latest_review; + + return ( +
+ {localService ? ( + + {t( + isEnabled + ? "mcpTools.mine.enabled" + : "mcpTools.status.disabled" + )} + + ) : ( + + {t("market.install.mcp.notInstalled")} + + )} + + {listingState.label} + + {latestReview ? ( + + ) : null} +
+ ); + }} + getItemKey={getMineItemKey} + selectionKey={JSON.stringify([ + search, + deploymentType, + tagPredicates, + ])} + canBatchDelete={(item) => + item.kind === "local" + ? item.service.permission === "EDIT" + : Boolean(item.service.communityId) + } + batchDeleteItems={batchMode ? deleteSelectedItems : undefined} + renderBatchToolbar={renderBatchToolbar} + cardSelectionInHeader + items={filteredItems} + page={page} + total={filteredItems.length} + onPageChange={setPage} + pageSize={pageSize} + onPageSizeChange={presentation ? setPageSize : undefined} + createCard={ + presentation ? undefined : + } + paginateItems + showToolbar={false} + emptyState={presentation ? undefined : null} + renderItem={(item, _index, selection) => { + const key = getMineItemKey(item); + const onlineService = + item.kind === "local" + ? resolveOnlineService( + item.service, + onlineServiceByCommunityId, + onlineServiceBySourceMcpId + ) + : item.service; + return ( + + setReviewProgressItem({ item, onlineService: os }) + } + onHealthCheck={handleHealthCheck} + healthChecking={refreshingMineKey === getMineItemKey(item)} + connectionStatus={connectionStatuses[key] ?? "unchecked"} + tagRefreshKey={ + item.kind === "local" + ? tagRefreshKeys?.[item.service.mcpId] + : undefined + } + /> + ); + }} + /> + )} +
+ + setReviewProgressItem(null)} + onCancelApply={handleCancelApply} + onTakeDown={handleTakeDown} + /> + + setApplyListingItem(null)} + onConfirm={async (content, sharedFields) => { + if (!applyListingItem) return; + await doSubmitVersionUpdate( + applyListingItem.item, + applyListingItem.onlineService, + content, + sharedFields + ); + }} + /> +
+ ); +} + +export type DeploymentFilter = McpDeploymentType | typeof FILTER_ALL; + +type DeploymentCountable = { + transportType: CommunityMcpCard["transportType"]; + deploymentType?: McpDeploymentType; + configJson?: Record; + serverUrl?: string; +}; + +const deploymentCategories = [ + McpDeploymentType.REMOTE_LINK, + McpDeploymentType.CONTAINER, + McpDeploymentType.API, + McpDeploymentType.LOCAL_IMAGE, +]; + +export function getDeploymentCategoryStats( + items: DeploymentCountable[], + t: (key: string) => string +): Array<{ value: DeploymentFilter; label: string; count: number }> { + const hasLocalImage = items.some( + (item) => resolveDeploymentType(item) === McpDeploymentType.LOCAL_IMAGE + ); + return [ + { + value: FILTER_ALL, + label: t("mcpTools.deploymentType.all"), + count: items.length, + }, + ...deploymentCategories + .filter((dt) => dt !== McpDeploymentType.LOCAL_IMAGE || hasLocalImage) + .map((deploymentType) => ({ + value: deploymentType, + label: t(getDeploymentTypeLabelKey(deploymentType)), + count: items.filter( + (item) => resolveDeploymentType(item) === deploymentType + ).length, + })), + ]; +} + +export function getDeduplicatedMineItems( + localServices: McpServiceItem[], + publishedServices: CommunityMcpCard[] +): MineMcpCardItem[] { + // Only show local MCPs that belong to the current user or are shared via groups + const myLocalServices = localServices.filter( + (s) => s.permission === "EDIT" || s.groupIds + ); + const linkedCommunityIds = new Set(); + const localNames = new Set(); + + for (const service of myLocalServices) { + if (service.communityId) linkedCommunityIds.add(service.communityId); + localNames.add(normalizeMcpName(service.name)); + } + + const visiblePublishedServices = publishedServices.filter((service) => { + // Published-by-me items (have sourceMcpId) are hidden from "我的" tab. + // They are managed via the repository tab. This prevents them from + // reappearing after the local copy is deleted. + if (service.sourceMcpId != null) return false; + if (service.communityId && linkedCommunityIds.has(service.communityId)) { + return false; + } + return !localNames.has(normalizeMcpName(service.name)); + }); + + return [ + ...myLocalServices.map((service) => ({ + kind: "local" as const, + service, + })), + ...visiblePublishedServices.map((service) => ({ + kind: "community" as const, + service, + })), + ]; +} + +function normalizeMcpName(name: string): string { + return name.trim().toLowerCase(); +} + +export function getMineItemKey(item: MineMcpCardItem): string { + return item.kind === "local" + ? `local-${item.service.mcpId}` + : `community-${item.service.communityId || item.service.name}`; +} + +export function toMcpContainerConfigPayload( + value?: Record +): McpContainerConfigPayload | undefined { + if (!value || typeof value.mcpServers !== "object" || !value.mcpServers) { + return undefined; + } + return value as unknown as McpContainerConfigPayload; +} + +export function resolveOnlineService( + service: McpServiceItem, + serviceByCommunityId: Map, + serviceBySourceMcpId: Map +): CommunityMcpCard | undefined { + const reviewService = serviceBySourceMcpId.get(service.mcpId); + if (reviewService) return reviewService; + if (service.communityId) { + const marketService = serviceByCommunityId.get(service.communityId); + if ( + marketService?.sourceMcpId == null || + marketService.sourceMcpId === service.mcpId + ) { + return marketService; + } + } + return undefined; +} + +export function PlaceholderBox({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} diff --git a/frontend/components/repository/mcp/review-center.tsx b/frontend/components/repository/mcp/review-center.tsx new file mode 100644 index 0000000000..000b0c1710 --- /dev/null +++ b/frontend/components/repository/mcp/review-center.tsx @@ -0,0 +1,301 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useState } from "react"; +import { App, Button, Empty, Spin } from "antd"; +import { useUserDisplayName } from "@/hooks/auth/useUserDisplayName"; +import { useTranslation } from "react-i18next"; +import { CheckCircle, Clock, Eye, XCircle } from "lucide-react"; +import { + approveCommunityMcpTool, + rejectCommunityMcpTool, +} from "@/services/mcpToolsService"; +import type { CommunityMcpCard } from "@/types/mcpTools"; +import { + formatRegistryDate, + getDeploymentTypeLabelKey, + resolveDeploymentType, +} from "@/lib/mcpTools"; +import { useMcpCommunityReview } from "@/hooks/mcpTools/useMcpCommunityReview"; +import McpToolsPagination from "./components/McpToolsPagination"; +import McpRepositoryReviewConfirmModal, { + type McpRepositoryReviewAction, +} from "./components/McpRepositoryReviewConfirmModal"; +import TransportIcon from "./components/shared/TransportIcon"; +import { PlaceholderBox } from "./my-mcp"; + +export function ReviewCenter({ + browser, + onSelect, + onReviewed, +}: { + browser: ReturnType; + onSelect: (service: CommunityMcpCard) => void; + onReviewed: () => Promise; +}) { + const { t } = useTranslation("common"); + const { message } = App.useApp(); + const [reviewingId, setReviewingId] = useState(null); + const [confirmAction, setConfirmAction] = + useState(null); + const [confirmService, setConfirmService] = useState( + null + ); + + const openReviewConfirm = ( + service: CommunityMcpCard, + action: McpRepositoryReviewAction + ) => { + setConfirmService(service); + setConfirmAction(action); + }; + + const handleReviewConfirm = async (content?: string) => { + if (!confirmService?.reviewId || !confirmAction) return; + setReviewingId(confirmService.reviewId); + try { + if (confirmAction === "approve") { + await approveCommunityMcpTool(confirmService.reviewId, content); + message.success( + t("repository.review.approveSuccess", { name: confirmService.name }) + ); + } else { + await rejectCommunityMcpTool(confirmService.reviewId, content); + message.success( + t("repository.review.rejectSuccess", { name: confirmService.name }) + ); + } + setConfirmAction(null); + setConfirmService(null); + await onReviewed(); + } catch { + message.error(t("repository.review.actionFailed")); + throw new Error("Review action failed"); + } finally { + setReviewingId(null); + } + }; + + return ( +
+ {browser.loading ? ( + + + + ) : browser.services.length === 0 ? ( + + + + ) : ( +
+ + + + + + + + + + + + + {browser.services.map((service) => ( + onSelect(service)} + onApprove={() => openReviewConfirm(service, "approve")} + onReject={() => openReviewConfirm(service, "reject")} + /> + ))} + +
+ {t("repository.review.column.name")} + + {t("repository.review.column.deploymentType")} + + {t("repository.review.column.submitter")} + + {t("repository.review.column.listingNote")} + + {t("repository.review.column.status")} + + {t("repository.review.column.actions")} +
+
+ )} + + + + { + setConfirmAction(null); + setConfirmService(null); + }} + onConfirm={handleReviewConfirm} + /> +
+ ); +} + +function ReviewTableRow({ + service, + reviewing, + onSelect, + onApprove, + onReject, +}: { + service: CommunityMcpCard; + reviewing: boolean; + onSelect: () => void; + onApprove: () => void; + onReject: () => void; +}) { + const { t } = useTranslation("common"); + const displayName = useUserDisplayName(); + const deploymentType = resolveDeploymentType(service); + const deploymentLabel = t(getDeploymentTypeLabelKey(deploymentType)); + const reviewStatus = service.reviewStatus || "pending"; + const isPending = reviewStatus === "pending"; + const author = displayName( + service.authorCasUsername, + service.authorDisplayName || service.authorName || "-" + ); + const submitDate = formatRegistryDate(service.createdAt || ""); + const listingNote = service.content?.trim() || "—"; + + const statusBadge = (() => { + if (reviewStatus === "approved") { + return ( + + + {t("repository.review.status.approved")} + + ); + } + if (reviewStatus === "rejected") { + return ( + + + {t("repository.review.status.rejected")} + + ); + } + return ( + + + {t("repository.review.status.pending")} + + ); + })(); + + return ( + + {/* MCP Service */} + +
+ +
+
+ {service.name} +
+
+
+ + + {/* Deployment Type */} + + + {deploymentLabel} + + + + {/* Submitter */} + +
{author}
+
{submitDate}
+ + + {/* Listing note */} + + + {listingNote} + + + + {/* Status */} + {statusBadge} + + {/* Actions */} + + {isPending ? ( +
+ + + +
+ ) : ( + + )} + + + ); +} diff --git a/frontend/components/repository/resource-space.css b/frontend/components/repository/resource-space.css new file mode 100644 index 0000000000..850b9540ad --- /dev/null +++ b/frontend/components/repository/resource-space.css @@ -0,0 +1,241 @@ +.resource-space .resource-card-grid { + gap: 16px; +} + +.resource-space[data-resource-type="mcp"] .resource-card-grid, +.resource-space[data-resource-type="skill"] .resource-card-grid { + row-gap: 12px; +} + +.resource-space .ant-tabs-nav { + margin-bottom: 16px; +} + +.resource-space .ant-table-wrapper:not(.resource-scroll-table) { + overflow-x: auto; +} + +.resource-space .resource-scroll-table { + min-width: 0; + max-width: 100%; +} + +.resource-scroll-table .ant-table-cell { + white-space: nowrap; +} + +.resource-scroll-table .ant-table-cell:not(.ant-table-cell-fix) { + overflow: hidden; + text-overflow: ellipsis; +} + +.resource-space[data-resource-type="mcp"] + .resource-scroll-table + .ant-table-thead + > tr + > th:has(+ th.ant-table-cell-fix-end:not(.ant-table-cell-scrollbar))::before { + display: none; +} + +.resource-space[data-resource-type="mcp"] + .resource-scroll-table + .ant-table-thead + > tr + > th.ant-table-cell-fix-end:not(.ant-table-cell-scrollbar)::before { + position: absolute; + inset-inline-start: 0; + inset-inline-end: auto; + top: 50%; + width: 1px; + height: 14px; + transform: translateY(-50%); + background: #c9c9c9; + content: ""; +} + +.resource-repository-pagination .ant-pagination { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; +} + +.resource-repository-pagination .ant-pagination-total-text, +.resource-scroll-table .ant-pagination-total-text { + margin-inline-end: auto; +} + +.resource-repository-content:has(.resource-scroll-table), +.resource-repository-content div:has(.resource-scroll-table), +.resource-scroll-table, +.resource-scroll-table div:has(> .ant-table-body), +.resource-scroll-table div:has(> .ant-table-container), +.resource-scroll-table .ant-spin:has(> .ant-spin-container), +.resource-scroll-table .ant-spin-nested-loading, +.resource-scroll-table .ant-spin-container { + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; +} + +.resource-repository-content:has(.resource-scroll-table) { + overflow: hidden; +} + +.resource-repository-content:has(.repository-card-scroll) { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.resource-repository-content div:has(.resource-scroll-table) { + flex: 1 1 auto; +} + +.resource-scroll-table .ant-table-header, +.resource-scroll-table .ant-table-pagination { + flex-shrink: 0; +} + +.resource-scroll-table .ant-table-body { + min-height: 0; +} + +.mcp-edit-code { + max-height: 61.8cqw; + overscroll-behavior-x: contain; +} + +.mcp-edit-code[data-overflow-y] { + overscroll-behavior-y: contain; +} + +:root + :is( + .repository-detail-body.mcp-edit-drawer-body, + .mcp-edit-drawer-body .mcp-edit-code + ) { + --scrollbar-visible-thumb: transparent; + --scrollbar-thumb-hover: var(--scrollbar-visible-thumb); + scrollbar-color: var(--scrollbar-visible-thumb) transparent !important; + scrollbar-width: thin; +} + +.mcp-edit-drawer-body:hover:not(:has(.mcp-edit-code[data-overflow-y]:hover)), +.mcp-edit-drawer-body .mcp-edit-code:hover { + --scrollbar-visible-thumb: #969696; +} + +.mcp-mine .resource-scroll-table .ant-table-container { + max-height: 500px; +} + +.auto-hide-scrollbars + .resource-scroll-table + :is(.ant-table-content, .ant-table-body) { + --scrollbar-visible-thumb: var(--scrollbar-thumb); +} + +@supports selector(::-webkit-scrollbar) { + :root + :is( + .repository-detail-body.mcp-edit-drawer-body, + .mcp-edit-drawer-body .mcp-edit-code + ) { + scrollbar-width: auto; + scrollbar-color: auto !important; + } + + .mcp-edit-drawer-body::-webkit-scrollbar-thumb, + .mcp-edit-drawer-body .mcp-edit-code::-webkit-scrollbar-thumb { + background-color: var(--scrollbar-visible-thumb); + } + + .mcp-edit-code::-webkit-scrollbar { + width: 4px; + height: 4px; + } + + .mcp-edit-code::-webkit-scrollbar-thumb { + border: 0; + } + + .resource-scroll-table { + --resource-table-scrollbar-size: 4px; + } + + :is(.repository-card-body, .repository-detail-body) { + scrollbar-width: auto; + scrollbar-color: auto !important; + } + + :is(.repository-card-body, .repository-detail-body)::-webkit-scrollbar { + width: 4px; + } + + :is(.repository-card-body, .repository-detail-body)::-webkit-scrollbar-thumb { + border: 0; + } + + .resource-scroll-table :is(.ant-table-content, .ant-table-body) { + scrollbar-width: auto; + scrollbar-color: auto !important; + } + + .resource-scroll-table + :is(.ant-table-content, .ant-table-body)::-webkit-scrollbar { + height: var(--resource-table-scrollbar-size); + width: var(--resource-table-scrollbar-size); + } + + /* Match the fixed header gutter to the table body's custom scrollbar. */ + .resource-space + .resource-scroll-table + .ant-table-header + table:has(.ant-table-cell-scrollbar) + > colgroup + > col:last-child { + width: var(--resource-table-scrollbar-size) !important; + } + + .resource-space + .resource-scroll-table + .ant-table-header + th.ant-table-cell-fix-end:has(+ .ant-table-cell-scrollbar) { + inset-inline-end: var(--resource-table-scrollbar-size) !important; + } + + .resource-scroll-table + :is(.ant-table-content, .ant-table-body)::-webkit-scrollbar-thumb { + border: 0; + } +} +.resource-space { + background: #fff; +} + +.resource-review-table .ant-table-thead > tr > th { + font-size: 14px; + font-weight: 500; +} + +.resource-review-table .ant-table-tbody > tr > td { + border-bottom-color: #f3f3f3; +} +@font-face { + font-family: "HarmonyOS Sans SC"; + src: url("/fonts/harmonyos-sans-sc/HarmonyOS_Sans_SC_Regular.ttf") + format("truetype"); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "HarmonyOS Sans SC"; + src: url("/fonts/harmonyos-sans-sc/HarmonyOS_Sans_SC_Medium.ttf") + format("truetype"); + font-weight: 500; + font-style: normal; + font-display: swap; +} diff --git a/frontend/components/repository/skill/components/CreateNewSkillCard.tsx b/frontend/components/repository/skill/components/CreateNewSkillCard.tsx new file mode 100644 index 0000000000..250cbe4769 --- /dev/null +++ b/frontend/components/repository/skill/components/CreateNewSkillCard.tsx @@ -0,0 +1,21 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useTranslation } from "react-i18next"; + +import CreateResourceCard from "@/components/resource/CreateResourceCard"; + +interface CreateNewSkillCardProps { + onClick: () => void; +} + +export function CreateNewSkillCard({ onClick }: CreateNewSkillCardProps) { + const { t } = useTranslation("common"); + return ( + + ); +} diff --git a/frontend/components/repository/skill/components/MineApplyListingModal.tsx b/frontend/components/repository/skill/components/MineApplyListingModal.tsx new file mode 100644 index 0000000000..9990823fcb --- /dev/null +++ b/frontend/components/repository/skill/components/MineApplyListingModal.tsx @@ -0,0 +1,104 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Input, Modal } from "antd"; +import { useTranslation } from "react-i18next"; + +import type { + MyEditableSkillItem, + SkillRepositoryListingCreatePayload, +} from "@/types/skillRepository"; + +interface MineApplyListingModalProps { + open: boolean; + skill: MyEditableSkillItem | null; + loading?: boolean; + onClose: () => void; + onConfirm: (payload: SkillRepositoryListingCreatePayload) => Promise; +} + +export function MineApplyListingModal({ + open, + skill, + loading = false, + onClose, + onConfirm, +}: MineApplyListingModalProps) { + const { t } = useTranslation("common"); + const [listingContent, setListingContent] = useState(""); + const [submitting, setSubmitting] = useState(false); + const submittingRef = useRef(false); + + useEffect(() => { + if (!open) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- Preserve the existing modal reset when its external open state changes. + setListingContent(""); + setSubmitting(false); + submittingRef.current = false; + } + }, [open]); + + if (!skill) { + return null; + } + + const title = skill.name?.trim() || t("skillRepository.common.untitled"); + const isBusy = loading || submitting; + + const handleOk = async () => { + if (submittingRef.current || loading) { + return; + } + submittingRef.current = true; + setSubmitting(true); + try { + await onConfirm({ + icon: "skill", + tags: skill.tags ?? [], + content: listingContent.trim() || undefined, + }); + onClose(); + } finally { + submittingRef.current = false; + setSubmitting(false); + } + }; + + return ( + +
+

+ {t("skillRepository.mine.confirmApplyContent")} +

+
+ + setListingContent(event.target.value)} + placeholder={t("repository.mine.applyModal.contentPlaceholder")} + rows={4} + disabled={isBusy} + /> +
+
+
+ ); +} diff --git a/frontend/components/repository/skill/components/MineSkillsView.tsx b/frontend/components/repository/skill/components/MineSkillsView.tsx new file mode 100644 index 0000000000..19bedc4359 --- /dev/null +++ b/frontend/components/repository/skill/components/MineSkillsView.tsx @@ -0,0 +1,678 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import ResourceReviewLink from "@/components/repository/ResourceReviewLink"; +import RepositoryDisplayControls from "@/components/repository/RepositoryDisplayControls"; +import { useRepositoryPresentation } from "@/hooks/useRepositoryPresentation"; +import { useEffect, useRef, useState } from "react"; +import { App, Button, Dropdown, Input, Popover, Select } from "antd"; +import type { MenuProps } from "antd"; +import { useTranslation } from "react-i18next"; +import { + Bot, + ClipboardCheck, + Clock, + Eye, + MoreHorizontal, + Pencil, + Search, + Tag, + Trash2, + Upload, +} from "lucide-react"; + +import { CreateNewSkillCard } from "./CreateNewSkillCard"; +import ResourceCardGrid from "@/components/resource/ResourceCardGrid"; +import ResourceCard from "@/components/resource/ResourceCard"; +import { MineApplyListingModal } from "./MineApplyListingModal"; +import { SkillReviewStatusModal } from "./SkillReviewStatusModal"; +import { AsyncContent, FilterButton } from "./SkillRepositoryControls"; +import { + formatRepositoryDate, + getSkillRepositoryStatusLabel, + pickReviewDisplayRepositoryInfo, +} from "./skillRepositoryShared"; +import type { + MyEditableSkillItem, + MyEditableSkillListItem, + MySkillRepositoryInfoItem, + MineOwnershipFilter, + NewSkillPaddingItem, + SkillRepositoryListingCreatePayload, + SkillRepositoryListingStatus, +} from "@/types/skillRepository"; +import TagFilterControls from "@/components/tag/TagFilterControls"; +import type { + TagDefinition, + TagResourcePredicate, +} from "@/types/tagManagement"; + +const MINE_OWNERSHIP_FILTERS: MineOwnershipFilter[] = [ + "all", + "created", + "others", +]; + +export function isNewSkillPaddingItem( + item: MyEditableSkillListItem +): item is NewSkillPaddingItem { + return "new_skill_padding" in item && item.new_skill_padding === true; +} + +export type SkillReviewDeepLinkTarget = { + skillRepositoryId: number; + skillId: number; +}; + +function findRepositoryInfoById( + repositoryInfo: MySkillRepositoryInfoItem[], + skillRepositoryId: number +): MySkillRepositoryInfoItem | null { + return ( + repositoryInfo.find( + (item) => item.skill_repository_id === skillRepositoryId + ) ?? null + ); +} + +export function MineSkillsView({ + skills, + counts, + ownership, + onOwnershipChange, + searchQuery, + onSearchChange, + tagDefinitions, + tagPredicates, + onTagPredicatesChange, + isLoading, + isError, + isFetching, + page, + total, + onPageChange, + pageSize, + onPageSizeChange, + onRetry, + onCreateSkill, + onEditSkill, + onViewSkill, + onDeleteSkill, + onApplyListing, + isUpdatingStatus, + onSetNotShared, + reviewDeepLink = null, + deepLinkFallbackSkill = null, + deepLinkFallbackLoading = false, + onReviewDeepLinkConsumed, +}: { + skills: MyEditableSkillListItem[]; + counts: { all: number; created: number; others: number }; + ownership: MineOwnershipFilter; + onOwnershipChange: (ownership: MineOwnershipFilter) => void; + searchQuery: string; + onSearchChange: (value: string) => void; + tagDefinitions: TagDefinition[]; + tagPredicates: TagResourcePredicate[]; + onTagPredicatesChange: (predicates: TagResourcePredicate[]) => void; + isLoading: boolean; + isError: boolean; + isFetching: boolean; + page: number; + total: number; + onPageChange: (page: number) => void; + pageSize: number; + onPageSizeChange: (size: number) => void; + onRetry: () => void; + onCreateSkill: () => void; + onEditSkill: (skill: MyEditableSkillItem) => void; + onViewSkill: (skill: MyEditableSkillItem) => void; + onDeleteSkill: (skill: MyEditableSkillItem) => Promise; + onApplyListing: ( + skill: MyEditableSkillItem, + payload: SkillRepositoryListingCreatePayload + ) => Promise; + isUpdatingStatus: boolean; + onSetNotShared: (repositoryInfo: MySkillRepositoryInfoItem) => Promise; + reviewDeepLink?: SkillReviewDeepLinkTarget | null; + deepLinkFallbackSkill?: MyEditableSkillItem | null; + deepLinkFallbackLoading?: boolean; + onReviewDeepLinkConsumed?: () => void; +}) { + const { t } = useTranslation("common"); + const presentation = useRepositoryPresentation(); + const showCreateCard = + Boolean(presentation) && + page === 1 && + ownership === "all" && + !searchQuery.trim() && + tagPredicates.length === 0; + const { message, modal } = App.useApp(); + const [reviewModalOpen, setReviewModalOpen] = useState(false); + const [reviewModalSkill, setReviewModalSkill] = + useState(null); + const [reviewModalInfo, setReviewModalInfo] = + useState(null); + const [applyModalOpen, setApplyModalOpen] = useState(false); + const [applyModalSkill, setApplyModalSkill] = + useState(null); + const [applySubmitting, setApplySubmitting] = useState(false); + const consumedDeepLinkRef = useRef(null); + const ownershipLabelKey: Record = { + all: "repository.mine.filter.all", + created: "repository.mine.filter.created", + others: "repository.mine.filter.others", + }; + + const openReviewModal = ( + skill: MyEditableSkillItem, + repositoryInfo?: MySkillRepositoryInfoItem | null + ) => { + const info = + repositoryInfo ?? + pickReviewDisplayRepositoryInfo(skill.repository_info ?? []); + if (!info) { + return; + } + setReviewModalSkill(skill); + setReviewModalInfo(info); + setReviewModalOpen(true); + }; + + const closeReviewModal = () => { + setReviewModalOpen(false); + setReviewModalSkill(null); + setReviewModalInfo(null); + }; + + const handleSetNotShared = async () => { + if (!reviewModalInfo) return; + + try { + await onSetNotShared(reviewModalInfo); + closeReviewModal(); + } catch (error) { + message.error( + error instanceof Error + ? error.message + : t("skillRepository.common.statusUpdateFailed") + ); + throw error; + } + }; + + useEffect(() => { + if (!reviewDeepLink) { + consumedDeepLinkRef.current = null; + return; + } + + if (consumedDeepLinkRef.current === reviewDeepLink.skillRepositoryId) { + return; + } + + if (isLoading && deepLinkFallbackLoading) { + return; + } + + const skillFromList = skills.find( + (item): item is MyEditableSkillItem => + !isNewSkillPaddingItem(item) && item.skill_id === reviewDeepLink.skillId + ); + const skill = skillFromList ?? deepLinkFallbackSkill; + + if (!skill) { + if (isLoading || deepLinkFallbackLoading) { + return; + } + message.error(t("notifications.deepLink.skillNotFound")); + consumedDeepLinkRef.current = reviewDeepLink.skillRepositoryId; + onReviewDeepLinkConsumed?.(); + return; + } + + const repositoryInfo = findRepositoryInfoById( + skill.repository_info ?? [], + reviewDeepLink.skillRepositoryId + ); + + if (!repositoryInfo) { + message.error(t("notifications.deepLink.skillNotFound")); + consumedDeepLinkRef.current = reviewDeepLink.skillRepositoryId; + onReviewDeepLinkConsumed?.(); + return; + } + + // eslint-disable-next-line react-hooks/set-state-in-effect -- Resolve the review deep link once its skill data is available. + openReviewModal(skill, repositoryInfo); + consumedDeepLinkRef.current = reviewDeepLink.skillRepositoryId; + onReviewDeepLinkConsumed?.(); + }, [ + deepLinkFallbackLoading, + deepLinkFallbackSkill, + isLoading, + message, + onReviewDeepLinkConsumed, + reviewDeepLink, + skills, + t, + ]); + + const handleEnableSkill = (skill: MyEditableSkillItem) => { + setApplyModalSkill(skill); + setApplyModalOpen(true); + }; + + const handleConfirmApply = async ( + payload: SkillRepositoryListingCreatePayload + ) => { + if (!applyModalSkill) { + return; + } + setApplySubmitting(true); + try { + await onApplyListing(applyModalSkill, payload); + } finally { + setApplySubmitting(false); + } + }; + + const handleDeleteSkill = (skill: MyEditableSkillItem) => { + const title = skill.name?.trim() || t("skillRepository.common.untitled"); + modal.confirm({ + title: t("skillRepository.mine.deleteTitle"), + content: t("skillRepository.mine.deleteContent", { name: title }), + okText: t("common.delete"), + cancelText: t("common.cancel"), + okButtonProps: { danger: true }, + onOk: async () => { + await onDeleteSkill(skill); + }, + }); + }; + + return ( +
+
+
+
+ onSearchChange(event.target.value)} + placeholder={t("skillRepository.searchPlaceholder")} + prefix={} + className={ + presentation + ? "h-8 gap-1 rounded border-[#C9C9C9] bg-white px-3 py-[5px] [&_input::placeholder]:text-[#AEAEAE]" + : "rounded-xl" + } + allowClear + /> +
+ + + {tagPredicates.length > 0 ? ( + + ) : null} +
+ } + > + + + {presentation && ( + + className="h-8 w-[296px] max-w-full" + aria-label={t(ownershipLabelKey.all)} + value={ownership} + onChange={onOwnershipChange} + options={MINE_OWNERSHIP_FILTERS.map((value) => ({ + value, + label: `${t(ownershipLabelKey[value])} (${counts[value]})`, + }))} + /> + )} +
+ +
+ + {!presentation && ( +
+
+ {MINE_OWNERSHIP_FILTERS.map((filter) => ( + onOwnershipChange(filter)} + > + {t(ownershipLabelKey[filter])} + + {counts[filter]} + + + ))} +
+
+ )} + +
+ + <> + + ) : undefined + } + isCreateItem={isNewSkillPaddingItem} + onListSelect={(skill) => { + if (!isNewSkillPaddingItem(skill)) onViewSkill(skill); + }} + batchDelete={async (skill) => { + if (!isNewSkillPaddingItem(skill)) await onDeleteSkill(skill); + }} + canBatchDelete={(skill) => + !isNewSkillPaddingItem(skill) && skill.permission === "EDIT" + } + selectionKey={JSON.stringify([ + searchQuery, + ownership, + tagPredicates, + ])} + items={skills} + page={page} + total={total} + onPageChange={onPageChange} + pageSize={pageSize} + onPageSizeChange={presentation ? onPageSizeChange : undefined} + paginateItems={false} + showToolbar={false} + renderItem={(skill) => + isNewSkillPaddingItem(skill) ? ( + + ) : ( + onEditSkill(skill)} + onView={() => onViewSkill(skill)} + onDelete={() => handleDeleteSkill(skill)} + onApplyListing={() => handleEnableSkill(skill)} + onViewReview={() => openReviewModal(skill)} + /> + ) + } + /> + + +
+ + + { + setApplyModalOpen(false); + setApplyModalSkill(null); + }} + onConfirm={handleConfirmApply} + /> +
+ ); +} + +const MINE_SKILL_STATUS_CLASS: Record = { + not_shared: + "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300", + pending_review: + "bg-orange-50 text-orange-700 dark:bg-orange-500/10 dark:text-orange-300", + rejected: "bg-red-50 text-red-700 dark:bg-red-500/10 dark:text-red-300", + shared: + "bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300", +}; + +function getMineSkillMenuItems({ + canApplyListing, + canEdit, + canPublish, + hasRepositoryInfo, + t, + onApplyListing, + onViewReview, + onDelete, +}: { + canApplyListing: boolean; + canEdit: boolean; + canPublish: boolean; + hasRepositoryInfo: boolean; + t: (key: string) => string; + onApplyListing: () => void; + onViewReview: () => void; + onDelete: () => void; +}): MenuProps["items"] { + const items: MenuProps["items"] = []; + if (canApplyListing) { + items.push({ + key: "apply-listing", + label: t("skillRepository.mine.button.apply"), + icon: , + onClick: onApplyListing, + }); + } + if (canPublish && hasRepositoryInfo) { + items.push({ + key: "review", + label: t("skillRepository.mine.viewReviewProgress"), + icon: , + onClick: onViewReview, + }); + } + if (canEdit) { + items.push({ + key: "delete", + label: t("common.delete"), + icon: , + danger: true, + onClick: onDelete, + }); + } + return items; +} + +function MineSkillCard({ + skill, + onEdit, + onView, + onDelete, + onApplyListing, + onViewReview, +}: { + skill: MyEditableSkillItem; + onEdit: () => void; + onView: () => void; + onDelete: () => void; + onApplyListing: () => void; + onViewReview: () => void; +}) { + const { t } = useTranslation("common"); + const presentation = useRepositoryPresentation(); + const latestRepository = pickReviewDisplayRepositoryInfo( + (skill.repository_info ?? []).filter((item) => item.status === "shared") + ); + const hasRepositoryInfo = latestRepository != null; + const repositoryStatus = latestRepository?.status ?? "not_shared"; + const canEdit = + skill.permission !== "READ_ONLY" && skill.permission !== "PRIVATE"; + const canPublish = skill.can_publish === true; + const updatedAt = formatRepositoryDate(skill.updated_at ?? skill.update_time); + const tags = skill.tags?.filter((tag) => tag.trim()) ?? []; + const canApplyListing = + canPublish && skill.latest_review?.status !== "pending_review"; + const menuItems = getMineSkillMenuItems({ + canApplyListing, + canEdit, + canPublish, + hasRepositoryInfo: false, + t, + onApplyListing, + onViewReview, + onDelete, + }); + + return ( + + +
+ } + fixedHeaderLayout + description={ + skill.description || t("skillRepository.common.noDescription") + } + tags={ + tags.length > 0 ? ( + <> + {tags.map((tag) => ( + + {tag} + + ))} + + ) : undefined + } + meta={ + updatedAt ? ( + + + {updatedAt} + + ) : undefined + } + footerLayout="inline" + headerActions={ + canEdit || canApplyListing || hasRepositoryInfo ? ( +
+
+ {canEdit || canApplyListing ? ( + +
+
+ + {hasRepositoryInfo ? ( + + {getSkillRepositoryStatusLabel(t, repositoryStatus)} + + ) : null} +
+
+ ) : undefined + } + footer={ +
+ {canEdit ? ( + + ) : ( + + )} +
+ } + /> + ); +} diff --git a/frontend/components/repository/skill/components/RepositoryView.tsx b/frontend/components/repository/skill/components/RepositoryView.tsx new file mode 100644 index 0000000000..793f1c648a --- /dev/null +++ b/frontend/components/repository/skill/components/RepositoryView.tsx @@ -0,0 +1,236 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import RepositoryDisplayControls from "@/components/repository/RepositoryDisplayControls"; +import { useRepositoryPresentation } from "@/hooks/useRepositoryPresentation"; +import { Button, Dropdown, Input, Tooltip } from "antd"; +import { ChevronDown, Copy, Search } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import TagFilterPopover from "@/components/tag/TagFilterPopover"; +import ResourceCardGrid from "@/components/resource/ResourceCardGrid"; + +import { SkillRepositoryCard } from "./SkillRepositoryCard"; +import { AsyncContent } from "./SkillRepositoryControls"; +import type { SkillRepositoryListingItem } from "@/types/skillRepository"; +import type { + TagDefinition, + TagResourcePredicate, +} from "@/types/tagManagement"; + +export function RepositoryView({ + searchQuery, + onSearchChange, + tagDefinitions, + tagPredicates, + onTagPredicatesChange, + listings, + isLoading, + isError, + isFetching, + page, + total, + onPageChange, + pageSize, + onPageSizeChange, + onRetry, + onInstall, + onUninstall, + onDetailClick, + showAdminMenu, + onTakeDown, + installingRepositoryId, + takingDownRepositoryId, +}: { + searchQuery: string; + onSearchChange: (value: string) => void; + tagDefinitions: TagDefinition[]; + tagPredicates: TagResourcePredicate[]; + onTagPredicatesChange: (value: TagResourcePredicate[]) => void; + listings: SkillRepositoryListingItem[]; + isLoading: boolean; + isError: boolean; + isFetching: boolean; + page: number; + total: number; + onPageChange: (page: number) => void; + pageSize: number; + onPageSizeChange: (size: number) => void; + onRetry: () => void; + onInstall: (listing: SkillRepositoryListingItem) => void; + onUninstall?: ( + listing: SkillRepositoryListingItem, + copy: { skill_id: number; name: string } + ) => void; + onDetailClick: (listing: SkillRepositoryListingItem) => void; + showAdminMenu: boolean; + onTakeDown: (listing: SkillRepositoryListingItem) => void; + installingRepositoryId: number | null; + takingDownRepositoryId: number | null; +}) { + const { t } = useTranslation("common"); + const presentation = useRepositoryPresentation(); + return ( +
+
+
+ onSearchChange(event.target.value)} + placeholder={t("skillRepository.searchPlaceholder")} + prefix={} + className={ + presentation + ? "h-8 gap-1 rounded border-[#C9C9C9] bg-white px-3 py-[5px] text-sm leading-[22px] [&_input::placeholder]:text-[#AEAEAE]" + : "rounded-xl" + } + /> +
+ + +
+ +
+ + <> + ( + onDetailClick(listing)} + showAdminMenu={showAdminMenu} + isTakingDown={ + takingDownRepositoryId === listing.skill_repository_id + } + onTakeDown={() => onTakeDown(listing)} + action={ + onInstall(listing), + }, + ...(listing.installed_copies || []).map((copy) => ({ + key: String(copy.skill_id), + label: `${t("resourceCenter.uninstall")}: ${copy.name}`, + disabled: !copy.can_uninstall, + onClick: () => onUninstall?.(listing, copy), + })), + ], + }} + trigger={["click"]} + > + + + + {presentation && onUninstall ? ( +
+
+ ); +} diff --git a/frontend/components/repository/skill/components/ReviewSkillList.tsx b/frontend/components/repository/skill/components/ReviewSkillList.tsx new file mode 100644 index 0000000000..47ce8ca4d9 --- /dev/null +++ b/frontend/components/repository/skill/components/ReviewSkillList.tsx @@ -0,0 +1,193 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { Button, Tag } from "antd"; +import { Bot, Check, Eye, X } from "lucide-react"; +import { useUserDisplayName } from "@/hooks/auth/useUserDisplayName"; +import { useTranslation } from "react-i18next"; + +import { AsyncContent, PaginationBar } from "./SkillRepositoryControls"; +import type { + SkillRepositoryListingItem, + SkillRepositoryListingStatus, +} from "@/types/skillRepository"; + +const GRID_COLS = "grid-cols-[minmax(0,2fr)_160px_100px_minmax(0,1.5fr)_260px]"; + +const REVIEW_STATUS_LABEL_KEYS: Record = { + not_shared: "repository.review.status.notShared", + pending_review: "repository.review.status.pending", + rejected: "repository.review.status.rejected", + shared: "repository.review.status.approved", +}; + +const REVIEW_STATUS_COLORS: Record = { + not_shared: "default", + pending_review: "processing", + rejected: "error", + shared: "success", +}; + +function getSubmitterDisplay( + submittedBy: string | null | undefined, + t: (key: string) => string +) { + return submittedBy?.trim() || t("repository.review.unknownSubmitter"); +} + +export function ReviewSkillList({ + listings, + isLoading, + isError, + isFetching, + page, + pageSize, + total, + onPageChange, + onRetry, + updatingRepositoryId, + onDetailClick, + onApprove, + onReject, +}: { + listings: SkillRepositoryListingItem[]; + isLoading: boolean; + isError: boolean; + isFetching: boolean; + page: number; + pageSize: number; + total: number; + onPageChange: (page: number) => void; + onRetry: () => void; + updatingRepositoryId: number | null; + onDetailClick: (listing: SkillRepositoryListingItem) => void; + onApprove: (listing: SkillRepositoryListingItem) => void; + onReject: (listing: SkillRepositoryListingItem) => void; +}) { + const { t } = useTranslation("common"); + const displayName = useUserDisplayName(); + return ( +
+ + <> +
+
+
+ {t("repository.review.column.name")} + {t("repository.review.column.submitter")} + {t("repository.review.column.status")} + {t("repository.review.column.listingNote")} + {t("repository.review.column.actions")} +
+ +
    + {listings.map((listing) => { + const isUpdating = + updatingRepositoryId === listing.skill_repository_id; + const isPendingReview = listing.status === "pending_review"; + const listingNote = listing.content?.trim() || "—"; + + return ( +
  • +
    +
    + +
    +
    +

    + {listing.name || + t("skillRepository.common.untitled")} +

    + {listing.description ? ( +

    + {listing.description} +

    + ) : null} +
    +
    + +
    + {displayName( + listing.submitted_by_cas_username, + getSubmitterDisplay(listing.submitted_by, t) + )} +
    + +
    + + {t(REVIEW_STATUS_LABEL_KEYS[listing.status])} + +
    + +
    + {listingNote} +
    + +
    + + {isPendingReview ? ( + <> + + + + ) : null} +
    +
  • + ); + })} +
+
+
+ + +
+
+ ); +} diff --git a/frontend/components/repository/skill/components/SkillRepositoryCard.tsx b/frontend/components/repository/skill/components/SkillRepositoryCard.tsx new file mode 100644 index 0000000000..2a17c160db --- /dev/null +++ b/frontend/components/repository/skill/components/SkillRepositoryCard.tsx @@ -0,0 +1,176 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useRepositoryPresentation } from "@/hooks/useRepositoryPresentation"; +import ResourcePublishedAt from "@/components/repository/ResourcePublishedAt"; +import type { ReactNode } from "react"; +import type { MenuProps } from "antd"; +import { Button, Dropdown, Tag } from "antd"; +import { + Bot, + Download, + FileText, + MoreHorizontal, + PackageX, +} from "lucide-react"; +import { useUserDisplayName } from "@/hooks/auth/useUserDisplayName"; +import { useTranslation } from "react-i18next"; + +import ResourceCard from "@/components/resource/ResourceCard"; +import type { + SkillRepositoryListingItem, + SkillRepositoryListingStatus, +} from "@/types/skillRepository"; +import { + getSkillRepositoryStatusLabel, + STATUS_COLORS, +} from "./skillRepositoryShared"; + +export function StatusTag({ + status, +}: { + status: SkillRepositoryListingStatus; +}) { + const { t } = useTranslation("common"); + return ( + + {getSkillRepositoryStatusLabel(t, status)} + + ); +} + +export function SkillRepositoryCard({ + listing, + onDetailClick, + action, + showAdminMenu = false, + isTakingDown = false, + onTakeDown, +}: { + listing: SkillRepositoryListingItem; + onDetailClick?: () => void; + action?: ReactNode; + showAdminMenu?: boolean; + isTakingDown?: boolean; + onTakeDown?: () => void; +}) { + const { t } = useTranslation("common"); + const presentation = useRepositoryPresentation(); + const displayName = useUserDisplayName(); + const tags = listing.tags?.filter((tag) => tag.trim()) ?? []; + const author = listing.author?.trim() + ? displayName(listing.author_cas_username, listing.author.trim()) + : displayName( + listing.submitted_by_cas_username, + listing.submitted_by?.trim() || "" + ); + const menuItems: MenuProps["items"] = + showAdminMenu && onTakeDown != null + ? [ + { + key: "takeDown", + label: t("skillRepository.action.status.notShared"), + icon: , + danger: true, + disabled: isTakingDown, + onClick: onTakeDown, + }, + ] + : []; + + return ( + + {presentation ? ( + + ) : ( + + )} +
+ } + description={ + listing.description || t("skillRepository.common.noDescription") + } + tagsPlacement={presentation ? "header" : "body"} + tags={ + tags.length > 0 ? ( + <> + {tags.map((tag) => ( + + {tag} + + ))} + + ) : ( + + ) + } + headerActions={ + presentation ? undefined : ( + + + {(listing.downloads ?? 0).toLocaleString()} + + ) + } + actions={ + menuItems.length > 0 ? ( + + +
+ ); + } + + if (isEmpty) { + return ( +
+ +
+ ); + } + + return ( +
{children}
+ ); +} + +export function PaginationBar({ + page, + pageSize, + total, + onPageChange, +}: { + page: number; + pageSize: number; + total: number; + onPageChange: (page: number) => void; +}) { + const { t } = useTranslation("common"); + const totalPages = total > 0 ? Math.ceil(total / pageSize) : 0; + if (totalPages <= 1) return null; + + return ( +
+ + {Array.from({ length: totalPages }, (_, index) => index + 1).map( + (pageNumber) => ( + + ) + )} + +
+ ); +} + +export function FilterButton({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: ReactNode; +}) { + return ( + + ); +} + +export function CountBadge({ + count, + strong = false, +}: { + count: number; + strong?: boolean; +}) { + if (!strong) { + return ( + + {count} + + ); + } + + if (count <= 0) return null; + + return ( + + {count} + + ); +} diff --git a/frontend/components/repository/skill/components/SkillRepositoryReviewConfirmModal.tsx b/frontend/components/repository/skill/components/SkillRepositoryReviewConfirmModal.tsx new file mode 100644 index 0000000000..8a24cfba14 --- /dev/null +++ b/frontend/components/repository/skill/components/SkillRepositoryReviewConfirmModal.tsx @@ -0,0 +1,98 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useEffect, useState } from "react"; +import { Input, Modal } from "antd"; +import { useTranslation } from "react-i18next"; + +import type { SkillRepositoryListingItem } from "@/types/skillRepository"; + +export type SkillRepositoryReviewAction = "approve" | "reject"; + +interface SkillRepositoryReviewConfirmModalProps { + open: boolean; + action: SkillRepositoryReviewAction | null; + listing: SkillRepositoryListingItem | null; + loading?: boolean; + onClose: () => void; + onConfirm: (content?: string) => Promise; +} + +export function SkillRepositoryReviewConfirmModal({ + open, + action, + listing, + loading = false, + onClose, + onConfirm, +}: SkillRepositoryReviewConfirmModalProps) { + const { t } = useTranslation("common"); + const [reviewOpinion, setReviewOpinion] = useState(""); + + useEffect(() => { + if (!open) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- Preserve the existing modal reset when its external open state changes. + setReviewOpinion(""); + } + }, [open]); + + if (!action || !listing) { + return null; + } + + const isApprove = action === "approve"; + const title = listing.name?.trim() || t("skillRepository.common.untitled"); + + const handleOk = async () => { + const trimmed = reviewOpinion.trim(); + await onConfirm(trimmed || undefined); + }; + + return ( + +
+

+ {isApprove + ? t("repository.review.confirmApproveContent", { name: title }) + : t("repository.review.confirmRejectContent", { name: title })} +

+
+ + setReviewOpinion(event.target.value)} + placeholder={t("repository.review.reviewOpinionPlaceholder")} + rows={4} + disabled={loading} + /> +
+
+
+ ); +} diff --git a/frontend/components/repository/skill/components/SkillReviewStatusModal.tsx b/frontend/components/repository/skill/components/SkillReviewStatusModal.tsx new file mode 100644 index 0000000000..10bfdc04c0 --- /dev/null +++ b/frontend/components/repository/skill/components/SkillReviewStatusModal.tsx @@ -0,0 +1,170 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { Button, Modal } from "antd"; +import { CheckCircle2, Clock, PackageX, Store, XCircle } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import { + formatRepositoryDate, + isCancelableRepositoryStatus, + isTakeDownableRepositoryStatus, +} from "./skillRepositoryShared"; +import type { + MyEditableSkillItem, + MySkillRepositoryInfoItem, +} from "@/types/skillRepository"; + +export function SkillReviewStatusModal({ + open, + skill, + repositoryInfo, + isUpdatingStatus, + onClose, + onSetNotShared, +}: { + open: boolean; + skill: MyEditableSkillItem | null; + repositoryInfo: MySkillRepositoryInfoItem | null; + isUpdatingStatus: boolean; + onClose: () => void; + onSetNotShared: () => Promise; +}) { + const { t } = useTranslation("common"); + if (!skill || !repositoryInfo) return null; + + const title = skill.name?.trim() || `Skill #${skill.skill_id}`; + const isPending = repositoryInfo.status === "pending_review"; + const isRejected = repositoryInfo.status === "rejected"; + const canCancelApply = isCancelableRepositoryStatus(repositoryInfo.status); + const canTakeDown = isTakeDownableRepositoryStatus(repositoryInfo.status); + const submittedAt = formatRepositoryDate(repositoryInfo.create_time); + const listingContent = repositoryInfo.content?.trim() ?? ""; + + const statusConfig = isPending + ? { + icon: Clock, + label: t("repository.listingStatus.pendingLabel"), + description: t("repository.listingStatus.pendingDescription"), + tone: "border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200", + iconClass: "text-amber-600 dark:text-amber-300", + } + : isRejected + ? { + icon: XCircle, + label: t("repository.listingStatus.rejectedLabel"), + description: t("repository.listingStatus.rejectedDescription"), + tone: "border-red-200 bg-red-50 text-red-800 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200", + iconClass: "text-red-600 dark:text-red-300", + } + : { + icon: CheckCircle2, + label: t("repository.listingStatus.listedLabel"), + description: t("repository.listingStatus.listedDescription"), + tone: "border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-200", + iconClass: "text-emerald-600 dark:text-emerald-300", + }; + + const StatusIcon = statusConfig.icon; + + const confirmSetNotShared = () => { + Modal.confirm({ + title: canTakeDown + ? t("repository.listingStatus.confirmTakeDownTitle") + : t("repository.listingStatus.confirmCancelApplyTitle"), + content: canTakeDown + ? t("repository.listingStatus.confirmTakeDownContent", { + name: title, + }) + : t("repository.listingStatus.confirmCancelApplyContent", { + name: title, + }), + okText: canTakeDown + ? t("repository.listingStatus.takeDown") + : t("repository.listingStatus.cancelApply"), + cancelText: t("common.cancel"), + okButtonProps: { danger: true }, + onOk: onSetNotShared, + }); + }; + + return ( + + + {t("repository.listingStatus.title")} + + } + footer={ +
+ + {canCancelApply || canTakeDown ? ( + + ) : null} +
+ } + > +

{title}

+ +
+ +
+

{statusConfig.label}

+

+ {statusConfig.description} +

+ {listingContent ? ( +

+ {isPending + ? t("repository.listingStatus.pendingNote", { + content: listingContent, + }) + : t("repository.listingStatus.reviewOpinion", { + content: listingContent, + })} +

+ ) : null} +
+
+ + {submittedAt ? ( +
+
+ {t("repository.listingStatus.submittedAt")} + + {submittedAt} + +
+
+ ) : null} +
+ ); +} diff --git a/frontend/components/repository/skill/components/skillRepositoryShared.ts b/frontend/components/repository/skill/components/skillRepositoryShared.ts new file mode 100644 index 0000000000..3eeeb1009e --- /dev/null +++ b/frontend/components/repository/skill/components/skillRepositoryShared.ts @@ -0,0 +1,95 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import type { + MySkillRepositoryInfoItem, + SkillRepositoryListingStatus, +} from "@/types/skillRepository"; + +type Translate = (key: string, options?: Record) => string; + +export const STATUS_LABEL_KEYS: Record = { + not_shared: "repository.status.notListed", + pending_review: "repository.status.reviewing", + rejected: "repository.status.rejected", + shared: "repository.status.listed", +}; + +export const STATUS_COLORS: Record = { + not_shared: "default", + pending_review: "processing", + rejected: "error", + shared: "success", +}; + +export function getSkillRepositoryStatusLabel( + t: Translate, + status: SkillRepositoryListingStatus +): string { + return t(STATUS_LABEL_KEYS[status]); +} + +export function parseRepositoryTime(value?: string | null): number { + if (!value) return 0; + const timestamp = Date.parse(value); + return Number.isNaN(timestamp) ? 0 : timestamp; +} + +export function formatRepositoryDate(value?: string | null): string | null { + const timestamp = parseRepositoryTime(value); + if (!timestamp) return null; + return new Date(timestamp).toISOString().slice(0, 10); +} + +export function getSkillSourceLabel( + source: string | null | undefined, + t: Translate +): string { + if (source === "custom") { + return t("skillRepository.source.custom"); + } + if (source === "repository") { + return t("skillRepository.source.repository"); + } + return source || "-"; +} + +export function pickLatestRepositoryInfo( + items: MySkillRepositoryInfoItem[] +): MySkillRepositoryInfoItem | null { + if (!items.length) return null; + return [...items].sort( + (a, b) => + parseRepositoryTime(b.create_time) - parseRepositoryTime(a.create_time) + )[0]; +} + +export function pickReviewDisplayRepositoryInfo( + items: MySkillRepositoryInfoItem[] +): MySkillRepositoryInfoItem | null { + const pending = pickLatestRepositoryInfo( + items.filter((item) => item.status === "pending_review") + ); + if (pending) return pending; + + const rejected = pickLatestRepositoryInfo( + items.filter((item) => item.status === "rejected") + ); + if (rejected) return rejected; + + const shared = pickLatestRepositoryInfo( + items.filter((item) => item.status === "shared") + ); + return shared; +} + +export function isCancelableRepositoryStatus( + status: MySkillRepositoryInfoItem["status"] +): boolean { + return status === "pending_review" || status === "rejected"; +} + +export function isTakeDownableRepositoryStatus( + status: MySkillRepositoryInfoItem["status"] +): boolean { + return status === "shared"; +} diff --git a/frontend/components/repository/skill/my-skill.tsx b/frontend/components/repository/skill/my-skill.tsx new file mode 100644 index 0000000000..5038ca58d8 --- /dev/null +++ b/frontend/components/repository/skill/my-skill.tsx @@ -0,0 +1,372 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useRepositoryTagPredicates } from "@/hooks/useRepositoryTagPredicates"; + +import { + useRepositoryPageSize, + useRepositoryQueryState, +} from "@/hooks/useRepositoryQueryState"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { App } from "antd"; +import dynamic from "next/dynamic"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; +import { useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { useTagDefinitions, useTagLibraries } from "@/hooks/useTagManagement"; +import type { TagResourcePredicate } from "@/types/tagManagement"; +import { + invalidateSkillRepositoryCaches, + SKILLS_LIST_QUERY_KEY, + useCreateSkillRepositoryListing, + useMyEditableSkills, + useUpdateSkillRepositoryStatus, +} from "@/hooks/skillRepository/useSkillRepositoryListings"; +import { parseSkillReviewDeepLinkParams } from "@/lib/notificationNavigation"; +import { ApiError } from "@/services/api"; +import { deleteSkillByName } from "@/services/skillService"; +import { useRepositoryPresentation } from "@/hooks/useRepositoryPresentation"; +import { deleteLocalRepositorySkill } from "@/services/skillRepositoryService"; +import { fetchMyEditableSkills } from "@/services/skillRepositoryService"; +import type { + MineOwnershipFilter, + MyEditableSkillItem, + MySkillRepositoryInfoItem, +} from "@/types/skillRepository"; +import type { Skill } from "@/types/agentConfig"; +import { + isNewSkillPaddingItem, + MineSkillsView, +} from "./components/MineSkillsView"; + +const SkillBuildModal = dynamic( + () => import("@/app/agents/components/capability/SkillBuildModal"), + { ssr: false } +); +const SkillDetailModal = dynamic( + () => import("@/app/agents/components/capability/SkillDetailModal"), + { ssr: false } +); +const SEARCH_DEBOUNCE_MS = 300; + +export function MySkill({ active }: { active: boolean }) { + const { t } = useTranslation("common"); + const presentation = useRepositoryPresentation(); + const { message } = App.useApp(); + const [pageSize, setPageSize] = useRepositoryPageSize(); + const queryClient = useQueryClient(); + const searchParams = useSearchParams(); + const router = useRouter(); + const params = useParams<{ locale: string }>(); + const locale = params.locale || "en"; + const [minePage, setMinePage] = useRepositoryQueryState("page", 1); + const [mineOwnership, setMineOwnership] = + useRepositoryQueryState("ownership", "all", [ + "all", + "created", + "others", + ]); + const [mineSearch, setMineSearch] = useRepositoryQueryState("search", ""); + const [mineTagPredicates, setMineTagPredicates] = + useRepositoryTagPredicates(); + const [debouncedMineSearch, setDebouncedMineSearch] = useState(""); + const [skillBuildOpen, setSkillBuildOpen] = useState(false); + const [skillBuildLoaded, setSkillBuildLoaded] = useState(false); + const [editingSkill, setEditingSkill] = useState( + null + ); + useEffect(() => { + if (!active) return; + const id = Number( + searchParams.get("edit_skill_id") ?? + (searchParams.get("tab") === "skill" + ? searchParams.get("skill_id") + : null) + ); + if (!Number.isSafeInteger(id) || id <= 0) return; + let cancelled = false; + void (async () => { + try { + for (let page = 1; ; page += 1) { + const result = await fetchMyEditableSkills({ + ownership: "all", + page, + page_size: 100, + new_skill_padding: false, + }); + if (cancelled) return; + const skill = result.items.find( + (item): item is MyEditableSkillItem => + "skill_id" in item && item.skill_id === id + ); + if (skill) { + setEditingSkill(skill); + setSkillBuildLoaded(true); + setSkillBuildOpen(true); + return; + } + if (page >= result.pagination.total_pages) { + message.warning(t("ui.localization.skillEditDenied")); + return; + } + } + } catch { + if (!cancelled) message.error(t("ui.localization.skillLoadFailed")); + } + })(); + return () => { + cancelled = true; + }; + }, [active, searchParams, message, t]); + const [skillDetailLoaded, setSkillDetailLoaded] = useState(false); + const [viewingSkill, setViewingSkill] = useState( + null + ); + const { data: tagLibraries } = useTagLibraries(); + const defaultTagLibrary = + tagLibraries?.find( + (library) => library.bucket_key === "default_resource" + ) ?? null; + const { data: mineTagDefinitions } = useTagDefinitions( + defaultTagLibrary?.bucket_id ?? null + ); + + useEffect(() => { + const timer = window.setTimeout( + () => setDebouncedMineSearch(mineSearch), + SEARCH_DEBOUNCE_MS + ); + return () => window.clearTimeout(timer); + }, [mineSearch]); + + const reviewDeepLink = useMemo( + () => parseSkillReviewDeepLinkParams(searchParams), + [searchParams] + ); + const handleReviewDeepLinkConsumed = useCallback(() => { + router.replace( + searchParams.get("tab") === "skill" + ? `/${locale}/resource-space?tab=skill&view=mine` + : `/${locale}/skill-space?tab=mine` + ); + }, [locale, router, searchParams]); + const mineParams = useMemo( + () => ({ + ownership: mineOwnership, + page: minePage, + page_size: pageSize, + ...(debouncedMineSearch.trim() + ? { search: debouncedMineSearch.trim() } + : {}), + ...(mineTagPredicates.length > 0 + ? { tag_predicates: mineTagPredicates } + : {}), + ...(!presentation && + mineOwnership === "all" && + !debouncedMineSearch.trim() && + mineTagPredicates.length === 0 + ? { new_skill_padding: true } + : {}), + }), + [ + debouncedMineSearch, + mineOwnership, + minePage, + mineTagPredicates, + pageSize, + presentation, + ] + ); + const { + data: mineData, + isLoading: isMineLoading, + isError: isMineError, + isFetching: isMineFetching, + refetch: refetchMine, + } = useMyEditableSkills(mineParams, active); + const { data: deepLinkMineData, isLoading: isDeepLinkMineLoading } = + useMyEditableSkills( + { ownership: "all", page: 1, page_size: 100, new_skill_padding: false }, + active && reviewDeepLink != null + ); + const createListingMutation = useCreateSkillRepositoryListing(); + const updateStatusMutation = useUpdateSkillRepositoryStatus(); + const mineItems = mineData?.items ?? []; + const mineTotal = mineData?.pagination?.total ?? 0; + const mineCounts = mineData?.counts ?? { all: 0, created: 0, others: 0 }; + const deepLinkFallbackSkill = useMemo(() => { + if (!reviewDeepLink) return null; + const items = deepLinkMineData?.items ?? []; + return ( + items.find( + (item): item is MyEditableSkillItem => + !isNewSkillPaddingItem(item) && + item.skill_id === reviewDeepLink.skillId + ) ?? null + ); + }, [deepLinkMineData?.items, reviewDeepLink]); + + useEffect(() => { + if (active) refetchMine().catch(() => {}); + }, [active, refetchMine]); + + useEffect(() => { + const total = mineData?.pagination?.total; + if (total == null) return; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + if (minePage > totalPages) { + /* eslint-disable react-hooks/set-state-in-effect -- Clamp after server pagination changes. */ + setMinePage(totalPages); + /* eslint-enable react-hooks/set-state-in-effect */ + } + }, [mineData?.pagination?.total, minePage, pageSize]); + const handleSetNotShared = async ( + repositoryInfo: MySkillRepositoryInfoItem + ) => { + const wasShared = repositoryInfo.status === "shared"; + await updateStatusMutation.mutateAsync({ + skillRepositoryId: repositoryInfo.skill_repository_id, + status: "not_shared", + }); + message.success( + wasShared + ? t("repository.mine.takeDownSuccess") + : t("repository.mine.cancelApplySuccess") + ); + }; + + const refreshSkillCaches = async () => { + await Promise.all([ + invalidateSkillRepositoryCaches(queryClient), + queryClient.invalidateQueries({ queryKey: [SKILLS_LIST_QUERY_KEY] }), + ]); + }; + + const handleSkillBuildSuccess = async () => { + await refreshSkillCaches().catch(() => {}); + setEditingSkill(null); + }; + return ( + <> + {active ? ( + { + setMineOwnership(ownership); + setMinePage(1); + }} + searchQuery={mineSearch} + onSearchChange={(value) => { + setMineSearch(value); + setMinePage(1); + }} + tagDefinitions={mineTagDefinitions ?? []} + tagPredicates={mineTagPredicates} + onTagPredicatesChange={(value) => { + setMineTagPredicates(value); + setMinePage(1); + }} + isLoading={isMineLoading} + isError={isMineError} + isFetching={isMineFetching} + page={minePage} + total={mineTotal} + onPageChange={setMinePage} + pageSize={pageSize} + onPageSizeChange={setPageSize} + onRetry={() => refetchMine()} + onCreateSkill={() => { + setEditingSkill(null); + setSkillBuildLoaded(true); + setSkillBuildOpen(true); + }} + onEditSkill={(skill) => { + setEditingSkill(skill); + setSkillBuildLoaded(true); + setSkillBuildOpen(true); + }} + onViewSkill={(skill) => { + setSkillDetailLoaded(true); + setViewingSkill(skill); + }} + onDeleteSkill={async (skill) => { + const name = skill.name?.trim(); + if (!name) { + message.error(t("skillRepository.delete.emptyName")); + return; + } + const result = presentation + ? (await deleteLocalRepositorySkill(skill.skill_id), + { success: true, message: "" }) + : await deleteSkillByName(name); + if (!result.success) { + message.error( + result.message || t("repository.mine.deleteFailed") + ); + throw new Error(result.message || "Delete skill failed"); + } + message.success(t("repository.mine.deleteSuccess")); + await refreshSkillCaches(); + }} + onApplyListing={async (skill, payload) => { + try { + await createListingMutation.mutateAsync({ + skillId: skill.skill_id, + payload, + }); + message.success(t("repository.mine.applySuccess")); + } catch (error) { + if (error instanceof ApiError && Number(error.code) === 403) { + message.error(t("skillRepository.mine.applyForbidden")); + return; + } + message.error( + error instanceof Error + ? error.message + : t("repository.mine.applyError") + ); + } + }} + isUpdatingStatus={updateStatusMutation.isPending} + onSetNotShared={handleSetNotShared} + reviewDeepLink={reviewDeepLink} + deepLinkFallbackSkill={deepLinkFallbackSkill} + deepLinkFallbackLoading={isDeepLinkMineLoading} + onReviewDeepLinkConsumed={handleReviewDeepLinkConsumed} + /> + ) : null} + {skillBuildLoaded ? ( + { + setSkillBuildOpen(false); + setEditingSkill(null); + }} + onSuccess={handleSkillBuildSuccess} + /> + ) : null} + {skillDetailLoaded ? ( + setViewingSkill(null)} + /> + ) : null} + + ); +} diff --git a/frontend/components/repository/skill/review-center.tsx b/frontend/components/repository/skill/review-center.tsx new file mode 100644 index 0000000000..e391cb1f61 --- /dev/null +++ b/frontend/components/repository/skill/review-center.tsx @@ -0,0 +1,183 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { App } from "antd"; +import { useTranslation } from "react-i18next"; +import { + useSkillRepositoryListingDetail, + useSkillRepositoryListings, + useUpdateSkillRepositoryStatus, +} from "@/hooks/skillRepository/useSkillRepositoryListings"; +import type { + SkillRepositoryListingItem, + SkillRepositoryListingStatus, +} from "@/types/skillRepository"; +import { SkillDetail } from "@/components/skill/skill-detail"; +import { ReviewSkillList } from "./components/ReviewSkillList"; +import { + SkillRepositoryReviewConfirmModal, + type SkillRepositoryReviewAction, +} from "./components/SkillRepositoryReviewConfirmModal"; +import { getSkillRepositoryStatusLabel } from "./components/skillRepositoryShared"; + +const REVIEW_PAGE_SIZE = 10; +const STATUS_ACTION_LABEL_KEYS: Partial< + Record +> = { + not_shared: "skillRepository.action.status.notShared", + shared: "skillRepository.action.status.shared", + rejected: "skillRepository.action.status.rejected", +}; + +export function ReviewCenter({ active }: { active: boolean }) { + const { t } = useTranslation("common"); + const { message } = App.useApp(); + const [reviewPage, setReviewPage] = useState(1); + const [detailRepositoryId, setDetailRepositoryId] = useState( + null + ); + const [reviewListing, setReviewListing] = + useState(null); + const [reviewAction, setReviewAction] = + useState(null); + const reviewParams = useMemo( + () => ({ + status: "pending_review" as const, + page: reviewPage, + page_size: REVIEW_PAGE_SIZE, + sort_by_update_time: true, + }), + [reviewPage] + ); + const { + data: reviewData, + isLoading: isReviewLoading, + isError: isReviewError, + isFetching: isReviewFetching, + refetch: refetchReview, + } = useSkillRepositoryListings(reviewParams, active); + const updateStatusMutation = useUpdateSkillRepositoryStatus(); + const { + data: detailData, + isLoading: isDetailLoading, + isError: isDetailError, + isFetching: isDetailFetching, + refetch: refetchDetail, + } = useSkillRepositoryListingDetail( + detailRepositoryId, + detailRepositoryId != null + ); + const reviewItems = reviewData?.items ?? []; + const reviewTotal = reviewData?.pagination?.total ?? 0; + const updatingRepositoryId = updateStatusMutation.isPending + ? (updateStatusMutation.variables?.skillRepositoryId ?? null) + : null; + + useEffect(() => { + if (active) refetchReview().catch(() => {}); + }, [active, refetchReview]); + + useEffect(() => { + const total = reviewData?.pagination?.total; + if (total == null) return; + const totalPages = Math.max(1, Math.ceil(total / REVIEW_PAGE_SIZE)); + if (reviewPage > totalPages) { + /* eslint-disable react-hooks/set-state-in-effect -- Clamp after server pagination changes. */ + setReviewPage(totalPages); + /* eslint-enable react-hooks/set-state-in-effect */ + } + }, [reviewData?.pagination?.total, reviewPage]); + + const openDetail = (listing: SkillRepositoryListingItem) => { + setDetailRepositoryId(listing.skill_repository_id); + }; + + const handleUpdateStatus = async ( + listing: SkillRepositoryListingItem, + status: SkillRepositoryListingStatus, + content?: string + ) => { + try { + await updateStatusMutation.mutateAsync({ + skillRepositoryId: listing.skill_repository_id, + status, + content, + }); + message.success( + t("skillRepository.action.success", { + action: STATUS_ACTION_LABEL_KEYS[status] + ? t(STATUS_ACTION_LABEL_KEYS[status]) + : getSkillRepositoryStatusLabel(t, status), + }) + ); + } catch (error) { + message.error( + error instanceof Error + ? error.message + : t("skillRepository.common.statusUpdateFailed") + ); + } + }; + const openReviewConfirmModal = ( + listing: SkillRepositoryListingItem, + action: SkillRepositoryReviewAction + ) => { + setReviewListing(listing); + setReviewAction(action); + }; + + const closeReviewConfirmModal = () => { + setReviewListing(null); + setReviewAction(null); + }; + return ( + <> + {active ? ( + refetchReview()} + updatingRepositoryId={updatingRepositoryId} + onDetailClick={openDetail} + onApprove={(listing) => openReviewConfirmModal(listing, "approve")} + onReject={(listing) => openReviewConfirmModal(listing, "reject")} + /> + ) : null} + setDetailRepositoryId(null)} + onRetry={() => refetchDetail()} + /> + { + if (!reviewListing || !reviewAction) { + return; + } + await handleUpdateStatus( + reviewListing, + reviewAction === "approve" ? "shared" : "rejected", + content + ); + closeReviewConfirmModal(); + }} + /> + + ); +} diff --git a/frontend/components/repository/skill/skill-space.tsx b/frontend/components/repository/skill/skill-space.tsx new file mode 100644 index 0000000000..9a2328f81c --- /dev/null +++ b/frontend/components/repository/skill/skill-space.tsx @@ -0,0 +1,383 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useRepositoryTagPredicates } from "@/hooks/useRepositoryTagPredicates"; + +import { + useRepositoryPageSize, + useRepositoryQueryState, +} from "@/hooks/useRepositoryQueryState"; +import { useEffect, useMemo, useState } from "react"; +import { App, Input, Modal } from "antd"; +import { useQueryClient } from "@tanstack/react-query"; +import { uninstallSkillRepositoryCopy } from "@/services/skillRepositoryService"; +import { useTranslation } from "react-i18next"; +import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; +import { USER_ROLES } from "@/const/auth"; +import { useTagDefinitions, useTagLibraries } from "@/hooks/useTagManagement"; +import type { TagResourcePredicate } from "@/types/tagManagement"; +import { + useInstallSkillFromRepository, + useSkillRepositoryListingDetail, + useSkillRepositoryListings, + useUpdateSkillRepositoryStatus, +} from "@/hooks/skillRepository/useSkillRepositoryListings"; +import type { + SkillRepositoryListingItem, + SkillRepositoryListingStatus, +} from "@/types/skillRepository"; +import { SkillDetail } from "@/components/skill/skill-detail"; +import { RepositoryView } from "./components/RepositoryView"; +import { getSkillRepositoryStatusLabel } from "./components/skillRepositoryShared"; + +const SEARCH_DEBOUNCE_MS = 300; +const STATUS_ACTION_LABEL_KEYS: Partial< + Record +> = { + not_shared: "skillRepository.action.status.notShared", + shared: "skillRepository.action.status.shared", + rejected: "skillRepository.action.status.rejected", +}; + +export function SkillSpace({ + active, + enhanced = false, + sortByPublished = false, +}: { + active: boolean; + enhanced?: boolean; + sortByPublished?: boolean; +}) { + const queryClient = useQueryClient(); + const { t } = useTranslation("common"); + const { message, modal } = App.useApp(); + const { user } = useAuthorizationContext(); + const isAdmin = user?.role === USER_ROLES.ADMIN; + const [pageSize, setPageSize] = useRepositoryPageSize(); + const [repositoryPage, setRepositoryPage] = useRepositoryQueryState( + "page", + 1 + ); + const [repositorySearch, setRepositorySearch] = useRepositoryQueryState( + "search", + "" + ); + const [repositoryTagPredicates, setRepositoryTagPredicates] = + useRepositoryTagPredicates(); + const [debouncedRepositorySearch, setDebouncedRepositorySearch] = + useState(""); + const [detailRepositoryId, setDetailRepositoryId] = useState( + null + ); + const [copyListing, setCopyListing] = + useState(null); + const [copyTargetName, setCopyTargetName] = useState(""); + const [copyNameError, setCopyNameError] = useState(null); + const { data: tagLibraries } = useTagLibraries(); + const defaultTagLibrary = + tagLibraries?.find( + (library) => library.bucket_key === "default_resource" + ) ?? null; + const { data: tagDefinitions } = useTagDefinitions( + defaultTagLibrary?.bucket_id ?? null + ); + + useEffect(() => { + const timer = window.setTimeout( + () => setDebouncedRepositorySearch(repositorySearch), + SEARCH_DEBOUNCE_MS + ); + return () => window.clearTimeout(timer); + }, [repositorySearch]); + + const repositoryParams = useMemo( + () => ({ + status: "shared" as const, + ...(enhanced ? { include_installations: true } : {}), + ...(sortByPublished ? { sort_by_published_time: true } : {}), + page: repositoryPage, + page_size: pageSize, + ...(debouncedRepositorySearch.trim() + ? { search: debouncedRepositorySearch.trim() } + : {}), + ...(repositoryTagPredicates.length > 0 + ? { tag_predicates: repositoryTagPredicates } + : {}), + }), + [ + enhanced, + sortByPublished, + debouncedRepositorySearch, + repositoryPage, + pageSize, + repositoryTagPredicates, + ] + ); + const { + data: repositoryData, + isLoading: isRepositoryLoading, + isError: isRepositoryError, + isFetching: isRepositoryFetching, + refetch: refetchRepository, + } = useSkillRepositoryListings(repositoryParams, active); + const installMutation = useInstallSkillFromRepository(); + const updateStatusMutation = useUpdateSkillRepositoryStatus(); + const { + data: detailData, + isLoading: isDetailLoading, + isError: isDetailError, + isFetching: isDetailFetching, + refetch: refetchDetail, + } = useSkillRepositoryListingDetail( + detailRepositoryId, + detailRepositoryId != null + ); + const repositoryItems = repositoryData?.items ?? []; + const repositoryTotal = repositoryData?.pagination?.total ?? 0; + const updatingRepositoryId = updateStatusMutation.isPending + ? (updateStatusMutation.variables?.skillRepositoryId ?? null) + : null; + const installingRepositoryId = installMutation.isPending + ? (installMutation.variables?.skillRepositoryId ?? null) + : null; + + useEffect(() => { + if (active) refetchRepository().catch(() => {}); + }, [active, refetchRepository]); + + useEffect(() => { + const total = repositoryData?.pagination?.total; + if (total == null) return; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + if (repositoryPage > totalPages) { + /* eslint-disable react-hooks/set-state-in-effect -- Clamp after server pagination changes. */ + setRepositoryPage(totalPages); + /* eslint-enable react-hooks/set-state-in-effect */ + } + }, [repositoryData?.pagination?.total, repositoryPage, pageSize]); + const getDuplicateSkillNames = (error: unknown): string[] | null => { + const detail = + error instanceof Error && "detail" in error + ? (error as { detail?: unknown }).detail + : null; + if ( + typeof detail !== "object" || + detail == null || + !("type" in detail) || + (detail as { type?: unknown }).type !== "skill_duplicate" + ) { + return null; + } + const duplicates = (detail as { duplicate_skills?: unknown }) + .duplicate_skills; + return Array.isArray(duplicates) + ? duplicates.filter((name): name is string => typeof name === "string") + : []; + }; + + const openDetail = (listing: SkillRepositoryListingItem) => { + setDetailRepositoryId(listing.skill_repository_id); + }; + + const handleInstall = (listing: SkillRepositoryListingItem) => { + const baseName = listing.name?.trim() || "Skill"; + setCopyListing(listing); + setCopyTargetName( + t("skillRepository.copy.defaultName", { name: baseName }) + ); + setCopyNameError(null); + }; + + const handleConfirmInstall = async () => { + if (!copyListing) { + return; + } + const targetName = copyTargetName.trim(); + if (!targetName) { + setCopyNameError(t("skillRepository.copy.nameRequired")); + return; + } + try { + setCopyNameError(null); + const result = await installMutation.mutateAsync({ + skillRepositoryId: copyListing.skill_repository_id, + targetName, + }); + message.success( + result.name + ? t("skillRepository.copy.successWithName", { name: result.name }) + : t("skillRepository.copy.success") + ); + setCopyListing(null); + setCopyTargetName(""); + } catch (error) { + const duplicateNames = getDuplicateSkillNames(error); + if (duplicateNames) { + setCopyNameError( + duplicateNames.length > 0 + ? t("skillRepository.copy.duplicateWithNames", { + names: duplicateNames.join("、"), + }) + : t("skillRepository.copy.duplicate") + ); + return; + } + message.error( + error instanceof Error + ? error.message + : t("skillRepository.copy.failed") + ); + } + }; + + const handleUpdateStatus = async ( + listing: SkillRepositoryListingItem, + status: SkillRepositoryListingStatus, + content?: string + ) => { + try { + await updateStatusMutation.mutateAsync({ + skillRepositoryId: listing.skill_repository_id, + status, + content, + }); + message.success( + t("skillRepository.action.success", { + action: STATUS_ACTION_LABEL_KEYS[status] + ? t(STATUS_ACTION_LABEL_KEYS[status]) + : getSkillRepositoryStatusLabel(t, status), + }) + ); + } catch (error) { + message.error( + error instanceof Error + ? error.message + : t("skillRepository.common.statusUpdateFailed") + ); + } + }; + const confirmTakeDown = (listing: SkillRepositoryListingItem) => { + modal.confirm({ + title: t("skillRepository.action.confirmTakeDown"), + content: listing.name, + okText: t("skillRepository.action.status.notShared"), + cancelText: t("common.cancel"), + okButtonProps: { danger: true }, + onOk: () => handleUpdateStatus(listing, "not_shared"), + }); + }; + return ( + <> + {active ? ( + { + setRepositorySearch(value); + setRepositoryPage(1); + }} + tagDefinitions={tagDefinitions ?? []} + tagPredicates={repositoryTagPredicates} + onTagPredicatesChange={(value) => { + setRepositoryTagPredicates(value); + setRepositoryPage(1); + }} + listings={repositoryItems} + isLoading={isRepositoryLoading} + isError={isRepositoryError} + isFetching={isRepositoryFetching} + page={repositoryPage} + total={repositoryTotal} + onPageChange={setRepositoryPage} + pageSize={pageSize} + onPageSizeChange={setPageSize} + onRetry={() => refetchRepository()} + onUninstall={ + enhanced + ? (listing, copy) => + modal.confirm({ + title: t("resourceCenter.uninstall"), + content: copy.name, + centered: true, + okText: t("common.confirm"), + cancelText: t("common.cancel"), + okButtonProps: { danger: true }, + onOk: async () => { + try { + await uninstallSkillRepositoryCopy( + listing.skill_repository_id, + copy.skill_id + ); + await queryClient.invalidateQueries({ + predicate: ({ queryKey }) => + /skill/i.test(String(queryKey[0])), + }); + } catch (error) { + message.error(t("resourceCenter.operationFailed")); + throw error; + } + }, + }) + : undefined + } + onInstall={handleInstall} + onDetailClick={openDetail} + showAdminMenu={isAdmin} + onTakeDown={confirmTakeDown} + installingRepositoryId={installingRepositoryId} + takingDownRepositoryId={updatingRepositoryId} + /> + ) : null} + setDetailRepositoryId(null)} + onRetry={() => refetchDetail()} + /> + { + setCopyListing(null); + setCopyTargetName(""); + setCopyNameError(null); + }} + > +
+ + { + setCopyTargetName(event.target.value); + if (copyNameError) { + setCopyNameError(null); + } + }} + onPressEnter={handleConfirmInstall} + /> + {copyNameError ? ( +

{copyNameError}

+ ) : ( +

+ {t("skillRepository.copy.renameHint")} +

+ )} +
+
+ + ); +} diff --git a/frontend/components/resource/CreateResourceCard.tsx b/frontend/components/resource/CreateResourceCard.tsx index 621a729873..77ce5ead03 100644 --- a/frontend/components/resource/CreateResourceCard.tsx +++ b/frontend/components/resource/CreateResourceCard.tsx @@ -1,5 +1,9 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + "use client"; +import { useRepositoryPresentation } from "@/hooks/useRepositoryPresentation"; +import { Button } from "antd"; import { Plus } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -15,6 +19,13 @@ export default function CreateResourceCard({ onClick, className, }: CreateResourceCardProps) { + const presentation = useRepositoryPresentation(); + if (presentation?.layout === "list") + return ( + + ); return (
+ ); + return (
@@ -113,13 +151,16 @@ export default function ResourceCard({
{icon ? {icon} : null}
{title} {subtitle ? ( -
+
{subtitle}
) : null} {badge ? (
{badge}
) : null} + {tags && tagsPlacement === "header" ? ( +
+ {tags} +
+ ) : null}
- {headerActions || actions ? ( + {headerActions || actions || (footerInHeader && footer) ? (
event.stopPropagation()} > {headerActions} {actions} + {footerInHeader ? footer : null}
) : null}
-
+
{statusRow ? ( -
{statusRow}
+
+ {statusRow} +
) : null} {description ? (
- {description} + {descriptionLabel && ( + + {descriptionLabel} + + )} +
+ {description} +
) : null} - {tags ? ( + {tags && tagsPlacement === "body" ? (
*]:max-w-full [&>*]:[overflow-wrap:anywhere]", - description ? "mt-3" : "mt-4" + "flex flex-wrap gap-2 text-xs [&>*]:max-w-full [&>*]:[overflow-wrap:anywhere]", + !measuredAgent && "pb-2", + !measuredAgent && + (compact ? "mt-2.5" : description ? "mt-3" : "mt-4") )} > {tags}
) : null} -
+
{footerLayout === "stacked" ? ( <> {meta ? ( -
+
{meta}
) : null} - {footer ? ( + {!footerInHeader && footer ? (
) : meta || footer ? ( -
-
{meta}
- {footer ? ( +
+
+ {meta} +
+ {!footerInHeader && footer ? (
{ page?: number; total?: number; onPageChange?: (page: number) => void; + onPageSizeChange?: (pageSize: number) => void; search?: string; searchPlaceholder?: string; onSearchChange?: (value: string) => void; @@ -43,7 +60,18 @@ export interface ResourceCardGridProps { emptyState?: ReactNode; /** Set to false when items already contain only the current server page. */ paginateItems?: boolean; - renderItem: (item: T, index: number) => ReactNode; + renderItem: (item: T, index: number, selection?: ReactNode) => ReactNode; + renderBatchToolbar?: (controls: ReactNode) => ReactNode; + cardSelectionInHeader?: boolean; + onListSelect?: (item: T) => void; + renderListStatus?: (item: T) => ReactNode; + renderListActions?: (item: T) => ReactNode; + batchDelete?: (item: T) => Promise; + batchDeleteItems?: (items: T[]) => Promise>; + canBatchDelete?: (item: T) => boolean; + getItemKey?: (item: T) => string; + selectionKey?: string; + isCreateItem?: (item: T) => boolean; } export default function ResourceCardGrid({ @@ -51,6 +79,7 @@ export default function ResourceCardGrid({ page, total, onPageChange, + onPageSizeChange, search, searchPlaceholder, onSearchChange, @@ -70,7 +99,67 @@ export default function ResourceCardGrid({ emptyState = , paginateItems = true, renderItem, + renderBatchToolbar, + cardSelectionInHeader, + onListSelect, + renderListStatus, + renderListActions, + batchDelete, + batchDeleteItems, + canBatchDelete, + getItemKey, + selectionKey, + isCreateItem, }: ResourceCardGridProps) { + const presentation = useRepositoryPresentation(); + const isAgentRepository = + presentation?.resourceType === "agent" && + presentation.query?.get("view") !== "mine"; + const isMcpRepository = + presentation?.resourceType === "mcp" && + presentation.query?.get("view") !== "mine"; + const isMineMcp = + presentation?.resourceType === "mcp" && + presentation.query?.get("view") === "mine"; + const useRepositoryCardLayout = isAgentRepository || isMcpRepository; + const useResponsiveCards = + useRepositoryCardLayout || presentation?.resourceType === "mcp"; + const { t } = useTranslation("common"); + const displayName = useUserDisplayName(); + const authorColumn: TableColumnType = { + title: t("resourceCenter.author"), + render: (_, item) => { + const row = resourceValues(item); + return displayName( + String(row.author_cas_username || row.authorCasUsername || ""), + String(row.author || row.authorDisplayName || row.submitted_by || "—") + ); + }, + }; + const { modal } = App.useApp(); + const [selected, setSelected] = useState([]); + const [batchErrors, setBatchErrors] = useState([]); + const [deleting, setDeleting] = useState(false); + const keyOf = (item: T) => { + if (getItemKey) return getItemKey(item); + const row = item as unknown as Record; + return String( + row.agent_repository_id ?? + row.skill_repository_id ?? + row.communityId ?? + row.skill_id ?? + row.agent_id ?? + row.name + ); + }; + const batching = + !!presentation && !!(batchDeleteItems || batchDelete) && !!canBatchDelete; + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- Selection is scoped to a page and filter context. + setSelected([]); + setBatchErrors([]); + }, [batching, page, selectionKey]); + const columnCount = normalizePositiveInteger(columns, 4); const rowCount = normalizePositiveInteger(rows, 3); const slotsPerPage = normalizePositiveInteger( @@ -79,9 +168,10 @@ export default function ResourceCardGrid({ ); const createEnabled = showCreateCard ?? Boolean(createCard || (createCardTitle && onCreate)); - const itemsPerPage = createEnabled - ? Math.max(1, slotsPerPage - 1) - : slotsPerPage; + const itemsPerPage = + createEnabled && !presentation + ? Math.max(1, slotsPerPage - 1) + : slotsPerPage; const itemCount = total ?? items.length; const totalPages = Math.max(1, Math.ceil(itemCount / itemsPerPage)); const currentPage = Math.min(Math.max(page ?? 1, 1), totalPages); @@ -90,6 +180,56 @@ export default function ResourceCardGrid({ const start = (currentPage - 1) * itemsPerPage; return items.slice(start, start + itemsPerPage); }, [currentPage, items, itemsPerPage, paginateItems]); + const selectable = batching + ? visibleItems.filter((item) => canBatchDelete!(item)) + : []; + const removeSelected = () => + modal.confirm({ + title: t("resourceCenter.deleteSelected"), + content: t("resourceCenter.selectedCount", { count: selected.length }), + centered: true, + okButtonProps: { danger: true }, + okText: t("common.confirm"), + cancelText: t("common.cancel"), + onOk: async () => { + setDeleting(true); + const failed: string[] = []; + const errors: string[] = []; + const targets = selectable.filter((item) => + selected.includes(keyOf(item)) + ); + if (batchDeleteItems && targets.length) { + try { + const failures = await batchDeleteItems(targets); + failed.push(...failures.keys()); + errors.push(...failures.values()); + } catch (error) { + failed.push(...targets.map(keyOf)); + errors.push( + error instanceof Error + ? error.message + : t("resourceCenter.operationFailed") + ); + } + } else if (batchDelete) { + for (const item of targets) { + try { + await batchDelete(item); + } catch (error) { + failed.push(keyOf(item)); + errors.push( + error instanceof Error + ? error.message + : t("resourceCenter.operationFailed") + ); + } + } + } + setSelected(failed); + setBatchErrors(errors); + setDeleting(false); + }, + }); const shouldShowCreateCard = createEnabled; const createCardNode = shouldShowCreateCard ? (createCard ?? @@ -111,8 +251,50 @@ export default function ResourceCardGrid({ if (page !== undefined && page > totalPages) onPageChange?.(totalPages); }, [onPageChange, page, totalPages]); + const batchControls = batching ? ( +
+ 0 && + selectable.every((item) => selected.includes(keyOf(item))) + } + indeterminate={ + selected.length > 0 && selected.length < selectable.length + } + onChange={(event) => + setSelected(event.target.checked ? selectable.map(keyOf) : []) + } + > + {t("resourceCenter.selectPage")} + + +
+ ) : null; + return ( <> + {renderBatchToolbar + ? renderBatchToolbar(batchControls) + : batchControls &&
{batchControls}
} + {batchErrors.length > 0 && ( + + )} {toolbarVisible ? (
{hasSearchControl ? ( @@ -148,36 +330,277 @@ export default function ResourceCardGrid({ ) : null}
) : null} -
- {headerItem != null && currentPage === 1 ? headerItem : null} - {createCardNode} - {itemCount === 0 && !createCardNode ? ( -
- {emptyState} + {presentation?.layout === "list" && + (createCardNode || + visibleItems.some((item) => isCreateItem?.(item))) && ( +
+ {createCardNode} + {visibleItems + .filter((item) => isCreateItem?.(item)) + .map((item, index) => renderItem(item, index))}
- ) : ( - visibleItems.map((item, index) => renderItem(item, index)) )} -
+ {presentation?.layout === "list" ? ( + + className="resource-scroll-table w-full" + scroll={{ x: 1200, y: "100%" }} + tableLayout="fixed" + size="middle" + pagination={false} + dataSource={ + visibleItems.filter((item) => !isCreateItem?.(item)) as (T & + object)[] + } + rowSelection={ + batching + ? { + selectedRowKeys: selected, + onChange: (keys) => setSelected(keys.map(String)), + getCheckboxProps: (item) => ({ + disabled: deleting || !canBatchDelete!(item), + }), + } + : undefined + } + rowKey={keyOf} + columns={[ + { + title: t("resourceCenter.name"), + width: isAgentRepository ? 312 : undefined, + ellipsis: true, + render: (_, item) => { + const row = resourceValues(item); + const name = String( + row.display_name || row.name || row.skill_name || "—" + ); + return onListSelect ? ( + + ) : ( + name + ); + }, + }, + ...(isMineMcp && renderListStatus + ? [ + { + title: t("mcpTools.mine.statusColumn"), + width: 184, + render: (_: unknown, item: T & object) => + renderListStatus(item), + }, + ] + : []), + { + title: t("resourceCenter.tags"), + width: useRepositoryCardLayout || isMineMcp ? 156 : undefined, + render: (_, item) => { + const tags = resourceValues(item).tags; + if ( + (useRepositoryCardLayout || isMineMcp) && + Array.isArray(tags) + ) { + return ( + + isAgentRepository + ? getAgentRepositoryTagLabel(String(tag), t) + : String(tag) + )} + /> + ); + } + return Array.isArray(tags) ? ( +
+ {tags.map((tag) => ( + {String(tag)} + ))} +
+ ) : ( + "—" + ); + }, + }, + ...(isAgentRepository + ? [ + { + title: t("resourceCenter.type"), + width: "6%", + render: (_: unknown, item: T & object) => { + const row = resourceValues(item); + if (row.is_official === true) { + return t("agentRepository.card.official"); + } + return row.is_main_agent === false + ? t("space.detail.tabs.subAgents") + : t("agentRepository.card.mainAgent"); + }, + }, + ] + : []), + ...(presentation?.resourceType !== "mcp" + ? [ + { + title: t("resourceCenter.version"), + render: (_: unknown, item: T & object) => { + const row = resourceValues(item); + return String( + row.version_label ?? + row.version_name ?? + row.current_version_no ?? + row.version_no ?? + row.version ?? + "—" + ); + }, + }, + ] + : []), + { + title: t("resourceCenter.description"), + ellipsis: true, + render: (_, item) => + String(resourceValues(item).description || "—"), + }, + ...(!isAgentRepository ? [authorColumn] : []), + { + title: t("resourceCenter.downloads"), + render: (_, item) => { + const row = resourceValues(item); + return String(row.downloads ?? row.installCount ?? 0); + }, + }, + ...(isAgentRepository ? [authorColumn] : []), + { + title: t("resourceCenter.publishedAt"), + render: (_, item) => { + const row = resourceValues(item); + const value = row.published_at || row.publishedAt; + return value + ? new Date(String(value)).toLocaleString() + : t("resourceCenter.notRecorded"); + }, + }, + ...(presentation + ? [ + { + title: t("resourceCenter.actions"), + width: isAgentRepository ? 167 : 160, + fixed: "right" as const, + render: (_: unknown, item: T & object) => + renderListActions + ? renderListActions(item) + : renderItem(item, visibleItems.indexOf(item)), + }, + ] + : []), + ]} + /> + ) : ( +
+
+ {headerItem != null && currentPage === 1 ? headerItem : null} + {createCardNode} + {itemCount === 0 && !createCardNode ? ( +
+ {emptyState} +
+ ) : ( + visibleItems.map((item, index) => { + if (!batching) return renderItem(item, index); + const selection = ( + + setSelected((current) => + event.target.checked + ? [...current, keyOf(item)] + : current.filter((key) => key !== keyOf(item)) + ) + } + /> + ); + if (cardSelectionInHeader) + return renderItem(item, index, selection); + return ( +
+ {selection} + {renderItem(item, index)} +
+ ); + }) + )} +
+
+ )} + {itemCount > 0 && onPageChange ? ( -
+
t("resourceCenter.total", { count }) + : undefined + } + showSizeChanger={Boolean(onPageSizeChange)} + pageSizeOptions={[10, 12, 20, 50, 100]} + showQuickJumper={Boolean(presentation) && itemCount > itemsPerPage} + onChange={(nextPage, size) => { + if (size !== itemsPerPage && onPageSizeChange) + onPageSizeChange(size); + else onPageChange(nextPage); + }} />
) : null} @@ -198,3 +621,10 @@ function cnFilter(active: boolean) { : "border-transparent text-slate-500 hover:text-slate-800" }`; } + +function resourceValues(item: unknown): Record { + const row = item as Record; + return row.service && typeof row.service === "object" + ? (row.service as Record) + : row; +} diff --git a/frontend/components/resource/ResourceDetail.tsx b/frontend/components/resource/ResourceDetail.tsx index bf3309646b..e474da64b0 100644 --- a/frontend/components/resource/ResourceDetail.tsx +++ b/frontend/components/resource/ResourceDetail.tsx @@ -1,7 +1,13 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + "use client"; import { Modal } from "antd"; import type { CSSProperties, ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { useRepositoryPresentation } from "@/hooks/useRepositoryPresentation"; +import { StandardDrawer } from "@/components/common/StandardDrawer"; +import { StandardCloseButton } from "@/components/common/StandardCloseButton"; export interface ResourceDetailProps { open: boolean; @@ -21,6 +27,39 @@ export default function ResourceDetail({ className, bodyStyle, }: ResourceDetailProps) { + const presentation = useRepositoryPresentation(); + const { t } = useTranslation("common"); + if ( + presentation?.resourceType === "agent" || + presentation?.resourceType === "mcp" + ) { + return ( + +
+
+ +
+ {children} +
+
+ ); + } return (
-
+
{isLoading ? (
@@ -80,6 +89,66 @@ export function SkillDetail({ {t("repository.common.retry")}
+ ) : view && repositoryLayout ? ( +
+
+
+ {t("skillManagement.form.name")} +
+
{view.title}
+
+
+
+ {t("skillManagement.form.source")} +
+
+ {detail?.source === "custom" || + detail?.source === "repository" + ? t(`skillRepository.source.${detail.source}`) + : detail?.source || "—"} +
+
+
+
+ {t("skillManagement.form.tags")} +
+
+ {view.tags.length + ? view.tags.map((tag) => ( + + {tag} + + )) + : "—"} +
+
+
+
+ {t("skillManagement.form.description")} +
+
+ {view.description || + t("skillRepository.common.noDescription")} +
+
+
+ + {displayName(view.authorCasUsername, view.author || "—")} + + {t(statusLabelKeys[view.status])} + + + {view.downloads.toLocaleString()} + + + + {view.updatedAt} + +
+
) : view ? (
diff --git a/frontend/components/tag/RepositoryTagFilter.tsx b/frontend/components/tag/RepositoryTagFilter.tsx index d4ef91786d..f69c4f2899 100644 --- a/frontend/components/tag/RepositoryTagFilter.tsx +++ b/frontend/components/tag/RepositoryTagFilter.tsx @@ -1,8 +1,11 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + "use client"; import { Button, Popover, Select } from "antd"; import { Tag } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { useRepositoryPresentation } from "@/hooks/useRepositoryPresentation"; export interface RepositoryTagStat { tag: string; @@ -21,6 +24,27 @@ export default function RepositoryTagFilter({ onChange, }: RepositoryTagFilterProps) { const { t } = useTranslation("common"); + const presentation = useRepositoryPresentation(); + + if (presentation) { + return ( + + + + ({ + value: value.value_id, + label: + value.display_value || value.definition.definition_name, + }))} + onChange={(ids: number[]) => { + const added = ids.find( + (id) => !draft?.tag_value_ids.includes(id) + ); + const single = values.find( + (value) => + value.value_id === added && + value.definition.selection_mode === "single_select" + )?.definition; + if (single) { + const nextIds = ids.filter( + (id) => + id === added || + !single.values?.some((value) => value.value_id === id) + ); + form.setFieldValue("tag_value_ids", nextIds); + change({ tag_value_ids: nextIds, tagsTouched: true }); + } + }} + /> + + {!!unmatchedTags.length && + !draft?.tagsTouched && + !tagsLoading && + !librariesLoading && ( + + )} + + + +

+ {t("skillRepository.detail.skillFiles")} +

+ change({ files })} + disabled={unavailable || !draft} + /> +
+ + { + selectFile(event.target.files?.[0]); + event.target.value = ""; + }} + /> +

+ {t("skillCreation.uploadOnConfirm")} +

+ {parsed && ( +

+ {t("skillCreation.parseResult")} +

+ )} +
+ )} + +
+ + setAdding(false)} + onOk={addFile} + okText={t("common.confirm")} + cancelText={t("common.cancel")} + centered + > + { + setPath(event.target.value); + setPathError(false); + }} + /> + {pathError && ( +

{t("skillCreation.invalidPath")}

+ )} +
+ + ); +} diff --git a/frontend/components/skill/SkillGroupSelection.tsx b/frontend/components/skill/SkillGroupSelection.tsx new file mode 100644 index 0000000000..b459d0b723 --- /dev/null +++ b/frontend/components/skill/SkillGroupSelection.tsx @@ -0,0 +1,117 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useState } from "react"; +import { Alert, Button } from "antd"; +import { useTranslation } from "react-i18next"; +import { StandardTable } from "@/components/common/StandardTable"; +import type { Group } from "@/services/groupService"; + +interface SkillGroupSelectionProps { + id?: string; + groups: Group[]; + value?: number[]; + onChange?: (value: number[]) => void; + loading?: boolean; + disabled?: boolean; + isError?: boolean; + onRetry?: () => void; +} + +/** Form-controlled group IDs retain selections across the table's local pages. */ +export default function SkillGroupSelection({ + id, + groups, + value = [], + onChange, + loading = false, + disabled = false, + isError = false, + onRetry, +}: SkillGroupSelectionProps) { + const { t } = useTranslation("common"); + const [page, setPage] = useState(1); + const pageSize = 10; + const ids = new Set(groups.map((group) => group.group_id)); + if (isError) { + return ( + + {t("repository.common.retry")} + + } + /> + ); + } + return ( +
+ + rowKey="group_id" + size="small" + loading={loading} + tableLayout="fixed" + dataSource={groups} + scroll={{ x: 560 }} + locale={{ emptyText: t("skillCreation.noGroups") }} + rowSelection={{ + selectedRowKeys: value.filter((id) => ids.has(id)), + preserveSelectedRowKeys: true, + columnWidth: 40, + onChange: (keys) => + onChange?.(keys.map(Number).filter((id) => ids.has(id))), + getCheckboxProps: (group) => ({ + disabled: disabled || loading, + "aria-label": group.group_name, + }), + }} + columns={[ + { + title: t("skillCreation.groupName"), + dataIndex: "group_name", + width: 180, + ellipsis: true, + render: (name: string) => ( + + {name} + + ), + }, + { + title: t("skillCreation.groupDescription"), + dataIndex: "group_description", + ellipsis: true, + render: (description?: string) => ( + {description || "—"} + ), + }, + { + title: t("skillCreation.groupMembers"), + dataIndex: "user_count", + width: 80, + render: (count?: number) => count ?? "—", + }, + ]} + pagination={{ + current: Math.min( + page, + Math.max(1, Math.ceil(groups.length / pageSize)) + ), + pageSize, + total: groups.length, + simple: true, + showSizeChanger: false, + hideOnSinglePage: false, + onChange: setPage, + disabled: disabled || loading, + size: "small", + }} + /> +
+ ); +} diff --git a/frontend/components/skill/SkillReadOnlyDetail.tsx b/frontend/components/skill/SkillReadOnlyDetail.tsx new file mode 100644 index 0000000000..9e875ed3c1 --- /dev/null +++ b/frontend/components/skill/SkillReadOnlyDetail.tsx @@ -0,0 +1,259 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { App, Button, Empty, Input, Modal, Tree } from "antd"; +import { + Copy, + Download, + FileText, + Folder, + Maximize2, + Search, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { MarkdownRenderer } from "@/components/common/markdownRenderer"; +import { buildSkillDetailTree } from "@/lib/skillDetailFiles"; +import type { SkillFileContent } from "@/types/skill"; + +export default function SkillReadOnlyDetail({ + name, + source, + tags, + description, + files, +}: { + name: string; + source: string; + tags: string[]; + description: string; + files: SkillFileContent[]; +}) { + const { t } = useTranslation("common"); + const { message } = App.useApp(); + const [expandedPreview, setExpandedPreview] = useState(false); + const [search, setSearch] = useState(""); + const [selectedPath, setSelectedPath] = useState(""); + const [expandedKeys, setExpandedKeys] = useState([]); + useEffect(() => { + setSearch(""); + setExpandedPreview(false); + setSelectedPath( + files.find((file) => /(^|\/)SKILL\.md$/i.test(file.path))?.path ?? + files[0]?.path ?? + "" + ); + setExpandedKeys([]); + }, [files]); + const tree = useMemo( + () => buildSkillDetailTree(files, search), + [files, search] + ); + const active = files.find((file) => file.path === selectedPath); + const readable = + active && active.status !== "unsupported" && active.status !== "read_error"; + const copy = async () => { + if (!readable) return; + try { + await navigator.clipboard.writeText(active.content); + message.success(t("common.copied")); + } catch { + message.error(t("agentUsageGuide.copyFailed")); + } + }; + const download = () => { + if (!readable) return; + const url = URL.createObjectURL( + new Blob([active.content], { type: "text/plain;charset=utf-8" }) + ); + const link = document.createElement("a"); + link.href = url; + link.download = active.path.split("/").pop() || "file.txt"; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 0); + }; + const renderPreview = () => + !active ? ( + + ) : !readable ? ( +

+ {t( + active.status === "read_error" + ? "skillManagement.detail.fileReadFailed" + : "skillManagement.detail.filePreviewUnsupported" + )} +

+ ) : active.path.toLowerCase().endsWith(".md") ? ( + { + if (files.some((file) => file.path === path)) setSelectedPath(path); + }} + /> + ) : ( +
+        {active.content}
+      
+ ); + return ( +
+
+ {[ + ["name", name], + ["source", source], + ].map(([key, value]) => ( +
+
+ {t(`skillManagement.form.${key}`)} +
+
{value || "—"}
+
+ ))} +
+
{t("skillManagement.form.tags")}
+
+ {tags.length + ? tags.map((tag) => ( + + {tag} + + )) + : "—"} +
+
+
+
+ {t("skillManagement.form.description")} +
+
+ {description || t("skillRepository.common.noDescription")} +
+
+
+
+

+ {t("skillRepository.detail.skillFiles")} +

+
+ +
+
+ + {active?.path || t("skillManagement.detail.selectFile")} + +
+
+ {renderPreview()} +
+
+
+
+ setExpandedPreview(false)} + footer={null} + title={active?.path} + width="90vw" + centered + destroyOnHidden + > +
{renderPreview()}
+
+
+ ); +} diff --git a/frontend/components/skill/skill-detail.tsx b/frontend/components/skill/skill-detail.tsx index 62dfe5b202..7056e86a6e 100644 --- a/frontend/components/skill/skill-detail.tsx +++ b/frontend/components/skill/skill-detail.tsx @@ -14,6 +14,7 @@ import { useUserDisplayName } from "@/hooks/auth/useUserDisplayName"; import { useTranslation } from "react-i18next"; import { useRepositoryPresentation } from "@/hooks/useRepositoryPresentation"; import ResourceDetail from "@/components/resource/ResourceDetail"; +import RepositorySkillDetail from "./RepositorySkillDetail"; import { mapSkillRepositoryDetail } from "@/lib/skillRepositoryDetail"; import type { SkillRepositoryListingDetail } from "@/types/skillRepository"; @@ -60,6 +61,20 @@ export function SkillDetail({ const displayName = useUserDisplayName(); const view = detail ? mapSkillRepositoryDetail(detail) : null; + if (repositoryLayout) { + return ( + + ); + } + return ( ; + complete: boolean; + targets: string[] | null; + started: boolean; + checkpoints: Record< + string, + { files: Record; started: boolean } + >; +} + +export const initialSkillCreationDraft: SkillCreationDraft = { + files: {}, + complete: false, + targets: null, + started: false, + checkpoints: {}, +}; + +export interface SkillCreationEvent { + type: string; + content?: string; + path?: string; + paths?: string[]; + phase?: string; + attempt_id?: string; +} + +/** Keep generated files separate from the generic Agent's conversation state. */ +export function applySkillCreationEvent( + draft: SkillCreationDraft, + event: SkillCreationEvent +): SkillCreationDraft { + if (event.type === "model_attempt_control" && event.attempt_id) { + const checkpoints = { ...draft.checkpoints }; + if (event.phase === "begin") { + checkpoints[event.attempt_id] = { + files: { ...draft.files }, + started: draft.started, + }; + return { ...draft, checkpoints }; + } + const saved = checkpoints[event.attempt_id]; + delete checkpoints[event.attempt_id]; + return event.phase === "rollback" && saved + ? { ...draft, files: saved.files, started: saved.started, checkpoints } + : { ...draft, checkpoints }; + } + if (event.type === "agent_new_run") { + return { ...draft, complete: false, started: false, targets: null }; + } + if (event.type === "target_files") { + return { ...draft, targets: event.paths?.length ? event.paths : null }; + } + if (event.type === "skill_body" || event.type === "file_content") { + const path = event.type === "skill_body" ? "SKILL.md" : event.path; + if (!path) return draft; + const files = { ...draft.files }; + if (!draft.started) { + if (draft.targets) { + for (const target of draft.targets) delete files[target]; + } else { + for (const target of Object.keys(files)) delete files[target]; + } + } + files[path] = (files[path] ?? "") + (event.content ?? ""); + return { ...draft, files, started: true, complete: false }; + } + if (event.type === "done") { + return { ...draft, complete: Boolean(draft.files["SKILL.md"]?.trim()) }; + } + if (event.type === "error") { + return { ...draft, complete: false }; + } + return draft; +} + +export function buildSkillSavePayload(draft: SkillCreationDraft) { + if (!draft.complete) return null; + const raw = draft.files["SKILL.md"]?.trim(); + if (!raw) return null; + const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/); + if (!match) return null; + let metadata: Record; + try { + const parsed = load(match[1]); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) + return null; + metadata = parsed as Record; + } catch { + return null; + } + const name = typeof metadata.name === "string" ? metadata.name.trim() : ""; + const description = + typeof metadata.description === "string" ? metadata.description.trim() : ""; + if (!name || !description) return null; + const tags = Array.isArray(metadata.tags) + ? metadata.tags.filter((tag): tag is string => typeof tag === "string") + : []; + return { + name, + description, + tags, + source: "custom", + content: raw.slice(match[0].length).trim(), + files: Object.entries(draft.files) + .filter(([path]) => path !== "SKILL.md") + .map(([path, content]) => ({ path, content })), + }; +} + +export type SkillChatPayload = NonNullable< + ReturnType +>; + +export function buildEditedSkillFiles(payload: SkillChatPayload) { + return { + ...Object.fromEntries( + payload.files.map((file) => [file.path, file.content]) + ), + "SKILL.md": `---\n${dump({ name: payload.name, description: payload.description, tags: payload.tags }, { lineWidth: -1 })}---\n${payload.content}`, + }; +} diff --git a/frontend/lib/skillCreationDraft.ts b/frontend/lib/skillCreationDraft.ts new file mode 100644 index 0000000000..d0f574c7d8 --- /dev/null +++ b/frontend/lib/skillCreationDraft.ts @@ -0,0 +1,112 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import JSZip from "jszip"; +import { load, JSON_SCHEMA } from "js-yaml"; +import type { SkillFileContent } from "../types/skill"; + +/** Parse locally without uploading; keep the original ZIP for lossless submission. */ +export async function parseSkillCreationFile(file: File) { + const decode = (bytes: Uint8Array) => { + for (const encoding of ["utf-8", "gb18030", "big5"]) { + try { + return new TextDecoder(encoding, { fatal: true }).decode(bytes); + } catch { + /* Try the next supported encoding. */ + } + } + throw new Error("Unreadable skill text"); + }; + let files: SkillFileContent[]; + if (/\.md$/i.test(file.name)) { + files = [ + { + path: "SKILL.md", + content: decode(new Uint8Array(await file.arrayBuffer())), + }, + ]; + } else if (/\.zip$/i.test(file.name)) { + const zip = await JSZip.loadAsync(new Uint8Array(await file.arrayBuffer())); + const entries = Object.values(zip.files).filter( + (entry) => !entry.dir && !entry.name.startsWith("__MACOSX/") + ); + const skillEntry = entries.find( + (entry) => entry.name.split("/").pop()?.toLowerCase() === "skill.md" + ); + if (!skillEntry) throw new Error("SKILL.md is missing or unreadable"); + const prefix = skillEntry.name.slice( + 0, + skillEntry.name.lastIndexOf("/") + 1 + ); + files = await Promise.all( + entries + .filter((entry) => entry.name.startsWith(prefix)) + .map(async (entry): Promise => { + const path = entry.name.slice(prefix.length); + if ( + !/\.(md|txt|json|csv|html?|xml|ya?ml|toml|ini|py|js|jsx|ts|tsx|sh|css|sql)$/i.test( + path + ) + ) + return { path, content: "", status: "unsupported" }; + try { + return { + path, + content: decode(await entry.async("uint8array")), + status: "readable", + }; + } catch { + return { path, content: "", status: "read_error" }; + } + }) + ); + } else { + throw new Error("Only Markdown and ZIP skill files are supported"); + } + const primary = files.find( + (entry) => entry.path.toLowerCase() === "skill.md" + ); + if (!primary || primary.status === "read_error") + throw new Error("SKILL.md is missing or unreadable"); + primary.path = "SKILL.md"; + const match = primary.content + .replace(/^\uFEFF/, "") + .match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/); + const metadata = match ? load(match[1], { schema: JSON_SCHEMA }) : {}; + const fields = + metadata && typeof metadata === "object" && !Array.isArray(metadata) + ? (metadata as Record) + : {}; + if (match) + primary.content = primary.content + .replace(/^\uFEFF/, "") + .slice(match[0].length); + return { + name: typeof fields.name === "string" ? fields.name : "", + description: + typeof fields.description === "string" ? fields.description : "", + tags: Array.isArray(fields.tags) + ? fields.tags.filter((tag): tag is string => typeof tag === "string") + : [], + files: files.sort((a, b) => + a.path === "SKILL.md" + ? -1 + : b.path === "SKILL.md" + ? 1 + : a.path.localeCompare(b.path) + ), + }; +} + +/** Validate relative draft paths before adding them to a future skill archive. */ +export function isValidSkillDraftPath(path: string) { + return ( + Boolean(path.trim()) && + path === path.trim() && + !/[\\\x00-\x1f]/.test(path) && + !path.startsWith("/") && + !/^[a-z]:/i.test(path) && + path + .split("/") + .every((part) => part !== "" && part !== "." && part !== "..") + ); +} diff --git a/frontend/lib/skillDetailFiles.ts b/frontend/lib/skillDetailFiles.ts new file mode 100644 index 0000000000..d8be562e06 --- /dev/null +++ b/frontend/lib/skillDetailFiles.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import type { SkillFileContent } from "../types/skill"; + +export interface SkillDetailFileNode { + key: string; + title: string; + isLeaf: boolean; + children?: SkillDetailFileNode[]; +} + +/** Preserve folder ancestry when filtering, using full paths as stable keys. */ +export function buildSkillDetailTree(files: SkillFileContent[], search = "") { + const roots: SkillDetailFileNode[] = []; + const nodes = new Map(); + const query = search.trim().toLowerCase(); + for (const file of files) { + if (query && !file.path.toLowerCase().includes(query)) continue; + const parts = file.path.split("/").filter(Boolean); + let children = roots; + parts.forEach((part, index) => { + const key = parts.slice(0, index + 1).join("/"); + let node = nodes.get(key); + if (!node) { + const isLeaf = index === parts.length - 1; + node = { + key, + title: part, + isLeaf, + ...(isLeaf ? {} : { children: [] }), + }; + nodes.set(key, node); + children.push(node); + } + children = node.children ?? []; + }); + } + return roots; +} diff --git a/frontend/lib/skillRepositoryIcon.ts b/frontend/lib/skillRepositoryIcon.ts new file mode 100644 index 0000000000..0a2b0dcaa8 --- /dev/null +++ b/frontend/lib/skillRepositoryIcon.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import type { SkillRepositoryListingItem } from "../types/skillRepository"; + +const presentationKinds = new Set([ + "ppt", + "pptx", + "powerpoint", + "presentation", + "presentations", + "slides", + "演示", + "演示文稿", + "幻灯片", +]); +const documentKinds = new Set([ + "file", + "files", + "document", + "documents", + "docx", + "pdf", + "xlsx", + "excel", + "spreadsheet", + "spreadsheets", + "csv", + "文档", + "文件", + "表格", +]); + +function classify(value: string) { + const tokens = value.toLowerCase().split(/[^a-z0-9\u3400-\u9fff]+/); + if (tokens.some((token) => presentationKinds.has(token))) { + return "presentation" as const; + } + if (tokens.some((token) => documentKinds.has(token))) { + return "document" as const; + } + return undefined; +} + +/** Use explicit icon identifiers first; infer only from tags and name, never prose. */ +export function getSkillRepositoryIconKind( + listing: Pick +) { + const icon = listing.icon?.trim().toLowerCase(); + if (icon === "layers") return "general" as const; + if (icon === "file-text") return "document" as const; + if (icon === "presentation") return "presentation" as const; + return ( + classify((listing.tags ?? []).join(" ")) ?? + classify(listing.name) ?? + "general" + ); +} diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index cee5f1a939..91003f595c 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -1,4 +1,50 @@ { + "skillRepository.mine.applyTitle": "Apply to list “{{name}}”?", + "skillRepository.mine.applyNotePlaceholder": "Optionally enter a listing note", + "skillCreation.groupPermission": "Group permission", + "skillCreation.selectGroups": "Select user groups", + "skillCreation.groupName": "User group", + "skillCreation.groupDescription": "Description", + "skillCreation.groupMembers": "Members", + "skillCreation.groupsLoadFailed": "Could not load user groups. Please retry.", + "skillCreation.noGroups": "No user groups available", + "skillCreation.switchMode": "Switch creation method?", + "skillCreation.discardDraft": "The current unsaved configuration and file draft will be cleared.", + "skillCreation.unmatchedTags": "These file tags are not defined in the tag library and will not be added automatically: {{tags}}", + "skillCreation.title": "Create skill", + "skillCreation.parameters": "Configure parameters", + "skillCreation.groups": "Select user groups", + "skillCreation.manual": "Create manually", + "skillCreation.manualHint": "Enter skill information manually", + "skillCreation.import": "Import skill file", + "skillCreation.importHint": "Upload a local skill file", + "skillCreation.uploadFile": "Upload file", + "skillCreation.addFile": "Add file", + "skillCreation.parseFile": "Parse file", + "skillCreation.parseResult": "Parsed result", + "skillCreation.uploadOnConfirm": "Files are parsed locally and uploaded when creation is confirmed.", + "skillCreation.next": "Next", + "skillCreation.previous": "Previous", + "skillCreation.newFile": "New file", + "skillCreation.filePath": "File path", + "skillCreation.fileContent": "File content", + "skillCreation.invalidPath": "Enter an unused relative file path, such as scripts/helper.py.", + "skillCreation.parseFailed": "Parsing failed. Check the file format and SKILL.md content.", + "skillCreation.parseFirst": "Select and parse a skill file first.", + "skillCreation.missingPrimary": "A skill must contain SKILL.md.", + "skillCreation.missingCreatedId": "The creation response has no skill ID. Refresh the list to check the result before retrying.", + "skillCreation.partialCreated": "The skill has been created. If configuration fails, retrying will continue configuring the same skill.", + "skillRepository.detail.skillFiles": "Skill files", + "skillRepository.detail.downloadFile": "Download file", + "skillRepository.mine.exitManagement": "Exit management", + "skillRepository.mine.clearSelection": "Clear selection", + "skillRepository.mine.batchManage": "Manage in bulk", + "skillRepository.mine.createSkill": "Create skill", + "skillRepository.mine.createForm": "Create with a form", + "skillRepository.mine.createChat": "Create in workbench chat", + "skillRepository.mine.allCreators": "All creators", + "skillRepository.tabs.allSkills": "All skills", + "skillRepository.tabs.installedSkills": "Installed skills", "resourceCenter": { "selectPage": "Select current page", "deleteSelected": "Delete selected", @@ -3073,7 +3119,6 @@ "skillRepository.repository.copy": "Copy", "skillRepository.card.downloadAria": "Downloads {{count}}", "skillRepository.mine.addSkillService": "Add Skill service", - "skillRepository.mine.createSkill": "Create Skill", "skillRepository.mine.summary": "{{count}} services · Skills marked with Hub are listed in the repository", "skillRepository.mine.empty": "No manageable Skills yet", "skillRepository.mine.confirmApplyTitle": "List {{name}}?", @@ -6502,5 +6547,26 @@ "nl2agent.resourceBinding.recommendedKnowledge": "Recommended knowledge bases:", "nl2agent.resourceBinding.knowledgeNone": "None", "nl2agent.resourceBinding.knowledgeDescription": "Search selected knowledge bases for relevant information and supporting references.", - "nl2agent.resourceBinding.selectedKnowledge": "Selected knowledge bases:" + "nl2agent.resourceBinding.selectedKnowledge": "Selected knowledge bases:", + "skillChat.title": "Skill creation", + "skillChat.untitled": "Untitled skill", + "skillChat.newSkill": "New skill conversation", + "skillChat.expand": "Toggle full screen", + "skillChat.collapse": "Collapse editor", + "skillChat.open": "Configure skill", + "skillChat.localSaveFailed": "Local draft could not be saved. Confirm to save your changes.", + "skillChat.localSavedAt": "Local draft saved at {{time}}", + "skillChat.localDraft": "Local draft. Confirm to save to your skills.", + "skillChat.generating": "Generating your skill. Editing is available when generation finishes.", + "skillChat.waitDraft": "Describe the skill in the conversation to start configuring it here.", + "skillChat.lookupFailed": "Could not check whether this skill already exists. Retry before saving.", + "skillChat.tagsFailed": "Could not load tags. Retry before saving.", + "skillChat.newVersion": "New generated content is available. Your edits have been kept.", + "skillChat.useGenerated": "Use generated content", + "skillChat.summary": "Artifacts", + "skillChat.openRepository": "Open in skill repository", + "skillChat.conversation": "Skill creation conversation", + "skillChat.waitForCompletion": "Wait for generation or saving to finish.", + "skillChat.discardTitle": "Discard the current skill draft?", + "skillChat.discardDescription": "Changes that have not been confirmed will be cleared." } diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index eb34a7571a..f467011ef4 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -1,4 +1,50 @@ { + "skillRepository.mine.applyTitle": "确定申请上架“{{name}}”?", + "skillRepository.mine.applyNotePlaceholder": "可选择填写上架说明", + "skillCreation.groupPermission": "用户组权限", + "skillCreation.selectGroups": "选择用户组", + "skillCreation.groupName": "用户组", + "skillCreation.groupDescription": "描述", + "skillCreation.groupMembers": "人数", + "skillCreation.groupsLoadFailed": "用户组加载失败,请重试。", + "skillCreation.noGroups": "暂无可选择的用户组", + "skillCreation.switchMode": "切换创建方式?", + "skillCreation.discardDraft": "当前未保存的配置和文件草稿将被清空。", + "skillCreation.unmatchedTags": "以下文件标签尚未在标签库中定义,不会自动添加:{{tags}}", + "skillCreation.title": "创建技能", + "skillCreation.parameters": "配置参数", + "skillCreation.groups": "选择用户组", + "skillCreation.manual": "手动创建", + "skillCreation.manualHint": "手动填写技能信息", + "skillCreation.import": "导入技能文件", + "skillCreation.importHint": "从本地上传技能文件", + "skillCreation.uploadFile": "上传文件", + "skillCreation.addFile": "添加文件", + "skillCreation.parseFile": "解析文件", + "skillCreation.parseResult": "解析结果", + "skillCreation.uploadOnConfirm": "文件先在本地解析,确认创建时上传。", + "skillCreation.next": "下一步", + "skillCreation.previous": "上一步", + "skillCreation.newFile": "新建文件", + "skillCreation.filePath": "文件路径", + "skillCreation.fileContent": "文件内容", + "skillCreation.invalidPath": "请输入未被占用的相对文件路径,例如 scripts/helper.py。", + "skillCreation.parseFailed": "解析失败,请检查文件格式及 SKILL.md 内容。", + "skillCreation.parseFirst": "请先选择并解析技能文件。", + "skillCreation.missingPrimary": "技能必须包含 SKILL.md 文件。", + "skillCreation.missingCreatedId": "创建响应缺少技能 ID,请刷新技能列表确认结果,避免重复创建。", + "skillCreation.partialCreated": "技能已创建。若后续配置保存失败,重试将继续配置同一个技能。", + "skillRepository.detail.skillFiles": "技能文件", + "skillRepository.detail.downloadFile": "下载文件", + "skillRepository.mine.exitManagement": "退出管理", + "skillRepository.mine.clearSelection": "清空选择", + "skillRepository.mine.batchManage": "批量管理", + "skillRepository.mine.createSkill": "创建技能", + "skillRepository.mine.createForm": "填写表单创建", + "skillRepository.mine.createChat": "工作台对话创建", + "skillRepository.mine.allCreators": "全部创建者", + "skillRepository.tabs.allSkills": "全部技能", + "skillRepository.tabs.installedSkills": "我安装的", "resourceCenter": { "selectPage": "全选当前页", "deleteSelected": "批量删除", @@ -3116,7 +3162,6 @@ "skillRepository.repository.copy": "复制", "skillRepository.card.downloadAria": "下载 {{count}}", "skillRepository.mine.addSkillService": "添加Skill服务", - "skillRepository.mine.createSkill": "创建 Skill", "skillRepository.mine.summary": "共 {{count}} 个服务 · 标题旁带 Hub 标签的已上架到仓库", "skillRepository.mine.empty": "暂无可管理 Skill", "skillRepository.mine.confirmApplyTitle": "确认上架 {{name}}?", @@ -6498,5 +6543,26 @@ "nl2agent.resourceBinding.recommendedKnowledge": "推荐知识库:", "nl2agent.resourceBinding.knowledgeNone": "空", "nl2agent.resourceBinding.knowledgeDescription": "根据用户问题检索所选知识库中的相关资料,为回答提供依据和引用。", - "nl2agent.resourceBinding.selectedKnowledge": "已选知识库:" + "nl2agent.resourceBinding.selectedKnowledge": "已选知识库:", + "skillChat.title": "Skill创建", + "skillChat.untitled": "未命名技能", + "skillChat.newSkill": "新建技能对话", + "skillChat.expand": "切换全屏", + "skillChat.collapse": "收起编辑区", + "skillChat.open": "配置技能", + "skillChat.localSaveFailed": "本地草稿保存失败,请及时确认保存", + "skillChat.localSavedAt": "本地草稿已保存于 {{time}}", + "skillChat.localDraft": "本地草稿,确认后保存到技能仓库", + "skillChat.generating": "正在生成技能,完成后可编辑并保存", + "skillChat.waitDraft": "在左侧描述你想创建的技能,生成后可在这里配置", + "skillChat.lookupFailed": "无法确认技能是否已存在,请重试后保存", + "skillChat.tagsFailed": "标签加载失败,请重试后保存", + "skillChat.newVersion": "有新的生成结果,当前保留你的手动修改", + "skillChat.useGenerated": "使用生成结果", + "skillChat.summary": "产物汇总", + "skillChat.openRepository": "在技能仓库中打开", + "skillChat.conversation": "技能创建对话", + "skillChat.waitForCompletion": "请等待当前生成或保存完成", + "skillChat.discardTitle": "放弃当前技能草稿?", + "skillChat.discardDescription": "尚未确认保存的修改将被清除。" } diff --git a/frontend/tests/component/repositorySkillDetailFiles.test.tsx b/frontend/tests/component/repositorySkillDetailFiles.test.tsx new file mode 100644 index 0000000000..1ceffab6c6 --- /dev/null +++ b/frontend/tests/component/repositorySkillDetailFiles.test.tsx @@ -0,0 +1,66 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { render, screen } from "@testing-library/react"; +import { App } from "antd"; +import type { ReactNode } from "react"; +import JSZip from "jszip"; +import { expect, test, vi } from "vitest"; +import RepositorySkillDetail from "@/components/skill/RepositorySkillDetail"; +import type { SkillRepositoryListingDetail } from "@/types/skillRepository"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock("@/components/common/StandardModal", () => ({ + StandardModal: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})); +vi.mock("@/components/common/markdownRenderer", () => ({ + MarkdownRenderer: ({ content }: { content: string }) =>
{content}
, +})); +vi.mock("@/lib/logger", () => ({ default: { error: vi.fn() } })); + +function show(detail: Partial) { + render( + + + + ); +} + +test("reads the frozen ZIP and removes its common root folder", async () => { + const zip = new JSZip(); + zip.file("example/SKILL.md", "Frozen skill content"); + zip.file("example/manual.pdf", new Uint8Array([0, 1, 2])); + show({ skill_zip_base64: await zip.generateAsync({ type: "base64" }) }); + expect(await screen.findByText("Frozen skill content")).toBeVisible(); + expect(screen.getByText("manual.pdf")).toBeVisible(); + expect(screen.queryByText("example/SKILL.md")).not.toBeInTheDocument(); +}); + +test("uses snapshot content when the archive is absent", async () => { + show({ skill_info_json: { content: "Snapshot content" } }); + expect(await screen.findByText("Snapshot content")).toBeVisible(); +}); + +test("reports corrupt archives while retaining the skill metadata", async () => { + show({ skill_zip_base64: "invalid-archive" }); + expect( + await screen.findByText("skillManagement.detail.fileReadFailed") + ).toBeVisible(); + expect(screen.getByText("Example")).toBeVisible(); +}); diff --git a/frontend/tests/component/skillApplyListingModal.test.tsx b/frontend/tests/component/skillApplyListingModal.test.tsx new file mode 100644 index 0000000000..46e8c718ae --- /dev/null +++ b/frontend/tests/component/skillApplyListingModal.test.tsx @@ -0,0 +1,129 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { App } from "antd"; +import { expect, test, vi } from "vitest"; +import { MineApplyListingModal } from "@/components/repository/skill/components/MineApplyListingModal"; +import type { MyEditableSkillItem } from "@/types/skillRepository"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, options?: { name?: string }) => + options?.name ? `${key}: ${options.name}` : key, + }), +})); +const skill: MyEditableSkillItem = { + skill_id: 7, + name: "Canvas-Design", + tags: ["design"], + repository_info: [], +}; + +function show( + onConfirm = vi.fn().mockResolvedValue(undefined), + loading = false +) { + const onClose = vi.fn(); + const result = render( + + + + ); + return { ...result, onConfirm, onClose }; +} + +test("shows the skill name and accepts an optional empty note", async () => { + const { onConfirm, onClose } = show(); + expect( + screen.getByText("skillRepository.mine.applyTitle: Canvas-Design") + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "common.confirm" })); + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + expect(onConfirm).toHaveBeenCalledWith({ + icon: "skill", + tags: ["design"], + content: undefined, + }); +}); + +test("permits 1000 characters and prevents a payload above that limit", async () => { + const { onConfirm } = show(); + const input = screen.getByLabelText("repository.mine.applyModal.content"); + fireEvent.change(input, { target: { value: "x".repeat(1001) } }); + expect(screen.getByRole("button", { name: "common.confirm" })).toBeDisabled(); + expect(onConfirm).not.toHaveBeenCalled(); + fireEvent.change(input, { target: { value: "x".repeat(1000) } }); + fireEvent.click(screen.getByRole("button", { name: "common.confirm" })); + await waitFor(() => + expect(onConfirm).toHaveBeenCalledWith( + expect.objectContaining({ content: "x".repeat(1000) }) + ) + ); +}); + +test("retains the note after failure and closes only after a successful retry", async () => { + const submit = vi + .fn() + .mockRejectedValueOnce(new Error("Failed")) + .mockResolvedValueOnce(undefined); + const { onClose } = show(submit); + fireEvent.change( + screen.getByLabelText("repository.mine.applyModal.content"), + { target: { value: " Listing note " } } + ); + fireEvent.click(screen.getByRole("button", { name: "common.confirm" })); + expect( + await screen.findByText("repository.mine.applyError") + ).toBeInTheDocument(); + expect(onClose).not.toHaveBeenCalled(); + expect( + screen.getByLabelText("repository.mine.applyModal.content") + ).toHaveValue(" Listing note "); + fireEvent.click(screen.getByRole("button", { name: "common.confirm" })); + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + expect(submit).toHaveBeenLastCalledWith( + expect.objectContaining({ content: "Listing note" }) + ); +}); + +test("blocks duplicate submission and dismissal while the request is pending", async () => { + let resolve!: () => void; + const submit = vi.fn( + () => + new Promise((done) => { + resolve = done; + }) + ); + const { onClose } = show(submit); + const confirm = screen.getByRole("button", { name: "common.confirm" }); + fireEvent.click(confirm); + fireEvent.click(confirm); + expect(submit).toHaveBeenCalledTimes(1); + expect(screen.getByRole("button", { name: "common.cancel" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "common.close" })); + expect(onClose).not.toHaveBeenCalled(); + resolve(); + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); +}); + +test("renders no listing dialog when the selected skill is missing", () => { + render( + + + + ); + expect( + screen.queryByRole("button", { name: "common.confirm" }) + ).not.toBeInTheDocument(); +}); diff --git a/frontend/tests/component/skillBatchSelection.test.tsx b/frontend/tests/component/skillBatchSelection.test.tsx new file mode 100644 index 0000000000..d999896ae2 --- /dev/null +++ b/frontend/tests/component/skillBatchSelection.test.tsx @@ -0,0 +1,111 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { App } from "antd"; +import { expect, test, vi } from "vitest"; +import ResourceCardGrid from "@/components/resource/ResourceCardGrid"; +import { RepositoryPresentationContext } from "@/hooks/useRepositoryPresentation"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock("@/hooks/auth/useUserDisplayName", () => ({ + useUserDisplayName: () => (_: unknown, fallback: string) => fallback, +})); +vi.mock("@/components/common/StandardTable", () => ({ + StandardTable: () => null, +})); +vi.mock("@/components/resource/CreateResourceCard", () => ({ + default: () => null, +})); +vi.mock("@/components/repository/agent/components/AgentCardTags", () => ({ + AgentCardTags: () => null, +})); + +function renderManagement( + remove: (item: { skill_id: number }) => Promise +) { + const items = [ + { skill_id: 1, name: "Editable skill", permission: "EDIT" }, + { skill_id: 2, name: "Read-only skill", permission: "READ_ONLY" }, + ]; + return render( + + + item.permission === "EDIT"} + cardSelectionInHeader + renderItem={(item, _index, selection) => ( +
{selection}
+ )} + renderBatchToolbar={(_controls, state) => ( + <> + {state.selectedCount} + {state.selectPageControl} + + + + )} + /> +
+
+ ); +} + +test("selects editable resources only and clears the management selection", () => { + const remove = vi.fn().mockResolvedValue(undefined); + renderManagement(remove); + expect( + screen.getByRole("button", { name: "Delete selected" }) + ).toBeDisabled(); + expect( + screen.getByRole("checkbox", { name: "Read-only skill" }) + ).toBeDisabled(); + fireEvent.click( + screen.getByRole("checkbox", { name: "resourceCenter.selectPage" }) + ); + expect(screen.getByLabelText("Selected count")).toHaveTextContent("1"); + expect( + screen.getByRole("checkbox", { name: "Editable skill" }) + ).toBeChecked(); + expect( + screen.getByRole("checkbox", { name: "Read-only skill" }) + ).not.toBeChecked(); + fireEvent.click(screen.getByRole("button", { name: "Clear selection" })); + expect(screen.getByLabelText("Selected count")).toHaveTextContent("0"); + expect(remove).not.toHaveBeenCalled(); +}); + +test.each([false, true])( + "confirms deletion and retains failed selection: %s", + async (fails) => { + const remove = vi.fn().mockImplementation(async () => { + if (fails) throw new Error("Deletion failed"); + }); + renderManagement(remove); + fireEvent.click(screen.getByRole("checkbox", { name: "Editable skill" })); + fireEvent.click(screen.getByRole("button", { name: "Delete selected" })); + expect(remove).not.toHaveBeenCalled(); + fireEvent.click( + await screen.findByRole("button", { name: "common.confirm" }) + ); + await waitFor(() => expect(remove).toHaveBeenCalledTimes(1)); + await waitFor(() => + expect(screen.getByLabelText("Selected count")).toHaveTextContent( + fails ? "1" : "0" + ) + ); + expect(remove.mock.calls[0][0].skill_id).toBe(1); + if (fails) expect(await screen.findByText("Deletion failed")).toBeVisible(); + } +); diff --git a/frontend/tests/component/skillChatCreateModal.test.tsx b/frontend/tests/component/skillChatCreateModal.test.tsx new file mode 100644 index 0000000000..bb67eabcb7 --- /dev/null +++ b/frontend/tests/component/skillChatCreateModal.test.tsx @@ -0,0 +1,228 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import type { ReactNode } from "react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { App } from "antd"; +import { beforeEach, expect, test, vi } from "vitest"; +import SkillChatCreateModal from "@/components/skill/SkillChatCreateModal"; +import type { + SkillCreationEvent, + SkillChatPayload, +} from "@/lib/skillChatDraft"; + +const chat = vi.hoisted(() => ({ snapshot: {} as Record })); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key, i18n: { language: "zh" } }), +})); +vi.mock("@/components/providers/AuthorizationProvider", () => ({ + useAuthorizationContext: () => ({ user: { id: "u", tenantId: "t" } }), +})); +vi.mock("@/lib/logger", () => ({ default: { error: vi.fn(), warn: vi.fn() } })); +vi.mock("@/components/common/StandardModal", () => ({ + StandardModal: ({ + open, + children, + onOpenChange, + }: { + open: boolean; + children: ReactNode; + onOpenChange: (open: boolean) => void; + }) => + open ? ( +
+ + {children} +
+ ) : null, +})); +vi.mock("@/app/newchat/assistant-ui/nl2skill-chat-panel", () => ({ + Nl2SkillChatPanel: ({ + onStreamEvent, + getDraftSnapshot, + onSkillFileSelect, + }: { + onStreamEvent: (event: SkillCreationEvent) => void; + getDraftSnapshot: () => Record; + onSkillFileSelect: (path: string) => void; + }) => ( +
+ + + + + +
+ ), +})); +vi.mock("@/components/skill/SkillChatEditor", () => ({ + default: ({ + payload, + open, + running, + selectedPath, + onNew, + onDraftChange, + onSaved, + onBusyChange, + }: { + payload: SkillChatPayload | null; + open: boolean; + running: boolean; + selectedPath: string; + onNew: () => void; + onDraftChange: (payload: SkillChatPayload) => void; + onSaved: (payload: SkillChatPayload) => void; + onBusyChange: (value: boolean) => void; + }) => ( +
+ {payload?.name ?? "empty"} + {selectedPath} + {String(running)} + {String(open)} + + + + +
+ ), +})); +beforeEach(() => { + sessionStorage.clear(); + chat.snapshot = {}; +}); +function show() { + const props = { + open: true, + onClose: vi.fn(), + onSuccess: vi.fn(), + onOpenSkill: vi.fn(), + }; + const result = render( + + + + ); + return { ...result, props }; +} +test("opens an independent dialog and feeds generated content to its editor", () => { + const { props } = show(); + expect(screen.getByRole("dialog")).toBeVisible(); + expect(screen.getByLabelText("Generated name")).toHaveTextContent("empty"); + fireEvent.click(screen.getByText("Generate skill")); + expect(screen.getByLabelText("Generated name")).toHaveTextContent( + "generated-skill" + ); + expect(screen.getByText("skillChat.summary")).toBeVisible(); + fireEvent.click(screen.getByText("Open reference")); + expect(screen.getByLabelText("Selected file")).toHaveTextContent( + "references/guide.txt" + ); + expect(props.onClose).not.toHaveBeenCalled(); +}); +test("passes manual edits back to the next conversation draft snapshot", () => { + show(); + fireEvent.click(screen.getByText("Generate skill")); + fireEvent.click(screen.getByText("Edit draft")); + fireEvent.click(screen.getByText("Read snapshot")); + expect(chat.snapshot).toMatchObject({ + name: "generated-skill", + description: "Manual description", + content: "Edited body", + }); + expect(chat.snapshot.files).toEqual([ + { + path: "SKILL.md", + content: expect.stringContaining("Manual description"), + }, + ]); +}); +test("refreshes Mine after saving and opens the result without leaving for the workbench", async () => { + const { props } = show(); + fireEvent.click(screen.getByText("Generate skill")); + fireEvent.click(screen.getByText("Save skill")); + await waitFor(() => expect(props.onSuccess).toHaveBeenCalledTimes(1)); + fireEvent.click( + screen.getByRole("button", { name: "skillChat.openRepository" }) + ); + expect(props.onOpenSkill).toHaveBeenCalledWith("generated-skill"); +}); +test("closing an empty dialog returns directly to the repository", () => { + const { props } = show(); + fireEvent.click(screen.getByText("Close dialog")); + expect(props.onClose).toHaveBeenCalledTimes(1); +}); +test("requires explicit confirmation before discarding generated edits", async () => { + const { props } = show(); + fireEvent.click(screen.getByText("Generate skill")); + fireEvent.click(screen.getByText("Close dialog")); + expect(props.onClose).not.toHaveBeenCalled(); + fireEvent.click( + await screen.findByRole("button", { name: "common.confirm" }) + ); + await waitFor(() => expect(props.onClose).toHaveBeenCalledTimes(1)); +}); +test("prevents closing while generation or saving is active", () => { + const { props } = show(); + fireEvent.click(screen.getByText("Start generation")); + fireEvent.click(screen.getByText("Close dialog")); + expect(props.onClose).not.toHaveBeenCalled(); + fireEvent.click(screen.getByText("Fail generation")); + fireEvent.click(screen.getByText("Start saving")); + fireEvent.click(screen.getByText("Close dialog")); + expect(props.onClose).not.toHaveBeenCalled(); +}); +test("rolls back a failed continuation to the last usable draft", () => { + show(); + fireEvent.click(screen.getByText("Generate skill")); + fireEvent.click(screen.getByText("Edit draft")); + fireEvent.click(screen.getByText("Start generation")); + fireEvent.click(screen.getByText("Fail generation")); + fireEvent.click(screen.getByText("Read snapshot")); + expect(chat.snapshot).toMatchObject({ + description: "Manual description", + content: "Edited body", + }); + expect(screen.getByLabelText("Run status")).toHaveTextContent("false"); +}); diff --git a/frontend/tests/component/skillChatEditor.test.tsx b/frontend/tests/component/skillChatEditor.test.tsx new file mode 100644 index 0000000000..d8757c2a01 --- /dev/null +++ b/frontend/tests/component/skillChatEditor.test.tsx @@ -0,0 +1,315 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { App } from "antd"; +import { beforeEach, expect, test, vi } from "vitest"; +import SkillChatEditor from "@/components/skill/SkillChatEditor"; +import { buildEditedSkillFiles } from "@/lib/skillChatDraft"; +import { + buildSkillSavePayload, + initialSkillCreationDraft, +} from "@/lib/skillChatDraft"; + +const api = vi.hoisted(() => ({ + create: vi.fn(), + update: vi.fn(), + fetch: vi.fn(), + list: vi.fn(), + tags: vi.fn(), + allowed: true, +})); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock("@/components/providers/AuthorizationProvider", () => ({ + useAuthorizationContext: () => ({ + user: { tenantId: "t" }, + hasPermission: () => api.allowed, + getAccessibleGroupIds: () => [7], + }), +})); +vi.mock("@/hooks/group/useGroupList", () => ({ + useGroupList: () => ({ + data: { + groups: [ + { group_id: 7, group_name: "Allowed" }, + { group_id: 8, group_name: "Other" }, + ], + }, + isLoading: false, + }), +})); +vi.mock("@/hooks/useTagManagement", () => ({ + useTagLibraries: () => ({ + data: [{ bucket_key: "default_resource", bucket_id: 1 }], + loading: false, + }), + useTagDefinitions: () => ({ + data: [ + { + definition_id: 10, + definition_name: "Topic", + status: "active", + selection_mode: "single_select", + values: [ + { value_id: 20, display_value: "Finance", status: "active" }, + { value_id: 21, display_value: "Analysis", status: "active" }, + ], + }, + ], + loading: false, + }), +})); +vi.mock("@/services/agentConfigService", () => ({ + createSkill: api.create, + updateSkillById: api.update, + fetchSkillById: api.fetch, +})); +vi.mock("@/services/skillRepositoryService", () => ({ + fetchMyEditableSkills: api.list, +})); +vi.mock("@/services/tagManagementService", () => ({ + tagManagementApi: { replaceAssignments: api.tags }, +})); +vi.mock("@/lib/logger", () => ({ default: { error: vi.fn() } })); +vi.mock("@/components/skill/SkillCreationFiles", () => ({ + default: ({ + files, + disabled, + onChange, + }: { + files: { path: string; content: string }[]; + disabled: boolean; + onChange: (files: { path: string; content: string }[]) => void; + }) => ( + + ), +})); +vi.mock("@/components/skill/SkillGroupSelection", () => ({ + default: ({ + groups, + onChange, + disabled, + }: { + groups: { group_id: number; group_name: string }[]; + onChange?: (ids: number[]) => void; + disabled: boolean; + }) => ( +
+ {groups.map((group) => ( + + ))} +
+ ), +})); + +const payload = { + name: "workspace-summary", + description: "Summarize workspace", + source: "custom", + tags: ["Finance", "Analysis"], + content: "# Instructions", + files: [{ path: "references/a.txt", content: "Reference" }], +}; +beforeEach(() => { + sessionStorage.clear(); + api.allowed = true; + api.create + .mockReset() + .mockResolvedValue({ success: true, data: { skill_id: 42 } }); + api.update.mockReset().mockResolvedValue({ success: true }); + api.fetch + .mockReset() + .mockResolvedValue({ success: true, data: { skill_id: 42 } }); + api.list + .mockReset() + .mockResolvedValue({ items: [], pagination: { total_pages: 1 } }); + api.tags.mockReset().mockResolvedValue({}); +}); +function show( + overrides: Partial> = {} +) { + const props = { + scopeKey: "t:u:thread-a", + payload, + rawFiles: {}, + running: false, + open: true, + onClose: vi.fn(), + onNew: vi.fn(), + onDraftChange: vi.fn(), + onSaved: vi.fn(), + ...overrides, + }; + const result = render( + + + + ); + return { ...result, props }; +} +async function confirm() { + const button = screen.getByRole("button", { name: "common.confirm" }); + await waitFor(() => expect(button).toBeEnabled()); + fireEvent.click(button); +} +test("serializes edited metadata and supplementary files for the next generation", () => { + const edited = { + ...payload, + name: 'name: with "quotes"', + description: "First line\nSecond line", + }; + expect( + buildSkillSavePayload({ + ...initialSkillCreationDraft, + complete: true, + files: buildEditedSkillFiles(edited), + }) + ).toEqual(edited); +}); +test("confirms a new skill with actual edited files, accessible groups and canonical tags", async () => { + const { props } = show(); + fireEvent.click(await screen.findByRole("button", { name: "Edit file" })); + expect(props.onDraftChange).toHaveBeenCalledWith( + expect.objectContaining({ + content: "Edited instructions", + files: payload.files, + }) + ); + await confirm(); + await waitFor(() => expect(props.onSaved).toHaveBeenCalled()); + expect(api.create).toHaveBeenCalledWith( + expect.objectContaining({ + name: payload.name, + content: "Edited instructions", + group_ids: [7], + ingroup_permission: "READ_ONLY", + tags: [], + files: payload.files, + }) + ); + expect(api.tags).toHaveBeenCalledWith("skill", "42", { value_ids: [20] }); +}); +test("updates an already generated saved skill while preserving its ACL and tag assignments", async () => { + api.list.mockResolvedValue({ + items: [ + { + skill_id: 99, + name: payload.name, + group_ids: [9], + ingroup_permission: "PRIVATE", + }, + ], + pagination: { total_pages: 1 }, + }); + show(); + await confirm(); + await waitFor(() => expect(api.update).toHaveBeenCalled()); + expect(api.create).not.toHaveBeenCalled(); + expect(api.update.mock.calls[0][0]).toBe(99); + expect(api.update.mock.calls[0][1]).not.toHaveProperty("group_ids"); + expect(api.tags).not.toHaveBeenCalled(); +}); +test("retries a failed tag write using the newly created ID instead of creating another skill", async () => { + api.tags.mockRejectedValueOnce(new Error("Tag write failed")); + const { props } = show(); + await confirm(); + expect(await screen.findByText("Tag write failed")).toBeVisible(); + expect(props.onSaved).not.toHaveBeenCalled(); + await confirm(); + await waitFor(() => expect(props.onSaved).toHaveBeenCalled()); + expect(api.create).toHaveBeenCalledTimes(1); + expect(api.update).toHaveBeenCalledWith(42, expect.anything()); + expect(api.tags).toHaveBeenCalledTimes(2); +}); +test("keeps failed save edits and requires a successful existing-resource lookup before saving", async () => { + api.list.mockRejectedValueOnce(new Error("Network")); + show(); + expect(await screen.findByText("skillChat.lookupFailed")).toBeVisible(); + expect(screen.getByRole("button", { name: "common.confirm" })).toBeDisabled(); + fireEvent.click( + screen.getByRole("button", { name: "repository.common.retry" }) + ); + api.create.mockResolvedValueOnce({ success: false, message: "Save failed" }); + fireEvent.change(screen.getByLabelText("skillManagement.form.description"), { + target: { value: "Manual description" }, + }); + await confirm(); + expect(await screen.findByText("Save failed")).toBeVisible(); + expect(screen.getByLabelText("skillManagement.form.description")).toHaveValue( + "Manual description" + ); +}); +test("restores local edits for the same conversation and never restores them into another scope", async () => { + const first = show(); + fireEvent.change(screen.getByLabelText("skillManagement.form.description"), { + target: { value: "Local edit" }, + }); + first.unmount(); + const second = show(); + await waitFor(() => + expect( + screen.getByLabelText("skillManagement.form.description") + ).toHaveValue("Local edit") + ); + expect(second.props.onDraftChange).toHaveBeenCalledWith( + expect.objectContaining({ description: "Local edit" }) + ); + second.unmount(); + show({ scopeKey: "t:u:thread-b" }); + expect(screen.getByLabelText("skillManagement.form.description")).toHaveValue( + payload.description + ); +}); +test("does not save incomplete generation, missing required fields or a forbidden creation", async () => { + const result = show({ running: true }); + expect(screen.getByRole("button", { name: "common.confirm" })).toBeDisabled(); + result.unmount(); + const next = show(); + fireEvent.change(screen.getByLabelText("skillManagement.form.name"), { + target: { value: " " }, + }); + await confirm(); + expect( + await screen.findByText("skillManagement.form.nameRequired") + ).toBeVisible(); + expect(api.create).not.toHaveBeenCalled(); + next.unmount(); + api.allowed = false; + show(); + expect(screen.getByRole("button", { name: "common.confirm" })).toBeDisabled(); +}); +test("switches to user groups and exposes only accessible groups", async () => { + show(); + fireEvent.click(screen.getByText("skillCreation.groups")); + expect(screen.getByRole("button", { name: "Allowed" })).toBeVisible(); + expect( + screen.queryByRole("button", { name: "Other" }) + ).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Allowed" })); + await confirm(); + await waitFor(() => + expect(api.create).toHaveBeenCalledWith( + expect.objectContaining({ group_ids: [7] }) + ) + ); +}); diff --git a/frontend/tests/component/skillCreateModal.test.tsx b/frontend/tests/component/skillCreateModal.test.tsx new file mode 100644 index 0000000000..318333a023 --- /dev/null +++ b/frontend/tests/component/skillCreateModal.test.tsx @@ -0,0 +1,231 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { File as NativeFile } from "node:buffer"; +import type { ReactNode } from "react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { App } from "antd"; +import JSZip from "jszip"; +import { beforeEach, expect, test, vi } from "vitest"; +import SkillCreateModal from "@/components/skill/SkillCreateModal"; + +const api = vi.hoisted(() => ({ + create: vi.fn(), + upload: vi.fn(), + update: vi.fn(), + tags: vi.fn(), +})); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock("@/components/common/StandardModal", () => ({ + StandardModal: ({ + children, + footer, + open, + }: { + children: ReactNode; + footer: ReactNode; + open: boolean; + }) => + open ? ( +
+ {children} + {footer} +
+ ) : null, +})); +vi.mock("@/components/providers/AuthorizationProvider", () => ({ + useAuthorizationContext: () => ({ + user: { tenantId: "tenant" }, + getAccessibleGroupIds: () => [7], + }), +})); +vi.mock("@/hooks/group/useGroupList", () => ({ + useGroupList: () => ({ + data: { + groups: [ + { group_id: 7, group_name: "Allowed group" }, + { group_id: 8, group_name: "Other group" }, + ], + }, + isLoading: false, + }), +})); +vi.mock("@/hooks/useTagManagement", () => ({ + useTagLibraries: () => ({ + data: [{ bucket_key: "default_resource", bucket_id: 1 }], + }), + useTagDefinitions: () => ({ + loading: false, + data: [ + { + definition_id: 10, + definition_name: "Topic", + selection_mode: "multi_select", + status: "active", + values: [{ value_id: 20, display_value: "Finance", status: "active" }], + }, + ], + }), +})); +vi.mock("@/services/agentConfigService", () => ({ + createSkill: api.create, + createSkillFromFile: api.upload, + updateSkillById: api.update, +})); +vi.mock("@/services/tagManagementService", () => ({ + tagManagementApi: { replaceAssignments: api.tags }, +})); +vi.mock("@/const/errorMessageI18n", () => ({ + getTenantResourceLimitMessage: () => null, +})); +vi.mock("@/lib/logger", () => ({ default: { error: vi.fn() } })); +vi.mock("@/components/skill/SkillCreationFiles", () => ({ + default: () =>
Draft files
, +})); + +beforeEach(() => { + api.create + .mockReset() + .mockResolvedValue({ success: true, data: { skill_id: 42 } }); + api.upload + .mockReset() + .mockResolvedValue({ success: true, data: { skill_id: 43 } }); + api.update.mockReset().mockResolvedValue({ success: true }); + api.tags.mockReset().mockResolvedValue({}); +}); +function show() { + const onClose = vi.fn(); + const onSuccess = vi.fn(); + render( + + + + ); + return { onClose, onSuccess }; +} +function fill() { + fireEvent.change(screen.getByLabelText("skillManagement.form.name"), { + target: { value: "manual-skill" }, + }); + fireEvent.change(screen.getByLabelText("skillManagement.form.description"), { + target: { value: "Description" }, + }); +} + +test("preserves a dirty draft when creation-mode switching is cancelled", async () => { + show(); + fill(); + fireEvent.click( + screen.getByRole("button", { + name: /skillCreation.import skillCreation.importHint/, + }) + ); + await waitFor(() => + expect(screen.getByText("skillCreation.discardDraft")).toBeVisible() + ); + const cancelButtons = screen.getAllByRole("button", { + name: "common.cancel", + }); + fireEvent.click(cancelButtons[cancelButtons.length - 1]); + expect(screen.getByLabelText("skillManagement.form.name")).toHaveValue( + "manual-skill" + ); + expect(api.create).not.toHaveBeenCalled(); +}); + +test("validates configuration before progressing and creates only after group confirmation", async () => { + const { onSuccess } = show(); + fireEvent.click(screen.getByRole("button", { name: "skillCreation.next" })); + expect( + await screen.findByText("skillManagement.form.nameRequired") + ).toBeVisible(); + expect(api.create).not.toHaveBeenCalled(); + fill(); + fireEvent.click(screen.getByRole("button", { name: "skillCreation.next" })); + await screen.findByRole("button", { name: "skillCreation.previous" }); + expect(screen.getByRole("checkbox", { name: "Allowed group" })).toBeChecked(); + expect(screen.queryByText("Other group")).not.toBeInTheDocument(); + expect(api.create).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "common.confirm" })); + await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1)); + expect(api.create).toHaveBeenCalledWith( + expect.objectContaining({ + name: "manual-skill", + group_ids: [7], + ingroup_permission: "READ_ONLY", + tags: [], + }) + ); +}); + +test("retries tag configuration against the created skill instead of creating a duplicate", async () => { + api.tags.mockRejectedValueOnce(new Error("Tag write failed")); + const { onSuccess } = show(); + fill(); + fireEvent.mouseDown(screen.getByLabelText("skillManagement.form.tags")); + fireEvent.click(await screen.findByText("Finance")); + fireEvent.click(screen.getByRole("button", { name: "skillCreation.next" })); + await screen.findByRole("button", { name: "skillCreation.previous" }); + fireEvent.click(screen.getByRole("button", { name: "common.confirm" })); + expect(await screen.findByText("Tag write failed")).toBeVisible(); + expect(onSuccess).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "common.confirm" })); + await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1)); + expect(api.create).toHaveBeenCalledTimes(1); + expect(api.tags).toHaveBeenLastCalledWith("skill", "42", { value_ids: [20] }); +}); + +test("parses imports locally and submits the original ZIP only at final confirmation", async () => { + const { onSuccess } = show(); + const zip = new JSZip(); + zip.file( + "SKILL.md", + "---\nname: imported\ndescription: Imported skill\n---\nBody" + ); + zip.file("asset.png", new Uint8Array([255])); + const file = new NativeFile( + [await zip.generateAsync({ type: "uint8array" })], + "skill.zip" + ) as unknown as File; + fireEvent.click( + screen.getByRole("button", { + name: /skillCreation.import skillCreation.importHint/, + }) + ); + fireEvent.change(screen.getByLabelText("skillCreation.uploadFile"), { + target: { files: [file] }, + }); + fireEvent.click( + screen.getByRole("button", { name: "skillCreation.parseFile" }) + ); + await waitFor(() => + expect(screen.getByLabelText("skillManagement.form.name")).toHaveValue( + "imported" + ) + ); + expect(api.upload).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "skillCreation.next" })); + await screen.findByRole("button", { name: "skillCreation.previous" }); + fireEvent.click(screen.getByRole("button", { name: "common.confirm" })); + await waitFor(() => expect(onSuccess).toHaveBeenCalled()); + expect(api.upload).toHaveBeenCalledWith("imported", file, false); + expect(api.update).toHaveBeenCalledWith( + 43, + expect.objectContaining({ group_ids: [7], content: "Body" }) + ); +}); + +test("blocks resubmission when creation succeeds without a usable ID", async () => { + api.create.mockResolvedValueOnce({ success: true, data: {} }); + show(); + fill(); + fireEvent.click(screen.getByRole("button", { name: "skillCreation.next" })); + await screen.findByRole("button", { name: "skillCreation.previous" }); + fireEvent.click(screen.getByRole("button", { name: "common.confirm" })); + expect( + await screen.findByText("skillCreation.missingCreatedId") + ).toBeVisible(); + expect(screen.getByRole("button", { name: "common.confirm" })).toBeDisabled(); + expect(api.create).toHaveBeenCalledTimes(1); +}); diff --git a/frontend/tests/component/skillCreationDraft.test.tsx b/frontend/tests/component/skillCreationDraft.test.tsx new file mode 100644 index 0000000000..65ed8784ed --- /dev/null +++ b/frontend/tests/component/skillCreationDraft.test.tsx @@ -0,0 +1,97 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { File as NativeFile } from "node:buffer"; +import JSZip from "jszip"; +import { expect, test } from "vitest"; +import { + isValidSkillDraftPath, + parseSkillCreationFile, +} from "@/lib/skillCreationDraft"; + +const asFile = (file: NativeFile) => file as unknown as File; + +test("parses Markdown metadata and keeps one editable body without duplicate frontmatter", async () => { + const result = await parseSkillCreationFile( + asFile( + new NativeFile( + [ + "---\nname: example\ndescription: Description\ntags: [finance]\n---\n# Body", + ], + "example.md" + ) + ) + ); + expect(result).toMatchObject({ + name: "example", + description: "Description", + tags: ["finance"], + }); + expect(result.files).toEqual([{ path: "SKILL.md", content: "# Body" }]); +}); + +test("parses nested ZIP files and marks binary assets without decoding them", async () => { + const zip = new JSZip(); + zip.file( + "example/SKILL.md", + "---\nname: zip-skill\ndescription: ZIP skill\n---\nBody" + ); + zip.file("example/scripts/helper.py", "print('hello')"); + zip.file("example/assets/picture.png", new Uint8Array([0, 255, 1])); + const result = await parseSkillCreationFile( + asFile( + new NativeFile( + [await zip.generateAsync({ type: "uint8array" })], + "example.zip" + ) + ) + ); + expect(result.files[0]).toMatchObject({ path: "SKILL.md", content: "Body" }); + expect(result.files).toContainEqual({ + path: "assets/picture.png", + content: "", + status: "unsupported", + }); + expect(result.files).toContainEqual({ + path: "scripts/helper.py", + content: "print('hello')", + status: "readable", + }); +}); + +test("rejects unsupported input, corrupt ZIPs and missing primary files", async () => { + await expect( + parseSkillCreationFile(asFile(new NativeFile(["text"], "file.txt"))) + ).rejects.toThrow(); + await expect( + parseSkillCreationFile(asFile(new NativeFile(["bad archive"], "file.zip"))) + ).rejects.toThrow(); + const zip = new JSZip(); + zip.file("README.md", "Missing primary"); + await expect( + parseSkillCreationFile( + asFile( + new NativeFile( + [await zip.generateAsync({ type: "uint8array" })], + "file.zip" + ) + ) + ) + ).rejects.toThrow("SKILL.md"); +}); + +test.each([ + "", + "/absolute.txt", + "../escape.py", + "a/../escape.py", + "a//file.py", + "C:/file.py", + "a\\file.py", + " file.py", +])("rejects invalid draft path %s", (path) => { + expect(isValidSkillDraftPath(path)).toBe(false); +}); + +test("accepts nested relative file paths", () => { + expect(isValidSkillDraftPath("scripts/nested/helper.py")).toBe(true); +}); diff --git a/frontend/tests/component/skillCreationFiles.test.tsx b/frontend/tests/component/skillCreationFiles.test.tsx new file mode 100644 index 0000000000..5b588c0749 --- /dev/null +++ b/frontend/tests/component/skillCreationFiles.test.tsx @@ -0,0 +1,79 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { useState } from "react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { App } from "antd"; +import { expect, test, vi } from "vitest"; +import SkillCreationFiles from "@/components/skill/SkillCreationFiles"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock("@/components/common/markdownRenderer", () => ({ + MarkdownRenderer: ({ content }: { content: string }) =>
{content}
, +})); + +function Editor() { + const [files, setFiles] = useState([ + { path: "SKILL.md", content: "Initial body" }, + ]); + return ( + + + + ); +} + +test("edits the draft and previews the saved text without submitting to a server", () => { + render(); + expect(screen.getByText("Initial body")).toBeVisible(); + fireEvent.click(screen.getByRole("button", { name: "common.edit" })); + fireEvent.change( + screen.getByRole("textbox", { name: "skillCreation.fileContent" }), + { target: { value: "Changed body" } } + ); + fireEvent.click(screen.getByRole("button", { name: "common.save" })); + expect(screen.getByText("Changed body")).toBeVisible(); +}); + +test("rejects traversal paths and adds a valid nested file", async () => { + render(); + fireEvent.click( + screen.getByRole("button", { name: "skillCreation.newFile" }) + ); + fireEvent.change( + screen.getByRole("textbox", { name: "skillCreation.filePath" }), + { target: { value: "../escape.txt" } } + ); + fireEvent.click(screen.getByRole("button", { name: "common.confirm" })); + await waitFor(() => + expect(screen.getByText("skillCreation.invalidPath")).toBeVisible() + ); + fireEvent.change( + screen.getByRole("textbox", { name: "skillCreation.filePath" }), + { target: { value: "scripts/helper.txt" } } + ); + fireEvent.click(screen.getByRole("button", { name: "common.confirm" })); + expect( + await screen.findByRole("textbox", { name: "skillCreation.fileContent" }) + ).toHaveValue(""); + expect(screen.getByTitle("scripts/helper.txt")).toBeVisible(); +}); + +test("opens the file selected from the workbench conversation", () => { + render( + + + + ); + expect(screen.getByText("Selected reference")).toBeVisible(); + expect(screen.queryByText("Main instructions")).not.toBeInTheDocument(); +}); diff --git a/frontend/tests/component/skillDetailFiles.test.mjs b/frontend/tests/component/skillDetailFiles.test.mjs new file mode 100644 index 0000000000..21b4111f6f --- /dev/null +++ b/frontend/tests/component/skillDetailFiles.test.mjs @@ -0,0 +1,43 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { buildSkillDetailTree } from "../../lib/skillDetailFiles.ts"; + +test("builds nested directories and keeps duplicate basenames distinct", () => { + const tree = buildSkillDetailTree([ + { path: "SKILL.md", content: "" }, + { path: "docs/deep/readme.md", content: "" }, + { path: "scripts/readme.md", content: "" }, + ]); + assert.equal(tree[0].key, "SKILL.md"); + assert.equal(tree[1].children[0].children[0].key, "docs/deep/readme.md"); + assert.equal(tree[2].children[0].key, "scripts/readme.md"); + assert.equal(tree[1].isLeaf, false); +}); + +test("filters full paths case-insensitively while preserving ancestry", () => { + const files = [ + { path: "docs/nested/Report.md", content: "" }, + { path: "scripts/task.py", content: "" }, + ]; + assert.deepEqual(buildSkillDetailTree(files, " REPORT "), [ + { + key: "docs", + title: "docs", + isLeaf: false, + children: [ + { + key: "docs/nested", + title: "nested", + isLeaf: false, + children: [ + { key: "docs/nested/Report.md", title: "Report.md", isLeaf: true }, + ], + }, + ], + }, + ]); + assert.deepEqual(buildSkillDetailTree(files, "missing"), []); + assert.deepEqual(buildSkillDetailTree([]), []); +}); diff --git a/frontend/tests/component/skillGroupSelection.test.tsx b/frontend/tests/component/skillGroupSelection.test.tsx new file mode 100644 index 0000000000..8a6ed29a24 --- /dev/null +++ b/frontend/tests/component/skillGroupSelection.test.tsx @@ -0,0 +1,75 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { useState } from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { expect, test, vi } from "vitest"; +import SkillGroupSelection from "@/components/skill/SkillGroupSelection"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +const groups = Array.from({ length: 12 }, (_, index) => ({ + group_id: index + 1, + group_name: `Group ${index + 1}`, + group_description: `Description ${index + 1}`, + user_count: index, +})); + +function Selection() { + const [value, setValue] = useState([]); + return ( + <> + {value.join(",")} + + + ); +} + +test("renders actual group descriptions and zero member counts", () => { + render(); + expect(screen.getByText("Group 1")).toBeVisible(); + expect(screen.getByText("Description 1")).toBeVisible(); + expect(screen.getByText("0")).toBeVisible(); + expect(screen.queryByText("Group 11")).not.toBeInTheDocument(); +}); + +test("keeps cross-page selections and limits select-all to the current page", () => { + render(); + fireEvent.click(screen.getByRole("checkbox", { name: "Group 1" })); + fireEvent.click(screen.getByTitle("Next Page")); + fireEvent.click(screen.getByRole("checkbox", { name: "Group 11" })); + expect(screen.getByLabelText("Selected IDs")).toHaveTextContent("1,11"); + fireEvent.click(screen.getByTitle("Previous Page")); + expect(screen.getByRole("checkbox", { name: "Group 1" })).toBeChecked(); + fireEvent.click(screen.getAllByRole("checkbox")[0]); + const ids = screen + .getByLabelText("Selected IDs") + .textContent?.split(",") + .map(Number); + expect(ids).toHaveLength(11); + expect(ids).not.toContain(12); + fireEvent.click(screen.getAllByRole("checkbox")[0]); + expect(screen.getByLabelText("Selected IDs")).toHaveTextContent(/^11$/); +}); + +test("prevents selection while saving", () => { + const change = vi.fn(); + render( + + ); + expect(screen.getByRole("checkbox", { name: "Group 1" })).toBeDisabled(); + fireEvent.click(screen.getByRole("checkbox", { name: "Group 1" })); + expect(change).not.toHaveBeenCalled(); +}); + +test("supports empty and failed loads without fabricating group information", () => { + const retry = vi.fn(); + const { rerender } = render(); + expect(screen.getByText("skillCreation.noGroups")).toBeVisible(); + rerender(); + expect(screen.getByText("skillCreation.groupsLoadFailed")).toBeVisible(); + fireEvent.click( + screen.getByRole("button", { name: "repository.common.retry" }) + ); + expect(retry).toHaveBeenCalledTimes(1); +}); diff --git a/frontend/tests/component/skillReadOnlyDetail.test.tsx b/frontend/tests/component/skillReadOnlyDetail.test.tsx new file mode 100644 index 0000000000..2448179e3a --- /dev/null +++ b/frontend/tests/component/skillReadOnlyDetail.test.tsx @@ -0,0 +1,96 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { App } from "antd"; +import { expect, test, vi } from "vitest"; +import SkillReadOnlyDetail from "@/components/skill/SkillReadOnlyDetail"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock("@/components/common/markdownRenderer", () => ({ + MarkdownRenderer: ({ content }: { content: string }) =>
{content}
, +})); +const files = [ + { path: "SKILL.md", content: "Markdown body" }, + { path: "helper.txt", content: "Helper body" }, + { path: "manual.pdf", content: "", status: "unsupported" as const }, +]; +const show = () => + render( + + + + ); + +test("defaults to SKILL.md, switches files, and blocks unsupported actions", async () => { + show(); + expect(await screen.findByText("Markdown body")).toBeVisible(); + fireEvent.click(screen.getByText("helper.txt")); + expect(screen.getByText("Helper body")).toBeVisible(); + fireEvent.click(screen.getByText("manual.pdf")); + expect( + screen.getByText("skillManagement.detail.filePreviewUnsupported") + ).toBeVisible(); + expect(screen.getByRole("button", { name: "common.copy" })).toBeDisabled(); + expect( + screen.getByRole("button", { name: "skillRepository.detail.downloadFile" }) + ).toBeDisabled(); +}); + +test("search filters the tree without replacing the active preview", async () => { + show(); + fireEvent.change( + screen.getByRole("textbox", { name: "skillManagement.detail.files" }), + { target: { value: "missing" } } + ); + expect(screen.getByText("skillManagement.detail.noFiles")).toBeVisible(); + expect(screen.getByText("Markdown body")).toBeVisible(); +}); + +test("copies the active content and reports clipboard errors", async () => { + show(); + fireEvent.click(screen.getByRole("button", { name: "common.copy" })); + await waitFor(() => + expect(navigator.clipboard.writeText).toHaveBeenCalledWith("Markdown body") + ); + vi.mocked(navigator.clipboard.writeText).mockRejectedValueOnce( + new Error("Denied") + ); + fireEvent.click(screen.getByRole("button", { name: "common.copy" })); + expect(await screen.findByText("agentUsageGuide.copyFailed")).toBeVisible(); +}); + +test("downloads the selected readable file using its basename", async () => { + const create = URL.createObjectURL; + const revoke = URL.revokeObjectURL; + URL.createObjectURL = vi.fn(() => "blob:skill-test"); + URL.revokeObjectURL = vi.fn(); + const click = vi + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(() => {}); + try { + show(); + fireEvent.click( + screen.getByRole("button", { + name: "skillRepository.detail.downloadFile", + }) + ); + expect(URL.createObjectURL).toHaveBeenCalledWith(expect.any(Blob)); + expect(click).toHaveBeenCalledTimes(1); + expect(click.mock.instances[0].download).toBe("SKILL.md"); + await waitFor(() => + expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:skill-test") + ); + } finally { + click.mockRestore(); + URL.createObjectURL = create; + URL.revokeObjectURL = revoke; + } +}); diff --git a/frontend/tests/component/skillRepositoryIcon.test.mjs b/frontend/tests/component/skillRepositoryIcon.test.mjs new file mode 100644 index 0000000000..fc4a73f0b7 --- /dev/null +++ b/frontend/tests/component/skillRepositoryIcon.test.mjs @@ -0,0 +1,50 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { getSkillRepositoryIconKind } from "../../lib/skillRepositoryIcon.ts"; + +test("recognizes document and presentation names with token boundaries", () => { + for (const name of ["workspace-file-summarizer", "xlsx", "PDF-reader"]) { + assert.equal(getSkillRepositoryIconKind({ name }), "document"); + } + for (const name of ["ppt-generator", "PPT Create", "slides"]) { + assert.equal(getSkillRepositoryIconKind({ name }), "presentation"); + } +}); + +test("uses tags before names, and explicit icon identifiers before tags", () => { + assert.equal( + getSkillRepositoryIconKind({ name: "custom", tags: ["演示文稿"] }), + "presentation" + ); + assert.equal( + getSkillRepositoryIconKind({ name: "ppt", tags: ["file-management"] }), + "document" + ); + assert.equal( + getSkillRepositoryIconKind({ + name: "ppt", + tags: ["presentation"], + icon: "file-text", + }), + "document" + ); + assert.equal( + getSkillRepositoryIconKind({ name: "ppt", icon: "layers" }), + "general" + ); +}); + +test("keeps unsupported or ambiguous values on the generic fallback", () => { + for (const name of ["", "skill-creator", "slack-gif-creator", "profile"]) { + assert.equal( + getSkillRepositoryIconKind({ name, tags: [], icon: null }), + "general" + ); + } + assert.equal( + getSkillRepositoryIconKind({ name: "ppt-generator", icon: "unknown" }), + "presentation" + ); +}); diff --git a/test-e2e/changes/requirements/SKILL-CHAT-CREATE-MODAL-20261112.md b/test-e2e/changes/requirements/SKILL-CHAT-CREATE-MODAL-20261112.md new file mode 100644 index 0000000000..381c42cdd2 --- /dev/null +++ b/test-e2e/changes/requirements/SKILL-CHAT-CREATE-MODAL-20261112.md @@ -0,0 +1,57 @@ +# Resource repository skill chat creation dialog — 2026-11-12 + +## Clarified scope + +The user explicitly requested a dialog from the installed skills menu, with the +provided conversation/configuration layout inside it. They did not authorize +changing the workbench page. All changes previously introduced in +`frontend/features/workbench/` have therefore been reverted exactly to HEAD, +including the route intent hook and result card changes. Existing authorized +repository layout, form creation, details and batch management work is preserved. + +## Acceptance and implementation + +- MineSkillsView's chat creation menu invokes an independent callback; it does + not navigate to `/workbench`. MySkill mounts SkillChatCreateModal in place. +- The modal uses StandardModal, the existing NL2Skill chat panel unchanged, + shared text file editor, canonical tag selection and user group table. It + shows actual generated artifacts, configuration/groups tabs, local draft save + status, file selection, maximize/collapse and confirm controls. +- Its session owns generation, draft checkpoints and edited snapshots. Edited + metadata/files go into subsequent generation requests; a failed continuation + restores the last usable draft. New sessions do not share state. +- Confirm uses existing create/update/tag services. Existing resource ACLs are + preserved unless edited. Partial creation retains its ID for retries. Success + refreshes Mine; opening the saved artifact filters the current repository. +- Closing unsaved work requires confirmation; generation and saving block + dismissal. Saving also makes the conversation inert to avoid concurrent + generation. No demonstration conversations or generated artifacts are seeded. + +## Verification responsibilities and evidence + +Traditional UT: added frontend tests for the modal boundary and reused the +editor/file tests, updated to shared skill module imports. No Python product code +or HTTP contracts changed, so Python UT is not the relevant behavior surface. + +The isolated temporary harness ran: + +`node node_modules/vitest/vitest.mjs run tests/component/skillChatEditor.test.tsx tests/component/skillChatCreateModal.test.tsx tests/component/skillCreationFiles.test.tsx` + +Result: 18 PASS (8 editor, 7 modal, 3 file editor), with Vitest 4.1.12, React +19.2.3 and Ant Design 6.3.0. Modal tests mock the chat and editor boundaries to +prove their coordination; editor tests separately use the real form with mocked +service APIs. This is not real-model, browser, deployment or backend persistence +acceptance. No live application was changed or deployed. + +Formal assessment: existing PW-SKILL-01 covers Mine creation/editing/Agent +binding, and PW-SKILL-02 covers publication. Their contracts/scripts are preserved; +neither proves this new chat dialog journey. No redundant D1 quota or formal PASS +was manufactured. A workbench-specific Case is not applicable to the clarified +scope. A real dialog D4 run and SPEC/formal ownership integration remain +unverified/blocked: the sibling SPEC repository is absent and no build containing +these changes was deployed. Full frontend semantic type-check/build remain +unverified because the checkout lacks its complete frontend dependencies. + +Source checks: copyright and diff whitespace checks passed. Targeted files were +formatted with Prettier. Formal implementation validation could not start due +to the local Python interpreter lacking openpyxl; this is not a validation PASS. diff --git a/test-e2e/changes/requirements/SKILL-CREATE-WIZARD-20261112.md b/test-e2e/changes/requirements/SKILL-CREATE-WIZARD-20261112.md new file mode 100644 index 0000000000..f2a85113d9 --- /dev/null +++ b/test-e2e/changes/requirements/SKILL-CREATE-WIZARD-20261112.md @@ -0,0 +1,111 @@ +# Resource repository skill creation wizard — 2026-11-12 + +## Scope and reference + +User-provided screenshots define the manual/import creation layout. The owning +entry is `frontend/components/repository/skill/my-skill.tsx`. Existing edit and +non-repository `SkillBuildModal` flows remain in place; the new wizard is used +only for creation in the unified resource repository. Workbench creation remains +available through its existing entry. No backend endpoints, SQL or dependency +declarations are changed. + +The sibling SPEC repository and authoritative UI document were unavailable in +this checkout during the preceding repository layout work. This record captures +the current change, observable acceptance and verification limits, without +inventing a historical SPEC or formal Case IDs. + +## Acceptance and implementation + +- Configure parameters first, then select accessible user groups and their + existing EDIT/READ_ONLY/PRIVATE permission. Name and description are required; + description is capped at 1000 characters. Advancing does not create a resource. +- Manual creation starts with SKILL.md and supports nested text files, editing, + preview, copy, download and saving changes to the draft. New paths must be + relative, must not traverse directories and must not conflict with existing + file/directory paths, including case-insensitive SKILL.md collisions. +- Import accepts Markdown/ZIP, parses locally and populates editable metadata + and files. Missing/unreadable SKILL.md, malformed YAML/ZIP and unsupported + input show an error. Frontmatter is separated from the editable body so saving + does not duplicate it. Text decoding retains the prior UTF-8/GB18030/Big5 support. +- ZIP submission sends the original file at final confirmation and then updates + metadata/readable drafts. Binary assets are not decoded or replaced by empty + text. The existing backend/SDK save contract writes submitted extra files + without deleting unsubmitted files (`SkillManager.save_skill`). Actual live + storage retention is not established by frontend mocks. +- Tags use existing active library values and canonical assignments after the + resource ID is known; legacy flat tags are not manufactured. Imported labels + are matched to active values, single-select definitions are normalized and + unknown labels are reported. The user can review/change the selection. +- Partial failures after creation retain the created ID and retry its metadata + or tag configuration instead of creating a duplicate. A success response with + no usable ID blocks resubmission and asks the user to refresh the list. +- Saving disables duplicate submission and dismissal. Cancel before confirmation + performs no creation. Switching methods with a dirty draft asks before clearing + it, and stale parse results cannot overwrite a later choice or closed dialog. + +## Test responsibilities and limits + +### Group selection table follow-up + +The second-step screenshot is implemented with the shared `StandardTable`: +permission selector first, followed by real group name, description and member +count columns. Missing member counts are shown as a dash rather than invented +zeroes. The wizard continues to load the existing all-groups query and filters +it by `getAccessibleGroupIds`; the table paginates the resulting accessible list +locally in groups of 10, so totals do not leak inaccessible groups. + +Form-controlled IDs persist between pages. Header selection affects only the +current page, and selections on other pages are retained when that page is +cleared. Saving disables checkboxes and page changes. Loading/error states are +visible; failed group queries offer retry and block final creation confirmation. +Existing default selections and EDIT/READ_ONLY/PRIVATE semantics are unchanged. +The dialog preserves the reference's open space below the table and fixed footer, +within the viewport height; narrow tables scroll horizontally. + +Added `skillGroupSelection.test.tsx` covers real descriptions/zero counts, +cross-page selection and page-only select-all/clear, disabled selection and +empty/error/retry states. `skillCreateModal.test.tsx` now also checks that only +accessible groups are displayed and that existing default IDs reach creation. +The isolated run passed 9 tests across these two files; no live group data or +backend authorization behavior was changed or mutated by the tests. Python UT +remains inapplicable to the frontend-only adjustment. Full type/build and browser +visual/journey acceptance remain unverified; no deployment was performed. + +Traditional Python UT is not applicable: no Python behavior changes. Frontend UT +is added under `frontend/tests/component/` and remains separate from formal D1–D5. +No formal Case was removed, relabeled or added to meet a stage quota. + +Relevant selectors: + +- `skillCreationDraft.test.tsx`: Markdown metadata/body, nested ZIP/binary status, + unsupported/corrupt/missing-primary inputs and valid/invalid relative paths. +- `skillCreationFiles.test.tsx`: edit/save/preview and invalid/new nested paths. +- `skillCreateModal.test.tsx`: required-field gate, no mutation before final + confirmation, canonical tag retry against the same ID, original ZIP submission, + missing-ID retry guard and cancellation of a dirty method switch. + +These tests use real local ZIP/YAML parsing and Ant Design forms with mocked +network boundaries. The isolated harness uses Vitest 4.1.12, React 19.2.3 and +Ant Design 6.3.0; it is not the repository's full dependency set. Full type/build, +browser/D4 visual/journey and real backend creation/storage/permission acceptance +remain unverified because this task changes code without deploying the result. + +Copyright, Prettier parsing/formatting, translation key checks and +`git diff --check` are the applicable source checks. Final targeted test outcome +is recorded after execution; passing unit tests do not imply the unverified +higher-boundary acceptance above. + +## Execution evidence + +- The three targeted files passed after the final relevant reruns: 19 tests total + (12 parser/path tests, 2 editor tests and 5 wizard tests). The final dirty-switch + check waits for the real Ant Design modal animation before asserting visibility. +- Command in `/tmp/skill-detail-regression` with `NODE_ENV=test`: + `node node_modules/vitest/vitest.mjs run tests/component/skillCreationDraft.test.tsx tests/component/skillCreateModal.test.tsx tests/component/skillCreationFiles.test.tsx`; + the wizard file was rerun after its final animation-wait adjustment. +- Copyright validation for all seven changed/new source/test files passed; + Prettier parsing/formatting and source whitespace checks passed. English/Chinese + creation keys were checked against the component references. +- Tests used mocked create/update/tag APIs and in-memory ZIPs. No real skill was + created and no deployment was performed. Full build/type and real browser/API + acceptance remain unverified. diff --git a/test-e2e/changes/requirements/SKILL-REPOSITORY-LAYOUT-20261112.md b/test-e2e/changes/requirements/SKILL-REPOSITORY-LAYOUT-20261112.md new file mode 100644 index 0000000000..9aba8f379f --- /dev/null +++ b/test-e2e/changes/requirements/SKILL-REPOSITORY-LAYOUT-20261112.md @@ -0,0 +1,157 @@ +# Skill repository layout — 2026-11-12 + +## Scope and acceptance + +Reference: the user-provided resource repository / all skills screenshot. +Entry point: `frontend/components/repository/skill/components/RepositoryView.tsx`. +The sibling document repository and `../docs/前端ui.md` are unavailable in this checkout; +the screenshot and existing component references define this presentation-only adjustment. + +- All-skills cards fill three equal columns at container widths of at least 1200px, + two at 720–1199px, and one below 720px. Other resource grids retain their layout. +- The card header centers its icon and action; tags occupy one clipped row. + Descriptions use two lines, followed by the author label and installation count. +- Card mode omits extra sort/layout controls and publication timestamps to match + the reference. Existing search, tag filtering, tabs and pagination remain. +- Installation, copy/uninstall menus, loading guards, permission checks, error and + empty states retain their existing contracts. No sample data is introduced. +- The permission-controlled skill take-down menu is aligned to the bottom-right + of the card's metadata row. Installation stays in the header, and list view + retains its existing actions. Take-down callbacks and confirmations are unchanged. +- Skill icons distinguish green documents/files/spreadsheets, pink presentations, + and blue generic skills. Recognized icon identifiers (`file-text`, `presentation`, + `layers`) override inference; otherwise tags take precedence over name tokens. + Unknown and empty metadata safely falls back to blue. Descriptions and opaque + numeric category IDs are not used as classification evidence. + +## Verification responsibilities + +### Skill listing application follow-up + +Reference: the user's listing-application screenshot (2026-11-12). +`MineApplyListingModal` now uses the shared standard modal with a skill-specific +title, the existing review explanation, an optional note, a 1000-character +counter/limit and cancel/confirm footer actions. Agent listing strings and APIs +are unchanged. Existing icon/tag payload mapping and whitespace trimming remain. + +Submission disables editing/dismissal and duplicate confirmation. Failures keep +the modal and typed note available for retry; successful submission closes it. +The skill page previously swallowed rejected listing mutations, making them look +successful to the modal. Its callback now propagates the error after the existing +permission-specific/general notification, and the modal renders an inline failure +state rather than closing or leaving an unhandled promise rejection. + +Frontend UT: `skillApplyListingModal.test.tsx`, 5 tests passed in the isolated +Vitest 4.1.12 harness with real shared modal/Ant Design controls and mocked submit +callbacks. Coverage includes empty optional notes, the 1000/1001 boundary, +trimmed payloads, failure/retry, duplicate/dismissal guards and a missing skill. +No real listing was submitted. No Python changes apply; no formal Case is added +as a quota or claimed executed. Full type/build and browser/live review workflow +acceptance remain unverified, and deployment remains outside this code-only task. + +### Read-only skill detail follow-up + +Reference: the user's skill-detail modal screenshot (2026-11-12). Repository +skill listings and installed skills use the same read-only metadata/file-browser +layout. Editing/generation panels keep their existing `SkillDraftPanel` path. + +- Display name, localized source, tags and description as plain metadata; use + cancel/confirm/close to dismiss the read-only dialog without saving anything. +- Build nested file directories with full-path keys, preserve directory ancestry + during case-insensitive search, default to SKILL.md and allow switching files. +- Render Markdown through the existing shared renderer, retain skill directive + navigation and expanded preview, and display other readable files as escaped + preformatted text. The new repository browser does not provide code syntax + highlighting; the editor's existing highlighted preview is unaffected. +- Copy and download the selected readable text file; report clipboard failures. + Unsupported binary formats and read errors disable these actions and show a + localized message. Native Office/PDF preview and original binary downloads are + not added by this change. +- Installed detail reuses the existing permission-controlled file endpoints. + Repository detail reads its frozen ZIP locally, strips a shared archive root, + decodes supported UTF-8 text and reports malformed archives. When ZIP data is + absent, use available SKILL.md content from the frozen metadata snapshot. +- Python UT: not applicable to these frontend-only changes. Reuse existing + contracts; no new D1 quota or formal case is introduced. Browser/D4 visual + acceptance and full application type/build verification remain unverified. +- Native file-tree UT: `node --test frontend/tests/component/skillDetailFiles.test.mjs`, + 2 tests passed (nesting, duplicate basenames, filtering and empty results). +- Isolated component UT: `skillReadOnlyDetail.test.tsx` and + `repositorySkillDetailFiles.test.tsx`, 7 tests passed in Vitest 4.1.12 with mocked + Markdown rendering. This verifies file interaction and ZIP/error contracts, + not visual fidelity or live backend permissions. + +### Installed skills view + +- Reuse the three-column responsive grid, icon mapping, single header tag row, + two-line description, author and installation count styling for `view=mine`. + The per-card edit/delete/publish menu remains at the top-right in this view. +- Search, tags and creator ownership remain server-backed. The existing ownership + contract supports all/self/others, not selecting individual creator identities. +- Move form creation from the empty create-card slot into a persistent toolbar + dropdown; it remains available when the list is empty or filtered. Workbench + creation navigates to the existing localized workbench; selecting skill creation + there remains necessary because this checkout has no supported creation-mode link. +- Batch management is opt-in; only EDIT resources can be selected for deletion. + Leaving/re-entering batch mode or changing filters resets selection. Existing + confirmation, partial failure reporting and cache invalidation remain in use. +- Management mode replaces create/layout actions with delete, refresh and exit; + selected count, clear selection and select-current-page appear above the grid. + Card selection replaces the per-card action menu in the top-right header. + Clicking a card does not open its detail while managing; list mode uses its + existing table selection. Refresh clears selection before refetching, and an + empty result still renders disabled management controls and the exit action. +- Restore the existing cards/list switch in the installed-skills toolbar in both + layouts. Cards remain the default; switching uses the existing URL state and + pagination reset behavior. Do not add a sort control to this view. +- Retain real repository review and publishing controls beyond the minimal reference + screenshot. Do not fabricate author emails: display the available `created_by` + field and a missing-value placeholder when necessary. +- Python UT and new formal cases remain inapplicable to the visual adjustments; + component/journey regression for the toolbar is required but BLOCKED by dependency + preparation in this environment. Do not treat icon unit tests as toolbar proof. + +- Traditional Python UT: exempt; no Python, API or business behavior changes. +- Frontend regression: reuse `frontend/tests/component/skillRepositoryInstallAction.test.tsx` + for direct installation, installed/unknown copy menus and pending actions. +- Frontend icon mapping UT: `node --test frontend/tests/component/skillRepositoryIcon.test.mjs` + covers document/presentation success, metadata precedence, missing/unknown metadata + and token boundaries. This uses Node's native TypeScript support (Node 24 locally). +- Formal D1–D5: no new cases or bindings for this visual-only change; no existing + cases removed. Visual browser acceptance remains unverified until the frontend + is rebuilt with these source files. Component tests do not establish visual fidelity. + +## Evidence + +- Copyright header check: passed for the four changed TSX source files. +- `git diff --check`: passed. +- Icon mapping UT: 3 tests passed. No Python UT or formal Case execution is claimed + from these frontend unit results. +- Prettier syntax/format check uses the repository `.prettierrc` in an isolated + container directory; the running application's files are unchanged. +- Full dependency preparation is blocked by the existing peer conflict between + `@assistant-ui/react-ai-sdk` and `ai`. Project dependency declarations are unchanged. +- The minimal temporary dependency harness also failed during npm preparation + (`Cannot read properties of null (reading 'edgesOut')`). Component regression + execution is BLOCKED, not passed. Full type/build and browser checks are also + unverified; no application deployment is claimed. + +### Management regression follow-up + +The earlier preparation failures above describe the original full-project setup. +An isolated minimal harness subsequently succeeded using npm 11 with `--include=dev` +and `NODE_ENV=test` (the application container defaults to production mode). +The repository dependency files and running application were not changed. + +- Harness: `/tmp/skill-batch-regression` in the web container; Vitest 4.1.12, + React 19.2.3 and Ant Design 6.3.0. This is a targeted isolated run, not the + configured full dependency set/build. +- Command: `node node_modules/vitest/vitest.mjs run tests/component/skillBatchSelection.test.tsx tests/component/skillRepositoryInstallAction.test.tsx`. +- Result: 2 files, 8 tests passed. Three new tests cover selection permission, + select-page/clear, confirmation before mutation, deletion success, and retaining + selection plus the error message on failure. Five existing install-action tests + passed independently of the icon mapping UT. +- Copyright, translation JSON/key validation, Prettier parsing/formatting and + `git diff --check` passed for this adjustment. +- Browser screenshot/portal layout acceptance, full type/build checks and formal + Case execution remain unverified. These component results do not certify them. From 9f13c9c299fc787847d9afc3075f812f935177e2 Mon Sep 17 00:00:00 2001 From: ljy Date: Fri, 13 Nov 2026 15:27:38 +0800 Subject: [PATCH 3/3] Preserve legacy agent listing modal export --- .../agent-space/components/MineApplyListingModal.tsx | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 frontend/app/[locale]/agent-space/components/MineApplyListingModal.tsx diff --git a/frontend/app/[locale]/agent-space/components/MineApplyListingModal.tsx b/frontend/app/[locale]/agent-space/components/MineApplyListingModal.tsx new file mode 100644 index 0000000000..0592842338 --- /dev/null +++ b/frontend/app/[locale]/agent-space/components/MineApplyListingModal.tsx @@ -0,0 +1,5 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +export * from "@/components/repository/agent/components/MineApplyListingModal";