diff --git a/backend/contracts/constants/constants.py b/backend/contracts/constants/constants.py index 05b5e6e657..dc176d4af7 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 68cdaa94e3..74d03c200a 100644 --- a/backend/contracts/errors/exceptions.py +++ b/backend/contracts/errors/exceptions.py @@ -651,5 +651,9 @@ 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.""" + + class AccountConflictError(Exception): """An account email or stable identity conflicts with an existing record.""" 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 1e0bf75283..6e55c2b4bc 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 57631bab98..95b663af16 100644 --- a/backend/management/api/agent/agent_repository.py +++ b/backend/management/api/agent/agent_repository.py @@ -179,11 +179,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, @@ -191,7 +193,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 @@ -271,6 +278,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 532809ab0c..1705a76fff 100644 --- a/backend/management/api/application.py +++ b/backend/management/api/application.py @@ -14,6 +14,7 @@ from infrastructure.concurrency import config_thread_manager from management.api.a2a.a2a_client import router as a2a_client_router from management.api.a2a.a2a_server import router as a2a_server_router +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.configuration.config_sync import router as config_sync_router @@ -160,6 +161,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 a5eb46be50..3a55d1834f 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 permissions.resource_access import resource_read_tenant 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 @@ -436,6 +434,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") @@ -516,15 +516,18 @@ async def get_mcp_list( if authorized_tenant := resource_read_tenant(http_request, shared=True): remote_mcp_list = await get_remote_mcp_server_list( - tenant_id=authorized_tenant, is_need_auth=False, + tenant_id=authorized_tenant, is_need_auth=False, include_author=True, ) for record in remote_mcp_list: record.update(permission="READ_ONLY", registry_json=None, config_json=None, shared_fields=None) else: 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 49ce58abab..c99539ed3c 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): @@ -165,7 +166,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 22fbfa718d..e319a52c00 100644 --- a/backend/management/db/client.py +++ b/backend/management/db/client.py @@ -21,6 +21,7 @@ POSTGRES_USER, ) from management.db.models import TableBase +from management.db.repository_transactions import current_repository_session from nexent.storage.storage_client_factory import ( MinIOStorageConfig, create_storage_client_from_config, @@ -315,18 +316,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 e307a717b9..528e10513c 100644 --- a/backend/management/db/models/__init__.py +++ b/backend/management/db/models/__init__.py @@ -87,4 +87,5 @@ from management.db.evaluation.models import AgentEvaluation, AgentEvaluationCase +from management.db.resource_review_model import ResourceReview from management.db.identity.models import UserAccount diff --git a/backend/management/db/notification/repository.py b/backend/management/db/notification/repository.py index a1bfcbe92b..ed165840e7 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 ( SCOPE_SU, @@ -138,6 +139,7 @@ def create_notification( return {"notification_id": notification_id, "receiver_count": len(receivers)} +@after_repository_commit def deactivate_notifications( *, event_type: str, @@ -208,6 +210,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..e8b1d10aa8 --- /dev/null +++ b/backend/management/db/repository_reviews.py @@ -0,0 +1,96 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Authorized review-table queries and legacy repository mappings.""" + +from contracts.roles import has_any_role +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 has_any_role(role, {"SU", "SUPER_ADMIN"}) + if not global_mcp: + conditions.append(r.tenant_id == tenant_id) + if not has_any_role(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 bb72ead723..374fb0ecb0 100644 --- a/backend/management/db/skill/repository.py +++ b/backend/management/db/skill/repository.py @@ -344,6 +344,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, @@ -522,6 +523,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) @@ -553,7 +555,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 @@ -747,7 +748,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 ddeba0fbb8..0fedb0463a 100644 --- a/backend/management/services/mcp/service.py +++ b/backend/management/services/mcp/service.py @@ -35,7 +35,7 @@ from contracts.tool import MCPConfigRequest from infrastructure.http.client import create_httpx_client from management.db.identity.group import query_group_ids_by_user -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 management.db.mcp.market import ( get_mcp_market_record_by_source_mcp_id, update_mcp_market_record, @@ -494,6 +494,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: @@ -577,6 +585,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. @@ -584,6 +594,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, @@ -632,6 +643,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() @@ -703,7 +728,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"), @@ -712,6 +737,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 ae6d47d466..481030b5cb 100644 --- a/backend/management/services/repository/agent/common.py +++ b/backend/management/services/repository/agent/common.py @@ -138,6 +138,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 7b321f2949..829d7e649a 100644 --- a/backend/management/services/repository/agent/publishing.py +++ b/backend/management/services/repository/agent/publishing.py @@ -2,6 +2,7 @@ from typing import Any, Dict, Optional +from management.db.repository_transactions import repository_transaction, repository_unit_of_work from contracts.agent import AgentRepositorySnapshot from contracts.constants.constants import ( @@ -74,6 +75,8 @@ def _validate_repository_status_transition( transition = (current_status, new_status) if has_any_role(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}'" @@ -85,6 +88,8 @@ def _validate_repository_status_transition( raise UnauthorizedError("Not authorized to update this repository listing") if has_any_role(user_role, {'DEV'}) and not has_any_role(user_role, {'ADMIN'}) 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 has_any_role(user_role, {'ADMIN'}) and transition in _ADMIN_REVIEW_STATUS_TRANSITIONS: return None if transition not in _PUBLISHER_STATUS_TRANSITIONS: @@ -103,6 +108,7 @@ def _validate_repository_status_transition( ) +@repository_transaction def update_agent_repository_status_impl( *, agent_repository_id: int, @@ -134,19 +140,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, @@ -188,6 +207,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) @@ -215,14 +243,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, @@ -378,6 +406,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, @@ -397,6 +426,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, @@ -416,59 +447,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.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 8ffebc9e7d..373bc96ba3 100644 --- a/backend/management/services/repository/mcp/service.py +++ b/backend/management/services/repository/mcp/service.py @@ -22,6 +22,7 @@ UnauthorizedError, ) from contracts.roles import has_any_role, roles_from_record +from management.db.repository_transactions import repository_transaction from management.db.identity.group import query_group_ids_by_user from management.db.identity.user_tenant import get_user_tenant_by_user_id from management.db.mcp import remote as remote_mcp_db @@ -150,6 +151,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, @@ -245,6 +247,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) @@ -265,6 +268,8 @@ async def list_community_mcp_services( user_id=user_id if not has_any_role(user_role, 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) @@ -342,7 +347,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, @@ -354,7 +359,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, @@ -363,6 +368,7 @@ def _handle_mcp_review_status_notifications( ) +@repository_transaction async def publish_community_mcp_service( *, tenant_id: str, @@ -377,12 +383,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") @@ -407,61 +414,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, @@ -484,6 +454,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 (not has_any_role(user_role, {"ADMIN", "DEV"}) or current.get("tenant_id") != tenant_id + or (not has_any_role(user_role, {"ADMIN"}) 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 ) @@ -503,66 +478,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, @@ -597,6 +537,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, @@ -679,6 +633,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..01db029412 --- /dev/null +++ b/backend/management/services/repository/reviews.py @@ -0,0 +1,638 @@ +# 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 contracts.roles import has_any_role, roles_from_record +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 has_any_role(role, {"ADMIN"}) or (resource_type == "mcp" and has_any_role(role, {"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 = roles_from_record(get_user_tenant_by_user_id(user_id) or {}) + 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 cedb67c908..9fa689d70e 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 ( EVENT_TYPE_REPOSITORY_REVIEW_PENDING, @@ -140,6 +141,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 @@ -297,10 +299,14 @@ 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 role-union access and explicit unified repository grants.""" user_role = _get_user_role(user_id) - if not has_any_role(user_role, {"DEV", "ADMIN", "SU", "ASSET_OWNER"}): - raise ForbiddenError("User role USER is not authorized to access Skill Repository") + if has_any_role(user_role, {"DEV", "ADMIN", "SU", "ASSET_OWNER"}): + return + 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 is not authorized to access Skill Repository") def _can_publish_skill( @@ -534,6 +540,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, @@ -542,6 +549,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, @@ -550,64 +559,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( @@ -638,6 +591,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 has_any_role(user_role, {'ADMIN'}) and transition in _ADMIN_REVIEW_STATUS_TRANSITIONS: return None if transition not in _PUBLISHER_STATUS_TRANSITIONS: @@ -665,6 +620,8 @@ def _validate_repository_status_transition( transition = (current_status, new_status) if has_any_role(user_role, {'SU'}): + if current_status == new_status: + return None _validate_su_status_transition(transition, current_status, new_status) return None @@ -684,6 +641,7 @@ def _validate_repository_status_transition( ) +@repository_transaction def update_skill_repository_status_impl( *, skill_repository_id: int, @@ -709,19 +667,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, @@ -766,6 +730,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) @@ -791,14 +764,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, @@ -876,6 +849,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, @@ -887,17 +861,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"), @@ -1110,6 +1073,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: @@ -1137,6 +1102,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, @@ -1148,12 +1114,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 @@ -1165,7 +1139,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 73f9a4675d..db0e9460f4 100644 --- a/frontend/app/[locale]/agent-space/agent-space.tsx +++ b/frontend/app/[locale]/agent-space/agent-space.tsx @@ -1,389 +1,5 @@ // Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -"use client"; -import { hasRole } from "@/lib/roles"; - -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 { 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 = hasRole(user, "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={ - - } - /> - ); - }; +"use client"; - 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 b44c78f0d4..bed713eeaf 100644 --- a/frontend/app/[locale]/agent-space/components/AgentUsageGuideModal.tsx +++ b/frontend/app/[locale]/agent-space/components/AgentUsageGuideModal.tsx @@ -2,261 +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 - ); - 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 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"; 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 b35ace08c3..5f3b593fa4 100644 --- a/frontend/app/[locale]/agent-space/components/MyAgentCard.tsx +++ b/frontend/app/[locale]/agent-space/components/MyAgentCard.tsx @@ -2,299 +2,4 @@ "use client"; -import { Button, Dropdown, Tooltip } from "antd"; -import type { MenuProps } from "antd"; -import { - Bot, - ClipboardCheck, - Clock, - Eye, - 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; - onViewReview: ( - agent: MyEditableAgentItem, - mode: "review" | "reviewUpdate" - ) => void; - onDelete: () => void; - onUsageGuide: () => void; - highlighted?: boolean; - guideMenuOpen?: boolean; - onGuideMenuOpenChange?: (open: boolean) => void; - isDeleting?: boolean; -} - -const MENU_ACTION_I18N: Record, string> = { - review: "agentRepository.mine.menu.review", - reviewUpdate: "agentRepository.mine.menu.reviewUpdate", -}; - -export function MyAgentCard({ - agent, - onEdit, - onView, - onViewReview, - onDelete, - onUsageGuide, - highlighted = false, - guideMenuOpen, - onGuideMenuOpenChange, - 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 menuActions = getMineCardMenuActions(agent).filter( - (action) => action !== "apply" - ); - const repositoryBadge = getMineCardRepositoryStatusBadge( - agent.repository_info - ); - - const menuItems: MenuProps["items"] = menuActions.map((action) => { - const icon = ; - - return { - key: action, - label: t(MENU_ACTION_I18N[action]), - icon, - onClick: () => { - onViewReview( - agent, - action === "reviewUpdate" ? "reviewUpdate" : "review" - ); - }, - }; - }); - - 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 00deb4e64b..025758dc94 100644 --- a/frontend/app/[locale]/agent-space/my-agent.tsx +++ b/frontend/app/[locale]/agent-space/my-agent.tsx @@ -2,724 +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, - 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 MyAgentRepositoryInfoItem, - type MyEditableAgentItem, -} from "@/types/agentRepository"; -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 [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 [usageGuideAgent, setUsageGuideAgent] = - useState(null); - const [guidedMenuAgentId, setGuidedMenuAgentId] = useState( - null - ); - const consumedDeepLinkRef = useRef(null); - const consumedUsageGuideRef = useRef(null); - - 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 closeReviewModal = () => { - setReviewModalOpen(false); - setReviewModalAgent(null); - setReviewModalInfo(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, - }) - } - onViewReview={handleViewReview} - onDelete={() => handleDeleteAgent(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); - } - }} - 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 189a832beb..b4b8c1bfde 100644 --- a/frontend/app/[locale]/agent-space/review-center.tsx +++ b/frontend/app/[locale]/agent-space/review-center.tsx @@ -1,206 +1,5 @@ // Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -"use client"; -import { hasRole } from "@/lib/roles"; - -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 { - 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 = hasRole(user, "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 9a23e4d5e0..8e0658524c 100644 --- a/frontend/app/[locale]/agents/components/agent-config-actions.tsx +++ b/frontend/app/[locale]/agents/components/agent-config-actions.tsx @@ -5,8 +5,8 @@ import { useState } from "react"; import { useParams, usePathname, useRouter } from "next/navigation"; import { useTranslation } from "react-i18next"; -import { App, Button, Dropdown, Modal, Tooltip, type MenuProps } from "antd"; - +import { App, Button, Dropdown, Modal, Tooltip } from "antd"; +import type { MenuProps } from "antd"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Copy, @@ -15,8 +15,11 @@ import { GitBranch, MoreHorizontal, Network, - Share2, LineChart, + Share2, + ClipboardCheck, + Send, + Settings2, Trash2, } from "lucide-react"; @@ -31,34 +34,37 @@ import { deleteAgent, exportAgent, generateUniqueCopyName, - normalizeTags, searchAgentInfo, updateAgentInfo, updateToolConfig, } from "@/services/agentConfigService"; import { useAgentStore } from "@/stores/agentStore"; -import type { Agent } from "@/types/agentConfig"; -import type { - AgentRepositoryListingCreatePayload, - MyEditableAgentItem, -} from "@/types/agentRepository"; -import { isCurrentVersionListed } from "@/lib/agentRepositoryMine"; +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 { AgentApplyListingModal } from "@/components/agent/AgentApplyListingModal"; +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({ - agent, agentId: agentIdProp, agentName: agentNameProp, + agent, readOnly, variant = "buttons", + onConfigure, + onPublish, onManageVersions, }: { - agent?: Agent; agentId?: number | null; agentName?: string; + agent?: Agent; readOnly?: boolean; variant?: "buttons" | "menu" | "table"; + onConfigure?: (agentId: number) => void; + onPublish?: (agentId: number) => void; onManageVersions?: (agentId: number) => void; }) { const { t } = useTranslation("common"); @@ -71,6 +77,7 @@ export default function AgentConfigActions({ agentId?: string; locale?: string; }>(); + const { canAccessRoute } = useAuthorizationContext(); const storeAgentId = useAgentStore((state) => state.agentId); const agentId = agentIdProp ?? storeAgentId; const editedAgent = useAgentStore((state) => state.editedAgent); @@ -82,45 +89,28 @@ export default function AgentConfigActions({ const { agentInfo } = useAgentInfo(variant === "buttons" ? agentId : null); const [isRelationshipVisible, setIsRelationshipVisible] = useState(false); const [isA2ASettingsVisible, setIsA2ASettingsVisible] = useState(false); - const [listingTarget, setListingTarget] = - useState(null); + const [repositoryDialog, setRepositoryDialog] = useState< + "apply" | "guide" | null + >(null); const createListing = useCreateAgentRepositoryListing(); - const listingAgent: MyEditableAgentItem | null = + 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: agent.display_name || agent.name, - description: agent.description, - current_version_no: agent.current_version_no, - version_label: agent.version_label, - icon_url: agent.icon_url, - tags: normalizeTags(agent.tags), - permission: isReadOnly ? "READ_ONLY" : "EDIT", + name: agentName, repository_info: agent.repository_info ?? [], } : null; - const canEvaluate = (listingAgent?.current_version_no ?? 0) > 0; - const canApplyListing = - canEvaluate && - !isReadOnly && - listingAgent !== null && - !isCurrentVersionListed(listingAgent); - const submitListing = async ( - payload: AgentRepositoryListingCreatePayload - ) => { - if (!listingTarget || createListing.isPending) return; - try { - await createListing.mutateAsync({ - agentId: listingTarget.agent_id, - versionNo: listingTarget.current_version_no!, - payload, - }); - message.success(t("repository.mine.applySuccess")); - setListingTarget(null); - } catch { - message.error(t("repository.mine.applyError")); - } - }; const { data: a2aSettingsData, isLoading: isLoadingA2ASettings } = useQuery({ queryKey: ["a2aServerSettings", agentId], queryFn: () => a2aClientService.getServerSettings(agentId!), @@ -335,32 +325,80 @@ export default function AgentConfigActions({ const disabled = agentId === null; const menuItems: NonNullable = []; - if (agent) { - menuItems.push( - { - key: "listing", - icon: , - label: t("agentConfig.list.listing"), - disabled: !canApplyListing || createListing.isPending, - onClick: () => { - if (canApplyListing) setListingTarget(listingAgent); - }, + if (onConfigure) { + menuItems.push({ + key: "configure", + icon: , + label: t("agentConfig.list.configure"), + disabled, + onClick: () => { + if (agentId !== null) onConfigure(agentId); }, - { + }); + } + if (onPublish) { + menuItems.push({ + key: "publish", + icon: , + label: t("agentConfig.list.publish"), + disabled: disabled || isReadOnly, + onClick: () => { + if (agentId !== null) onPublish(agentId); + }, + }); + } + 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"), - disabled: !canEvaluate, onClick: () => { - if (canEvaluate && agentId !== null) - router.push( - `/${locale ?? pathname.split("/")[1]}/evaluation?agent_ids=${encodeURIComponent(JSON.stringify([agentId]))}` - ); + 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"), + }); + } } - if (agent) menuItems.push({ type: "divider" }); menuItems.push( { key: "copy", @@ -428,15 +466,34 @@ export default function AgentConfigActions({ return ( <> - {listingTarget && ( - { - if (!createListing.isPending) setListingTarget(null); + onClose={() => 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")); + } }} - onSubmit={submitListing} + /> + )} + {repositoryAgent && repositoryDialog === "guide" && ( + setRepositoryDialog(null)} /> )} {variant === "table" ? ( diff --git a/frontend/app/[locale]/agents/components/capability/SkillDetailModal.tsx b/frontend/app/[locale]/agents/components/capability/SkillDetailModal.tsx index dc1ad8c5fc..ab760e5fcc 100644 --- a/frontend/app/[locale]/agents/components/capability/SkillDetailModal.tsx +++ b/frontend/app/[locale]/agents/components/capability/SkillDetailModal.tsx @@ -21,6 +21,7 @@ import { import { normalizeSkillFiles } from "@/lib/skillFileUtils"; import log from "@/lib/logger"; import SkillDraftPanel from "./SkillDraftPanel"; +import SkillReadOnlyDetail from "@/components/skill/SkillReadOnlyDetail"; interface SkillDetailModalProps { skill: Skill | null; @@ -28,6 +29,7 @@ interface SkillDetailModalProps { onClose: () => void; zIndex?: number; maskClosable?: boolean; + repositoryLayout?: boolean; } const OFFICIAL_SOURCES = new Set(["official", "\u5b98\u65b9"]); @@ -64,6 +66,7 @@ export default function SkillDetailModal({ onClose, zIndex = 1000, maskClosable = true, + repositoryLayout = false, }: SkillDetailModalProps) { const { t } = useTranslation("common"); const [form] = Form.useForm(); @@ -72,12 +75,17 @@ export default function SkillDetailModal({ ]); const [activeSkillTab, setActiveSkillTab] = useState("SKILL.md"); const [loading, setLoading] = useState(false); + const [detailInfo, setDetailInfo] = useState | null>(null); const [fileTreeMessage, setFileTreeMessage] = useState(null); useEffect(() => { if (!open || !skill) return; let cancelled = false; + setDetailInfo(skill); form.setFieldsValue({ name: skill.name, description: skill.description || "", @@ -100,6 +108,7 @@ export default function SkillDetailModal({ detailResult.success && detailResult.data ? detailResult.data : skill; const skillName = detail.name?.trim() || skill.name; if (!cancelled) { + setDetailInfo(detail); form.setFieldsValue({ name: skillName, description: detail.description || "", @@ -150,16 +159,21 @@ export default function SkillDetailModal({ title={t("skillManagement.detail.title")} open={open} onCancel={handleClose} - footer={null} - width={820} + footer={repositoryLayout ? undefined : null} + onOk={handleClose} + okText={t("common.confirm")} + cancelText={t("common.cancel")} + centered={repositoryLayout} + destroyOnHidden={repositoryLayout} + width={repositoryLayout ? 860 : 820} zIndex={zIndex} mask={{ closable: maskClosable }} className="skill-detail-modal" styles={{ body: { - height: 620, + height: repositoryLayout ? "auto" : 620, maxHeight: "75vh", - overflow: "hidden", + overflow: repositoryLayout ? "auto" : "hidden", }, }} > @@ -173,14 +187,27 @@ export default function SkillDetailModal({ ) : null}
- + {repositoryLayout ? ( +
+ + + ) : ( + + )}