From a924459c6ab72d1921739d0957ba568398d4a233 Mon Sep 17 00:00:00 2001 From: Chenlifeng <174292121+Lifeng-Chen@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:38:51 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E2=9C=A8=20Feature:=20add=20agent=20reposi?= =?UTF-8?q?tory=20page=20and=20APIs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce Agent Repository backend APIs, database/service support, frontend views, client services, and tests. Migrate Agent Space navigation and permissions to /agent-repository with updated SQL and localization. --- backend/apps/agent_repository_app.py | 107 +- backend/consts/model.py | 35 + backend/database/agent_repository_db.py | 348 ++++++- backend/database/db_models.py | 16 +- backend/services/agent_repository_service.py | 469 ++++++++- docker/init.sql | 106 +- .../sql/v2.2.2_0622_update_left_nav_menu.sql | 10 +- .../components/AgentRepositoryCard.tsx | 111 +++ .../components/AgentRepositoryDetailModal.tsx | 215 ++++ .../components/MineAgentsView.tsx | 250 +++++ .../components/MineReviewStatusModal.tsx | 146 +++ .../components/MyAgentCard.tsx | 195 ++++ .../app/[locale]/agent-repository/page.tsx | 594 ++++++++++++ frontend/app/[locale]/agent-space/page.tsx | 213 +--- .../components/navigation/SideNavigation.tsx | 105 +- .../useAgentRepositoryListings.ts | 101 ++ frontend/lib/agentRepositoryMine.test.ts | 281 ++++++ frontend/lib/agentRepositoryMine.ts | 125 +++ frontend/public/locales/en/common.json | 86 ++ frontend/public/locales/zh/common.json | 86 ++ frontend/services/agentRepositoryService.ts | 184 ++++ frontend/services/api.ts | 38 + frontend/types/agentRepository.ts | 97 ++ .../charts/nexent-common/files/init.sql | 8 +- test/backend/app/test_agent_repository_app.py | 290 +++++- .../services/test_agent_repository_service.py | 918 ++++++++++++++++-- 26 files changed, 4775 insertions(+), 359 deletions(-) create mode 100644 frontend/app/[locale]/agent-repository/components/AgentRepositoryCard.tsx create mode 100644 frontend/app/[locale]/agent-repository/components/AgentRepositoryDetailModal.tsx create mode 100644 frontend/app/[locale]/agent-repository/components/MineAgentsView.tsx create mode 100644 frontend/app/[locale]/agent-repository/components/MineReviewStatusModal.tsx create mode 100644 frontend/app/[locale]/agent-repository/components/MyAgentCard.tsx create mode 100644 frontend/app/[locale]/agent-repository/page.tsx create mode 100644 frontend/hooks/agentRepository/useAgentRepositoryListings.ts create mode 100644 frontend/lib/agentRepositoryMine.test.ts create mode 100644 frontend/lib/agentRepositoryMine.ts create mode 100644 frontend/services/agentRepositoryService.ts create mode 100644 frontend/types/agentRepository.ts diff --git a/backend/apps/agent_repository_app.py b/backend/apps/agent_repository_app.py index e9da2fde0..5f1bedd1b 100644 --- a/backend/apps/agent_repository_app.py +++ b/backend/apps/agent_repository_app.py @@ -6,10 +6,14 @@ from starlette.responses import JSONResponse from consts.exceptions import SkillDuplicateError, UnauthorizedError +from consts.model import AgentRepositoryListingCreateRequest from services.agent_repository_service import ( create_agent_repository_listing_impl, + get_agent_repository_listing_detail_impl, import_agent_from_repository_impl, + list_agent_repository_categories_impl, list_agent_repository_listings_impl, + list_my_editable_agents_impl, update_agent_repository_status_impl, ) from utils.auth_utils import get_current_user_id @@ -21,12 +25,31 @@ @agent_repository_router.get("") async def list_agent_repository_listings_api( status: Optional[str] = Query(None, description="Filter by listing status"), + agent_id: Optional[int] = Query(None, description="Filter by source agent ID"), + deduplicate_by_agent_id: Optional[bool] = Query( + None, + description="Whether to return one listing per agent", + ), + category_id: Optional[int] = Query( + None, + description="Filter by marketplace category ID", + ), authorization: str = Header(None), ): """List all marketplace repository listings with optional status filter.""" try: get_current_user_id(authorization) - result = list_agent_repository_listings_impl(status=status) + should_deduplicate = ( + agent_id is None + if deduplicate_by_agent_id is None + else deduplicate_by_agent_id + ) + result = list_agent_repository_listings_impl( + status=status, + agent_id=agent_id, + deduplicate_by_agent_id=should_deduplicate, + category_id=category_id, + ) return JSONResponse(status_code=HTTPStatus.OK, content=result) except UnauthorizedError as e: raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) @@ -40,6 +63,78 @@ async def list_agent_repository_listings_api( ) +@agent_repository_router.get("/categories") +async def list_agent_repository_categories_api( + authorization: str = Header(None), +): + """List hardcoded marketplace category options for repository filtering.""" + try: + get_current_user_id(authorization) + return JSONResponse( + status_code=HTTPStatus.OK, + content=list_agent_repository_categories_impl(), + ) + except UnauthorizedError as e: + raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) + except Exception as e: + logger.error(f"List agent repository categories error: {str(e)}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="List agent repository categories error.", + ) + + +@agent_repository_router.get("/mine") +async def list_my_editable_agents_api( + ownership: Optional[str] = Query( + "all", + description="Filter by ownership: all / created / others", + ), + authorization: str = Header(None), +): + """List editable draft agents for the current user with repository listing info.""" + try: + user_id, tenant_id = get_current_user_id(authorization) + result = list_my_editable_agents_impl( + tenant_id=tenant_id, + user_id=user_id, + ownership=ownership or "all", + ) + return JSONResponse(status_code=HTTPStatus.OK, content=result) + except UnauthorizedError as e: + raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) + except ValueError as e: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) + except Exception as e: + logger.error(f"List my editable agents error: {str(e)}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="List my editable agents error.", + ) + + +@agent_repository_router.get("/{agent_repository_id}") +async def get_agent_repository_listing_detail_api( + agent_repository_id: int, + authorization: str = Header(None), +): + """Get detailed marketplace repository listing by primary key.""" + try: + get_current_user_id(authorization) + result = get_agent_repository_listing_detail_impl(agent_repository_id) + return JSONResponse(status_code=HTTPStatus.OK, content=result) + except UnauthorizedError as e: + raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) + except ValueError as e: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e)) + except Exception as e: + logger.error(f"Get agent repository listing detail error: {str(e)}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Get agent repository listing detail error.", + ) + + @agent_repository_router.patch("/{agent_repository_id}/status") async def update_agent_repository_status_api( agent_repository_id: int, @@ -47,19 +142,20 @@ async def update_agent_repository_status_api( ..., embed=True, description=( - "New status: NOT_SHARED (未共享) / PENDING_REVIEW (待审核) / " - "REJECTED (审核驳回) / SHARED (已共享)" + "New status: not_shared (未共享) / pending_review (待审核) / " + "rejected (审核驳回) / shared (已共享)" ), ), authorization: str = Header(None), ): """Update marketplace repository listing status (share, unshare, approve, reject).""" try: - user_id, _ = get_current_user_id(authorization) + user_id, tenant_id = get_current_user_id(authorization) result = update_agent_repository_status_impl( agent_repository_id=agent_repository_id, status=status, user_id=user_id, + tenant_id=tenant_id, ) return JSONResponse(status_code=HTTPStatus.OK, content=result) except UnauthorizedError as e: @@ -78,16 +174,19 @@ async def update_agent_repository_status_api( async def create_agent_repository_listing_api( agent_id: int, version_no: int, + payload: Optional[AgentRepositoryListingCreateRequest] = Body(None), authorization: str = Header(None), ): """Create or update a marketplace repository listing from an agent version snapshot.""" try: user_id, tenant_id = get_current_user_id(authorization) + card_fields = payload.model_dump(exclude_none=True) if payload else None result = await create_agent_repository_listing_impl( agent_id=agent_id, tenant_id=tenant_id, user_id=user_id, version_no=version_no, + card_fields=card_fields, ) return JSONResponse(status_code=HTTPStatus.OK, content=result) except UnauthorizedError as e: diff --git a/backend/consts/model.py b/backend/consts/model.py index 00e5b8a0a..ab47f9a49 100644 --- a/backend/consts/model.py +++ b/backend/consts/model.py @@ -627,6 +627,41 @@ class AgentRepositorySnapshot(ExportAndImportDataFormat): skills: Optional[List["SkillZipEntry"]] = None +class AgentRepositoryListingCreateRequest(BaseModel): + """Request body for creating a marketplace listing from an agent version.""" + icon: Optional[str] = Field(None, description="Marketplace card icon (emoji or URL)") + downloads: int = Field(0, ge=0, description="Initial download/copy count for card display") + tags: Optional[List[str]] = Field(None, description="Marketplace tags") + category_id: Optional[int] = Field(0, description="Optional marketplace category ID") + tool_count: Optional[int] = Field( + None, ge=0, description="Total tool count across all agents in the bundle" + ) + + +class AgentRepositoryCategoryItem(BaseModel): + """Marketplace category option for agent repository filtering.""" + id: int + name: str + + +class AgentRepositoryListingDetailResponse(BaseModel): + """Detailed marketplace listing payload for repository detail view.""" + agent_repository_id: int + agent_id: Optional[int] = None + name: str + display_name: Optional[str] = None + description: Optional[str] = None + author: Optional[str] = None + icon: Optional[str] = None + status: str + version_label: Optional[str] = None + downloads: int = 0 + created_at: Optional[str] = None + model_name: Optional[str] = None + duty_prompt: Optional[str] = None + tools: List[str] = Field(default_factory=list) + + class SkillZipEntry(BaseModel): """A skill bundled inside an agent export ZIP.""" skill_name: str diff --git a/backend/database/agent_repository_db.py b/backend/database/agent_repository_db.py index a6bb4f48b..3f5099b8e 100644 --- a/backend/database/agent_repository_db.py +++ b/backend/database/agent_repository_db.py @@ -1,20 +1,25 @@ import logging import math -from typing import Any, Dict, List, Optional +from typing import Any, Collection, Dict, List, Optional -from sqlalchemy import func, or_, update +from sqlalchemy import and_, case, false, func, or_, true, update +from consts.const import ( + CAN_EDIT_ALL_USER_ROLES, + PERMISSION_EDIT, +) from database.client import as_dict, filter_property, get_db_session -from database.db_models import AgentRepository +from database.db_models import AgentInfo, AgentRepository, AgentVersion +from database.group_db import query_group_ids_by_user logger = logging.getLogger("agent_repository_db") -# Listing status: NOT_SHARED (未共享), PENDING_REVIEW (待审核), -# REJECTED (审核驳回), SHARED (已共享) -STATUS_NOT_SHARED = "NOT_SHARED" -STATUS_PENDING_REVIEW = "PENDING_REVIEW" -STATUS_REJECTED = "REJECTED" -STATUS_SHARED = "SHARED" +# Listing status: not_shared (未共享), pending_review (待审核), +# rejected (审核驳回), shared (已共享) +STATUS_NOT_SHARED = "not_shared" +STATUS_PENDING_REVIEW = "pending_review" +STATUS_REJECTED = "rejected" +STATUS_SHARED = "shared" VALID_REPOSITORY_STATUSES = frozenset({ STATUS_NOT_SHARED, @@ -23,6 +28,16 @@ STATUS_SHARED, }) +OWNERSHIP_ALL = "all" +OWNERSHIP_CREATED = "created" +OWNERSHIP_OTHERS = "others" + +VALID_OWNERSHIP_FILTERS = frozenset({ + OWNERSHIP_ALL, + OWNERSHIP_CREATED, + OWNERSHIP_OTHERS, +}) + _UPSERT_IMMUTABLE_FIELDS = frozenset({ "agent_id", "agent_repository_id", @@ -30,7 +45,7 @@ }) _UPSERT_SNAPSHOT_FIELDS = frozenset({ - "source_version_no", + "version_no", "name", "display_name", "description", @@ -38,7 +53,9 @@ "category_id", "tags", "tool_count", - "version_label", + "version_name", + "icon", + "downloads", "agent_info_json", }) @@ -93,13 +110,21 @@ def get_agent_repository_by_id_and_publisher( return as_dict(record) if record else None -def get_agent_repository_by_agent_id(agent_id: int) -> Optional[dict]: - """Fetch an active repository listing by root agent_id.""" +def get_agent_repository_by_agent_id( + agent_id: int, + version_no: Optional[int] = None, +) -> Optional[dict]: + """Fetch an active repository listing by root agent_id and optional version.""" with get_db_session() as session: - record = session.query(AgentRepository).filter( + query = session.query(AgentRepository).filter( AgentRepository.agent_id == agent_id, AgentRepository.delete_flag != "Y", - ).first() + ) + if version_no is not None: + query = query.filter( + AgentRepository.version_no == version_no + ) + record = query.first() return as_dict(record) if record else None @@ -111,8 +136,8 @@ def upsert_agent_repository_record( """Insert or update a repository listing keyed by agent_id. When no record exists, inserts a new listing. When a record exists: - - Same source_version_no: updates status (and updated_by) only. - - Different source_version_no: updates all snapshot fields, preserving + - Same version_no: updates status (and updated_by) only. + - Different version_no: updates all snapshot fields, preserving agent_id, agent_repository_id, and publisher_tenant_id. Returns: @@ -131,8 +156,8 @@ def upsert_agent_repository_record( ) return repository_id, False - existing_version = existing.get("source_version_no") - incoming_version = repository_data.get("source_version_no") + existing_version = existing.get("version_no") + incoming_version = repository_data.get("version_no") repository_id = int(existing["agent_repository_id"]) if existing_version == incoming_version: @@ -166,30 +191,52 @@ def upsert_agent_repository_record( def list_agent_repository_summaries( *, status: Optional[str] = None, + agent_id: Optional[int] = None, + category_id: Optional[int] = None, ) -> List[dict]: """List all active repository summaries without heavy JSON blobs.""" with get_db_session() as session: query = session.query( AgentRepository.agent_repository_id, + AgentRepository.agent_id, AgentRepository.author, + AgentRepository.submitted_by, AgentRepository.name, AgentRepository.display_name, AgentRepository.description, AgentRepository.status, + AgentRepository.category_id, + AgentRepository.tags, + AgentRepository.tool_count, + AgentRepository.version_name, + AgentRepository.icon, + AgentRepository.downloads, ).filter( AgentRepository.delete_flag != "Y", ) if status: query = query.filter(AgentRepository.status == status) + if agent_id is not None: + query = query.filter(AgentRepository.agent_id == agent_id) + if category_id is not None: + query = query.filter(AgentRepository.category_id == category_id) rows = query.order_by(AgentRepository.agent_repository_id.desc()).all() return [ { "agent_repository_id": row.agent_repository_id, + "agent_id": row.agent_id, "author": row.author, + "submitted_by": row.submitted_by, "name": row.name, "display_name": row.display_name, "description": row.description, "status": row.status, + "category_id": row.category_id, + "tags": row.tags, + "tool_count": row.tool_count, + "version_name": row.version_name, + "icon": row.icon, + "downloads": row.downloads, } for row in rows ] @@ -269,11 +316,14 @@ def update_agent_repository_by_id( "display_name", "description", "author", + "submitted_by", "category_id", "tags", "tool_count", - "version_label", - "source_version_no", + "version_name", + "icon", + "downloads", + "version_no", "agent_info_json", "status", } @@ -305,8 +355,22 @@ def update_agent_repository_status_by_id( repository_id: int, status: str, user_id: str, + publisher_tenant_id: Optional[str] = None, + publisher_user_id: Optional[str] = None, + submitted_by: Optional[str] = None, ) -> int: """Update repository listing status by primary key. Returns affected row count.""" + update_values: Dict[str, Any] = { + "status": status, + "updated_by": user_id, + } + if publisher_tenant_id is not None: + update_values["publisher_tenant_id"] = publisher_tenant_id + if publisher_user_id is not None: + update_values["publisher_user_id"] = publisher_user_id + if submitted_by is not None: + update_values["submitted_by"] = submitted_by + with get_db_session() as session: result = session.execute( update(AgentRepository) @@ -314,7 +378,28 @@ def update_agent_repository_status_by_id( AgentRepository.agent_repository_id == repository_id, AgentRepository.delete_flag != "Y", ) - .values(status=status, updated_by=user_id) + .values(**update_values) + ) + return int(result.rowcount or 0) + + +def reset_agent_repository_status( + *, + agent_repository_id: int, + agent_id: int, + status: str, +) -> int: + """Set other active listings with the same agent and status to not_shared.""" + with get_db_session() as session: + result = session.execute( + update(AgentRepository) + .where( + AgentRepository.agent_id == agent_id, + AgentRepository.status == status, + AgentRepository.agent_repository_id != agent_repository_id, + AgentRepository.delete_flag != "Y", + ) + .values(status=STATUS_NOT_SHARED) ) return int(result.rowcount or 0) @@ -356,3 +441,222 @@ def list_agent_repository_by_publisher( ) rows = query.order_by(AgentRepository.agent_repository_id.desc()).all() return [as_dict(row) for row in rows] + + +def _build_group_ids_overlap_condition(user_group_ids: set[int]): + """Build SQL condition for CSV group_ids overlapping user_group_ids.""" + if not user_group_ids: + return false() + padded = func.concat(",", AgentInfo.group_ids, ",") + return or_(*(padded.like(f"%,{gid},%") for gid in user_group_ids)) + + +def _build_editable_agent_filter( + user_id: str, + *, + can_edit_all: bool, + user_group_ids: set[int], +): + """Build SQL WHERE clause for agents the user can edit.""" + if can_edit_all: + return true() + group_overlap = _build_group_ids_overlap_condition(user_group_ids) + return or_( + AgentInfo.created_by == user_id, + and_( + AgentInfo.ingroup_permission == PERMISSION_EDIT, + group_overlap, + ), + ) + + +def _resolve_editable_agent_access( + user_id: str, + user_role: str, +) -> tuple[bool, set[int], Any]: + """Resolve role-based edit access and the editable-agent SQL filter.""" + role = (user_role or "").upper() + can_edit_all = role in CAN_EDIT_ALL_USER_ROLES + user_group_ids: set[int] = set() + if not can_edit_all: + user_group_ids = set(query_group_ids_by_user(user_id) or []) + editable_filter = _build_editable_agent_filter( + user_id, + can_edit_all=can_edit_all, + user_group_ids=user_group_ids, + ) + return can_edit_all, user_group_ids, editable_filter + + +def _build_ownership_filter(user_id: str, ownership_filter: str): + """Build SQL WHERE clause for mine-tab ownership filtering.""" + if ownership_filter == OWNERSHIP_CREATED: + return AgentInfo.created_by == user_id + if ownership_filter == OWNERSHIP_OTHERS: + return or_( + AgentInfo.created_by != user_id, + AgentInfo.created_by.is_(None), + ) + return true() + + +def _build_editable_agent_base_filters( + tenant_id: str, + editable_filter: Any, +) -> tuple[Any, ...]: + """Shared base filters for editable draft agents in a tenant.""" + return ( + AgentInfo.tenant_id == tenant_id, + AgentInfo.version_no == 0, + AgentInfo.delete_flag != "Y", + AgentInfo.enabled.is_(True), + editable_filter, + ) + + +def list_agent_repository_by_agent_ids( + agent_ids: List[int], + *, + statuses: Collection[str], + publisher_tenant_id: str, +) -> List[dict]: + """List repository rows for the given agents, scoped to publisher tenant and statuses.""" + if not agent_ids: + return [] + + status_list = list(statuses) + with get_db_session() as session: + rows = ( + session.query( + AgentRepository.agent_repository_id, + AgentRepository.agent_id, + AgentRepository.status, + AgentRepository.version_no, + AgentRepository.version_name, + AgentRepository.create_time, + ) + .filter( + AgentRepository.delete_flag != "Y", + AgentRepository.publisher_tenant_id == publisher_tenant_id, + AgentRepository.agent_id.in_(agent_ids), + AgentRepository.status.in_(status_list), + ) + .order_by( + AgentRepository.agent_id, + AgentRepository.create_time.desc(), + ) + .all() + ) + + return [ + { + "agent_repository_id": row.agent_repository_id, + "agent_id": row.agent_id, + "status": row.status, + "version_no": row.version_no, + "version_name": row.version_name, + "create_time": row.create_time, + } + for row in rows + ] + + +def list_editable_agents_for_user( + tenant_id: str, + user_id: str, + *, + user_role: str, + ownership_filter: str = OWNERSHIP_ALL, +) -> List[dict]: + """List draft agents in a tenant that the user can edit. + + Queries version_no=0 rows and returns agent_id, name, display_name, description, + current_version_no, and the current published version_name and create_time + (via LEFT JOIN on ag_tenant_agent_version_t) for agents where permission resolves to EDIT. + """ + _, _, editable_filter = _resolve_editable_agent_access(user_id, user_role) + ownership_clause = _build_ownership_filter(user_id, ownership_filter) + + with get_db_session() as session: + rows = ( + session.query( + AgentInfo.agent_id, + AgentInfo.name, + AgentInfo.display_name, + AgentInfo.description, + AgentInfo.current_version_no, + AgentInfo.created_by, + AgentVersion.version_name, + AgentVersion.create_time, + ) + .outerjoin( + AgentVersion, + and_( + AgentInfo.agent_id == AgentVersion.agent_id, + AgentInfo.current_version_no == AgentVersion.version_no, + AgentInfo.tenant_id == AgentVersion.tenant_id, + AgentVersion.delete_flag == "N", + ), + ) + .filter( + *_build_editable_agent_base_filters(tenant_id, editable_filter), + ownership_clause, + ) + .order_by(AgentInfo.create_time.desc()) + .all() + ) + + return [ + { + "agent_id": row.agent_id, + "name": row.name, + "display_name": row.display_name, + "description": row.description, + "current_version_no": row.current_version_no, + "created_by": row.created_by, + "version_name": row.version_name, + "version_create_time": row.create_time, + } + for row in rows + ] + + +def count_editable_agents_by_ownership( + tenant_id: str, + user_id: str, + *, + user_role: str, +) -> Dict[str, int]: + """Count editable draft agents grouped by ownership for mine-tab badges.""" + _, _, editable_filter = _resolve_editable_agent_access(user_id, user_role) + created_case = case( + (AgentInfo.created_by == user_id, 1), + else_=0, + ) + others_case = case( + ( + or_( + AgentInfo.created_by != user_id, + AgentInfo.created_by.is_(None), + ), + 1, + ), + else_=0, + ) + + with get_db_session() as session: + row = ( + session.query( + func.count(AgentInfo.agent_id), + func.coalesce(func.sum(created_case), 0), + func.coalesce(func.sum(others_case), 0), + ) + .filter(*_build_editable_agent_base_filters(tenant_id, editable_filter)) + .one() + ) + + return { + "all": int(row[0] or 0), + "created": int(row[1] or 0), + "others": int(row[2] or 0), + } diff --git a/backend/database/db_models.py b/backend/database/db_models.py index 5450b5f74..1d1220e20 100644 --- a/backend/database/db_models.py +++ b/backend/database/db_models.py @@ -718,23 +718,27 @@ class AgentRepository(TableBase): publisher_user_id = Column(String(100), nullable=False, doc="Publisher user ID") agent_id = Column(Integer, nullable=False, doc="Root agent ID from ag_tenant_agent_t; upsert key") - source_version_no = Column(Integer, nullable=False, - doc="Published version number frozen at share time") + version_no = Column(Integer, nullable=False, + doc="Published version number frozen at share time") name = Column(String(100), nullable=False, doc="Root agent programmatic name for display and search") display_name = Column(String(100), doc="Root agent display name") description = Column(Text, doc="Root agent description") author = Column(String(100), doc="Agent author") + submitted_by = Column(String(100), doc="Submitter email when listing enters pending_review") category_id = Column(Integer, doc="Optional marketplace category ID") tags = Column(ARRAY(Text), doc="Marketplace tags") tool_count = Column(Integer, doc="Total tool count across all agents in the bundle (display only)") - version_label = Column(String(100), - doc="Repository entry version label for display (e.g. v1.0)") + icon = Column(String(100), doc="Marketplace card icon (emoji or URL)") + downloads = Column(Integer, default=0, + doc="Marketplace download/copy count for card display") + version_name = Column(String(100), + doc="Repository entry version name for display (from ag_tenant_agent_version_t)") agent_info_json = Column(JSONB, nullable=False, doc="Frozen ExportAndImportDataFormat snapshot with optional skills") - status = Column(String(30), default="NOT_SHARED", - doc="Listing status: NOT_SHARED (未共享) / PENDING_REVIEW (待审核) / REJECTED (审核驳回) / SHARED (已共享)") + status = Column(String(30), default="not_shared", + doc="Listing status: not_shared (未共享) / pending_review (待审核) / rejected (审核驳回) / shared (已共享)") class UserTokenInfo(TableBase): diff --git a/backend/services/agent_repository_service.py b/backend/services/agent_repository_service.py index 87649bcd1..24161d01b 100644 --- a/backend/services/agent_repository_service.py +++ b/backend/services/agent_repository_service.py @@ -1,20 +1,30 @@ import logging -from typing import Any, Dict, Optional +from typing import Any, Dict, FrozenSet, List, Optional, Tuple -from consts.const import ASSET_OWNER_TENANT_ID +from consts.exceptions import UnauthorizedError from consts.model import AgentRepositorySnapshot from database.agent_db import search_agent_info_by_agent_id from database.agent_version_db import search_version_by_version_no from database.agent_repository_db import ( + STATUS_NOT_SHARED, STATUS_PENDING_REVIEW, + STATUS_REJECTED, + STATUS_SHARED, + OWNERSHIP_ALL, + VALID_OWNERSHIP_FILTERS, VALID_REPOSITORY_STATUSES, + count_editable_agents_by_ownership, get_agent_repository_by_agent_id, get_agent_repository_by_id, insert_agent_repository_record, + list_agent_repository_by_agent_ids, list_agent_repository_summaries, + list_editable_agents_for_user, + reset_agent_repository_status, update_agent_repository_by_id, update_agent_repository_status_by_id, ) +from database.user_tenant_db import get_user_tenant_by_user_id from services.agent_service import ( collect_skill_zip_entries, export_agent_dict_for_repository_impl, @@ -24,15 +34,59 @@ logger = logging.getLogger("agent_repository_service") +_SU_STATUS_TRANSITIONS: FrozenSet[Tuple[str, str]] = frozenset({ + (STATUS_PENDING_REVIEW, STATUS_REJECTED), + (STATUS_PENDING_REVIEW, STATUS_SHARED), + (STATUS_SHARED, STATUS_NOT_SHARED), +}) + +_PUBLISHER_STATUS_TRANSITIONS: FrozenSet[Tuple[str, str]] = frozenset({ + (STATUS_NOT_SHARED, STATUS_PENDING_REVIEW), + (STATUS_REJECTED, STATUS_PENDING_REVIEW), + (STATUS_PENDING_REVIEW, STATUS_NOT_SHARED), + (STATUS_REJECTED, STATUS_NOT_SHARED), + (STATUS_SHARED, STATUS_NOT_SHARED), +}) + +_PUBLISHER_RESUBMIT_TRANSITIONS: FrozenSet[Tuple[str, str]] = frozenset({ + (STATUS_NOT_SHARED, STATUS_PENDING_REVIEW), + (STATUS_REJECTED, STATUS_PENDING_REVIEW), +}) + +_ADMIN_REVIEW_STATUS_TRANSITIONS: FrozenSet[Tuple[str, str]] = frozenset({ + (STATUS_PENDING_REVIEW, STATUS_REJECTED), + (STATUS_PENDING_REVIEW, STATUS_SHARED), +}) + +_REPOSITORY_STATUS_PRIORITY: Dict[str, int] = { + STATUS_SHARED: 4, + STATUS_PENDING_REVIEW: 3, + STATUS_REJECTED: 2, + STATUS_NOT_SHARED: 1, +} + +_AGENT_REPOSITORY_CATEGORIES: List[Dict[str, Any]] = [ + {"id": 1, "name": "写作助手"}, + {"id": 2, "name": "编程开发"}, + {"id": 3, "name": "数据分析"}, + {"id": 4, "name": "客户服务"}, + {"id": 5, "name": "效率工具"}, + {"id": 6, "name": "创意设计"}, + {"id": 0, "name": "其它"}, +] + _UPDATE_SNAPSHOT_FIELDS = ( "display_name", "description", "author", + "submitted_by", "category_id", "tags", "tool_count", - "version_label", - "source_version_no", + "version_name", + "icon", + "downloads", + "version_no", "agent_info_json", "status", ) @@ -42,17 +96,60 @@ def _to_summary_item(record: Dict[str, Any]) -> Dict[str, Any]: """Map a DB record to a lightweight marketplace summary item.""" return { "agent_repository_id": record.get("agent_repository_id"), + "agent_id": record.get("agent_id"), "author": record.get("author"), + "submitted_by": record.get("submitted_by"), "name": record.get("name"), "display_name": record.get("display_name"), "description": record.get("description"), "status": record.get("status"), + "category_id": record.get("category_id"), + "tags": record.get("tags") or [], + "tool_count": record.get("tool_count"), + "version_label": record.get("version_name"), + "icon": record.get("icon"), + "downloads": record.get("downloads") or 0, } +def _deduplicate_repository_summaries_by_agent_id( + records: List[Dict[str, Any]], +) -> List[Dict[str, Any]]: + """Keep one repository summary per agent using marketplace status priority.""" + selected_records: Dict[Tuple[str, Any], Dict[str, Any]] = {} + + for record in records: + agent_id = record.get("agent_id") + dedupe_key = ( + ("agent", agent_id) + if agent_id is not None + else ("repository", record.get("agent_repository_id")) + ) + current = selected_records.get(dedupe_key) + if current is None or _repository_summary_rank(record) > _repository_summary_rank(current): + selected_records[dedupe_key] = record + + return sorted( + selected_records.values(), + key=lambda record: int(record.get("agent_repository_id") or 0), + reverse=True, + ) + + +def _repository_summary_rank(record: Dict[str, Any]) -> Tuple[int, int]: + """Rank summaries by status priority, then newest repository ID.""" + return ( + _REPOSITORY_STATUS_PRIORITY.get(str(record.get("status") or ""), 0), + int(record.get("agent_repository_id") or 0), + ) + + def list_agent_repository_listings_impl( *, status: Optional[str] = None, + agent_id: Optional[int] = None, + deduplicate_by_agent_id: bool = True, + category_id: Optional[int] = None, ) -> Dict[str, Any]: """List all repository listings with optional status filter.""" if status is not None and status not in VALID_REPOSITORY_STATUSES: @@ -60,15 +157,284 @@ def list_agent_repository_listings_impl( f"Invalid status '{status}'; must be one of: " f"{', '.join(sorted(VALID_REPOSITORY_STATUSES))}" ) - records = list_agent_repository_summaries(status=status) + records = list_agent_repository_summaries( + status=status, + agent_id=agent_id, + category_id=category_id, + ) + if deduplicate_by_agent_id: + records = _deduplicate_repository_summaries_by_agent_id(records) return {"items": [_to_summary_item(record) for record in records]} +def list_agent_repository_categories_impl() -> List[Dict[str, Any]]: + """Return hardcoded marketplace category options for repository filtering.""" + return list(_AGENT_REPOSITORY_CATEGORIES) + + +_MY_AGENT_REPOSITORY_STATUSES = frozenset({ + STATUS_SHARED, + STATUS_PENDING_REVIEW, + STATUS_REJECTED, +}) + + +def _reset_repository_peer_statuses( + *, + agent_repository_id: int, + agent_id: int, + status: str, +) -> None: + """Reset peer listings with the same status; also clear rejected when submitting.""" + reset_agent_repository_status( + agent_repository_id=agent_repository_id, + agent_id=agent_id, + status=status, + ) + if status == STATUS_PENDING_REVIEW: + reset_agent_repository_status( + agent_repository_id=agent_repository_id, + agent_id=agent_id, + status=STATUS_REJECTED, + ) + + +def _to_repository_info_item(record: Dict[str, Any]) -> Dict[str, Any]: + """Map a repository DB row to a my-agents repository_info entry.""" + return { + "agent_repository_id": record.get("agent_repository_id"), + "status": record.get("status"), + "version_no": record.get("version_no"), + "version_label": record.get("version_name"), + "create_time": _serialize_created_at(record.get("create_time")), + } + + +def list_my_editable_agents_impl( + tenant_id: str, + user_id: str, + ownership: str = OWNERSHIP_ALL, +) -> Dict[str, Any]: + """List editable draft agents for the current user with repository listing info.""" + normalized_ownership = (ownership or OWNERSHIP_ALL).strip().lower() + if normalized_ownership not in VALID_OWNERSHIP_FILTERS: + raise ValueError( + f"Invalid ownership filter: {ownership}. " + f"Allowed values: {', '.join(sorted(VALID_OWNERSHIP_FILTERS))}." + ) + + user_tenant_record = get_user_tenant_by_user_id(user_id) or {} + user_role = str(user_tenant_record.get("user_role") or "").upper() + + counts = count_editable_agents_by_ownership( + tenant_id, + user_id, + user_role=user_role, + ) + agents = list_editable_agents_for_user( + tenant_id, + user_id, + user_role=user_role, + ownership_filter=normalized_ownership, + ) + agent_ids = [int(agent["agent_id"]) for agent in agents if agent.get("agent_id") is not None] + + repository_by_agent_id: Dict[int, List[Dict[str, Any]]] = {} + if agent_ids: + repository_records = list_agent_repository_by_agent_ids( + agent_ids, + statuses=_MY_AGENT_REPOSITORY_STATUSES, + publisher_tenant_id=tenant_id, + ) + for record in repository_records: + agent_id = record.get("agent_id") + if agent_id is None: + continue + repository_by_agent_id.setdefault(int(agent_id), []).append( + _to_repository_info_item(record) + ) + + items = [ + { + "agent_id": agent.get("agent_id"), + "name": agent.get("display_name") or agent.get("name"), + "description": agent.get("description"), + "current_version_no": agent.get("current_version_no"), + "version_label": agent.get("version_name"), + "version_create_time": _serialize_created_at(agent.get("version_create_time")), + "repository_info": repository_by_agent_id.get(int(agent["agent_id"]), []) + if agent.get("agent_id") is not None + else [], + } + for agent in agents + ] + + return { + "items": items, + "counts": counts, + } + + +def _resolve_submitter_email(user_id: str) -> Optional[str]: + """Resolve submitter email from user_tenant_t for pending_review listings.""" + user_tenant = get_user_tenant_by_user_id(user_id) or {} + email = str(user_tenant.get("user_email") or "").strip() + return email or None + + +def _extract_root_agent_from_snapshot(agent_info_json: Any) -> Dict[str, Any]: + """Resolve the root agent entry from a frozen repository snapshot.""" + if not isinstance(agent_info_json, dict): + return {} + root_agent_id = agent_info_json.get("agent_id") + agent_info_map = agent_info_json.get("agent_info") + if root_agent_id is None or not isinstance(agent_info_map, dict): + return {} + return ( + agent_info_map.get(str(root_agent_id)) + or agent_info_map.get(root_agent_id) + or {} + ) + + +def _extract_tool_names(root_agent: Dict[str, Any]) -> List[str]: + """Collect display tool names from a root agent snapshot entry.""" + tools: List[str] = [] + for tool in root_agent.get("tools") or []: + if not isinstance(tool, dict): + continue + name = tool.get("origin_name") or tool.get("name") + if name: + tools.append(str(name)) + return tools + + +def _serialize_created_at(create_time: Any) -> Optional[str]: + """Serialize DB create_time to an ISO string for API consumers.""" + if create_time is None: + return None + if hasattr(create_time, "isoformat"): + return create_time.isoformat() + return str(create_time) + + +def get_agent_repository_listing_detail_impl( + agent_repository_id: int, +) -> Dict[str, Any]: + """Load a repository listing and return a detail payload for the UI.""" + record = get_agent_repository_by_id(agent_repository_id) + if not record: + raise ValueError("Repository listing not found") + + root_agent = _extract_root_agent_from_snapshot(record.get("agent_info_json")) + + return { + "agent_repository_id": record.get("agent_repository_id"), + "agent_id": record.get("agent_id"), + "name": record.get("name"), + "display_name": record.get("display_name"), + "description": record.get("description"), + "author": record.get("author"), + "submitted_by": record.get("submitted_by"), + "icon": record.get("icon"), + "status": record.get("status"), + "version_label": record.get("version_name"), + "downloads": record.get("downloads") or 0, + "created_at": _serialize_created_at(record.get("create_time")), + "model_name": root_agent.get("model_name"), + "duty_prompt": root_agent.get("duty_prompt"), + "tools": _extract_tool_names(root_agent), + } + + +def _get_user_role(user_id: str) -> str: + """Resolve user role from user_tenant_t; default to USER when unset.""" + user_tenant = get_user_tenant_by_user_id(user_id) + if not user_tenant: + return "USER" + return str(user_tenant.get("user_role") or "USER") + + +def _validate_create_listing_permission( + *, + user_id: str, + agent_info: Dict[str, Any], +) -> None: + """Only ADMIN, or DEV whose email matches agent.author, may share to marketplace.""" + user_role = _get_user_role(user_id) + if user_role == "ADMIN": + return + if user_role == "DEV": + user_tenant = get_user_tenant_by_user_id(user_id) or {} + user_email = str(user_tenant.get("user_email") or "").strip() + agent_author = str(agent_info.get("author") or "").strip() + if user_email and agent_author and user_email.lower() == agent_author.lower(): + return + raise UnauthorizedError("Not authorized to create repository listing") + raise UnauthorizedError( + f"User role {user_role} not authorized to create repository listing" + ) + + +def _validate_repository_status_transition( + *, + user_role: str, + current_status: str, + new_status: str, + record: Dict[str, Any], + user_id: str, + tenant_id: str, +) -> Optional[Dict[str, str]]: + """Validate role, ownership, and allowed status transition. + + Returns publisher fields to update when not_shared -> pending_review, + otherwise None. + """ + transition = (current_status, new_status) + + if user_role == "SU": + if transition not in _SU_STATUS_TRANSITIONS: + raise ValueError( + f"Invalid status transition from '{current_status}' to '{new_status}'" + ) + return None + + if user_role in ("ADMIN", "DEV"): + if record.get("publisher_tenant_id") != tenant_id: + raise UnauthorizedError( + "Not authorized to update this repository listing" + ) + if user_role == "DEV" and record.get("publisher_user_id") != user_id: + raise UnauthorizedError( + "Not authorized to update this repository listing" + ) + if ( + user_role == "ADMIN" + and transition in _ADMIN_REVIEW_STATUS_TRANSITIONS + ): + return None + if transition not in _PUBLISHER_STATUS_TRANSITIONS: + raise ValueError( + f"Invalid status transition from '{current_status}' to '{new_status}'" + ) + if transition in _PUBLISHER_RESUBMIT_TRANSITIONS: + return { + "publisher_tenant_id": tenant_id, + "publisher_user_id": user_id, + } + return None + + raise UnauthorizedError( + f"User role {user_role} not authorized to update repository status" + ) + + def update_agent_repository_status_impl( *, agent_repository_id: int, status: str, user_id: str, + tenant_id: str, ) -> Dict[str, Any]: """Update a repository listing status by primary key.""" if status not in VALID_REPOSITORY_STATUSES: @@ -81,14 +447,47 @@ def update_agent_repository_status_impl( if not record: raise ValueError("Repository listing not found") + current_status = record.get("status") + publisher_updates: Optional[Dict[str, str]] = None + submitted_by: Optional[str] = None + 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) + rows_affected = update_agent_repository_status_by_id( repository_id=agent_repository_id, status=status, user_id=user_id, + publisher_tenant_id=( + publisher_updates["publisher_tenant_id"] + if publisher_updates + else None + ), + publisher_user_id=( + publisher_updates["publisher_user_id"] + if publisher_updates + else None + ), + submitted_by=submitted_by, ) if rows_affected == 0: raise ValueError("Repository listing not found") + _reset_repository_peer_statuses( + agent_repository_id=agent_repository_id, + agent_id=record["agent_id"], + status=status, + ) + updated = get_agent_repository_by_id(agent_repository_id) if not updated: raise ValueError("Failed to load repository listing after update") @@ -105,12 +504,15 @@ def _to_list_item(record: Dict[str, Any]) -> Dict[str, Any]: "display_name": record.get("display_name"), "description": record.get("description"), "author": record.get("author"), + "submitted_by": record.get("submitted_by"), "category_id": record.get("category_id"), "tags": record.get("tags") or [], "tool_count": record.get("tool_count"), - "version_label": record.get("version_label"), + "version_label": record.get("version_name"), + "icon": record.get("icon"), + "downloads": record.get("downloads") or 0, "status": record.get("status"), - "source_version_no": record.get("source_version_no"), + "version_no": record.get("version_no"), "publisher_tenant_id": record.get("publisher_tenant_id"), "created_at": record.get("create_time"), "updated_at": record.get("update_time"), @@ -136,7 +538,7 @@ def _validate_create_payload(repository_data: Dict[str, Any]) -> None: """Validate required fields before inserting a repository listing.""" required_fields = ( "agent_id", - "source_version_no", + "version_no", "name", "agent_info_json", ) @@ -157,18 +559,6 @@ def _validate_create_payload(repository_data: Dict[str, Any]) -> None: raise ValueError(f"agent_info_json must contain '{key}'") -def _validate_agent_info_json_shareable(agent_info_json: dict) -> None: - """Reject marketplace share when any agent in the tree belongs to ASSET_OWNER tenant.""" - agent_info_map = agent_info_json.get("agent_info") - if not isinstance(agent_info_map, dict): - return - for entry in agent_info_map.values(): - if not isinstance(entry, dict): - continue - if entry.get("tenant_id") == ASSET_OWNER_TENANT_ID: - raise ValueError("租户管理员智能体无法共享") - - async def _build_agent_info_json( agent_id: int, tenant_id: str, @@ -199,60 +589,76 @@ async def _build_repository_data_from_agent( tenant_id: str, user_id: str, version_no: int, + *, + card_fields: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """Build a repository upsert payload from a published agent version snapshot.""" agent_info = search_agent_info_by_agent_id(agent_id, tenant_id, version_no) + _validate_create_listing_permission(user_id=user_id, agent_info=agent_info) agent_info_json = await _build_agent_info_json( agent_id=agent_id, tenant_id=tenant_id, user_id=user_id, version_no=version_no, ) - _validate_agent_info_json_shareable(agent_info_json) version_meta = search_version_by_version_no(agent_id, tenant_id, version_no) - version_label = ( + version_name = ( version_meta.get("version_name") if version_meta and version_meta.get("version_name") else f"v{version_no}" ) - return { + repository_data: Dict[str, Any] = { "agent_id": agent_id, - "source_version_no": version_no, + "version_no": version_no, "name": agent_info["name"], "display_name": agent_info.get("display_name"), "description": agent_info.get("description"), "author": agent_info.get("author"), - "version_label": version_label, + "submitted_by": _resolve_submitter_email(user_id), + "version_name": version_name, "agent_info_json": agent_info_json, "status": STATUS_PENDING_REVIEW, } + if card_fields: + for key in ("icon", "downloads", "tags", "category_id", "tool_count"): + if key in card_fields and card_fields[key] is not None: + repository_data[key] = card_fields[key] + + return repository_data + async def create_agent_repository_listing_impl( agent_id: int, tenant_id: str, user_id: str, version_no: int, + *, + card_fields: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """Create or update a repository listing from a published agent version. Loads agent metadata and builds agent_info_json via the export pipeline, then inserts or updates the marketplace table. - When a listing for the same agent_id already exists, snapshot fields are - updated via update_agent_repository_by_id. + When a listing for the same agent version already exists, snapshot fields + are updated via update_agent_repository_by_id. """ if version_no < 0: raise ValueError("version_no must be >= 0") repository_data = await _build_repository_data_from_agent( - agent_id, tenant_id, user_id, version_no + agent_id, + tenant_id, + user_id, + version_no, + card_fields=card_fields, ) _validate_create_payload(repository_data) - existing = get_agent_repository_by_agent_id(agent_id) + existing = get_agent_repository_by_agent_id(agent_id, version_no) if not existing: repository_id = insert_agent_repository_record( repository_data=repository_data, @@ -280,6 +686,11 @@ async def create_agent_repository_listing_impl( record = get_agent_repository_by_id(repository_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"], + ) return _to_detail_item(record, is_updated=is_updated) diff --git a/docker/init.sql b/docker/init.sql index 5b0ff025b..821907990 100644 --- a/docker/init.sql +++ b/docker/init.sql @@ -1097,7 +1097,9 @@ INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_ (216, 'ASSET_OWNER', 'RESOURCE', 'MODEL', 'READ'), (217, 'ASSET_OWNER', 'RESOURCE', 'MODEL', 'UPDATE'), (218, 'ASSET_OWNER', 'RESOURCE', 'MODEL', 'DELETE'), -(219, 'ASSET_OWNER', 'RESOURCE', 'USER.ROLE', 'READ'); +(219, 'ASSET_OWNER', 'RESOURCE', 'USER.ROLE', 'READ'), +(220, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/users'), +(221, 'SU', 'VISIBILITY', 'LEFT_NAV_MENU', '/asset-owner-resources'); -- SU Menus (root level) INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_category, permission_type, permission_subtype) VALUES @@ -1118,7 +1120,7 @@ INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_ (1109, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/agents', '/agent-dev'), (1110, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/memory', '/agent-dev'); INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_category, permission_type, permission_subtype, parent_key) VALUES -(1111, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-space', '/resource-space'), +(1111, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-repository', '/resource-space'), (1112, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/mcp-space', '/resource-space'), (1113, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/skill-space', '/resource-space'); @@ -1135,7 +1137,7 @@ INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_ (1208, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/agents', '/agent-dev'), (1209, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/memory', '/agent-dev'); INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_category, permission_type, permission_subtype, parent_key) VALUES -(1210, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-space', '/resource-space'), +(1210, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-repository', '/resource-space'), (1211, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/mcp-space', '/resource-space'), (1212, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/skill-space', '/resource-space'); @@ -1159,7 +1161,7 @@ INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_ (1408, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/agents', '/agent-dev'), (1409, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/memory', '/agent-dev'); INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_category, permission_type, permission_subtype, parent_key) VALUES -(1410, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-space', '/resource-space'), +(1410, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-repository', '/resource-space'), (1411, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/mcp-space', '/resource-space'), (1412, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/skill-space', '/resource-space'); @@ -1175,7 +1177,7 @@ INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_ (1507, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/knowledges', '/agent-dev'), (1508, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/agents', '/agent-dev'); INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_category, permission_type, permission_subtype, parent_key) VALUES -(1509, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-space', '/resource-space'), +(1509, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-repository', '/resource-space'), (1510, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/mcp-space', '/resource-space'), (1511, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/skill-space', '/resource-space'); @@ -1966,3 +1968,97 @@ COMMENT ON TABLE nexent.user_cas_session_t IS 'Server-side session records for C COMMENT ON COLUMN nexent.user_cas_session_t.session_id IS 'JWT sid claim for revocation checks'; COMMENT ON COLUMN nexent.user_cas_session_t.cas_user_id IS 'User identifier returned by CAS'; COMMENT ON COLUMN nexent.user_cas_session_t.cas_session_index IS 'CAS SessionIndex or service ticket'; + +-- ag_agent_repository_t: Agent marketplace repository for frozen shareable agent snapshots +CREATE SEQUENCE IF NOT EXISTS nexent.ag_agent_repository_t_agent_repository_id_seq; + +CREATE TABLE IF NOT EXISTS nexent.ag_agent_repository_t ( + agent_repository_id BIGINT NOT NULL DEFAULT nextval('nexent.ag_agent_repository_t_agent_repository_id_seq'), + publisher_tenant_id VARCHAR(100) NOT NULL, + publisher_user_id VARCHAR(100) NOT NULL, + agent_id INTEGER NOT NULL, + version_no INTEGER NOT NULL, + name VARCHAR(100) NOT NULL, + display_name VARCHAR(100), + description TEXT, + author VARCHAR(100), + submitted_by VARCHAR(100), + category_id INTEGER, + tags TEXT[], + tool_count INTEGER, + icon VARCHAR(100), + downloads INTEGER DEFAULT 0, + version_name VARCHAR(100), + agent_info_json JSONB NOT NULL, + status VARCHAR(30) DEFAULT 'not_shared', + create_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + created_by VARCHAR(100), + updated_by VARCHAR(100), + delete_flag VARCHAR(1) DEFAULT 'N', + CONSTRAINT ag_agent_repository_t_pkey PRIMARY KEY (agent_repository_id) +); + +ALTER SEQUENCE nexent.ag_agent_repository_t_agent_repository_id_seq + OWNED BY nexent.ag_agent_repository_t.agent_repository_id; + +ALTER TABLE nexent.ag_agent_repository_t OWNER TO root; + +COMMENT ON TABLE nexent.ag_agent_repository_t IS 'Agent marketplace repository for frozen shareable agent snapshots'; +COMMENT ON COLUMN nexent.ag_agent_repository_t.agent_repository_id IS 'Agent repository listing ID, unique primary key'; +COMMENT ON COLUMN nexent.ag_agent_repository_t.publisher_tenant_id IS 'Publisher tenant ID'; +COMMENT ON COLUMN nexent.ag_agent_repository_t.publisher_user_id IS 'Publisher user ID'; +COMMENT ON COLUMN nexent.ag_agent_repository_t.agent_id IS 'Root agent ID from ag_tenant_agent_t; unique per version_no when active (delete_flag = N)'; +COMMENT ON COLUMN nexent.ag_agent_repository_t.version_no IS 'Published version number frozen at share time'; +COMMENT ON COLUMN nexent.ag_agent_repository_t.name IS 'Root agent programmatic name for display and search'; +COMMENT ON COLUMN nexent.ag_agent_repository_t.display_name IS 'Root agent display name'; +COMMENT ON COLUMN nexent.ag_agent_repository_t.description IS 'Root agent description'; +COMMENT ON COLUMN nexent.ag_agent_repository_t.author IS 'Agent author'; +COMMENT ON COLUMN nexent.ag_agent_repository_t.submitted_by IS 'Submitter email when listing enters pending_review'; +COMMENT ON COLUMN nexent.ag_agent_repository_t.category_id IS 'Optional marketplace category ID'; +COMMENT ON COLUMN nexent.ag_agent_repository_t.tags IS 'Marketplace tags'; +COMMENT ON COLUMN nexent.ag_agent_repository_t.tool_count IS 'Total tool count across all agents in the bundle (display only)'; +COMMENT ON COLUMN nexent.ag_agent_repository_t.version_name IS 'Repository entry version name for display (from ag_tenant_agent_version_t)'; +COMMENT ON COLUMN nexent.ag_agent_repository_t.icon IS 'Marketplace card icon (emoji or URL)'; +COMMENT ON COLUMN nexent.ag_agent_repository_t.downloads IS 'Marketplace download/copy count for card display'; +COMMENT ON COLUMN nexent.ag_agent_repository_t.agent_info_json IS 'Frozen ExportAndImportDataFormat snapshot with optional skills'; +COMMENT ON COLUMN nexent.ag_agent_repository_t.status IS 'Listing status: not_shared (未共享) / pending_review (待审核) / rejected (审核驳回) / shared (已共享)'; +COMMENT ON COLUMN nexent.ag_agent_repository_t.create_time IS 'Creation time'; +COMMENT ON COLUMN nexent.ag_agent_repository_t.update_time IS 'Update time'; +COMMENT ON COLUMN nexent.ag_agent_repository_t.created_by IS 'Creator ID'; +COMMENT ON COLUMN nexent.ag_agent_repository_t.updated_by IS 'Updater ID'; +COMMENT ON COLUMN nexent.ag_agent_repository_t.delete_flag IS 'Soft delete flag: Y/N'; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_agent_repository_agent_version_active + ON nexent.ag_agent_repository_t (agent_id, version_no) + WHERE delete_flag = 'N'; + +CREATE INDEX IF NOT EXISTS idx_agent_repository_publisher_delete + ON nexent.ag_agent_repository_t (publisher_tenant_id, delete_flag); + +CREATE INDEX IF NOT EXISTS idx_agent_repository_status_delete + ON nexent.ag_agent_repository_t (status, delete_flag); + +CREATE INDEX IF NOT EXISTS idx_agent_repository_name_delete + ON nexent.ag_agent_repository_t (name, delete_flag); + +CREATE INDEX IF NOT EXISTS idx_agent_repository_tags_gin + ON nexent.ag_agent_repository_t USING GIN (tags); + +CREATE OR REPLACE FUNCTION update_ag_agent_repository_update_time() +RETURNS TRIGGER AS $$ +BEGIN + NEW.update_time = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION update_ag_agent_repository_update_time() IS 'Auto-update update_time for ag_agent_repository_t'; + +DROP TRIGGER IF EXISTS update_ag_agent_repository_update_time_trigger ON nexent.ag_agent_repository_t; +CREATE TRIGGER update_ag_agent_repository_update_time_trigger +BEFORE UPDATE ON nexent.ag_agent_repository_t +FOR EACH ROW +EXECUTE FUNCTION update_ag_agent_repository_update_time(); + +COMMENT ON TRIGGER update_ag_agent_repository_update_time_trigger ON nexent.ag_agent_repository_t IS 'Trigger to maintain update_time'; diff --git a/docker/sql/v2.2.2_0622_update_left_nav_menu.sql b/docker/sql/v2.2.2_0622_update_left_nav_menu.sql index 2de41f987..195c9b5ea 100644 --- a/docker/sql/v2.2.2_0622_update_left_nav_menu.sql +++ b/docker/sql/v2.2.2_0622_update_left_nav_menu.sql @@ -13,7 +13,7 @@ ADD COLUMN IF NOT EXISTS parent_key VARCHAR(50); -- New Menu Structure: -- ROOT: /, /chat, /agent-dev, /resource-space, /resource-manage, /owner-manage, /users -- AGENT-DEV: /models, /knowledges, /agents, /memory --- RESOURCE-SPACE: /agent-space, /mcp-space, /skill-space +-- RESOURCE-SPACE: /agent-repository, /mcp-space, /skill-space -- ============================================================ -- ID Format: xx -- SU=10xx, ADMIN=11xx, DEV=12xx, USER=13xx, SPEED=14xx, ASSET_OWNER=15xx @@ -39,7 +39,7 @@ INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_ (1109, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/agents', '/agent-dev'), (1110, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/memory', '/agent-dev'); INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_category, permission_type, permission_subtype, parent_key) VALUES -(1111, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-space', '/resource-space'), +(1111, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-repository', '/resource-space'), (1112, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/mcp-space', '/resource-space'), (1113, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/skill-space', '/resource-space'); @@ -56,7 +56,7 @@ INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_ (1208, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/agents', '/agent-dev'), (1209, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/memory', '/agent-dev'); INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_category, permission_type, permission_subtype, parent_key) VALUES -(1210, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-space', '/resource-space'), +(1210, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-repository', '/resource-space'), (1211, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/mcp-space', '/resource-space'), (1212, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/skill-space', '/resource-space'); @@ -80,7 +80,7 @@ INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_ (1408, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/agents', '/agent-dev'), (1409, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/memory', '/agent-dev'); INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_category, permission_type, permission_subtype, parent_key) VALUES -(1410, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-space', '/resource-space'), +(1410, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-repository', '/resource-space'), (1411, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/mcp-space', '/resource-space'), (1412, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/skill-space', '/resource-space'); @@ -96,6 +96,6 @@ INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_ (1507, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/knowledges', '/agent-dev'), (1508, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/agents', '/agent-dev'); INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_category, permission_type, permission_subtype, parent_key) VALUES -(1509, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-space', '/resource-space'), +(1509, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-repository', '/resource-space'), (1510, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/mcp-space', '/resource-space'), (1511, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/skill-space', '/resource-space'); \ No newline at end of file diff --git a/frontend/app/[locale]/agent-repository/components/AgentRepositoryCard.tsx b/frontend/app/[locale]/agent-repository/components/AgentRepositoryCard.tsx new file mode 100644 index 000000000..f6103b404 --- /dev/null +++ b/frontend/app/[locale]/agent-repository/components/AgentRepositoryCard.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { Button, Card } from "antd"; +import { Bot, Copy, Download, Eye } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import type { AgentRepositoryListingItem } from "@/types/agentRepository"; + +interface AgentRepositoryCardProps { + listing: AgentRepositoryListingItem; + onDetailClick?: (listing: AgentRepositoryListingItem) => void; +} + +export function AgentRepositoryCard({ listing, onDetailClick }: AgentRepositoryCardProps) { + const { t } = useTranslation("common"); + + const title = + listing.display_name?.trim() || listing.name?.trim() || t("agentRepository.card.untitled"); + const tags = listing.tags?.filter((tag) => tag.trim()) ?? []; + const toolCount = listing.tool_count ?? 0; + const versionText = listing.version_label; + const downloads = listing.downloads ?? 0; + const showMetaRow = versionText != null || downloads > 0; + const showTagsRow = tags.length > 0 || toolCount > 0; + + return ( + +
+
+
+ {listing.icon?.trim() ? ( + {listing.icon.trim()} + ) : ( + + )} +
+
+

+ {title} +

+ {listing.author ? ( +

+ {listing.author} +

+ ) : null} +
+
+ +

+ {listing.description?.trim() || t("agentRepository.card.noDescription")} +

+ + {showTagsRow ? ( +
+ {tags.map((tag) => ( + + {tag} + + ))} + {toolCount > 0 ? ( + + {t("agentRepository.card.toolCount", { count: toolCount })} + + ) : null} +
+ ) : null} + + {showMetaRow ? ( +
+ {versionText ? ( + + + {versionText} + + ) : ( + + )} + {downloads > 0 ? ( + + + {downloads.toLocaleString()} + + ) : null} +
+ ) : null} + +
+ + +
+
+
+ ); +} diff --git a/frontend/app/[locale]/agent-repository/components/AgentRepositoryDetailModal.tsx b/frontend/app/[locale]/agent-repository/components/AgentRepositoryDetailModal.tsx new file mode 100644 index 000000000..e07683224 --- /dev/null +++ b/frontend/app/[locale]/agent-repository/components/AgentRepositoryDetailModal.tsx @@ -0,0 +1,215 @@ +"use client"; + +import { Button, Modal, Spin, Tag } from "antd"; +import { + Bot, + Calendar, + CheckCircle2, + Clock, + Cpu, + Download, + Wrench, + XCircle, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; +import type { + AgentRepositoryListingDetail, + AgentRepositoryListingStatus, +} from "@/types/agentRepository"; + +interface AgentRepositoryDetailModalProps { + open: boolean; + onClose: () => void; + detail: AgentRepositoryListingDetail | null | undefined; + isLoading: boolean; + isError: boolean; + isFetching: boolean; + onRetry: () => void; +} + +function formatCreatedAt(value?: string | null): string | null { + if (!value) { + return null; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + return date.toLocaleDateString(); +} + +function StatusBadge({ status }: { status: AgentRepositoryListingStatus }) { + const { t } = useTranslation("common"); + + const config: Record< + AgentRepositoryListingStatus, + { className: string; Icon: typeof CheckCircle2 } + > = { + shared: { + className: + "border-primary/30 bg-primary/10 text-primary dark:border-primary/40 dark:bg-primary/20", + Icon: CheckCircle2, + }, + pending_review: { + className: + "border-amber-300 bg-amber-50 text-amber-700 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-300", + Icon: Clock, + }, + rejected: { + className: + "border-red-300 bg-red-50 text-red-700 dark:border-red-500/40 dark:bg-red-500/10 dark:text-red-300", + Icon: XCircle, + }, + not_shared: { + className: + "border-slate-300 bg-slate-50 text-slate-600 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300", + Icon: Clock, + }, + }; + + const { className, Icon } = config[status]; + + return ( + + + {t(`agentRepository.detail.status.${status}`)} + + ); +} + +export function AgentRepositoryDetailModal({ + open, + onClose, + detail, + isLoading, + isError, + isFetching, + onRetry, +}: AgentRepositoryDetailModalProps) { + const { t } = useTranslation("common"); + + const title = + detail?.display_name?.trim() || + detail?.name?.trim() || + t("agentRepository.card.untitled"); + const createdAtText = formatCreatedAt(detail?.created_at); + const downloads = detail?.downloads ?? 0; + const tools = detail?.tools?.filter((tool) => tool.trim()) ?? []; + + return ( + + {isLoading ? ( +
+ +
+ ) : isError ? ( +
+

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

+ +
+ ) : detail ? ( +
+
+
+
+ {detail.icon?.trim() ? ( + {detail.icon.trim()} + ) : ( + + )} +
+
+
+

+ {title} +

+ +
+
+ {detail.model_name ? ( + + + {detail.model_name} + + ) : null} + {detail.version_label ? ( + {detail.version_label} + ) : null} + {downloads > 0 ? ( + + + {t("agentRepository.detail.downloads", { + count: downloads.toLocaleString(), + })} + + ) : null} + {createdAtText ? ( + + + {createdAtText} + + ) : null} +
+
+
+
+ +
+
+

+ {t("agentRepository.detail.intro")} +

+

+ {detail.description?.trim() || + t("agentRepository.card.noDescription")} +

+
+ + {tools.length > 0 ? ( +
+

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

+
+ {tools.map((tool) => ( + + {tool} + + ))} +
+
+ ) : null} + + {detail.duty_prompt?.trim() ? ( +
+

+ {t("agentRepository.detail.role")} +

+
+                  {detail.duty_prompt}
+                
+
+ ) : null} +
+
+ ) : null} +
+ ); +} diff --git a/frontend/app/[locale]/agent-repository/components/MineAgentsView.tsx b/frontend/app/[locale]/agent-repository/components/MineAgentsView.tsx new file mode 100644 index 000000000..4fc002b10 --- /dev/null +++ b/frontend/app/[locale]/agent-repository/components/MineAgentsView.tsx @@ -0,0 +1,250 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { App, Button, Empty, Input, Spin } from "antd"; +import { Search } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { + useCreateAgentRepositoryListing, + useUpdateAgentRepositoryStatus, +} from "@/hooks/agentRepository/useAgentRepositoryListings"; +import { + isCancelableRepositoryStatus, + pickReviewDisplayRepositoryInfo, +} from "@/lib/agentRepositoryMine"; +import type { + MineOwnershipFilter, + MyAgentRepositoryInfoItem, + MyEditableAgentItem, + MyEditableAgentOwnershipCounts, +} from "@/types/agentRepository"; +import { MineReviewStatusModal } from "./MineReviewStatusModal"; +import { MyAgentCard } from "./MyAgentCard"; + +const MINE_OWNERSHIP_FILTERS: MineOwnershipFilter[] = [ + "all", + "created", + "others", +]; + +interface MineAgentsViewProps { + agents: MyEditableAgentItem[]; + counts: MyEditableAgentOwnershipCounts; + ownership: MineOwnershipFilter; + onOwnershipChange: (ownership: MineOwnershipFilter) => void; + isLoading: boolean; + isError: boolean; + isFetching: boolean; + onRetry: () => void; +} + +export function MineAgentsView({ + agents, + counts, + ownership, + onOwnershipChange, + isLoading, + isError, + isFetching, + onRetry, +}: MineAgentsViewProps) { + const { t } = useTranslation("common"); + const { message } = App.useApp(); + const router = useRouter(); + const params = useParams<{ locale: string }>(); + const locale = params.locale || "en"; + const [searchQuery, setSearchQuery] = useState(""); + const [reviewModalOpen, setReviewModalOpen] = useState(false); + const [reviewModalAgent, setReviewModalAgent] = + useState(null); + const [reviewModalInfo, setReviewModalInfo] = + useState(null); + const [reviewModalMode, setReviewModalMode] = useState< + "review" | "reviewUpdate" + >("review"); + const [applyingAgentId, setApplyingAgentId] = useState(null); + + const createListingMutation = useCreateAgentRepositoryListing(); + const updateStatusMutation = useUpdateAgentRepositoryStatus(); + + const normalizedQuery = searchQuery.trim().toLowerCase(); + const filteredAgents = useMemo(() => { + if (!normalizedQuery) { + return agents; + } + return agents.filter((agent) => { + const name = (agent.name || "").toLowerCase(); + const description = (agent.description || "").toLowerCase(); + return name.includes(normalizedQuery) || description.includes(normalizedQuery); + }); + }, [agents, normalizedQuery]); + + const handleEdit = (agentId: number) => { + router.push(`/${locale}/agents?agent_id=${agentId}`); + }; + + const closeReviewModal = () => { + setReviewModalOpen(false); + setReviewModalAgent(null); + setReviewModalInfo(null); + }; + + const handleApplyListing = async (agent: MyEditableAgentItem) => { + const versionNo = agent.current_version_no ?? 0; + if (versionNo <= 0) { + return; + } + + setApplyingAgentId(agent.agent_id); + try { + await createListingMutation.mutateAsync({ + agentId: agent.agent_id, + versionNo, + }); + message.success( + t("agentRepository.mine.applySuccess", { + name: agent.name?.trim() || t("agentRepository.card.untitled"), + }) + ); + } catch { + message.error(t("agentRepository.mine.applyError")); + } finally { + setApplyingAgentId(null); + } + }; + + const handleViewReview = ( + agent: MyEditableAgentItem, + mode: "review" | "reviewUpdate" + ) => { + const repositoryInfo = pickReviewDisplayRepositoryInfo( + agent.repository_info ?? [] + ); + if (!repositoryInfo) { + return; + } + setReviewModalAgent(agent); + setReviewModalInfo(repositoryInfo); + setReviewModalMode(mode); + setReviewModalOpen(true); + }; + + const handleCancelApply = async () => { + if (!reviewModalInfo || !isCancelableRepositoryStatus(reviewModalInfo.status)) { + return; + } + + try { + await updateStatusMutation.mutateAsync({ + agentRepositoryId: reviewModalInfo.agent_repository_id, + status: "not_shared", + }); + message.success(t("agentRepository.mine.cancelApplySuccess")); + closeReviewModal(); + } catch { + message.error(t("agentRepository.mine.cancelApplyError")); + } + }; + + const ownershipLabelKey: Record = { + all: "agentRepository.mine.filter.all", + created: "agentRepository.mine.filter.created", + others: "agentRepository.mine.filter.others", + }; + + const hasActiveFilter = ownership !== "all" || normalizedQuery.length > 0; + const showFilteredEmpty = !isLoading && !isError && filteredAgents.length === 0; + + return ( +
+
+ + setSearchQuery(e.target.value)} + placeholder={t("agentRepository.mine.searchPlaceholder")} + className="h-11 rounded-xl pl-10" + allowClear + /> +
+ +
+ {MINE_OWNERSHIP_FILTERS.map((filter) => ( + + ))} +
+ + {isLoading ? ( +
+ +
+ ) : isError ? ( +
+

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

+ +
+ ) : showFilteredEmpty ? ( + + ) : ( +
+ {filteredAgents.map((agent) => ( +
+ handleEdit(agent.agent_id)} + onApplyListing={() => handleApplyListing(agent)} + onViewReview={(mode) => handleViewReview(agent, mode)} + isApplying={ + applyingAgentId === agent.agent_id && + createListingMutation.isPending + } + /> +
+ ))} +
+ )} + + +
+ ); +} diff --git a/frontend/app/[locale]/agent-repository/components/MineReviewStatusModal.tsx b/frontend/app/[locale]/agent-repository/components/MineReviewStatusModal.tsx new file mode 100644 index 000000000..984350f2a --- /dev/null +++ b/frontend/app/[locale]/agent-repository/components/MineReviewStatusModal.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { Button, Modal } from "antd"; +import { CheckCircle2, Clock, Store, XCircle } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { + formatMineDate, + formatRepositoryVersionLabel, + isCancelableRepositoryStatus, +} from "@/lib/agentRepositoryMine"; +import type { + MyAgentRepositoryInfoItem, + MyEditableAgentItem, +} from "@/types/agentRepository"; + +interface MineReviewStatusModalProps { + open: boolean; + agent: MyEditableAgentItem | null; + repositoryInfo: MyAgentRepositoryInfoItem | null; + mode: "review" | "reviewUpdate"; + isCancelling?: boolean; + onClose: () => void; + onCancelApply: () => void; +} + +export function MineReviewStatusModal({ + open, + agent, + repositoryInfo, + mode, + isCancelling = false, + onClose, + onCancelApply, +}: 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 versionLabel = formatRepositoryVersionLabel(repositoryInfo); + const submittedAt = formatMineDate(repositoryInfo.create_time); + + const statusConfig = isPending + ? { + icon: Clock, + label: t("agentRepository.mine.reviewModal.pendingLabel"), + description: t("agentRepository.mine.reviewModal.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("agentRepository.mine.reviewModal.rejectedLabel"), + description: t("agentRepository.mine.reviewModal.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("agentRepository.mine.reviewModal.sharedLabel"), + description: t("agentRepository.mine.reviewModal.sharedDescription"), + 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("agentRepository.mine.reviewModal.title"); + + return ( + + + {canCancelApply ? ( + + ) : null} + + } + title={ + + + {modalTitle} + + } + centered + destroyOnHidden + > +

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

+ +
+ +
+

{statusConfig.label}

+

+ {statusConfig.description} +

+
+
+ +
+
+ {t("agentRepository.mine.reviewModal.version")} + + {versionLabel} + +
+ {submittedAt ? ( +
+ {t("agentRepository.mine.reviewModal.submittedAt")} + + {submittedAt} + +
+ ) : null} +
+
+ ); +} diff --git a/frontend/app/[locale]/agent-repository/components/MyAgentCard.tsx b/frontend/app/[locale]/agent-repository/components/MyAgentCard.tsx new file mode 100644 index 000000000..f98600806 --- /dev/null +++ b/frontend/app/[locale]/agent-repository/components/MyAgentCard.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { Button, Card, Dropdown } from "antd"; +import type { MenuProps } from "antd"; +import { + Bot, + ClipboardCheck, + Clock, + MoreHorizontal, + Pencil, + Share2, + Store, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { + formatMineDate, + getMineCardMenuActions, + pickLatestSharedVersionName, + type MineCardMenuAction, +} from "@/lib/agentRepositoryMine"; +import type { MyEditableAgentItem } from "@/types/agentRepository"; + +interface MyAgentCardProps { + agent: MyEditableAgentItem; + onEdit: () => void; + onApplyListing: () => void; + onViewReview: (mode: "review" | "reviewUpdate") => void; + isApplying?: boolean; +} + +const MENU_ACTION_I18N: Record = { + apply: "agentRepository.mine.menu.apply", + review: "agentRepository.mine.menu.review", + reviewUpdate: "agentRepository.mine.menu.reviewUpdate", +}; + +export function MyAgentCard({ + agent, + onEdit, + onApplyListing, + onViewReview, + isApplying = 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 published = (agent.current_version_no ?? 0) > 0; + const repositoryInfo = agent.repository_info ?? []; + const hasRepositoryInfo = repositoryInfo.length > 0; + const hasShared = repositoryInfo.some((item) => item.status === "shared"); + const hasPendingReview = repositoryInfo.some( + (item) => item.status === "pending_review" + ); + const hasRejected = repositoryInfo.some((item) => item.status === "rejected"); + const onlineVersion = pickLatestSharedVersionName(repositoryInfo); + const footerDate = formatMineDate(agent.version_create_time); + const versionLabel = agent.version_label; + const menuActions = getMineCardMenuActions(agent); + + const menuItems: MenuProps["items"] = menuActions.map((action) => { + const icon = + action === "apply" ? ( + + ) : ( + + ); + + return { + key: action, + label: t(MENU_ACTION_I18N[action]), + icon, + disabled: action === "apply" && isApplying, + onClick: () => { + if (action === "apply") { + onApplyListing(); + return; + } + onViewReview(action === "reviewUpdate" ? "reviewUpdate" : "review"); + }, + }; + }); + + return ( + +
+
+
+ +
+
+
+

+ {title} +

+ {hasRepositoryInfo ? ( + + + {t("agentRepository.mine.onHub")} + + ) : null} +
+
+ + {published + ? t("agentRepository.mine.lifecycle.published") + : t("agentRepository.mine.lifecycle.draft")} + + {hasShared ? ( + + + {t("agentRepository.mine.listed")} + + ) : null} + {onlineVersion ? ( + + {t("agentRepository.mine.onlineVersion", { version: onlineVersion })} + + ) : null} + {hasPendingReview ? ( + + {t("agentRepository.mine.updateReviewing")} + + ) : null} + {!hasPendingReview && hasRejected ? ( + + {t("agentRepository.detail.status.rejected")} + + ) : null} +
+
+
+ + {menuActions.length > 0 ? ( + +
+ +

+ {description} +

+ +
+
+ {versionLabel != null ? ( + + + {versionLabel} + + ) : null} + {footerDate ? ( + + + {footerDate} + + ) : null} +
+ + +
+
+ ); +} diff --git a/frontend/app/[locale]/agent-repository/page.tsx b/frontend/app/[locale]/agent-repository/page.tsx new file mode 100644 index 000000000..c1d816272 --- /dev/null +++ b/frontend/app/[locale]/agent-repository/page.tsx @@ -0,0 +1,594 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + App, + Button, + Card, + ConfigProvider, + Empty, + Input, + Modal, + Segmented, + Spin, +} from "antd"; +import { useTranslation } from "react-i18next"; +import { motion } from "framer-motion"; +import { Bot, Check, Clock, Inbox, Search, ShieldCheck, User, X } from "lucide-react"; +import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; +import { USER_ROLES } from "@/const/auth"; +import { useSetupFlow } from "@/hooks/useSetupFlow"; +import { + useAgentRepositoryCategories, + useAgentRepositoryListingDetail, + useAgentRepositoryListings, + useMyEditableAgents, + useUpdateAgentRepositoryStatus, +} from "@/hooks/agentRepository/useAgentRepositoryListings"; +import type { AgentRepositoryCategoryItem, AgentRepositoryListingItem, MineOwnershipFilter } from "@/types/agentRepository"; +import { AgentRepositoryCard } from "./components/AgentRepositoryCard"; +import { AgentRepositoryDetailModal } from "./components/AgentRepositoryDetailModal"; +import { MineAgentsView } from "./components/MineAgentsView"; + +enum AgentRepositoryTab { + REPOSITORY = "repository", + MINE = "mine", + REVIEW = "review", +} + +const agentRepositoryTheme = { + token: { colorPrimary: "#2563eb", colorInfo: "#3b82f6" }, +}; + +export default function AgentRepositoryPage() { + const { t } = useTranslation("common"); + const { pageVariants, pageTransition } = useSetupFlow(); + const { user } = useAuthorizationContext(); + const isAdmin = user?.role === USER_ROLES.ADMIN; + + const [tab, setTab] = useState(AgentRepositoryTab.REPOSITORY); + const [searchQuery, setSearchQuery] = useState(""); + const [selectedCategoryId, setSelectedCategoryId] = useState(null); + const [mineOwnership, setMineOwnership] = useState("all"); + const [detailOpen, setDetailOpen] = useState(false); + const [selectedRepositoryId, setSelectedRepositoryId] = useState(null); + + const isRepositoryTab = tab === AgentRepositoryTab.REPOSITORY; + const isReviewTab = tab === AgentRepositoryTab.REVIEW; + const isMineTab = tab === AgentRepositoryTab.MINE; + + const { data: categories = [] } = useAgentRepositoryCategories( + isRepositoryTab || isReviewTab + ); + + const categoryNameById = useMemo( + () => new Map(categories.map((item) => [item.id, item.name])), + [categories] + ); + + const listingParams = { + status: "shared" as const, + ...(selectedCategoryId == null ? {} : { category_id: selectedCategoryId }), + }; + + const { data, isLoading, isError, refetch, isFetching } = + useAgentRepositoryListings(listingParams, isRepositoryTab); + + const { + data: mineData, + isLoading: isMineLoading, + isError: isMineError, + isFetching: isMineFetching, + refetch: refetchMine, + } = useMyEditableAgents(mineOwnership, isMineTab); + + const { + data: reviewData, + isLoading: isReviewLoading, + isError: isReviewError, + isFetching: isReviewFetching, + refetch: refetchReview, + } = useAgentRepositoryListings( + { status: "pending_review", deduplicate_by_agent_id: false }, + isAdmin && isReviewTab + ); + + const updateStatusMutation = useUpdateAgentRepositoryStatus(); + + const { + data: detail, + isLoading: isDetailLoading, + isError: isDetailError, + isFetching: isDetailFetching, + refetch: refetchDetail, + } = useAgentRepositoryListingDetail(selectedRepositoryId, detailOpen); + + const handleDetailClick = (listing: AgentRepositoryListingItem) => { + setSelectedRepositoryId(listing.agent_repository_id); + setDetailOpen(true); + }; + + const handleDetailClose = () => { + setDetailOpen(false); + setSelectedRepositoryId(null); + }; + + const listings = data?.items ?? []; + const reviewListings = reviewData?.items ?? []; + const mineAgents = mineData?.items ?? []; + const mineCounts = mineData?.counts ?? { all: 0, created: 0, others: 0 }; + const pendingReviewCount = reviewListings.length; + + const normalizedQuery = searchQuery.trim().toLowerCase(); + const filteredListings = normalizedQuery + ? listings.filter((item) => { + const title = (item.display_name || item.name || "").toLowerCase(); + const author = (item.author || "").toLowerCase(); + const description = (item.description || "").toLowerCase(); + const tags = (item.tags || []) + .map((tag) => tag.toLowerCase()) + .join(" "); + return ( + title.includes(normalizedQuery) || + author.includes(normalizedQuery) || + description.includes(normalizedQuery) || + tags.includes(normalizedQuery) + ); + }) + : listings; + + const tabOptions = [ + { + value: AgentRepositoryTab.REPOSITORY, + label: ( + + + {t("agentRepository.page.tab.repository")} + + ), + }, + { + value: AgentRepositoryTab.MINE, + label: ( + + + {t("agentRepository.page.tab.mine")} + + ), + }, + ...(isAdmin + ? [ + { + value: AgentRepositoryTab.REVIEW, + label: ( + + + {t("agentRepository.page.tab.review")} + {pendingReviewCount > 0 ? ( + + {pendingReviewCount} + + ) : null} + + ), + }, + ] + : []), + ]; + + return ( + +
+
+ +
+
+
+
+ +
+
+

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

+

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

+
+
+
+ +
+ setTab(value as AgentRepositoryTab)} + options={tabOptions} + className="h-9 w-full max-w-md rounded-md border border-slate-200 bg-slate-100 p-[2px] text-sm shadow-sm sm:w-auto" + /> + {isRepositoryTab ? ( + + {t("agentRepository.page.resultCount", { + count: filteredListings.length, + })} + + ) : isMineTab ? ( + + {t("agentRepository.mine.resultCount", { + count: mineCounts[mineOwnership], + })} + + ) : null} +
+ + {isRepositoryTab ? ( + refetch()} + listings={filteredListings} + onDetailClick={handleDetailClick} + /> + ) : isReviewTab ? ( + refetchReview()} + onDetailClick={handleDetailClick} + updatingRepositoryId={ + updateStatusMutation.isPending + ? updateStatusMutation.variables?.agentRepositoryId ?? null + : null + } + onApprove={(listing) => + updateStatusMutation.mutateAsync({ + agentRepositoryId: listing.agent_repository_id, + status: "shared", + }) + } + onReject={(listing) => + updateStatusMutation.mutateAsync({ + agentRepositoryId: listing.agent_repository_id, + status: "rejected", + }) + } + /> + ) : isMineTab ? ( + refetchMine()} + /> + ) : null} +
+
+
+
+ refetchDetail()} + /> +
+ ); +} + +function RepositoryView({ + searchQuery, + onSearchChange, + categories, + selectedCategoryId, + onCategoryChange, + isLoading, + isError, + isFetching, + onRetry, + listings, + onDetailClick, +}: { + searchQuery: string; + onSearchChange: (value: string) => void; + categories: AgentRepositoryCategoryItem[]; + selectedCategoryId: number | null; + onCategoryChange: (categoryId: number | null) => void; + isLoading: boolean; + isError: boolean; + isFetching: boolean; + onRetry: () => void; + listings: AgentRepositoryListingItem[]; + onDetailClick: (listing: AgentRepositoryListingItem) => void; +}) { + const { t } = useTranslation("common"); + + return ( +
+
+ + onSearchChange(e.target.value)} + placeholder={t("agentRepository.page.searchPlaceholder")} + className="h-11 rounded-xl pl-10" + allowClear + /> +
+ +
+ + {categories.map((category) => ( + + ))} +
+ +

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

+ + {isLoading ? ( +
+ +
+ ) : isError ? ( +
+

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

+ +
+ ) : listings.length === 0 ? ( + + ) : ( +
+ {listings.map((listing) => ( + + ))} +
+ )} +
+ ); +} + +function ReviewCenterView({ + listings, + categoryNameById, + isLoading, + isError, + isFetching, + onRetry, + onDetailClick, + updatingRepositoryId, + onApprove, + onReject, +}: { + listings: AgentRepositoryListingItem[]; + categoryNameById: Map; + isLoading: boolean; + isError: boolean; + isFetching: boolean; + onRetry: () => void; + onDetailClick: (listing: AgentRepositoryListingItem) => void; + updatingRepositoryId: number | null; + onApprove: (listing: AgentRepositoryListingItem) => Promise; + onReject: (listing: AgentRepositoryListingItem) => Promise; +}) { + const { t } = useTranslation("common"); + const { message } = App.useApp(); + + const getListingTitle = (listing: AgentRepositoryListingItem) => + listing.display_name?.trim() || + listing.name?.trim() || + t("agentRepository.card.untitled"); + + const confirmReviewAction = ( + listing: AgentRepositoryListingItem, + action: "approve" | "reject" + ) => { + const title = getListingTitle(listing); + const isApprove = action === "approve"; + + Modal.confirm({ + title: isApprove + ? t("agentRepository.review.confirmApproveTitle") + : t("agentRepository.review.confirmRejectTitle"), + content: isApprove + ? t("agentRepository.review.confirmApproveContent", { name: title }) + : t("agentRepository.review.confirmRejectContent", { name: title }), + okText: isApprove + ? t("agentRepository.review.approve") + : t("agentRepository.review.reject"), + cancelText: t("common.cancel"), + okButtonProps: isApprove + ? undefined + : { danger: true }, + onOk: async () => { + try { + await (isApprove ? onApprove(listing) : onReject(listing)); + message.success( + isApprove + ? t("agentRepository.review.approveSuccess", { name: title }) + : t("agentRepository.review.rejectSuccess", { name: title }) + ); + } catch { + message.error( + isApprove + ? t("agentRepository.review.approveError") + : t("agentRepository.review.rejectError") + ); + throw new Error("Review action failed"); + } + }, + }); + }; + + return ( +
+ +
+ +

+ {t("agentRepository.review.title")} +

+ + {t("agentRepository.review.pendingCount", { count: listings.length })} + +
+

+ {t("agentRepository.review.description")} +

+
+ + {isLoading ? ( +
+ +
+ ) : isError ? ( +
+

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

+ +
+ ) : listings.length === 0 ? ( + + ) : ( +
+ {listings.map((listing) => { + const title = getListingTitle(listing); + const isUpdating = + updatingRepositoryId === listing.agent_repository_id; + const submitter = + listing.submitted_by?.trim() || + t("agentRepository.review.unknownSubmitter"); + const categoryName = + listing.category_id != null + ? categoryNameById.get(listing.category_id) ?? + t("agentRepository.review.unknownCategory") + : t("agentRepository.review.unknownCategory"); + + return ( + +
+
+
+ {listing.icon?.trim() ? ( + {listing.icon.trim()} + ) : ( + + )} +
+
+
+

+ {title} +

+ + + {t("agentRepository.detail.status.pending_review")} + +
+

+ {listing.description?.trim() || + t("agentRepository.card.noDescription")} +

+

+ {t("agentRepository.review.submitter", { name: submitter })} + {" · "} + {categoryName} +

+
+
+
+ + + +
+
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/frontend/app/[locale]/agent-space/page.tsx b/frontend/app/[locale]/agent-space/page.tsx index ebb925e0a..5ed29870e 100644 --- a/frontend/app/[locale]/agent-space/page.tsx +++ b/frontend/app/[locale]/agent-space/page.tsx @@ -1,216 +1,17 @@ "use client"; -import React, { useState } from "react"; +import { useEffect } from "react"; import { useRouter } from "next/navigation"; -import { useTranslation } from "react-i18next"; -import { motion } from "framer-motion"; -import { App } from "antd"; -import { Plus, RefreshCw, Upload } from "lucide-react"; - -import { useSetupFlow } from "@/hooks/useSetupFlow"; -import { usePublishedAgentList } from "@/hooks/agent/usePublishedAgentList"; -import { Agent } from "@/types/agentConfig"; -import AgentCard from "./components/AgentCard"; -import AgentImportWizard from "@/components/agent/AgentImportWizard"; -import { - openImportWizardWithFile, - ImportAgentData, -} from "@/lib/agentImportUtils"; -import log from "@/lib/logger"; /** - * Agent Space page component - * Displays agent cards grid and management controls + * Legacy Agent Space route — redirects to Agent Repository. */ -export default function SpacePage() { +export default function AgentSpaceRedirectPage() { const router = useRouter(); - const { t } = useTranslation("common"); - const { message } = App.useApp(); - const { pageVariants, pageTransition } = useSetupFlow(); - const [isImporting, setIsImporting] = useState(false); - const { agents, isLoading, invalidate } = usePublishedAgentList(); - - // Import wizard state - const [importWizardVisible, setImportWizardVisible] = useState(false); - const [importWizardData, setImportWizardData] = useState(null); - - const handleCreateAgent = () => { - router.push("/agents?create=true"); - }; - - const onRefresh = () => { - invalidate(); - }; - - const onImportAgent = () => { - openImportWizardWithFile({ - onSuccess: (agentData) => { - setImportWizardData(agentData); - setImportWizardVisible(true); - setIsImporting(false); - }, - onParseError: (msg) => { - message.error(t(msg)); - setIsImporting(false); - }, - onFileNotFound: (msg) => { - message.error(msg); - setIsImporting(false); - }, - onValidationError: (msg) => { - message.error(t(msg)); - setIsImporting(false); - }, - onGenericError: (error) => { - log.error("Failed to read import file:", error); - message.error(t("businessLogic.config.error.agentImportFailed")); - setIsImporting(false); - }, - }); - setIsImporting(true); - }; - - - return ( -
- -
- {/* Page header */} -
- -

- {t("space.title", "Agent Space")} -

-

- {t( - "space.description", - "Manage and interact with your intelligent agents" - )} -

-
- - {/* Refresh button */} - - - -
- - {/* Agent cards grid */} - - {/* Create/Import agent card - only for admin */} - -
- {/* Create new agent - top half */} - - - {/* Import agent - bottom half */} - -
-
- - {/* Agent cards */} - {agents.map((agent: Agent, index: number) => ( - - - - ))} -
- - {/* Empty state */} - {!isLoading && agents.length === 0 && ( - -

- {t( - "space.noAgents", - "No agents yet. Create your first agent to get started!" - )} -

-
- )} -
-
+ useEffect(() => { + router.replace("/agent-repository"); + }, [router]); - {/* Import Wizard Modal */} - { - setImportWizardVisible(false); - setImportWizardData(null); - }} - initialData={importWizardData} - onImportComplete={() => { - setImportWizardVisible(false); - setImportWizardData(null); - invalidate(); // Refresh the agent list - }} - /> -
- ); + return null; } diff --git a/frontend/components/navigation/SideNavigation.tsx b/frontend/components/navigation/SideNavigation.tsx index a2ce2f42f..6dd8084f2 100644 --- a/frontend/components/navigation/SideNavigation.tsx +++ b/frontend/components/navigation/SideNavigation.tsx @@ -15,6 +15,7 @@ import { Puzzle, Building2, Zap, + Inbox, } from "lucide-react"; import type { MenuProps } from "antd"; import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; @@ -54,22 +55,100 @@ interface ProcessedRoute extends RouteConfig { * All available routes with their metadata */ const ROUTE_CONFIG: RouteConfig[] = [ - { path: "/", Icon: Home, labelKey: "sidebar.homePage", order: 0, parentKey: null }, - { path: "/chat", Icon: Bot, labelKey: "sidebar.startChat", order: 1, parentKey: null }, + { + path: "/", + Icon: Home, + labelKey: "sidebar.homePage", + order: 0, + parentKey: null, + }, + { + path: "/chat", + Icon: Bot, + labelKey: "sidebar.startChat", + order: 1, + parentKey: null, + }, // Agent Development submenu - { path: "/agent-dev", Icon: Code, labelKey: "sidebar.agentDev", order: 2, parentKey: null }, - { path: "/models", Icon: Settings, labelKey: "sidebar.modelConfig", order: 3, parentKey: "/agent-dev" }, - { path: "/knowledges", Icon: BookOpen, labelKey: "sidebar.knowledgeBaseConfig", order: 4, parentKey: "/agent-dev" }, - { path: "/agents", Icon: Bot, labelKey: "sidebar.agentConfig", order: 5, parentKey: "/agent-dev" }, - { path: "/memory", Icon: Database, labelKey: "sidebar.memoryConfig", order: 6, parentKey: "/agent-dev" }, + { + path: "/agent-dev", + Icon: Code, + labelKey: "sidebar.agentDev", + order: 2, + parentKey: null, + }, + { + path: "/models", + Icon: Settings, + labelKey: "sidebar.modelConfig", + order: 3, + parentKey: "/agent-dev", + }, + { + path: "/knowledges", + Icon: BookOpen, + labelKey: "sidebar.knowledgeBaseConfig", + order: 4, + parentKey: "/agent-dev", + }, + { + path: "/agents", + Icon: Bot, + labelKey: "sidebar.agentConfig", + order: 5, + parentKey: "/agent-dev", + }, + { + path: "/memory", + Icon: Database, + labelKey: "sidebar.memoryConfig", + order: 6, + parentKey: "/agent-dev", + }, // Resource Space submenu - { path: "/resource-space", Icon: Globe, labelKey: "sidebar.resourceSpace", order: 7, parentKey: null }, - { path: "/agent-space", Icon: Bot, labelKey: "sidebar.agentSpace", order: 8, parentKey: "/resource-space" }, - { path: "/mcp-space", Icon: Puzzle, labelKey: "sidebar.mcpSpace", order: 9, parentKey: "/resource-space" }, - { path: "/skill-space", Icon: Zap, labelKey: "sidebar.skillSpace", order: 10, parentKey: "/resource-space" }, + { + path: "/resource-space", + Icon: Globe, + labelKey: "sidebar.resourceSpace", + order: 7, + parentKey: null, + }, + { + path: "/agent-repository", + Icon: Bot, + labelKey: "sidebar.agentSpace", + order: 8, + parentKey: "/resource-space", + }, + { + path: "/mcp-space", + Icon: Puzzle, + labelKey: "sidebar.mcpSpace", + order: 9, + parentKey: "/resource-space", + }, + { + path: "/skill-space", + Icon: Zap, + labelKey: "sidebar.skillSpace", + order: 10, + parentKey: "/resource-space", + }, // Management menus - { path: "/resource-manage", Icon: Building2, labelKey: "sidebar.resourceManage", order: 11, parentKey: null }, - { path: "/owner-manage", Icon: Building2, labelKey: "sidebar.ownerManage", order: 12, parentKey: null }, + { + path: "/resource-manage", + Icon: Building2, + labelKey: "sidebar.resourceManage", + order: 11, + parentKey: null, + }, + { + path: "/owner-manage", + Icon: Building2, + labelKey: "sidebar.ownerManage", + order: 12, + parentKey: null, + }, ]; /** diff --git a/frontend/hooks/agentRepository/useAgentRepositoryListings.ts b/frontend/hooks/agentRepository/useAgentRepositoryListings.ts new file mode 100644 index 000000000..7763ec41a --- /dev/null +++ b/frontend/hooks/agentRepository/useAgentRepositoryListings.ts @@ -0,0 +1,101 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import agentRepositoryService from "@/services/agentRepositoryService"; +import type { + AgentRepositoryListingListParams, + AgentRepositoryListingStatus, + MineOwnershipFilter, +} from "@/types/agentRepository"; + +const QUERY_KEY = "agentRepositoryListings"; +const CATEGORIES_QUERY_KEY = "agentRepositoryCategories"; +const DETAIL_QUERY_KEY = "agentRepositoryListingDetail"; +const MY_EDITABLE_AGENTS_QUERY_KEY = "myEditableAgents"; + +export function useAgentRepositoryListings( + params?: AgentRepositoryListingListParams, + enabled = true +) { + return useQuery({ + queryKey: [QUERY_KEY, params], + queryFn: () => agentRepositoryService.fetchAgentRepositoryListings(params), + staleTime: 60_000, + enabled, + }); +} + +export function useAgentRepositoryCategories(enabled = true) { + return useQuery({ + queryKey: [CATEGORIES_QUERY_KEY], + queryFn: () => agentRepositoryService.fetchAgentRepositoryCategories(), + staleTime: 300_000, + enabled, + }); +} + +export function useMyEditableAgents( + ownership: MineOwnershipFilter = "all", + enabled = true +) { + return useQuery({ + queryKey: [MY_EDITABLE_AGENTS_QUERY_KEY, ownership], + queryFn: () => agentRepositoryService.fetchMyEditableAgents({ ownership }), + staleTime: 60_000, + enabled, + }); +} + +export function useAgentRepositoryListingDetail( + agentRepositoryId: number | null, + enabled = true +) { + return useQuery({ + queryKey: [DETAIL_QUERY_KEY, agentRepositoryId], + queryFn: () => + agentRepositoryService.fetchAgentRepositoryListingDetail( + agentRepositoryId as number + ), + staleTime: 60_000, + enabled: enabled && agentRepositoryId != null, + }); +} + +export function useUpdateAgentRepositoryStatus() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + agentRepositoryId, + status, + }: { + agentRepositoryId: number; + status: AgentRepositoryListingStatus; + }) => + agentRepositoryService.updateAgentRepositoryStatus( + agentRepositoryId, + status + ), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEY] }); + queryClient.invalidateQueries({ queryKey: [MY_EDITABLE_AGENTS_QUERY_KEY] }); + }, + }); +} + +export function useCreateAgentRepositoryListing() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + agentId, + versionNo, + }: { + agentId: number; + versionNo: number; + }) => + agentRepositoryService.createAgentRepositoryListing(agentId, versionNo), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEY] }); + queryClient.invalidateQueries({ queryKey: [MY_EDITABLE_AGENTS_QUERY_KEY] }); + }, + }); +} diff --git a/frontend/lib/agentRepositoryMine.test.ts b/frontend/lib/agentRepositoryMine.test.ts new file mode 100644 index 000000000..41a34f145 --- /dev/null +++ b/frontend/lib/agentRepositoryMine.test.ts @@ -0,0 +1,281 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + getMineCardMenuActions, + isCancelableRepositoryStatus, + isCurrentVersionListed, + pickReviewDisplayRepositoryInfo, +} from "./agentRepositoryMine"; +import type { + MyAgentRepositoryInfoItem, + MyEditableAgentItem, +} from "../types/agentRepository"; + +function makeAgent( + overrides: Partial = {} +): MyEditableAgentItem { + return { + agent_id: 1, + repository_info: [], + ...overrides, + }; +} + +function makeRepoInfo( + overrides: Partial +): MyAgentRepositoryInfoItem { + return { + agent_repository_id: 1, + status: "pending_review", + version_no: 1, + version_label: "v1", + create_time: "2026-06-01T00:00:00.000Z", + ...overrides, + }; +} + +describe("agentRepositoryMine menu helpers", () => { + it("returns apply only for published agent without matching repository version", () => { + const agent = makeAgent({ + current_version_no: 2, + repository_info: [], + }); + + assert.deepEqual(getMineCardMenuActions(agent), ["apply"]); + assert.equal(isCurrentVersionListed(agent), false); + }); + + it("returns review only when repository has pending_review without shared", () => { + const agent = makeAgent({ + current_version_no: 1, + repository_info: [ + makeRepoInfo({ + agent_repository_id: 10, + status: "pending_review", + version_no: 1, + }), + ], + }); + + assert.deepEqual(getMineCardMenuActions(agent), ["review"]); + }); + + it("returns reviewUpdate when both pending_review and shared exist", () => { + const agent = makeAgent({ + current_version_no: 3, + repository_info: [ + makeRepoInfo({ + agent_repository_id: 11, + status: "shared", + version_no: 2, + create_time: "2026-05-01T00:00:00.000Z", + }), + makeRepoInfo({ + agent_repository_id: 12, + status: "pending_review", + version_no: 3, + create_time: "2026-06-20T00:00:00.000Z", + }), + ], + }); + + assert.deepEqual(getMineCardMenuActions(agent), ["reviewUpdate"]); + }); + + it("returns apply and reviewUpdate when current version is not listed yet", () => { + const agent = makeAgent({ + current_version_no: 3, + repository_info: [ + makeRepoInfo({ + agent_repository_id: 11, + status: "shared", + version_no: 2, + }), + makeRepoInfo({ + agent_repository_id: 12, + status: "pending_review", + version_no: 4, + }), + ], + }); + + assert.deepEqual(getMineCardMenuActions(agent), ["apply", "reviewUpdate"]); + }); + + it("pickReviewDisplayRepositoryInfo prefers latest pending_review", () => { + const items = [ + makeRepoInfo({ + agent_repository_id: 20, + status: "shared", + version_no: 1, + create_time: "2026-06-10T00:00:00.000Z", + }), + makeRepoInfo({ + agent_repository_id: 21, + status: "pending_review", + version_no: 2, + create_time: "2026-06-18T00:00:00.000Z", + }), + makeRepoInfo({ + agent_repository_id: 22, + status: "pending_review", + version_no: 3, + create_time: "2026-06-20T00:00:00.000Z", + }), + ]; + + const picked = pickReviewDisplayRepositoryInfo(items); + assert.equal(picked?.agent_repository_id, 22); + }); + + it("pickReviewDisplayRepositoryInfo falls back to latest shared", () => { + const items = [ + makeRepoInfo({ + agent_repository_id: 30, + status: "shared", + version_no: 1, + create_time: "2026-05-01T00:00:00.000Z", + }), + makeRepoInfo({ + agent_repository_id: 31, + status: "shared", + version_no: 2, + create_time: "2026-06-01T00:00:00.000Z", + }), + ]; + + const picked = pickReviewDisplayRepositoryInfo(items); + assert.equal(picked?.agent_repository_id, 31); + }); + + it("returns review when only rejected exists", () => { + const agent = makeAgent({ + current_version_no: 1, + repository_info: [ + makeRepoInfo({ + agent_repository_id: 40, + status: "rejected", + version_no: 1, + }), + ], + }); + + assert.deepEqual(getMineCardMenuActions(agent), ["review"]); + }); + + it("pickReviewDisplayRepositoryInfo falls back to latest rejected", () => { + const items = [ + makeRepoInfo({ + agent_repository_id: 50, + status: "rejected", + version_no: 1, + create_time: "2026-05-01T00:00:00.000Z", + }), + makeRepoInfo({ + agent_repository_id: 51, + status: "rejected", + version_no: 2, + create_time: "2026-06-01T00:00:00.000Z", + }), + ]; + + const picked = pickReviewDisplayRepositoryInfo(items); + assert.equal(picked?.agent_repository_id, 51); + }); + + it("returns reviewUpdate and prefers pending when pending shared and rejected coexist", () => { + const agent = makeAgent({ + current_version_no: 3, + repository_info: [ + makeRepoInfo({ + agent_repository_id: 60, + status: "shared", + version_no: 2, + create_time: "2026-05-01T00:00:00.000Z", + }), + makeRepoInfo({ + agent_repository_id: 61, + status: "rejected", + version_no: 1, + create_time: "2026-04-01T00:00:00.000Z", + }), + makeRepoInfo({ + agent_repository_id: 62, + status: "pending_review", + version_no: 3, + create_time: "2026-06-20T00:00:00.000Z", + }), + ], + }); + + assert.deepEqual(getMineCardMenuActions(agent), ["reviewUpdate"]); + const picked = pickReviewDisplayRepositoryInfo(agent.repository_info); + assert.equal(picked?.agent_repository_id, 62); + }); + + it("returns reviewUpdate and prefers rejected over shared when no pending", () => { + const agent = makeAgent({ + current_version_no: 2, + repository_info: [ + makeRepoInfo({ + agent_repository_id: 70, + status: "rejected", + version_no: 2, + version_label: "V2", + create_time: "2026-06-23T11:27:47.698555Z", + }), + makeRepoInfo({ + agent_repository_id: 71, + status: "shared", + version_no: 1, + version_label: "V1", + create_time: "2026-06-23T11:18:47.034823Z", + }), + ], + }); + + assert.deepEqual(getMineCardMenuActions(agent), ["reviewUpdate"]); + const picked = pickReviewDisplayRepositoryInfo(agent.repository_info); + assert.equal(picked?.agent_repository_id, 70); + }); + + it("matches user scenario with rejected V2 and shared V1", () => { + const agent = makeAgent({ + agent_id: 35, + current_version_no: 2, + repository_info: [ + makeRepoInfo({ + agent_repository_id: 7, + status: "rejected", + version_no: 2, + version_label: "V2", + create_time: "2026-06-23T11:27:47.698555Z", + }), + makeRepoInfo({ + agent_repository_id: 6, + status: "shared", + version_no: 1, + version_label: "V1", + create_time: "2026-06-23T11:18:47.034823Z", + }), + ], + }); + + assert.deepEqual(getMineCardMenuActions(agent), ["reviewUpdate"]); + const picked = pickReviewDisplayRepositoryInfo(agent.repository_info); + assert.equal(picked?.agent_repository_id, 7); + assert.equal(picked?.status, "rejected"); + }); + + it("returns no actions for draft agent with empty repository info", () => { + const agent = makeAgent({ current_version_no: 0, repository_info: [] }); + assert.deepEqual(getMineCardMenuActions(agent), []); + }); + + it("isCancelableRepositoryStatus allows pending_review and rejected only", () => { + assert.equal(isCancelableRepositoryStatus("pending_review"), true); + assert.equal(isCancelableRepositoryStatus("rejected"), true); + assert.equal(isCancelableRepositoryStatus("shared"), false); + }); +}); diff --git a/frontend/lib/agentRepositoryMine.ts b/frontend/lib/agentRepositoryMine.ts new file mode 100644 index 000000000..dab1f5ab7 --- /dev/null +++ b/frontend/lib/agentRepositoryMine.ts @@ -0,0 +1,125 @@ +import type { + MyAgentRepositoryInfoItem, + MyEditableAgentItem, +} from "@/types/agentRepository"; + +export type MineCardMenuAction = "apply" | "review" | "reviewUpdate"; + +function parseCreateTime(value?: string | null): number { + if (!value) { + return 0; + } + const timestamp = Date.parse(value); + return Number.isNaN(timestamp) ? 0 : timestamp; +} + +export function pickLatestRepositoryInfo( + items: MyAgentRepositoryInfoItem[] +): MyAgentRepositoryInfoItem | null { + if (!items.length) { + return null; + } + return [...items].sort( + (a, b) => parseCreateTime(b.create_time) - parseCreateTime(a.create_time) + )[0]; +} + +export function pickLatestSharedVersionName( + items: MyAgentRepositoryInfoItem[] +): string | null { + const sharedItems = items.filter((item) => item.status === "shared"); + const latest = pickLatestRepositoryInfo(sharedItems); + const versionName = latest?.version_label?.trim(); + return versionName || null; +} + +export function formatMineDate(iso?: string | null): string | null { + if (!iso) { + return null; + } + const timestamp = Date.parse(iso); + if (Number.isNaN(timestamp)) { + return null; + } + return new Date(timestamp).toISOString().slice(0, 10); +} + +export function isCurrentVersionListed(agent: MyEditableAgentItem): boolean { + const currentVersionNo = agent.current_version_no ?? 0; + if (currentVersionNo <= 0) { + return false; + } + return (agent.repository_info ?? []).some( + (item) => item.version_no === currentVersionNo + ); +} + +export function pickReviewDisplayRepositoryInfo( + items: MyAgentRepositoryInfoItem[] +): MyAgentRepositoryInfoItem | null { + const pendingItems = items.filter((item) => item.status === "pending_review"); + const pending = pickLatestRepositoryInfo(pendingItems); + if (pending) { + return pending; + } + const rejectedItems = items.filter((item) => item.status === "rejected"); + const rejected = pickLatestRepositoryInfo(rejectedItems); + if (rejected) { + return rejected; + } + const sharedItems = items.filter((item) => item.status === "shared"); + return pickLatestRepositoryInfo(sharedItems); +} + +export function pickPendingReviewRepositoryInfo( + items: MyAgentRepositoryInfoItem[] +): MyAgentRepositoryInfoItem | null { + const pendingItems = items.filter((item) => item.status === "pending_review"); + return pickLatestRepositoryInfo(pendingItems); +} + +export function isCancelableRepositoryStatus( + status: MyAgentRepositoryInfoItem["status"] +): boolean { + return status === "pending_review" || status === "rejected"; +} + +export function getMineCardMenuActions( + agent: MyEditableAgentItem +): MineCardMenuAction[] { + const repositoryInfo = agent.repository_info ?? []; + const actions: MineCardMenuAction[] = []; + const currentVersionNo = agent.current_version_no ?? 0; + + if (currentVersionNo > 0 && !isCurrentVersionListed(agent)) { + actions.push("apply"); + } + + if (repositoryInfo.length > 0) { + const hasPending = repositoryInfo.some( + (item) => item.status === "pending_review" + ); + const hasShared = repositoryInfo.some((item) => item.status === "shared"); + const hasRejected = repositoryInfo.some((item) => item.status === "rejected"); + if ((hasPending || hasRejected) && hasShared) { + actions.push("reviewUpdate"); + } else { + actions.push("review"); + } + } + + return actions; +} + +export function formatRepositoryVersionLabel( + item: MyAgentRepositoryInfoItem +): string { + const label = item.version_label?.trim(); + if (label) { + return label; + } + if (item.version_no != null) { + return `v${item.version_no}`; + } + return ""; +} diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 9487c5f33..b931180e1 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -1627,6 +1627,7 @@ "sidebar.mcpSpace": "MCP Space", "sidebar.skillSpace": "Skill Space", "sidebar.agentMarket": "Agent Market", + "sidebar.agentRepository": "Agent Repository", "sidebar.agentDev": "Agent Development", "sidebar.knowledgeBase": "Knowledge Base", "sidebar.modelManagement": "Model Management", @@ -1643,6 +1644,91 @@ "sidebar.modelConfig": "Model Configuration", "sidebar.memoryConfig": "Memory Configuration", + "agentRepository.page.title": "Agent Repository", + "agentRepository.page.subtitle": "Browse the tenant-shared repository, manage agents you can access, and publish or review listings.", + "agentRepository.page.tab.repository": "Repository", + "agentRepository.page.tab.mine": "Mine", + "agentRepository.page.tab.review": "Review Center", + "agentRepository.page.searchPlaceholder": "Search by name, description, or author", + "agentRepository.page.categoryAll": "All", + "agentRepository.page.repositoryHint": "Agents in the shared repository must be copied to your workspace before you can edit them.", + "agentRepository.page.resultCount": "{{count}} agents", + "agentRepository.page.empty": "No matching agents found", + "agentRepository.page.loadError": "Failed to load agent repository. Please try again later.", + "agentRepository.page.retry": "Retry", + "agentRepository.page.mineComingSoon": "The Mine tab is coming soon", + "agentRepository.mine.searchPlaceholder": "Search by agent name or description", + "agentRepository.mine.filter.all": "All", + "agentRepository.mine.filter.created": "Created by me", + "agentRepository.mine.filter.others": "Others", + "agentRepository.mine.empty": "No editable agents yet", + "agentRepository.mine.emptyFiltered": "No agents match the current filter", + "agentRepository.mine.loadError": "Failed to load your agents. Please try again later.", + "agentRepository.mine.lifecycle.published": "Published", + "agentRepository.mine.lifecycle.draft": "Draft", + "agentRepository.mine.onHub": "Hub", + "agentRepository.mine.listed": "Listed", + "agentRepository.mine.onlineVersion": "Live version {{version}}", + "agentRepository.mine.updateReviewing": "Update under review", + "agentRepository.mine.edit": "Edit", + "agentRepository.mine.menu.more": "More actions", + "agentRepository.mine.menu.apply": "Apply to list", + "agentRepository.mine.menu.review": "View review status", + "agentRepository.mine.menu.reviewUpdate": "View update review status", + "agentRepository.mine.reviewModal.title": "Listing review status", + "agentRepository.mine.reviewModal.reviewUpdateTitle": "Update review status", + "agentRepository.mine.reviewModal.agentName": "Listing review progress for \"{{name}}\"", + "agentRepository.mine.reviewModal.pendingLabel": "Under review", + "agentRepository.mine.reviewModal.pendingDescription": "Your listing request has been submitted and is waiting for admin review.", + "agentRepository.mine.reviewModal.sharedLabel": "Approved", + "agentRepository.mine.reviewModal.sharedDescription": "This agent is listed in the repository and available for teammates to copy.", + "agentRepository.mine.reviewModal.rejectedLabel": "Rejected", + "agentRepository.mine.reviewModal.rejectedDescription": "This listing request was not approved. You can revise and apply again.", + "agentRepository.mine.reviewModal.version": "Review version", + "agentRepository.mine.reviewModal.submittedAt": "Submitted at", + "agentRepository.mine.reviewModal.cancelApply": "Cancel listing request", + "agentRepository.mine.applySuccess": "Listing request for \"{{name}}\" submitted. Waiting for admin review.", + "agentRepository.mine.applyError": "Failed to submit listing request. Please try again later.", + "agentRepository.mine.cancelApplySuccess": "Listing request cancelled", + "agentRepository.mine.cancelApplyError": "Failed to cancel listing request. Please try again later.", + "agentRepository.mine.resultCount": "{{count}} agents", + "agentRepository.card.untitled": "Untitled agent", + "agentRepository.card.noDescription": "No description", + "agentRepository.card.copy": "Copy", + "agentRepository.card.detail": "Details", + "agentRepository.card.toolCount": "{{count}} tools", + + "agentRepository.detail.intro": "Introduction", + "agentRepository.detail.tools": "Built-in Tools", + "agentRepository.detail.role": "Agent Role", + "agentRepository.detail.downloads": "{{count}} installs", + "agentRepository.detail.loadError": "Failed to load agent details. Please try again later.", + "agentRepository.detail.retry": "Retry", + "agentRepository.detail.status.shared": "Shared", + "agentRepository.detail.status.pending_review": "Pending Review", + "agentRepository.detail.status.rejected": "Rejected", + "agentRepository.detail.status.not_shared": "Not Shared", + + "agentRepository.review.title": "Pending Review Queue", + "agentRepository.review.pendingCount": "{{count}} pending", + "agentRepository.review.description": "Review agents submitted by users and decide whether to publish them to the shared repository.", + "agentRepository.review.empty": "The review queue is empty. No agents are waiting for review.", + "agentRepository.review.loadError": "Failed to load the review queue. Please try again later.", + "agentRepository.review.submitter": "Submitted by: {{name}}", + "agentRepository.review.unknownSubmitter": "Unknown submitter", + "agentRepository.review.unknownCategory": "Uncategorized", + "agentRepository.review.viewDetail": "View Details", + "agentRepository.review.approve": "Approve", + "agentRepository.review.reject": "Reject", + "agentRepository.review.confirmApproveTitle": "Confirm Approval", + "agentRepository.review.confirmApproveContent": "Approve \"{{name}}\" and publish it to the shared repository?", + "agentRepository.review.confirmRejectTitle": "Confirm Rejection", + "agentRepository.review.confirmRejectContent": "Reject \"{{name}}\"? The submitter can revise and resubmit later.", + "agentRepository.review.approveSuccess": "\"{{name}}\" has been approved", + "agentRepository.review.rejectSuccess": "\"{{name}}\" has been rejected", + "agentRepository.review.approveError": "Failed to approve. Please try again later.", + "agentRepository.review.rejectError": "Failed to reject. Please try again later.", + "tenantResources.create": "Create", "tenantResources.subtitle": "Manage tenants, users, groups and resources", "tenantResources.title": "Tenant Resource Management", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index 4735f22c5..7bdaa9e6f 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -1598,6 +1598,7 @@ "sidebar.mcpSpace": "MCP 空间", "sidebar.skillSpace": "Skill 空间", "sidebar.agentMarket": "智能体市场", + "sidebar.agentRepository": "智能体仓库", "sidebar.agentDev": "智能体开发", "sidebar.agentConfig": "智能体配置", "sidebar.knowledgeBaseConfig": "知识库配置", @@ -1611,6 +1612,91 @@ "sidebar.mcpToolsManagement": "MCP 工具", "sidebar.monitoringManagement": "监控与运维", + "agentRepository.page.title": "智能体仓库", + "agentRepository.page.subtitle": "浏览同租户共享仓库、管理你有权限的智能体,并发布与审核。", + "agentRepository.page.tab.repository": "仓库", + "agentRepository.page.tab.mine": "我的", + "agentRepository.page.tab.review": "审核中心", + "agentRepository.page.searchPlaceholder": "搜索智能体名称、描述或作者", + "agentRepository.page.categoryAll": "全部", + "agentRepository.page.repositoryHint": "同租户内的智能体需先「复制为我的智能体」后才能编辑", + "agentRepository.page.resultCount": "共 {{count}} 个智能体", + "agentRepository.page.empty": "没有找到匹配的智能体", + "agentRepository.page.loadError": "加载智能体仓库失败,请稍后重试", + "agentRepository.page.retry": "重试", + "agentRepository.page.mineComingSoon": "「我的」功能即将上线", + "agentRepository.mine.searchPlaceholder": "搜索智能体名称或描述", + "agentRepository.mine.filter.all": "全部", + "agentRepository.mine.filter.created": "我创建的", + "agentRepository.mine.filter.others": "其它", + "agentRepository.mine.empty": "暂无可编辑的智能体", + "agentRepository.mine.emptyFiltered": "当前筛选下暂无智能体", + "agentRepository.mine.loadError": "加载我的智能体失败,请稍后重试", + "agentRepository.mine.lifecycle.published": "已发布", + "agentRepository.mine.lifecycle.draft": "草稿", + "agentRepository.mine.onHub": "Hub", + "agentRepository.mine.listed": "已上架", + "agentRepository.mine.onlineVersion": "线上版本 {{version}}", + "agentRepository.mine.updateReviewing": "更新审核中", + "agentRepository.mine.edit": "编辑", + "agentRepository.mine.menu.more": "更多操作", + "agentRepository.mine.menu.apply": "申请上架", + "agentRepository.mine.menu.review": "查看审核进度", + "agentRepository.mine.menu.reviewUpdate": "查看更新审核进度", + "agentRepository.mine.reviewModal.title": "上架审核状态", + "agentRepository.mine.reviewModal.reviewUpdateTitle": "更新审核状态", + "agentRepository.mine.reviewModal.agentName": "「{{name}}」的上架申请进度", + "agentRepository.mine.reviewModal.pendingLabel": "审核中", + "agentRepository.mine.reviewModal.pendingDescription": "你的上架申请已提交,正在等待管理员审核,请耐心等待。", + "agentRepository.mine.reviewModal.sharedLabel": "已通过", + "agentRepository.mine.reviewModal.sharedDescription": "审核已通过,该智能体已上架至智能体仓库,可供同租户成员复制使用。", + "agentRepository.mine.reviewModal.rejectedLabel": "已驳回", + "agentRepository.mine.reviewModal.rejectedDescription": "很遗憾,本次上架申请未通过审核,你可以修改后重新申请。", + "agentRepository.mine.reviewModal.version": "审核版本", + "agentRepository.mine.reviewModal.submittedAt": "提交时间", + "agentRepository.mine.reviewModal.cancelApply": "取消申请上架", + "agentRepository.mine.applySuccess": "已提交「{{name}}」的上架申请,等待管理员审核", + "agentRepository.mine.applyError": "提交上架申请失败,请稍后重试", + "agentRepository.mine.cancelApplySuccess": "已取消上架申请", + "agentRepository.mine.cancelApplyError": "取消上架申请失败,请稍后重试", + "agentRepository.mine.resultCount": "共 {{count}} 个智能体", + "agentRepository.card.untitled": "未命名智能体", + "agentRepository.card.noDescription": "暂无描述", + "agentRepository.card.copy": "复制", + "agentRepository.card.detail": "详情", + "agentRepository.card.toolCount": "{{count}} 个工具", + + "agentRepository.detail.intro": "智能体简介", + "agentRepository.detail.tools": "内置工具", + "agentRepository.detail.role": "智能体角色", + "agentRepository.detail.downloads": "{{count}} 次安装", + "agentRepository.detail.loadError": "加载智能体详情失败,请稍后重试", + "agentRepository.detail.retry": "重试", + "agentRepository.detail.status.shared": "已共享", + "agentRepository.detail.status.pending_review": "待审核", + "agentRepository.detail.status.rejected": "审核驳回", + "agentRepository.detail.status.not_shared": "未共享", + + "agentRepository.review.title": "待审核队列", + "agentRepository.review.pendingCount": "{{count}} 个待处理", + "agentRepository.review.description": "审核用户提交的智能体,决定是否上架到公开仓库。", + "agentRepository.review.empty": "审核队列已清空,暂无待处理的智能体", + "agentRepository.review.loadError": "加载待审核列表失败,请稍后重试", + "agentRepository.review.submitter": "提交者:{{name}}", + "agentRepository.review.unknownSubmitter": "未知提交者", + "agentRepository.review.unknownCategory": "未分类", + "agentRepository.review.viewDetail": "查看详情", + "agentRepository.review.approve": "通过", + "agentRepository.review.reject": "驳回", + "agentRepository.review.confirmApproveTitle": "确认通过审核", + "agentRepository.review.confirmApproveContent": "确定要通过「{{name}}」的审核并上架到公开仓库吗?", + "agentRepository.review.confirmRejectTitle": "确认驳回审核", + "agentRepository.review.confirmRejectContent": "确定要驳回「{{name}}」吗?驳回后提交者可以修改后重新提交。", + "agentRepository.review.approveSuccess": "已通过「{{name}}」的审核", + "agentRepository.review.rejectSuccess": "已驳回「{{name}}」", + "agentRepository.review.approveError": "通过审核失败,请稍后重试", + "agentRepository.review.rejectError": "驳回审核失败,请稍后重试", + "tenantResources.create": "创建", "tenantResources.subtitle": "管理租户、用户、用户组和资源", "tenantResources.title": "租户资源管理", diff --git a/frontend/services/agentRepositoryService.ts b/frontend/services/agentRepositoryService.ts new file mode 100644 index 000000000..742e7b67e --- /dev/null +++ b/frontend/services/agentRepositoryService.ts @@ -0,0 +1,184 @@ +/** + * Agent repository service for tenant marketplace listing API calls + */ + +import { API_ENDPOINTS, fetchWithErrorHandling } from "./api"; +import { getAuthHeaders } from "@/lib/auth"; +import log from "@/lib/logger"; +import type { + AgentRepositoryCategoryItem, + AgentRepositoryListingDetail, + AgentRepositoryListingItem, + AgentRepositoryListingListParams, + AgentRepositoryListingListResponse, + AgentRepositoryListingStatus, + MyEditableAgentListParams, + MyEditableAgentListResponse, +} from "@/types/agentRepository"; + +export async function fetchAgentRepositoryListings( + params?: AgentRepositoryListingListParams +): Promise { + try { + const url = API_ENDPOINTS.agentRepository.listings(params); + const response = await fetchWithErrorHandling(url, { + method: "GET", + headers: getAuthHeaders(), + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch agent repository listings: ${response.statusText}` + ); + } + + return response.json(); + } catch (error) { + log.error("Error fetching agent repository listings:", error); + throw error; + } +} + +export async function fetchAgentRepositoryCategories(): Promise< + AgentRepositoryCategoryItem[] +> { + try { + const response = await fetchWithErrorHandling( + API_ENDPOINTS.agentRepository.categories, + { + method: "GET", + headers: getAuthHeaders(), + } + ); + + if (!response.ok) { + throw new Error( + `Failed to fetch agent repository categories: ${response.statusText}` + ); + } + + return response.json(); + } catch (error) { + log.error("Error fetching agent repository categories:", error); + throw error; + } +} + +export async function fetchAgentRepositoryListingDetail( + agentRepositoryId: number +): Promise { + try { + const response = await fetchWithErrorHandling( + API_ENDPOINTS.agentRepository.detail(agentRepositoryId), + { + method: "GET", + headers: getAuthHeaders(), + } + ); + + if (!response.ok) { + throw new Error( + `Failed to fetch agent repository listing detail: ${response.statusText}` + ); + } + + return response.json(); + } catch (error) { + log.error("Error fetching agent repository listing detail:", error); + throw error; + } +} + +export async function fetchMyEditableAgents( + params?: MyEditableAgentListParams +): Promise { + try { + const response = await fetchWithErrorHandling( + API_ENDPOINTS.agentRepository.mineAgents(params), + { + method: "GET", + headers: getAuthHeaders(), + } + ); + + if (!response.ok) { + throw new Error(`Failed to fetch my editable agents: ${response.statusText}`); + } + + return response.json(); + } catch (error) { + log.error("Error fetching my editable agents:", error); + throw error; + } +} + +export async function createAgentRepositoryListing( + agentId: number, + versionNo: number +): Promise { + try { + const response = await fetchWithErrorHandling( + API_ENDPOINTS.agentRepository.createListing(agentId, versionNo), + { + method: "POST", + headers: { + ...getAuthHeaders(), + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + } + ); + + if (!response.ok) { + throw new Error( + `Failed to create agent repository listing: ${response.statusText}` + ); + } + + return response.json(); + } catch (error) { + log.error("Error creating agent repository listing:", error); + throw error; + } +} + +export async function updateAgentRepositoryStatus( + agentRepositoryId: number, + status: AgentRepositoryListingStatus +): Promise { + try { + const response = await fetchWithErrorHandling( + API_ENDPOINTS.agentRepository.updateStatus(agentRepositoryId), + { + method: "PATCH", + headers: { + ...getAuthHeaders(), + "Content-Type": "application/json", + }, + body: JSON.stringify({ status }), + } + ); + + if (!response.ok) { + throw new Error( + `Failed to update agent repository status: ${response.statusText}` + ); + } + + return response.json(); + } catch (error) { + log.error("Error updating agent repository status:", error); + throw error; + } +} + +const agentRepositoryService = { + fetchAgentRepositoryListings, + fetchAgentRepositoryCategories, + fetchAgentRepositoryListingDetail, + fetchMyEditableAgents, + createAgentRepositoryListing, + updateAgentRepositoryStatus, +}; + +export default agentRepositoryService; diff --git a/frontend/services/api.ts b/frontend/services/api.ts index e5b4ed025..28e517079 100644 --- a/frontend/services/api.ts +++ b/frontend/services/api.ts @@ -2,6 +2,7 @@ import { STATUS_CODES } from "@/const/auth"; import { ErrorCode } from "@/const/errorCode"; import { handleSessionExpired } from "@/lib/session"; import log from "@/lib/logger"; +import type { AgentRepositoryListingListParams, MyEditableAgentListParams } from "@/types/agentRepository"; import type { MarketAgentListParams } from "@/types/market"; const API_BASE_URL = "/api"; @@ -357,6 +358,43 @@ export const API_ENDPOINTS = { clear: `${API_BASE_URL}/memory/clear`, }, }, + agentRepository: { + listings: (params?: AgentRepositoryListingListParams) => { + const queryParams = new URLSearchParams(); + if (params?.status) queryParams.append("status", params.status); + if (params?.agent_id != null) { + queryParams.append("agent_id", String(params.agent_id)); + } + if (params?.deduplicate_by_agent_id != null) { + queryParams.append( + "deduplicate_by_agent_id", + String(params.deduplicate_by_agent_id) + ); + } + if (params?.category_id != null) { + queryParams.append("category_id", String(params.category_id)); + } + const queryString = queryParams.toString(); + return `${API_BASE_URL}/repository/agent${queryString ? `?${queryString}` : ""}`; + }, + categories: `${API_BASE_URL}/repository/agent/categories`, + mineAgents: (params?: MyEditableAgentListParams) => { + const queryParams = new URLSearchParams(); + if (params?.ownership) { + queryParams.append("ownership", params.ownership); + } + const queryString = queryParams.toString(); + return `${API_BASE_URL}/repository/agent/mine${queryString ? `?${queryString}` : ""}`; + }, + detail: (agentRepositoryId: number) => + `${API_BASE_URL}/repository/agent/${agentRepositoryId}`, + import: (agentRepositoryId: number) => + `${API_BASE_URL}/repository/agent/${agentRepositoryId}/import`, + updateStatus: (agentRepositoryId: number) => + `${API_BASE_URL}/repository/agent/${agentRepositoryId}/status`, + createListing: (agentId: number, versionNo: number) => + `${API_BASE_URL}/repository/agent/${agentId}/versions/${versionNo}`, + }, market: { agents: (params?: MarketAgentListParams) => { const queryParams = new URLSearchParams(); diff --git a/frontend/types/agentRepository.ts b/frontend/types/agentRepository.ts new file mode 100644 index 000000000..c903f9aed --- /dev/null +++ b/frontend/types/agentRepository.ts @@ -0,0 +1,97 @@ +/** + * Types for tenant agent repository (marketplace listings) + */ + +export type AgentRepositoryListingStatus = + | "not_shared" + | "pending_review" + | "rejected" + | "shared"; + +export interface AgentRepositoryListingItem { + agent_repository_id: number; + agent_id?: number; + name: string; + display_name?: string | null; + description?: string | null; + author?: string | null; + status: AgentRepositoryListingStatus; + icon?: string | null; + tags?: string[]; + tool_count?: number | null; + version_label?: string | null; + downloads?: number; + category_id?: number | null; + submitted_by?: string | null; +} + +export interface AgentRepositoryListingListResponse { + items: AgentRepositoryListingItem[]; +} + +export interface AgentRepositoryListingListParams { + status?: AgentRepositoryListingStatus; + agent_id?: number; + deduplicate_by_agent_id?: boolean; + category_id?: number; +} + +export interface AgentRepositoryCategoryItem { + id: number; + name: string; +} + +export interface AgentRepositoryListingDetail { + agent_repository_id: number; + agent_id?: number | null; + name: string; + display_name?: string | null; + description?: string | null; + author?: string | null; + icon?: string | null; + status: AgentRepositoryListingStatus; + version_label?: string | null; + downloads?: number; + created_at?: string | null; + model_name?: string | null; + duty_prompt?: string | null; + tools?: string[]; +} + +export interface MyAgentRepositoryInfoItem { + agent_repository_id: number; + status: Extract< + AgentRepositoryListingStatus, + "shared" | "pending_review" | "rejected" + >; + version_no?: number | null; + version_label?: string | null; + create_time?: string | null; +} + +export interface MyEditableAgentItem { + agent_id: number; + name?: string | null; + description?: string | null; + current_version_no?: number | null; + version_label?: string | null; + version_create_time?: string | null; + repository_info: MyAgentRepositoryInfoItem[]; +} + +export type MineOwnershipFilter = "all" | "created" | "others"; + +export interface MyEditableAgentOwnershipCounts { + all: number; + created: number; + others: number; +} + +export interface MyEditableAgentListParams { + ownership?: MineOwnershipFilter; +} + +export interface MyEditableAgentListResponse { + items: MyEditableAgentItem[]; + counts: MyEditableAgentOwnershipCounts; +} diff --git a/k8s/helm/nexent/charts/nexent-common/files/init.sql b/k8s/helm/nexent/charts/nexent-common/files/init.sql index fa55ba9c5..b8bb1c02d 100644 --- a/k8s/helm/nexent/charts/nexent-common/files/init.sql +++ b/k8s/helm/nexent/charts/nexent-common/files/init.sql @@ -1089,7 +1089,7 @@ INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_ (1109, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/agents', '/agent-dev'), (1110, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/memory', '/agent-dev'); INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_category, permission_type, permission_subtype, parent_key) VALUES -(1111, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-space', '/resource-space'), +(1111, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-repository', '/resource-space'), (1112, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/mcp-space', '/resource-space'), (1113, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/skill-space', '/resource-space'); @@ -1106,7 +1106,7 @@ INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_ (1208, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/agents', '/agent-dev'), (1209, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/memory', '/agent-dev'); INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_category, permission_type, permission_subtype, parent_key) VALUES -(1210, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-space', '/resource-space'), +(1210, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-repository', '/resource-space'), (1211, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/mcp-space', '/resource-space'), (1212, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/skill-space', '/resource-space'); @@ -1130,7 +1130,7 @@ INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_ (1408, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/agents', '/agent-dev'), (1409, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/memory', '/agent-dev'); INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_category, permission_type, permission_subtype, parent_key) VALUES -(1410, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-space', '/resource-space'), +(1410, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-repository', '/resource-space'), (1411, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/mcp-space', '/resource-space'), (1412, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/skill-space', '/resource-space'); @@ -1146,7 +1146,7 @@ INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_ (1507, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/knowledges', '/agent-dev'), (1508, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/agents', '/agent-dev'); INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_category, permission_type, permission_subtype, parent_key) VALUES -(1509, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-space', '/resource-space'), +(1509, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-repository', '/resource-space'), (1510, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/mcp-space', '/resource-space'), (1511, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/skill-space', '/resource-space'); diff --git a/test/backend/app/test_agent_repository_app.py b/test/backend/app/test_agent_repository_app.py index b9b0d573a..03d6f6325 100644 --- a/test/backend/app/test_agent_repository_app.py +++ b/test/backend/app/test_agent_repository_app.py @@ -27,6 +27,88 @@ def mock_auth_header(): return {"Authorization": "Bearer test_token"} +def test_list_agent_repository_listings_api_defaults_dedupe_without_agent_id( + mocker, + mock_auth_header, +): + """Test list API defaults to dedupe when agent_id is not provided.""" + mock_get_user_id = mocker.patch( + "apps.agent_repository_app.get_current_user_id" + ) + mock_list = mocker.patch( + "apps.agent_repository_app.list_agent_repository_listings_impl", + ) + + mock_get_user_id.return_value = ("test_user_id", "test_tenant_id") + mock_list.return_value = {"items": []} + + response = client.get("/repository/agent", headers=mock_auth_header) + + assert response.status_code == 200 + mock_get_user_id.assert_called_once_with(mock_auth_header["Authorization"]) + mock_list.assert_called_once_with( + status=None, + agent_id=None, + deduplicate_by_agent_id=True, + ) + + +def test_list_agent_repository_listings_api_disables_dedupe_for_agent_id( + mocker, + mock_auth_header, +): + """Test agent_id lookup defaults to returning all records for the agent.""" + mock_get_user_id = mocker.patch( + "apps.agent_repository_app.get_current_user_id" + ) + mock_list = mocker.patch( + "apps.agent_repository_app.list_agent_repository_listings_impl", + ) + + mock_get_user_id.return_value = ("test_user_id", "test_tenant_id") + mock_list.return_value = {"items": []} + + response = client.get( + "/repository/agent?agent_id=123", + headers=mock_auth_header, + ) + + assert response.status_code == 200 + mock_list.assert_called_once_with( + status=None, + agent_id=123, + deduplicate_by_agent_id=False, + ) + + +def test_list_agent_repository_listings_api_passes_explicit_dedupe( + mocker, + mock_auth_header, +): + """Test explicit dedupe query parameter overrides the agent_id default.""" + mock_get_user_id = mocker.patch( + "apps.agent_repository_app.get_current_user_id" + ) + mock_list = mocker.patch( + "apps.agent_repository_app.list_agent_repository_listings_impl", + ) + + mock_get_user_id.return_value = ("test_user_id", "test_tenant_id") + mock_list.return_value = {"items": []} + + response = client.get( + "/repository/agent?agent_id=123&deduplicate_by_agent_id=true", + headers=mock_auth_header, + ) + + assert response.status_code == 200 + mock_list.assert_called_once_with( + status=None, + agent_id=123, + deduplicate_by_agent_id=True, + ) + + def test_create_agent_repository_listing_api_success(mocker, mock_auth_header): """Test create_agent_repository_listing_api success case.""" mock_get_user_id = mocker.patch( @@ -41,7 +123,7 @@ def test_create_agent_repository_listing_api_success(mocker, mock_auth_header): mock_create_listing.return_value = { "agent_repository_id": 42, "agent_id": 123, - "source_version_no": 1, + "version_no": 1, "is_updated": False, } @@ -76,7 +158,7 @@ def test_create_agent_repository_listing_api_draft_version(mocker, mock_auth_hea mock_create_listing.return_value = { "agent_repository_id": 42, "agent_id": 123, - "source_version_no": 0, + "version_no": 0, "is_updated": True, } @@ -92,7 +174,7 @@ def test_create_agent_repository_listing_api_draft_version(mocker, mock_auth_hea user_id="test_user_id", version_no=0, ) - assert response.json()["source_version_no"] == 0 + assert response.json()["version_no"] == 0 def test_create_agent_repository_listing_api_bad_request(mocker, mock_auth_header): @@ -159,3 +241,205 @@ def test_create_agent_repository_listing_api_exception(mocker, mock_auth_header) assert response.status_code == 500 assert "Create agent repository listing error." in response.json()["detail"] + + +def test_update_agent_repository_status_api_success(mocker, mock_auth_header): + """Test update_agent_repository_status_api passes tenant_id to service.""" + mock_get_user_id = mocker.patch( + "apps.agent_repository_app.get_current_user_id" + ) + mock_update_status = mocker.patch( + "apps.agent_repository_app.update_agent_repository_status_impl", + ) + + mock_get_user_id.return_value = ("test_user_id", "test_tenant_id") + mock_update_status.return_value = { + "agent_repository_id": 42, + "status": "shared", + "name": "agent_one", + } + + response = client.patch( + "/repository/agent/42/status", + headers=mock_auth_header, + json={"status": "shared"}, + ) + + assert response.status_code == 200 + mock_get_user_id.assert_called_once_with(mock_auth_header["Authorization"]) + mock_update_status.assert_called_once_with( + agent_repository_id=42, + status="shared", + user_id="test_user_id", + tenant_id="test_tenant_id", + ) + assert response.json()["status"] == "shared" + + +def test_update_agent_repository_status_api_unauthorized(mocker, mock_auth_header): + """Test update_agent_repository_status_api maps UnauthorizedError to 401.""" + from consts.exceptions import UnauthorizedError + + mock_get_user_id = mocker.patch( + "apps.agent_repository_app.get_current_user_id" + ) + mock_update_status = mocker.patch( + "apps.agent_repository_app.update_agent_repository_status_impl", + ) + + mock_get_user_id.return_value = ("test_user_id", "test_tenant_id") + mock_update_status.side_effect = UnauthorizedError("Not authorized") + + response = client.patch( + "/repository/agent/42/status", + headers=mock_auth_header, + json={"status": "pending_review"}, + ) + + assert response.status_code == 401 + assert response.json()["detail"] == "Not authorized" + + +def test_update_agent_repository_status_api_bad_request(mocker, mock_auth_header): + """Test update_agent_repository_status_api maps ValueError to 400.""" + mock_get_user_id = mocker.patch( + "apps.agent_repository_app.get_current_user_id" + ) + mock_update_status = mocker.patch( + "apps.agent_repository_app.update_agent_repository_status_impl", + ) + + mock_get_user_id.return_value = ("test_user_id", "test_tenant_id") + mock_update_status.side_effect = ValueError("Invalid status transition") + + response = client.patch( + "/repository/agent/42/status", + headers=mock_auth_header, + json={"status": "shared"}, + ) + + assert response.status_code == 400 + assert response.json()["detail"] == "Invalid status transition" + + +def test_list_agent_repository_categories_api_success(mocker, mock_auth_header): + """Test categories API returns hardcoded category array.""" + mock_get_user_id = mocker.patch( + "apps.agent_repository_app.get_current_user_id" + ) + mock_list_categories = mocker.patch( + "apps.agent_repository_app.list_agent_repository_categories_impl", + ) + + mock_get_user_id.return_value = ("test_user_id", "test_tenant_id") + mock_list_categories.return_value = [ + {"id": 1, "name": "写作助手"}, + {"id": 1000, "name": "其它"}, + ] + + response = client.get("/repository/agent/categories", headers=mock_auth_header) + + assert response.status_code == 200 + assert response.json() == [ + {"id": 1, "name": "写作助手"}, + {"id": 1000, "name": "其它"}, + ] + mock_get_user_id.assert_called_once_with(mock_auth_header["Authorization"]) + mock_list_categories.assert_called_once_with() + + +def test_list_agent_repository_categories_api_unauthorized(mocker, mock_auth_header): + """Test categories API maps UnauthorizedError to 401.""" + from consts.exceptions import UnauthorizedError + + mock_get_user_id = mocker.patch( + "apps.agent_repository_app.get_current_user_id" + ) + mock_get_user_id.side_effect = UnauthorizedError("Not authorized") + + response = client.get("/repository/agent/categories", headers=mock_auth_header) + + assert response.status_code == 401 + assert response.json()["detail"] == "Not authorized" + + +def test_list_my_editable_agents_api_success_default_ownership( + mocker, + mock_auth_header, +): + """Test mine API returns items and counts with default ownership.""" + mock_get_user_id = mocker.patch( + "apps.agent_repository_app.get_current_user_id" + ) + mock_list_mine = mocker.patch( + "apps.agent_repository_app.list_my_editable_agents_impl", + ) + + mock_get_user_id.return_value = ("test_user_id", "test_tenant_id") + mock_list_mine.return_value = { + "items": [{"agent_id": 1, "name": "Agent One", "repository_info": []}], + "counts": {"all": 1, "created": 1, "others": 0}, + } + + response = client.get("/repository/agent/mine", headers=mock_auth_header) + + assert response.status_code == 200 + assert response.json() == { + "items": [{"agent_id": 1, "name": "Agent One", "repository_info": []}], + "counts": {"all": 1, "created": 1, "others": 0}, + } + mock_get_user_id.assert_called_once_with(mock_auth_header["Authorization"]) + mock_list_mine.assert_called_once_with( + tenant_id="test_tenant_id", + user_id="test_user_id", + ownership="all", + ) + + +def test_list_my_editable_agents_api_passes_ownership_filter( + mocker, + mock_auth_header, +): + """Test mine API forwards ownership query parameter to service.""" + mock_get_user_id = mocker.patch( + "apps.agent_repository_app.get_current_user_id" + ) + mock_list_mine = mocker.patch( + "apps.agent_repository_app.list_my_editable_agents_impl", + ) + + mock_get_user_id.return_value = ("test_user_id", "test_tenant_id") + mock_list_mine.return_value = {"items": [], "counts": {"all": 0, "created": 0, "others": 0}} + + response = client.get( + "/repository/agent/mine?ownership=others", + headers=mock_auth_header, + ) + + assert response.status_code == 200 + mock_list_mine.assert_called_once_with( + tenant_id="test_tenant_id", + user_id="test_user_id", + ownership="others", + ) + + +def test_list_my_editable_agents_api_bad_request(mocker, mock_auth_header): + """Test mine API maps ValueError to 400.""" + mock_get_user_id = mocker.patch( + "apps.agent_repository_app.get_current_user_id" + ) + mock_list_mine = mocker.patch( + "apps.agent_repository_app.list_my_editable_agents_impl", + ) + + mock_get_user_id.return_value = ("test_user_id", "test_tenant_id") + mock_list_mine.side_effect = ValueError("Invalid ownership filter: bad") + + response = client.get( + "/repository/agent/mine?ownership=bad", + headers=mock_auth_header, + ) + + assert response.status_code == 400 + assert response.json()["detail"] == "Invalid ownership filter: bad" diff --git a/test/backend/services/test_agent_repository_service.py b/test/backend/services/test_agent_repository_service.py index 648d20385..ec68f2b15 100644 --- a/test/backend/services/test_agent_repository_service.py +++ b/test/backend/services/test_agent_repository_service.py @@ -2,7 +2,7 @@ import sys from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, call, patch import pytest @@ -16,19 +16,34 @@ sys.modules.setdefault("sqlalchemy.dialects.postgresql", MagicMock()) _agent_repo_db_mock = MagicMock() -_agent_repo_db_mock.STATUS_PENDING_REVIEW = "PENDING_REVIEW" +_agent_repo_db_mock.STATUS_PENDING_REVIEW = "pending_review" +_agent_repo_db_mock.STATUS_NOT_SHARED = "not_shared" +_agent_repo_db_mock.STATUS_REJECTED = "rejected" +_agent_repo_db_mock.STATUS_SHARED = "shared" _agent_repo_db_mock.VALID_REPOSITORY_STATUSES = frozenset({ - "NOT_SHARED", - "PENDING_REVIEW", - "REJECTED", - "SHARED", + "not_shared", + "pending_review", + "rejected", + "shared", +}) +_agent_repo_db_mock.OWNERSHIP_ALL = "all" +_agent_repo_db_mock.VALID_OWNERSHIP_FILTERS = frozenset({ + "all", + "created", + "others", }) _agent_repo_db_mock.get_agent_repository_by_id = MagicMock() _agent_repo_db_mock.get_agent_repository_by_agent_id = MagicMock() _agent_repo_db_mock.insert_agent_repository_record = MagicMock() _agent_repo_db_mock.update_agent_repository_by_id = MagicMock() +_agent_repo_db_mock.update_agent_repository_status_by_id = MagicMock() +_agent_repo_db_mock.reset_agent_repository_status = MagicMock() sys.modules["database.agent_repository_db"] = _agent_repo_db_mock +_user_tenant_db_mock = MagicMock() +_user_tenant_db_mock.get_user_tenant_by_user_id = MagicMock() +sys.modules["database.user_tenant_db"] = _user_tenant_db_mock + _agent_db_mock = MagicMock() _agent_db_mock.search_agent_info_by_agent_id = MagicMock() sys.modules["database.agent_db"] = _agent_db_mock @@ -37,6 +52,7 @@ _agent_version_db_mock.search_version_by_version_no = MagicMock() sys.modules["database.agent_version_db"] = _agent_version_db_mock + class _SkillZipEntryMock: def __init__(self, skill_name: str, skill_zip_base64: str): self.skill_name = skill_name @@ -88,10 +104,694 @@ def model_dump(self): sys.modules["services.agent_service"] = _agent_service_mock from consts.const import ASSET_OWNER_TENANT_ID +from consts.exceptions import UnauthorizedError from backend.services import agent_repository_service as ars +def _repository_record( + *, + agent_repository_id: int = 1, + agent_id: int = 10, + status: str = "not_shared", + publisher_tenant_id: str = "tenant_a", + publisher_user_id: str = "user_a", +) -> dict: + return { + "agent_repository_id": agent_repository_id, + "agent_id": agent_id, + "author": "author", + "name": "agent_one", + "display_name": "Agent One", + "description": "desc", + "status": status, + "publisher_tenant_id": publisher_tenant_id, + "publisher_user_id": publisher_user_id, + } + + +def _pending_review_reset_calls( + *, + agent_repository_id: int = 1, + agent_id: int = 10, +) -> list: + return [ + call( + agent_repository_id=agent_repository_id, + agent_id=agent_id, + status="pending_review", + ), + call( + agent_repository_id=agent_repository_id, + agent_id=agent_id, + status="rejected", + ), + ] + + +def test_list_repository_listings_deduplicates_by_agent_id_by_default(): + records = [ + _repository_record( + agent_repository_id=100, + agent_id=10, + status="not_shared", + ), + _repository_record( + agent_repository_id=90, + agent_id=10, + status="shared", + ), + _repository_record( + agent_repository_id=80, + agent_id=20, + status="rejected", + ), + ] + + with patch.object(ars, "list_agent_repository_summaries", return_value=records): + result = ars.list_agent_repository_listings_impl() + + assert [item["agent_repository_id"] for item in result["items"]] == [90, 80] + assert result["items"][0]["status"] == "shared" + + +def test_list_repository_listings_can_skip_agent_id_deduplication(): + records = [ + _repository_record(agent_repository_id=100, agent_id=10, status="not_shared"), + _repository_record(agent_repository_id=90, agent_id=10, status="shared"), + _repository_record(agent_repository_id=80, agent_id=20, status="rejected"), + ] + + with patch.object(ars, "list_agent_repository_summaries", return_value=records): + result = ars.list_agent_repository_listings_impl( + deduplicate_by_agent_id=False, + ) + + assert [item["agent_repository_id"] for item in result["items"]] == [100, 90, 80] + + +def test_list_repository_listings_uses_newest_repository_for_status_tie(): + records = [ + _repository_record( + agent_repository_id=10, + agent_id=30, + status="pending_review", + ), + _repository_record( + agent_repository_id=11, + agent_id=30, + status="pending_review", + ), + ] + + with patch.object(ars, "list_agent_repository_summaries", return_value=records): + result = ars.list_agent_repository_listings_impl() + + assert [item["agent_repository_id"] for item in result["items"]] == [11] + + +def test_list_repository_listings_passes_agent_id_to_db(): + with patch.object( + ars, + "list_agent_repository_summaries", + return_value=[_repository_record(agent_repository_id=1, agent_id=123)], + ) as mock_list: + result = ars.list_agent_repository_listings_impl( + status="shared", + agent_id=123, + deduplicate_by_agent_id=False, + ) + + mock_list.assert_called_once_with(status="shared", agent_id=123, category_id=None) + assert [item["agent_repository_id"] for item in result["items"]] == [1] + + +def test_list_repository_listings_rejects_invalid_status_with_agent_id(): + with patch.object(ars, "list_agent_repository_summaries") as mock_list: + with pytest.raises(ValueError, match="Invalid status"): + ars.list_agent_repository_listings_impl( + status="invalid", + agent_id=123, + ) + + mock_list.assert_not_called() + + +def test_list_agent_repository_categories_impl_returns_hardcoded_categories(): + result = ars.list_agent_repository_categories_impl() + + assert len(result) == 7 + assert result[0] == {"id": 1, "name": "写作助手"} + assert result[-1] == {"id": 0, "name": "其它"} + assert [item["id"] for item in result] == [1, 2, 3, 4, 5, 6, 0] + + +def _editable_agent_record( + *, + agent_id: int = 1, + name: str = "agent_one", + display_name: str = "Agent One", +) -> dict: + return { + "agent_id": agent_id, + "name": name, + "display_name": display_name, + "description": "desc", + "current_version_no": 0, + "version_name": "v0", + "version_create_time": None, + "created_by": "user_a", + } + + +def test_list_my_editable_agents_impl_returns_items_and_counts(): + agents = [ + _editable_agent_record(agent_id=1), + _editable_agent_record(agent_id=2, name="agent_two", display_name="Agent Two"), + ] + counts = {"all": 2, "created": 1, "others": 1} + + with patch.object(ars, "get_user_tenant_by_user_id", return_value={"user_role": "USER"}), patch.object( + ars, "count_editable_agents_by_ownership", return_value=counts + ) as mock_counts, patch.object( + ars, "list_editable_agents_for_user", return_value=agents + ) as mock_list, patch.object( + ars, "list_agent_repository_by_agent_ids", return_value=[] + ) as mock_repo_list: + result = ars.list_my_editable_agents_impl( + tenant_id="tenant_a", + user_id="user_a", + ownership="created", + ) + + mock_counts.assert_called_once_with( + "tenant_a", + "user_a", + user_role="USER", + ) + mock_list.assert_called_once_with( + "tenant_a", + "user_a", + user_role="USER", + ownership_filter="created", + ) + mock_repo_list.assert_called_once() + assert "rejected" in mock_repo_list.call_args.kwargs["statuses"] + assert result["counts"] == counts + assert len(result["items"]) == 2 + assert result["items"][0]["agent_id"] == 1 + assert result["items"][0]["name"] == "Agent One" + assert result["items"][0]["repository_info"] == [] + + +def test_list_my_editable_agents_impl_includes_rejected_repository_info(): + agents = [_editable_agent_record(agent_id=1)] + counts = {"all": 1, "created": 1, "others": 0} + rejected_record = { + "agent_repository_id": 99, + "agent_id": 1, + "status": "rejected", + "version_no": 2, + "version_name": "v2", + "create_time": "2026-06-01T00:00:00", + } + + with patch.object(ars, "get_user_tenant_by_user_id", return_value={"user_role": "USER"}), patch.object( + ars, "count_editable_agents_by_ownership", return_value=counts + ), patch.object( + ars, "list_editable_agents_for_user", return_value=agents + ), patch.object( + ars, "list_agent_repository_by_agent_ids", return_value=[rejected_record] + ): + result = ars.list_my_editable_agents_impl( + tenant_id="tenant_a", + user_id="user_a", + ownership="all", + ) + + repository_info = result["items"][0]["repository_info"] + assert len(repository_info) == 1 + assert repository_info[0]["agent_repository_id"] == 99 + assert repository_info[0]["status"] == "rejected" + assert repository_info[0]["version_no"] == 2 + + +def test_list_my_editable_agents_impl_returns_empty_items_with_counts(): + counts = {"all": 0, "created": 0, "others": 0} + + with patch.object(ars, "get_user_tenant_by_user_id", return_value={"user_role": "USER"}), patch.object( + ars, "count_editable_agents_by_ownership", return_value=counts + ), patch.object( + ars, "list_editable_agents_for_user", return_value=[] + ), patch.object( + ars, "list_agent_repository_by_agent_ids" + ) as mock_repo_list: + result = ars.list_my_editable_agents_impl( + tenant_id="tenant_a", + user_id="user_a", + ownership="all", + ) + + mock_repo_list.assert_not_called() + assert result == {"items": [], "counts": counts} + + +def test_list_my_editable_agents_impl_rejects_invalid_ownership(): + with patch.object(ars, "get_user_tenant_by_user_id") as mock_get_role, patch.object( + ars, "count_editable_agents_by_ownership" + ) as mock_counts, patch.object( + ars, "list_editable_agents_for_user" + ) as mock_list: + with pytest.raises(ValueError, match="Invalid ownership filter"): + ars.list_my_editable_agents_impl( + tenant_id="tenant_a", + user_id="user_a", + ownership="invalid", + ) + + mock_get_role.assert_not_called() + mock_counts.assert_not_called() + mock_list.assert_not_called() + + +@pytest.fixture +def mock_status_update_deps(): + with patch.object(ars, "get_user_tenant_by_user_id") as mock_get_role, patch.object( + ars, "get_agent_repository_by_id" + ) as mock_get_by_id, patch.object( + ars, "update_agent_repository_status_by_id" + ) as mock_update_status, patch.object( + ars, "reset_agent_repository_status" + ) as mock_reset_status: + yield { + "get_user_role": mock_get_role, + "get_by_id": mock_get_by_id, + "update_status": mock_update_status, + "reset_status": mock_reset_status, + } + + +def test_reset_repository_peer_statuses_pending_review_also_clears_rejected(): + with patch.object(ars, "reset_agent_repository_status") as mock_reset: + ars._reset_repository_peer_statuses( + agent_repository_id=1, + agent_id=10, + status="pending_review", + ) + + mock_reset.assert_has_calls(_pending_review_reset_calls()) + + +def test_reset_repository_peer_statuses_non_pending_single_reset(): + with patch.object(ars, "reset_agent_repository_status") as mock_reset: + ars._reset_repository_peer_statuses( + agent_repository_id=1, + agent_id=10, + status="shared", + ) + + mock_reset.assert_called_once_with( + agent_repository_id=1, + agent_id=10, + status="shared", + ) + + +def test_update_status_su_pending_review_to_shared(mock_status_update_deps): + deps = mock_status_update_deps + deps["get_user_role"].return_value = {"user_role": "SU"} + record = _repository_record(status="pending_review") + deps["get_by_id"].side_effect = [record, {**record, "status": "shared"}] + deps["update_status"].return_value = 1 + + result = ars.update_agent_repository_status_impl( + agent_repository_id=1, + status="shared", + user_id="su_user", + tenant_id="any_tenant", + ) + + assert result["status"] == "shared" + deps["update_status"].assert_called_once_with( + repository_id=1, + status="shared", + user_id="su_user", + publisher_tenant_id=None, + publisher_user_id=None, + submitted_by=None, + ) + deps["reset_status"].assert_called_once_with( + agent_repository_id=1, + agent_id=10, + status="shared", + ) + + +def test_update_status_su_pending_review_to_rejected(mock_status_update_deps): + deps = mock_status_update_deps + deps["get_user_role"].return_value = {"user_role": "SU"} + record = _repository_record(status="pending_review") + deps["get_by_id"].side_effect = [record, {**record, "status": "rejected"}] + deps["update_status"].return_value = 1 + + result = ars.update_agent_repository_status_impl( + agent_repository_id=1, + status="rejected", + user_id="su_user", + tenant_id="any_tenant", + ) + + assert result["status"] == "rejected" + + +def test_update_status_su_shared_to_not_shared(mock_status_update_deps): + deps = mock_status_update_deps + deps["get_user_role"].return_value = {"user_role": "SU"} + record = _repository_record(status="shared") + deps["get_by_id"].side_effect = [record, {**record, "status": "not_shared"}] + deps["update_status"].return_value = 1 + + result = ars.update_agent_repository_status_impl( + agent_repository_id=1, + status="not_shared", + user_id="su_user", + tenant_id="any_tenant", + ) + + assert result["status"] == "not_shared" + + +def test_update_status_su_invalid_transition(mock_status_update_deps): + deps = mock_status_update_deps + deps["get_user_role"].return_value = {"user_role": "SU"} + deps["get_by_id"].return_value = _repository_record(status="not_shared") + + with pytest.raises(ValueError, match="Invalid status transition"): + ars.update_agent_repository_status_impl( + agent_repository_id=1, + status="shared", + user_id="su_user", + tenant_id="any_tenant", + ) + + +def test_update_status_admin_tenant_mismatch(mock_status_update_deps): + deps = mock_status_update_deps + deps["get_user_role"].return_value = {"user_role": "ADMIN"} + deps["get_by_id"].return_value = _repository_record( + status="not_shared", + publisher_tenant_id="other_tenant", + ) + + with pytest.raises(UnauthorizedError, match="Not authorized"): + ars.update_agent_repository_status_impl( + agent_repository_id=1, + status="pending_review", + user_id="admin_user", + tenant_id="tenant_a", + ) + + +def test_update_status_admin_not_shared_to_pending_review(mock_status_update_deps): + deps = mock_status_update_deps + deps["get_user_role"].return_value = {"user_role": "ADMIN"} + record = _repository_record(status="not_shared") + deps["get_by_id"].side_effect = [record, {**record, "status": "pending_review"}] + deps["update_status"].return_value = 1 + + with patch.object(ars, "_resolve_submitter_email", return_value="admin@example.com"): + result = ars.update_agent_repository_status_impl( + agent_repository_id=1, + status="pending_review", + user_id="admin_user", + tenant_id="tenant_a", + ) + + assert result["status"] == "pending_review" + deps["update_status"].assert_called_once_with( + repository_id=1, + status="pending_review", + user_id="admin_user", + publisher_tenant_id="tenant_a", + publisher_user_id="admin_user", + submitted_by="admin@example.com", + ) + deps["reset_status"].assert_has_calls(_pending_review_reset_calls()) + + +def test_update_status_admin_rejected_to_pending_review(mock_status_update_deps): + deps = mock_status_update_deps + deps["get_user_role"].return_value = {"user_role": "ADMIN"} + record = _repository_record(status="rejected") + deps["get_by_id"].side_effect = [record, {**record, "status": "pending_review"}] + deps["update_status"].return_value = 1 + + with patch.object(ars, "_resolve_submitter_email", return_value="admin@example.com"): + result = ars.update_agent_repository_status_impl( + agent_repository_id=1, + status="pending_review", + user_id="admin_user", + tenant_id="tenant_a", + ) + + assert result["status"] == "pending_review" + deps["update_status"].assert_called_once_with( + repository_id=1, + status="pending_review", + user_id="admin_user", + publisher_tenant_id="tenant_a", + publisher_user_id="admin_user", + submitted_by="admin@example.com", + ) + deps["reset_status"].assert_has_calls(_pending_review_reset_calls()) + + +def test_update_status_admin_pending_review_to_shared(mock_status_update_deps): + deps = mock_status_update_deps + deps["get_user_role"].return_value = {"user_role": "ADMIN"} + record = _repository_record( + status="pending_review", + publisher_user_id="other_user", + ) + deps["get_by_id"].side_effect = [record, {**record, "status": "shared"}] + deps["update_status"].return_value = 1 + + result = ars.update_agent_repository_status_impl( + agent_repository_id=1, + status="shared", + user_id="admin_user", + tenant_id="tenant_a", + ) + + assert result["status"] == "shared" + deps["update_status"].assert_called_once_with( + repository_id=1, + status="shared", + user_id="admin_user", + publisher_tenant_id=None, + publisher_user_id=None, + submitted_by=None, + ) + + +def test_update_status_admin_pending_review_to_rejected(mock_status_update_deps): + deps = mock_status_update_deps + deps["get_user_role"].return_value = {"user_role": "ADMIN"} + record = _repository_record( + status="pending_review", + publisher_user_id="other_user", + ) + deps["get_by_id"].side_effect = [record, {**record, "status": "rejected"}] + deps["update_status"].return_value = 1 + + result = ars.update_agent_repository_status_impl( + agent_repository_id=1, + status="rejected", + user_id="admin_user", + tenant_id="tenant_a", + ) + + assert result["status"] == "rejected" + + +def test_update_status_admin_review_tenant_mismatch(mock_status_update_deps): + deps = mock_status_update_deps + deps["get_user_role"].return_value = {"user_role": "ADMIN"} + deps["get_by_id"].return_value = _repository_record( + status="pending_review", + publisher_tenant_id="other_tenant", + ) + + with pytest.raises(UnauthorizedError, match="Not authorized"): + ars.update_agent_repository_status_impl( + agent_repository_id=1, + status="shared", + user_id="admin_user", + tenant_id="tenant_a", + ) + + +def test_update_status_admin_pending_review_to_not_shared(mock_status_update_deps): + deps = mock_status_update_deps + deps["get_user_role"].return_value = {"user_role": "ADMIN"} + record = _repository_record(status="pending_review") + deps["get_by_id"].side_effect = [record, {**record, "status": "not_shared"}] + deps["update_status"].return_value = 1 + + result = ars.update_agent_repository_status_impl( + agent_repository_id=1, + status="not_shared", + user_id="admin_user", + tenant_id="tenant_a", + ) + + assert result["status"] == "not_shared" + deps["update_status"].assert_called_once_with( + repository_id=1, + status="not_shared", + user_id="admin_user", + publisher_tenant_id=None, + publisher_user_id=None, + submitted_by=None, + ) + + +def test_update_status_dev_publisher_user_mismatch(mock_status_update_deps): + deps = mock_status_update_deps + deps["get_user_role"].return_value = {"user_role": "DEV"} + deps["get_by_id"].return_value = _repository_record( + status="not_shared", + publisher_user_id="other_user", + ) + + with pytest.raises(UnauthorizedError, match="Not authorized"): + ars.update_agent_repository_status_impl( + agent_repository_id=1, + status="pending_review", + user_id="dev_user", + tenant_id="tenant_a", + ) + + +def test_update_status_dev_valid_transition(mock_status_update_deps): + deps = mock_status_update_deps + deps["get_user_role"].return_value = {"user_role": "DEV"} + record = _repository_record( + status="rejected", + publisher_user_id="dev_user", + ) + deps["get_by_id"].side_effect = [record, {**record, "status": "not_shared"}] + deps["update_status"].return_value = 1 + + result = ars.update_agent_repository_status_impl( + agent_repository_id=1, + status="not_shared", + user_id="dev_user", + tenant_id="tenant_a", + ) + + assert result["status"] == "not_shared" + + +def test_update_status_user_role_rejected(mock_status_update_deps): + deps = mock_status_update_deps + deps["get_user_role"].return_value = {"user_role": "USER"} + deps["get_by_id"].return_value = _repository_record(status="not_shared") + + with pytest.raises(UnauthorizedError, match="not authorized"): + ars.update_agent_repository_status_impl( + agent_repository_id=1, + status="pending_review", + user_id="regular_user", + tenant_id="tenant_a", + ) + + +def test_update_status_same_status_noop(mock_status_update_deps): + deps = mock_status_update_deps + record = _repository_record(status="shared") + deps["get_by_id"].side_effect = [record, record] + deps["update_status"].return_value = 1 + + result = ars.update_agent_repository_status_impl( + agent_repository_id=1, + status="shared", + user_id="any_user", + tenant_id="tenant_a", + ) + + assert result["status"] == "shared" + deps["get_user_role"].assert_not_called() + deps["update_status"].assert_called_once_with( + repository_id=1, + status="shared", + user_id="any_user", + publisher_tenant_id=None, + publisher_user_id=None, + submitted_by=None, + ) + deps["reset_status"].assert_called_once_with( + agent_repository_id=1, + agent_id=10, + status="shared", + ) + + +def test_list_repository_listings_includes_submitted_by(): + records = [ + { + **_repository_record( + agent_repository_id=11, + agent_id=30, + status="pending_review", + ), + "submitted_by": "reviewer@example.com", + } + ] + + with patch.object(ars, "list_agent_repository_summaries", return_value=records): + result = ars.list_agent_repository_listings_impl(status="pending_review") + + assert result["items"][0]["submitted_by"] == "reviewer@example.com" + + +def test_resolve_submitter_email_uses_user_tenant_email(): + with patch.object( + ars, + "get_user_tenant_by_user_id", + return_value={"user_email": " dev@example.com "}, + ): + assert ars._resolve_submitter_email("user_a") == "dev@example.com" + + +@pytest.mark.asyncio +async def test_build_repository_data_from_agent_sets_submitted_by(): + with patch.object( + ars, "search_agent_info_by_agent_id", return_value={"name": "agent_one", "author": "author@example.com"} + ), patch.object( + ars, "_validate_create_listing_permission" + ), patch.object( + ars, "_build_agent_info_json", new_callable=AsyncMock, return_value={ + "agent_id": 1, + "agent_info": {"1": {"agent_id": 1}}, + "mcp_info": [], + } + ), patch.object( + ars, "search_version_by_version_no", return_value={"version_name": "v1"} + ), patch.object( + ars, "_resolve_submitter_email", return_value="submitter@example.com" + ): + repository_data = await ars._build_repository_data_from_agent( + agent_id=1, + tenant_id="tenant_a", + user_id="user_a", + version_no=1, + ) + + assert repository_data["submitted_by"] == "submitter@example.com" + assert repository_data["status"] == "pending_review" + + @pytest.mark.asyncio async def test_create_agent_repository_listing_impl_success(): agent_info_json = { @@ -108,13 +808,15 @@ async def test_create_agent_repository_listing_impl_success(): ars, "insert_agent_repository_record" ) as mock_insert, patch.object( ars, "get_agent_repository_by_id" - ) as mock_get_by_id: + ) as mock_get_by_id, patch.object( + ars, "reset_agent_repository_status" + ) as mock_reset_status: mock_build_data.return_value = { "agent_id": 1, - "source_version_no": 1, + "version_no": 1, "name": "agent_one", "agent_info_json": agent_info_json, - "status": "PENDING_REVIEW", + "status": "pending_review", } mock_get_by_agent_id.return_value = None mock_insert.return_value = 42 @@ -123,8 +825,8 @@ async def test_create_agent_repository_listing_impl_success(): "agent_id": 1, "name": "agent_one", "agent_info_json": agent_info_json, - "source_version_no": 1, - "status": "PENDING_REVIEW", + "version_no": 1, + "status": "pending_review", "tags": [], } @@ -139,7 +841,10 @@ async def test_create_agent_repository_listing_impl_success(): assert result["agent_info_json"] == agent_info_json assert result["is_updated"] is False mock_insert.assert_called_once() - mock_get_by_agent_id.assert_called_once_with(1) + mock_get_by_agent_id.assert_called_once_with(1, 1) + mock_reset_status.assert_has_calls( + _pending_review_reset_calls(agent_repository_id=42, agent_id=1) + ) @pytest.mark.asyncio @@ -158,13 +863,15 @@ async def test_create_agent_repository_listing_impl_updates_existing(): ars, "update_agent_repository_by_id" ) as mock_update, patch.object( ars, "get_agent_repository_by_id" - ) as mock_get_by_id: + ) as mock_get_by_id, patch.object( + ars, "reset_agent_repository_status" + ) as mock_reset_status: mock_build_data.return_value = { "agent_id": 1, - "source_version_no": 2, + "version_no": 2, "name": "agent_one", "agent_info_json": agent_info_json, - "status": "PENDING_REVIEW", + "status": "pending_review", } mock_get_by_agent_id.return_value = {"agent_repository_id": 42} mock_update.return_value = 1 @@ -173,8 +880,8 @@ async def test_create_agent_repository_listing_impl_updates_existing(): "agent_id": 1, "name": "agent_one", "agent_info_json": agent_info_json, - "source_version_no": 2, - "status": "PENDING_REVIEW", + "version_no": 2, + "status": "pending_review", "tags": [], } @@ -187,17 +894,21 @@ async def test_create_agent_repository_listing_impl_updates_existing(): assert result["agent_repository_id"] == 42 assert result["is_updated"] is True + mock_get_by_agent_id.assert_called_once_with(1, 2) mock_update.assert_called_once() mock_update.assert_called_with( repository_id=42, publisher_tenant_id="tenant_a", user_id="user_a", updates={ - "source_version_no": 2, + "version_no": 2, "agent_info_json": agent_info_json, - "status": "PENDING_REVIEW", + "status": "pending_review", }, ) + mock_reset_status.assert_has_calls( + _pending_review_reset_calls(agent_repository_id=42, agent_id=1) + ) @pytest.mark.asyncio @@ -216,13 +927,15 @@ async def test_create_agent_repository_listing_impl_accepts_draft_version(): ars, "insert_agent_repository_record" ) as mock_insert, patch.object( ars, "get_agent_repository_by_id" - ) as mock_get_by_id: + ) as mock_get_by_id, patch.object( + ars, "reset_agent_repository_status" + ) as mock_reset_status: mock_build_data.return_value = { "agent_id": 1, - "source_version_no": 0, + "version_no": 0, "name": "agent_one", "agent_info_json": agent_info_json, - "status": "PENDING_REVIEW", + "status": "pending_review", } mock_get_by_agent_id.return_value = None mock_insert.return_value = 42 @@ -231,8 +944,8 @@ async def test_create_agent_repository_listing_impl_accepts_draft_version(): "agent_id": 1, "name": "agent_one", "agent_info_json": agent_info_json, - "source_version_no": 0, - "status": "PENDING_REVIEW", + "version_no": 0, + "status": "pending_review", "tags": [], } @@ -244,8 +957,12 @@ async def test_create_agent_repository_listing_impl_accepts_draft_version(): ) assert result["agent_repository_id"] == 42 - assert result["source_version_no"] == 0 - mock_build_data.assert_awaited_once_with(1, "tenant_a", "user_a", 0) + assert result["version_no"] == 0 + mock_build_data.assert_awaited_once_with(1, "tenant_a", "user_a", 0, card_fields=None) + mock_get_by_agent_id.assert_called_once_with(1, 0) + mock_reset_status.assert_has_calls( + _pending_review_reset_calls(agent_repository_id=42, agent_id=1) + ) @pytest.mark.asyncio @@ -259,18 +976,107 @@ async def test_create_agent_repository_listing_impl_rejects_negative_version(): ) +def test_validate_create_listing_permission_admin(): + with patch.object( + ars, + "get_user_tenant_by_user_id", + return_value={"user_role": "ADMIN", "user_email": "admin@example.com"}, + ): + ars._validate_create_listing_permission( + user_id="admin_user", + agent_info={"author": "other@example.com"}, + ) + + +def test_validate_create_listing_permission_dev_matching_email(): + with patch.object( + ars, + "get_user_tenant_by_user_id", + return_value={"user_role": "DEV", "user_email": "Dev@Example.com"}, + ): + ars._validate_create_listing_permission( + user_id="dev_user", + agent_info={"author": "dev@example.com"}, + ) + + +def test_validate_create_listing_permission_dev_mismatch(): + with patch.object( + ars, + "get_user_tenant_by_user_id", + return_value={"user_role": "DEV", "user_email": "dev@example.com"}, + ): + with pytest.raises(UnauthorizedError, match="Not authorized"): + ars._validate_create_listing_permission( + user_id="dev_user", + agent_info={"author": "other@example.com"}, + ) + + +def test_validate_create_listing_permission_user_rejected(): + with patch.object( + ars, + "get_user_tenant_by_user_id", + return_value={"user_role": "USER", "user_email": "user@example.com"}, + ): + with pytest.raises(UnauthorizedError, match="not authorized"): + ars._validate_create_listing_permission( + user_id="regular_user", + agent_info={"author": "user@example.com"}, + ) + + +def test_validate_create_listing_permission_su_rejected(): + with patch.object( + ars, + "get_user_tenant_by_user_id", + return_value={"user_role": "SU", "user_email": "su@example.com"}, + ): + with pytest.raises(UnauthorizedError, match="not authorized"): + ars._validate_create_listing_permission( + user_id="su_user", + agent_info={"author": "su@example.com"}, + ) + + +@pytest.mark.asyncio +async def test_create_listing_impl_rejects_unauthorized_before_export(): + with patch.object( + ars, + "get_user_tenant_by_user_id", + return_value={"user_role": "USER", "user_email": "user@example.com"}, + ), patch.object( + ars, + "search_agent_info_by_agent_id", + return_value={ + "name": "agent_one", + "author": "user@example.com", + }, + ), patch.object( + ars, "_build_agent_info_json", new_callable=AsyncMock + ) as mock_build_json: + with pytest.raises(UnauthorizedError, match="not authorized"): + await ars.create_agent_repository_listing_impl( + agent_id=1, + tenant_id="tenant_a", + user_id="regular_user", + version_no=1, + ) + mock_build_json.assert_not_awaited() + + def test_validate_create_payload_requires_agent_info_json(): with pytest.raises(ValueError, match="agent_info_json"): ars._validate_create_payload({ "agent_id": 1, - "source_version_no": 1, + "version_no": 1, "name": "agent_one", }) with pytest.raises(ValueError, match="agent_info_json must contain"): ars._validate_create_payload({ "agent_id": 1, - "source_version_no": 1, + "version_no": 1, "name": "agent_one", "agent_info_json": {"agent_id": 1}, }) @@ -310,44 +1116,25 @@ async def test_build_repository_data_from_agent_includes_skills(): "version_name": "v1.0" } - result = await ars._build_repository_data_from_agent( - agent_id=1, - tenant_id="tenant_a", - user_id="user_a", - version_no=1, - ) + with patch.object( + ars, + "get_user_tenant_by_user_id", + return_value={"user_role": "ADMIN", "user_email": "admin@example.com"}, + ): + result = await ars._build_repository_data_from_agent( + agent_id=1, + tenant_id="tenant_a", + user_id="user_a", + version_no=1, + ) assert result["agent_info_json"]["agent_id"] == 1 assert result["agent_info_json"]["skills"][0]["skill_name"] == "SkillA" - assert result["version_label"] == "v1.0" - - -def test_validate_agent_info_json_rejects_asset_owner_agent(): - agent_info_json = { - "agent_id": 1, - "agent_info": { - "1": {"agent_id": 1, "tenant_id": ASSET_OWNER_TENANT_ID, "name": "owner_agent"}, - }, - "mcp_info": [], - } - with pytest.raises(ValueError, match="租户管理员智能体无法共享"): - ars._validate_agent_info_json_shareable(agent_info_json) - - -def test_validate_agent_info_json_allows_normal_tenant(): - agent_info_json = { - "agent_id": 1, - "agent_info": { - "1": {"agent_id": 1, "tenant_id": "tenant_a", "name": "agent_one"}, - "2": {"agent_id": 2, "tenant_id": "tenant_b", "name": "sub_agent"}, - }, - "mcp_info": [], - } - ars._validate_agent_info_json_shareable(agent_info_json) + assert result["version_name"] == "v1.0" @pytest.mark.asyncio -async def test_build_repository_data_from_agent_rejects_asset_owner(): +async def test_build_repository_data_from_agent_allows_asset_owner_sub_agent(): _agent_db_mock.search_agent_info_by_agent_id.return_value = { "name": "agent_one", "display_name": "Agent One", @@ -389,10 +1176,17 @@ async def test_build_repository_data_from_agent_rejects_asset_owner(): "version_name": "v1.0" } - with pytest.raises(ValueError, match="租户管理员智能体无法共享"): - await ars._build_repository_data_from_agent( + with patch.object( + ars, + "get_user_tenant_by_user_id", + return_value={"user_role": "ADMIN", "user_email": "admin@example.com"}, + ): + repository_data = await ars._build_repository_data_from_agent( agent_id=1, tenant_id="tenant_a", user_id="user_a", version_no=1, ) + + assert repository_data["agent_id"] == 1 + assert repository_data["status"] == "pending_review" From 0cbbbdce0330e80549d9a1cfa555d142fcac3cd0 Mon Sep 17 00:00:00 2001 From: Chenlifeng <174292121+Lifeng-Chen@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:38:42 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=E2=9C=A8=20Feature:=20add=20agent=20reposi?= =?UTF-8?q?tory=20page=20and=20APIs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce Agent Repository backend APIs, database/service support, frontend views, client services, and tests. Migrate Agent Space navigation and permissions to /agent-repository with updated SQL and localization. --- backend/apps/agent_repository_app.py | 25 +- backend/consts/model.py | 7 + backend/services/agent_repository_service.py | 81 ++- docker/init.sql | 8 +- .../sql/v2.2.2_0622_update_left_nav_menu.sql | 10 +- .../app/[locale]/agent-repository/page.tsx | 599 +---------------- .../agent-space/components/AgentCard.tsx | 345 ---------- .../components/AgentDetailModal.tsx | 377 ----------- .../components/AgentRepositoryCard.tsx | 19 +- .../components/AgentRepositoryDetailModal.tsx | 0 .../components/MineAgentsView.tsx | 76 ++- .../components/MineApplyListingModal.tsx | 231 +++++++ .../components/MineReviewStatusModal.tsx | 68 +- .../components/MyAgentCard.tsx | 0 frontend/app/[locale]/agent-space/page.tsx | 610 +++++++++++++++++- .../components/navigation/SideNavigation.tsx | 2 +- .../useAgentRepositoryListings.ts | 21 +- frontend/lib/agentRepositoryMine.ts | 6 + frontend/public/locales/en/common.json | 21 + frontend/public/locales/zh/common.json | 21 + frontend/services/agentRepositoryService.ts | 23 +- frontend/services/api.ts | 9 +- frontend/types/agentRepository.ts | 20 + .../charts/nexent-common/files/init.sql | 8 +- test/backend/app/test_agent_repository_app.py | 125 +++- .../services/test_agent_repository_service.py | 133 +++- 26 files changed, 1436 insertions(+), 1409 deletions(-) delete mode 100644 frontend/app/[locale]/agent-space/components/AgentCard.tsx delete mode 100644 frontend/app/[locale]/agent-space/components/AgentDetailModal.tsx rename frontend/app/[locale]/{agent-repository => agent-space}/components/AgentRepositoryCard.tsx (89%) rename frontend/app/[locale]/{agent-repository => agent-space}/components/AgentRepositoryDetailModal.tsx (100%) rename frontend/app/[locale]/{agent-repository => agent-space}/components/MineAgentsView.tsx (78%) create mode 100644 frontend/app/[locale]/agent-space/components/MineApplyListingModal.tsx rename frontend/app/[locale]/{agent-repository => agent-space}/components/MineReviewStatusModal.tsx (70%) rename frontend/app/[locale]/{agent-repository => agent-space}/components/MyAgentCard.tsx (100%) diff --git a/backend/apps/agent_repository_app.py b/backend/apps/agent_repository_app.py index 5f1bedd1b..460f350fb 100644 --- a/backend/apps/agent_repository_app.py +++ b/backend/apps/agent_repository_app.py @@ -6,13 +6,16 @@ from starlette.responses import JSONResponse from consts.exceptions import SkillDuplicateError, UnauthorizedError -from consts.model import AgentRepositoryListingCreateRequest +from consts.model import ( + AgentRepositoryListingCreateRequest, + AgentRepositoryOptionField, +) from services.agent_repository_service import ( create_agent_repository_listing_impl, get_agent_repository_listing_detail_impl, import_agent_from_repository_impl, - list_agent_repository_categories_impl, list_agent_repository_listings_impl, + list_agent_repository_options_impl, list_my_editable_agents_impl, update_agent_repository_status_impl, ) @@ -63,24 +66,30 @@ async def list_agent_repository_listings_api( ) -@agent_repository_router.get("/categories") -async def list_agent_repository_categories_api( +@agent_repository_router.get("/options") +async def list_agent_repository_options_api( + field: AgentRepositoryOptionField = Query( + ..., + description="Option group to return: categories / icons / tags", + ), authorization: str = Header(None), ): - """List hardcoded marketplace category options for repository filtering.""" + """List hardcoded marketplace listing presets for one option group.""" try: get_current_user_id(authorization) return JSONResponse( status_code=HTTPStatus.OK, - content=list_agent_repository_categories_impl(), + content=list_agent_repository_options_impl(field.value), ) except UnauthorizedError as e: raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) + except ValueError as e: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) except Exception as e: - logger.error(f"List agent repository categories error: {str(e)}") + logger.error(f"List agent repository options error: {str(e)}") raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="List agent repository categories error.", + detail="List agent repository options error.", ) diff --git a/backend/consts/model.py b/backend/consts/model.py index ab47f9a49..ad6ef932d 100644 --- a/backend/consts/model.py +++ b/backend/consts/model.py @@ -644,6 +644,13 @@ class AgentRepositoryCategoryItem(BaseModel): name: str +class AgentRepositoryOptionField(str, Enum): + """Selectable option groups for agent repository listing presets.""" + CATEGORIES = "categories" + ICONS = "icons" + TAGS = "tags" + + class AgentRepositoryListingDetailResponse(BaseModel): """Detailed marketplace listing payload for repository detail view.""" agent_repository_id: int diff --git a/backend/services/agent_repository_service.py b/backend/services/agent_repository_service.py index 24161d01b..ff0b167f5 100644 --- a/backend/services/agent_repository_service.py +++ b/backend/services/agent_repository_service.py @@ -75,6 +75,23 @@ {"id": 0, "name": "其它"}, ] +_AGENT_REPOSITORY_ICONS: List[str] = [ + "🤖", "✍️", "🔍", "📊", "💬", "📝", "🎨", "⚡", "🔧", "📚", +] + +_AGENT_REPOSITORY_TAGS: List[str] = [ + "营销", "文案", "内容创作", "代码审查", "质量", "DevOps", + "数据", "可视化", "BI", "客服", "工单", "自动化", + "会议", "纪要", "效率", "设计", "配色", "灵感", "表格", "办公", +] + +_VALID_REPOSITORY_CATEGORY_IDS: FrozenSet[int] = frozenset( + category["id"] for category in _AGENT_REPOSITORY_CATEGORIES +) +_VALID_REPOSITORY_ICONS: FrozenSet[str] = frozenset(_AGENT_REPOSITORY_ICONS) +_MAX_LISTING_TAGS = 5 +_MAX_LISTING_TAG_LENGTH = 20 + _UPDATE_SNAPSHOT_FIELDS = ( "display_name", "description", @@ -167,9 +184,61 @@ def list_agent_repository_listings_impl( return {"items": [_to_summary_item(record) for record in records]} -def list_agent_repository_categories_impl() -> List[Dict[str, Any]]: - """Return hardcoded marketplace category options for repository filtering.""" - return list(_AGENT_REPOSITORY_CATEGORIES) +def list_agent_repository_options_impl(field: str) -> List[Any]: + """Return hardcoded marketplace listing option presets for one field.""" + options_by_field = { + "categories": list(_AGENT_REPOSITORY_CATEGORIES), + "icons": list(_AGENT_REPOSITORY_ICONS), + "tags": list(_AGENT_REPOSITORY_TAGS), + } + if field not in options_by_field: + raise ValueError(f"Unsupported option field: {field}") + return options_by_field[field] + + +def _normalize_listing_tags(tags: Any) -> List[str]: + """Trim, deduplicate, and validate marketplace listing tags.""" + if not isinstance(tags, list): + raise ValueError("tags must be a list of strings") + + normalized: List[str] = [] + seen: set[str] = set() + for raw_tag in tags: + if not isinstance(raw_tag, str): + raise ValueError("tags must be a list of strings") + tag = raw_tag.strip() + if not tag: + continue + if len(tag) > _MAX_LISTING_TAG_LENGTH: + raise ValueError( + f"Each tag must be at most {_MAX_LISTING_TAG_LENGTH} characters" + ) + if tag in seen: + continue + seen.add(tag) + normalized.append(tag) + + if not normalized: + raise ValueError("tags must contain at least one non-empty tag") + if len(normalized) > _MAX_LISTING_TAGS: + raise ValueError(f"tags must contain at most {_MAX_LISTING_TAGS} items") + return normalized + + +def _validate_card_fields(repository_data: Dict[str, Any]) -> None: + """Validate marketplace card fields required for listing submission.""" + icon = repository_data.get("icon") + if not icon or not isinstance(icon, str) or icon not in _VALID_REPOSITORY_ICONS: + raise ValueError("icon is required and must be a supported marketplace icon") + + category_id = repository_data.get("category_id") + if category_id is None or category_id not in _VALID_REPOSITORY_CATEGORY_IDS: + raise ValueError("category_id is required and must be a supported category") + + tags = repository_data.get("tags") + if tags is None: + raise ValueError("tags is required for marketplace listing submission") + repository_data["tags"] = _normalize_listing_tags(tags) _MY_AGENT_REPOSITORY_STATUSES = frozenset({ @@ -558,6 +627,8 @@ def _validate_create_payload(repository_data: Dict[str, Any]) -> None: if key not in agent_info_json: raise ValueError(f"agent_info_json must contain '{key}'") + _validate_card_fields(repository_data) + async def _build_agent_info_json( agent_id: int, @@ -623,9 +694,11 @@ async def _build_repository_data_from_agent( } if card_fields: - for key in ("icon", "downloads", "tags", "category_id", "tool_count"): + for key in ("icon", "downloads", "category_id", "tool_count"): if key in card_fields and card_fields[key] is not None: repository_data[key] = card_fields[key] + if "tags" in card_fields and card_fields["tags"] is not None: + repository_data["tags"] = _normalize_listing_tags(card_fields["tags"]) return repository_data diff --git a/docker/init.sql b/docker/init.sql index 821907990..ad522a48a 100644 --- a/docker/init.sql +++ b/docker/init.sql @@ -1120,7 +1120,7 @@ INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_ (1109, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/agents', '/agent-dev'), (1110, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/memory', '/agent-dev'); INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_category, permission_type, permission_subtype, parent_key) VALUES -(1111, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-repository', '/resource-space'), +(1111, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-space', '/resource-space'), (1112, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/mcp-space', '/resource-space'), (1113, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/skill-space', '/resource-space'); @@ -1137,7 +1137,7 @@ INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_ (1208, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/agents', '/agent-dev'), (1209, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/memory', '/agent-dev'); INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_category, permission_type, permission_subtype, parent_key) VALUES -(1210, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-repository', '/resource-space'), +(1210, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-space', '/resource-space'), (1211, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/mcp-space', '/resource-space'), (1212, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/skill-space', '/resource-space'); @@ -1161,7 +1161,7 @@ INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_ (1408, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/agents', '/agent-dev'), (1409, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/memory', '/agent-dev'); INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_category, permission_type, permission_subtype, parent_key) VALUES -(1410, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-repository', '/resource-space'), +(1410, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-space', '/resource-space'), (1411, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/mcp-space', '/resource-space'), (1412, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/skill-space', '/resource-space'); @@ -1177,7 +1177,7 @@ INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_ (1507, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/knowledges', '/agent-dev'), (1508, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/agents', '/agent-dev'); INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_category, permission_type, permission_subtype, parent_key) VALUES -(1509, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-repository', '/resource-space'), +(1509, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-space', '/resource-space'), (1510, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/mcp-space', '/resource-space'), (1511, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/skill-space', '/resource-space'); diff --git a/docker/sql/v2.2.2_0622_update_left_nav_menu.sql b/docker/sql/v2.2.2_0622_update_left_nav_menu.sql index 195c9b5ea..2de41f987 100644 --- a/docker/sql/v2.2.2_0622_update_left_nav_menu.sql +++ b/docker/sql/v2.2.2_0622_update_left_nav_menu.sql @@ -13,7 +13,7 @@ ADD COLUMN IF NOT EXISTS parent_key VARCHAR(50); -- New Menu Structure: -- ROOT: /, /chat, /agent-dev, /resource-space, /resource-manage, /owner-manage, /users -- AGENT-DEV: /models, /knowledges, /agents, /memory --- RESOURCE-SPACE: /agent-repository, /mcp-space, /skill-space +-- RESOURCE-SPACE: /agent-space, /mcp-space, /skill-space -- ============================================================ -- ID Format: xx -- SU=10xx, ADMIN=11xx, DEV=12xx, USER=13xx, SPEED=14xx, ASSET_OWNER=15xx @@ -39,7 +39,7 @@ INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_ (1109, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/agents', '/agent-dev'), (1110, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/memory', '/agent-dev'); INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_category, permission_type, permission_subtype, parent_key) VALUES -(1111, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-repository', '/resource-space'), +(1111, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-space', '/resource-space'), (1112, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/mcp-space', '/resource-space'), (1113, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/skill-space', '/resource-space'); @@ -56,7 +56,7 @@ INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_ (1208, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/agents', '/agent-dev'), (1209, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/memory', '/agent-dev'); INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_category, permission_type, permission_subtype, parent_key) VALUES -(1210, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-repository', '/resource-space'), +(1210, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-space', '/resource-space'), (1211, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/mcp-space', '/resource-space'), (1212, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/skill-space', '/resource-space'); @@ -80,7 +80,7 @@ INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_ (1408, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/agents', '/agent-dev'), (1409, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/memory', '/agent-dev'); INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_category, permission_type, permission_subtype, parent_key) VALUES -(1410, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-repository', '/resource-space'), +(1410, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-space', '/resource-space'), (1411, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/mcp-space', '/resource-space'), (1412, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/skill-space', '/resource-space'); @@ -96,6 +96,6 @@ INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_ (1507, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/knowledges', '/agent-dev'), (1508, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/agents', '/agent-dev'); INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_category, permission_type, permission_subtype, parent_key) VALUES -(1509, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-repository', '/resource-space'), +(1509, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-space', '/resource-space'), (1510, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/mcp-space', '/resource-space'), (1511, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/skill-space', '/resource-space'); \ No newline at end of file diff --git a/frontend/app/[locale]/agent-repository/page.tsx b/frontend/app/[locale]/agent-repository/page.tsx index c1d816272..46c60e68c 100644 --- a/frontend/app/[locale]/agent-repository/page.tsx +++ b/frontend/app/[locale]/agent-repository/page.tsx @@ -1,594 +1,17 @@ "use client"; -import { useMemo, useState } from "react"; -import { - App, - Button, - Card, - ConfigProvider, - Empty, - Input, - Modal, - Segmented, - Spin, -} from "antd"; -import { useTranslation } from "react-i18next"; -import { motion } from "framer-motion"; -import { Bot, Check, Clock, Inbox, Search, ShieldCheck, User, X } from "lucide-react"; -import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; -import { USER_ROLES } from "@/const/auth"; -import { useSetupFlow } from "@/hooks/useSetupFlow"; -import { - useAgentRepositoryCategories, - useAgentRepositoryListingDetail, - useAgentRepositoryListings, - useMyEditableAgents, - useUpdateAgentRepositoryStatus, -} from "@/hooks/agentRepository/useAgentRepositoryListings"; -import type { AgentRepositoryCategoryItem, AgentRepositoryListingItem, MineOwnershipFilter } from "@/types/agentRepository"; -import { AgentRepositoryCard } from "./components/AgentRepositoryCard"; -import { AgentRepositoryDetailModal } from "./components/AgentRepositoryDetailModal"; -import { MineAgentsView } from "./components/MineAgentsView"; +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; -enum AgentRepositoryTab { - REPOSITORY = "repository", - MINE = "mine", - REVIEW = "review", -} - -const agentRepositoryTheme = { - token: { colorPrimary: "#2563eb", colorInfo: "#3b82f6" }, -}; - -export default function AgentRepositoryPage() { - const { t } = useTranslation("common"); - const { pageVariants, pageTransition } = useSetupFlow(); - const { user } = useAuthorizationContext(); - const isAdmin = user?.role === USER_ROLES.ADMIN; - - const [tab, setTab] = useState(AgentRepositoryTab.REPOSITORY); - const [searchQuery, setSearchQuery] = useState(""); - const [selectedCategoryId, setSelectedCategoryId] = useState(null); - const [mineOwnership, setMineOwnership] = useState("all"); - const [detailOpen, setDetailOpen] = useState(false); - const [selectedRepositoryId, setSelectedRepositoryId] = useState(null); - - const isRepositoryTab = tab === AgentRepositoryTab.REPOSITORY; - const isReviewTab = tab === AgentRepositoryTab.REVIEW; - const isMineTab = tab === AgentRepositoryTab.MINE; - - const { data: categories = [] } = useAgentRepositoryCategories( - isRepositoryTab || isReviewTab - ); - - const categoryNameById = useMemo( - () => new Map(categories.map((item) => [item.id, item.name])), - [categories] - ); - - const listingParams = { - status: "shared" as const, - ...(selectedCategoryId == null ? {} : { category_id: selectedCategoryId }), - }; - - const { data, isLoading, isError, refetch, isFetching } = - useAgentRepositoryListings(listingParams, isRepositoryTab); - - const { - data: mineData, - isLoading: isMineLoading, - isError: isMineError, - isFetching: isMineFetching, - refetch: refetchMine, - } = useMyEditableAgents(mineOwnership, isMineTab); - - const { - data: reviewData, - isLoading: isReviewLoading, - isError: isReviewError, - isFetching: isReviewFetching, - refetch: refetchReview, - } = useAgentRepositoryListings( - { status: "pending_review", deduplicate_by_agent_id: false }, - isAdmin && isReviewTab - ); - - const updateStatusMutation = useUpdateAgentRepositoryStatus(); - - const { - data: detail, - isLoading: isDetailLoading, - isError: isDetailError, - isFetching: isDetailFetching, - refetch: refetchDetail, - } = useAgentRepositoryListingDetail(selectedRepositoryId, detailOpen); - - const handleDetailClick = (listing: AgentRepositoryListingItem) => { - setSelectedRepositoryId(listing.agent_repository_id); - setDetailOpen(true); - }; - - const handleDetailClose = () => { - setDetailOpen(false); - setSelectedRepositoryId(null); - }; - - const listings = data?.items ?? []; - const reviewListings = reviewData?.items ?? []; - const mineAgents = mineData?.items ?? []; - const mineCounts = mineData?.counts ?? { all: 0, created: 0, others: 0 }; - const pendingReviewCount = reviewListings.length; - - const normalizedQuery = searchQuery.trim().toLowerCase(); - const filteredListings = normalizedQuery - ? listings.filter((item) => { - const title = (item.display_name || item.name || "").toLowerCase(); - const author = (item.author || "").toLowerCase(); - const description = (item.description || "").toLowerCase(); - const tags = (item.tags || []) - .map((tag) => tag.toLowerCase()) - .join(" "); - return ( - title.includes(normalizedQuery) || - author.includes(normalizedQuery) || - description.includes(normalizedQuery) || - tags.includes(normalizedQuery) - ); - }) - : listings; - - const tabOptions = [ - { - value: AgentRepositoryTab.REPOSITORY, - label: ( - - - {t("agentRepository.page.tab.repository")} - - ), - }, - { - value: AgentRepositoryTab.MINE, - label: ( - - - {t("agentRepository.page.tab.mine")} - - ), - }, - ...(isAdmin - ? [ - { - value: AgentRepositoryTab.REVIEW, - label: ( - - - {t("agentRepository.page.tab.review")} - {pendingReviewCount > 0 ? ( - - {pendingReviewCount} - - ) : null} - - ), - }, - ] - : []), - ]; - - return ( - -
-
- -
-
-
-
- -
-
-

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

-

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

-
-
-
- -
- setTab(value as AgentRepositoryTab)} - options={tabOptions} - className="h-9 w-full max-w-md rounded-md border border-slate-200 bg-slate-100 p-[2px] text-sm shadow-sm sm:w-auto" - /> - {isRepositoryTab ? ( - - {t("agentRepository.page.resultCount", { - count: filteredListings.length, - })} - - ) : isMineTab ? ( - - {t("agentRepository.mine.resultCount", { - count: mineCounts[mineOwnership], - })} - - ) : null} -
- - {isRepositoryTab ? ( - refetch()} - listings={filteredListings} - onDetailClick={handleDetailClick} - /> - ) : isReviewTab ? ( - refetchReview()} - onDetailClick={handleDetailClick} - updatingRepositoryId={ - updateStatusMutation.isPending - ? updateStatusMutation.variables?.agentRepositoryId ?? null - : null - } - onApprove={(listing) => - updateStatusMutation.mutateAsync({ - agentRepositoryId: listing.agent_repository_id, - status: "shared", - }) - } - onReject={(listing) => - updateStatusMutation.mutateAsync({ - agentRepositoryId: listing.agent_repository_id, - status: "rejected", - }) - } - /> - ) : isMineTab ? ( - refetchMine()} - /> - ) : null} -
-
-
-
- refetchDetail()} - /> -
- ); -} - -function RepositoryView({ - searchQuery, - onSearchChange, - categories, - selectedCategoryId, - onCategoryChange, - isLoading, - isError, - isFetching, - onRetry, - listings, - onDetailClick, -}: { - searchQuery: string; - onSearchChange: (value: string) => void; - categories: AgentRepositoryCategoryItem[]; - selectedCategoryId: number | null; - onCategoryChange: (categoryId: number | null) => void; - isLoading: boolean; - isError: boolean; - isFetching: boolean; - onRetry: () => void; - listings: AgentRepositoryListingItem[]; - onDetailClick: (listing: AgentRepositoryListingItem) => void; -}) { - const { t } = useTranslation("common"); - - return ( -
-
- - onSearchChange(e.target.value)} - placeholder={t("agentRepository.page.searchPlaceholder")} - className="h-11 rounded-xl pl-10" - allowClear - /> -
- -
- - {categories.map((category) => ( - - ))} -
- -

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

- - {isLoading ? ( -
- -
- ) : isError ? ( -
-

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

- -
- ) : listings.length === 0 ? ( - - ) : ( -
- {listings.map((listing) => ( - - ))} -
- )} -
- ); -} - -function ReviewCenterView({ - listings, - categoryNameById, - isLoading, - isError, - isFetching, - onRetry, - onDetailClick, - updatingRepositoryId, - onApprove, - onReject, -}: { - listings: AgentRepositoryListingItem[]; - categoryNameById: Map; - isLoading: boolean; - isError: boolean; - isFetching: boolean; - onRetry: () => void; - onDetailClick: (listing: AgentRepositoryListingItem) => void; - updatingRepositoryId: number | null; - onApprove: (listing: AgentRepositoryListingItem) => Promise; - onReject: (listing: AgentRepositoryListingItem) => Promise; -}) { - const { t } = useTranslation("common"); - const { message } = App.useApp(); - - const getListingTitle = (listing: AgentRepositoryListingItem) => - listing.display_name?.trim() || - listing.name?.trim() || - t("agentRepository.card.untitled"); - - const confirmReviewAction = ( - listing: AgentRepositoryListingItem, - action: "approve" | "reject" - ) => { - const title = getListingTitle(listing); - const isApprove = action === "approve"; - - Modal.confirm({ - title: isApprove - ? t("agentRepository.review.confirmApproveTitle") - : t("agentRepository.review.confirmRejectTitle"), - content: isApprove - ? t("agentRepository.review.confirmApproveContent", { name: title }) - : t("agentRepository.review.confirmRejectContent", { name: title }), - okText: isApprove - ? t("agentRepository.review.approve") - : t("agentRepository.review.reject"), - cancelText: t("common.cancel"), - okButtonProps: isApprove - ? undefined - : { danger: true }, - onOk: async () => { - try { - await (isApprove ? onApprove(listing) : onReject(listing)); - message.success( - isApprove - ? t("agentRepository.review.approveSuccess", { name: title }) - : t("agentRepository.review.rejectSuccess", { name: title }) - ); - } catch { - message.error( - isApprove - ? t("agentRepository.review.approveError") - : t("agentRepository.review.rejectError") - ); - throw new Error("Review action failed"); - } - }, - }); - }; - - return ( -
- -
- -

- {t("agentRepository.review.title")} -

- - {t("agentRepository.review.pendingCount", { count: listings.length })} - -
-

- {t("agentRepository.review.description")} -

-
+/** + * Legacy Agent Repository route — redirects to Agent Space. + */ +export default function AgentRepositoryRedirectPage() { + const router = useRouter(); - {isLoading ? ( -
- -
- ) : isError ? ( -
-

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

- -
- ) : listings.length === 0 ? ( - - ) : ( -
- {listings.map((listing) => { - const title = getListingTitle(listing); - const isUpdating = - updatingRepositoryId === listing.agent_repository_id; - const submitter = - listing.submitted_by?.trim() || - t("agentRepository.review.unknownSubmitter"); - const categoryName = - listing.category_id != null - ? categoryNameById.get(listing.category_id) ?? - t("agentRepository.review.unknownCategory") - : t("agentRepository.review.unknownCategory"); + useEffect(() => { + router.replace("/agent-space"); + }, [router]); - return ( - -
-
-
- {listing.icon?.trim() ? ( - {listing.icon.trim()} - ) : ( - - )} -
-
-
-

- {title} -

- - - {t("agentRepository.detail.status.pending_review")} - -
-

- {listing.description?.trim() || - t("agentRepository.card.noDescription")} -

-

- {t("agentRepository.review.submitter", { name: submitter })} - {" · "} - {categoryName} -

-
-
-
- - - -
-
-
- ); - })} -
- )} -
- ); + return null; } diff --git a/frontend/app/[locale]/agent-space/components/AgentCard.tsx b/frontend/app/[locale]/agent-space/components/AgentCard.tsx deleted file mode 100644 index cd4ecb57a..000000000 --- a/frontend/app/[locale]/agent-space/components/AgentCard.tsx +++ /dev/null @@ -1,345 +0,0 @@ -"use client"; - -import React, { useState, useMemo, useEffect } from "react"; -import { useTranslation } from "react-i18next"; -import { useRouter } from "next/navigation"; -import { App } from "antd"; -import { - Trash2, - Download, - Network, - MessageSquare, - CheckCircle, - XCircle, - Edit, - Sparkles, -} from "lucide-react"; -import { useQueryClient } from "@tanstack/react-query"; - -import { Avatar } from "antd"; -import AgentCallRelationshipModal from "@/components/agent/AgentCallRelationshipModal"; -import AgentDetailModal from "./AgentDetailModal"; -import { - deleteAgent, - exportAgent, - searchAgentInfo, - clearAgentNewMark, -} from "@/services/agentConfigService"; -import { generateAvatarFromName } from "@/lib/avatar"; -import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; -import { useDeployment } from "@/components/providers/deploymentProvider"; -import { useConfirmModal } from "@/hooks/useConfirmModal"; -import { USER_ROLES } from "@/const/auth"; -import { Agent } from "@/types/agentConfig"; -import log from "@/lib/logger"; - -interface AgentCardProps { - agent: Agent; - onRefresh: () => void; -} - -export default function AgentCard({ agent, onRefresh }: AgentCardProps) { - const { t } = useTranslation("common"); - const { message } = App.useApp(); - const { user } = useAuthorizationContext(); - const { isSpeedMode } = useDeployment(); - const { confirm } = useConfirmModal(); - const router = useRouter(); - - const [isDeleting, setIsDeleting] = useState(false); - const [isExporting, setIsExporting] = useState(false); - const [showRelationship, setShowRelationship] = useState(false); - const [showDetail, setShowDetail] = useState(false); - const [agentDetails, setAgentDetails] = useState(null); - const [isLoadingDetails, setIsLoadingDetails] = useState(false); - - - // Generate avatar URL from agent name - const avatarUrl = generateAvatarFromName(agent.display_name || agent.name); - - // Check if agent is new (marked as new in database) - const [isNewAgent, setIsNewAgent] = useState(() => agent.is_new || false); - - // Keep local isNewAgent state in sync when prop changes (e.g., after refresh) - useEffect(() => { - setIsNewAgent(agent.is_new || false); - }, [agent.is_new]); - - // Handle delete agent - const handleDelete = () => { - confirm({ - title: t("space.deleteConfirm.title", "Delete Agent"), - content: t( - "space.deleteConfirm.content", - `Are you sure you want to delete agent "${agent.display_name}"? This action cannot be undone.` - ), - onOk: async () => { - setIsDeleting(true); - try { - const result = await deleteAgent(parseInt(agent.id)); - if (result.success) { - message.success( - t("space.deleteSuccess", "Agent deleted successfully") - ); - onRefresh(); - } else { - message.error(result.message || "Failed to delete agent"); - } - } catch (error) { - log.error("Failed to delete agent:", error); - message.error("Failed to delete agent"); - } finally { - setIsDeleting(false); - } - }, - }); - }; - - // Handle export agent - const handleExport = async () => { - setIsExporting(true); - try { - const result = await exportAgent(parseInt(agent.id)); - if (result.success && result.data) { - // Create a download link - const dataStr = JSON.stringify(result.data, null, 2); - const dataBlob = new Blob([dataStr], { type: "application/json" }); - const url = URL.createObjectURL(dataBlob); - const link = document.createElement("a"); - link.href = url; - link.download = `agent_${agent.name}_${Date.now()}.json`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - - message.success( - t("space.exportSuccess", "Agent exported successfully") - ); - } else { - message.error(result.message || "Failed to export agent"); - } - } catch (error) { - log.error("Failed to export agent:", error); - message.error("Failed to export agent"); - } finally { - setIsExporting(false); - } - }; - - // Handle view relationship - const handleViewRelationship = () => { - setShowRelationship(true); - }; - - const handleChat = () => { - if (agent.id) { - sessionStorage.setItem("selectedAgentId", agent.id); - router.push("/chat"); - } - }; - - // Handle edit - navigate to agents view with agent id - const handleEdit = () => { - router.push(`/agents?agent_id=${agent.id}`); - }; - - const queryClient = useQueryClient(); - - // Handle view detail - const handleViewDetail = async () => { - // Mark agent as viewed (clear NEW marker in database) - if (isNewAgent) { - try { - const result = await clearAgentNewMark(agent.id); - if (result?.success) { - setIsNewAgent(false); - queryClient.invalidateQueries({ queryKey: ["agents"] }); - } else { - log.warn("Failed to clear NEW mark for agent", agent.id, result); - } - } catch (error) { - log.error("Error clearing NEW mark:", error); - } - } - - setShowDetail(true); - setIsLoadingDetails(true); - try { - // Use current_version_no if available (the currently published version) - // Falls back to 0 only if not set (for unpublished/draft agents) - const versionNo = agent.current_version_no ?? 0; - const result = await searchAgentInfo(parseInt(agent.id), undefined, versionNo); - if (result.success) { - setAgentDetails(result.data); - } else { - message.error(result.message || "Failed to load agent details"); - } - } catch (error) { - log.error("Failed to load agent details:", error); - message.error("Failed to load agent details"); - } finally { - setIsLoadingDetails(false); - } - }; - - return ( - <> -
- {/* Avatar and Status badge */} -
- - - {agent.display_name?.charAt(0)?.toUpperCase() || "A"} - - - - {/* Status badge and NEW marker */} -
- {/* NEW marker */} - {isNewAgent && ( -
- - {t("space.new", "NEW")} -
- )} - - {/* Status badge */} - {agent.is_available ? ( -
- - {t("space.status.available", "Available")} -
- ) : ( -
- - {t("space.status.unavailable", "Unavailable")} -
- )} -
-
- - {/* Agent info - flexible height */} -
-

- {agent.display_name || agent.name} -

- {agent.author ? ( -

- {t("market.by", { - defaultValue: "By {{author}}", - author: agent.author, - })} -

- ) : ( -
- )} -
-

- {agent.description || t("space.noDescription", "No description")} -

-
-
- - {/* Action buttons */} -
- - - - - - - - - - - - -
-
- - {/* Relationship Modal */} - setShowRelationship(false)} - agentId={parseInt(agent.id)} - agentName={agent.display_name || agent.name} - /> - - {/* Detail Modal */} - setShowDetail(false)} - agentDetails={agentDetails} - loading={isLoadingDetails} - /> - - ); -} diff --git a/frontend/app/[locale]/agent-space/components/AgentDetailModal.tsx b/frontend/app/[locale]/agent-space/components/AgentDetailModal.tsx deleted file mode 100644 index 0b574dbbf..000000000 --- a/frontend/app/[locale]/agent-space/components/AgentDetailModal.tsx +++ /dev/null @@ -1,377 +0,0 @@ -"use client"; - -import React from "react"; -import { Modal, Tabs, Tag, Descriptions, Empty, Avatar, Alert } from "antd"; -import { useTranslation } from "react-i18next"; -import { - CheckCircle, - XCircle, - Bot, - Settings, - FileText, - Wrench, - Users, - Sparkles, -} from "lucide-react"; -// Using AntD Avatar directly in this component -import { generateAvatarFromName } from "@/lib/avatar"; -import { getToolSourceLabel, getCategoryLabel } from "@/lib/agentLabelMapper"; -import { getLocalizedDescription } from "@/lib/utils"; -import { - isAgentPromptsHidden, - renderAgentPromptFieldValue, -} from "@/lib/agentPromptVisibility"; - -interface AgentDetailModalProps { - visible: boolean; - onClose: () => void; - agentDetails: any; - loading: boolean; -} - -export default function AgentDetailModal({ - visible, - onClose, - agentDetails, - loading, -}: AgentDetailModalProps) { - const { t } = useTranslation("common"); - - if (!agentDetails && !loading) { - return null; - } - - // Generate avatar URL from agent name (same as AgentCard) - const avatarUrl = agentDetails - ? generateAvatarFromName(agentDetails.display_name || agentDetails.name) - : ""; - - const items = [ - { - key: "basic", - label: ( - - - {t("space.detail.tabs.basic", "Basic Info")} - - ), - children: ( -
- - - {agentDetails?.id || "-"} - - - {agentDetails?.name || "-"} - - - {agentDetails?.display_name || "-"} - - - {agentDetails?.description || "-"} - - - {agentDetails?.is_available ? ( - } color="success" className="inline-flex items-center gap-1"> - {t("space.status.available", "Available")} - - ) : ( - } color="error" className="inline-flex items-center gap-1"> - {t("space.status.unavailable", "Unavailable")} - - )} - - -
- ), - }, - { - key: "model", - label: ( - - - {t("space.detail.tabs.model", "Model Config")} - - ), - children: ( -
- - - {agentDetails?.business_logic_model_name || "-"} - - - {agentDetails?.model || "-"} - - - {agentDetails?.max_step || 0} - - - {agentDetails?.provide_run_summary ? ( - {t("common.yes", "Yes")} - ) : ( - {t("common.no", "No")} - )} - - -
- ), - }, - { - key: "prompts", - label: ( - - - {t("space.detail.tabs.prompts", "Prompts")} - - ), - children: ( -
- {isAgentPromptsHidden(agentDetails) && ( - - )} -
-

- - {t("space.detail.dutyPrompt", "Duty Prompt")} -

-
-
-                {renderAgentPromptFieldValue(agentDetails, "duty_prompt", t)}
-              
-
-
-
-

- - {t("space.detail.constraintPrompt", "Constraint Prompt")} -

-
-
-                {renderAgentPromptFieldValue(agentDetails, "constraint_prompt", t)}
-              
-
-
-
-

- - {t("space.detail.fewShotsPrompt", "Few-Shots Prompt")} -

-
-
-                {renderAgentPromptFieldValue(agentDetails, "few_shots_prompt", t)}
-              
-
-
-
-

- - {t("space.detail.businessDescription", "Business Description")} -

-
-
-                {agentDetails?.business_description || t("common.none", "None")}
-              
-
-
-
- ), - }, - { - key: "tools", - label: ( - - - {t("space.detail.tabs.tools", "Tools")} ({agentDetails?.tools?.length || 0}) - - ), - children: ( -
- {agentDetails?.tools && agentDetails.tools.length > 0 ? ( - agentDetails.tools.map((tool: any) => ( -
-
-
-

{tool.name}

-

- {getLocalizedDescription(tool.description, tool.description_zh) || t("space.noDescription", "No description")} -

-
- {tool.is_available ? ( - } color="success" className="inline-flex items-center gap-1 ml-2"> - {t("space.status.available", "Available")} - - ) : ( - } color="error" className="inline-flex items-center gap-1 ml-2"> - {t("space.status.unavailable", "Unavailable")} - - )} -
-
- {tool.source && ( - - {t("common.source", "Source")}: {getToolSourceLabel(tool.source, t)} - - )} - {tool.category && ( - - {t("common.category", "Category")}: {getCategoryLabel(tool.category, t)} - - )} - {tool.usage && ( - - {t("common.usage", "Usage")}: {tool.usage} - - )} -
- {(() => { - let parsedInputs: Record = {}; - try { - parsedInputs = tool.inputs ? JSON.parse(tool.inputs) : {}; - } catch { - parsedInputs = {}; - } - return Object.keys(parsedInputs).length > 0 ? ( -
-
- {t("space.detail.inputParameters", "Input Parameters")}: -
-
- {Object.entries(parsedInputs).map(([key, value]) => ( -
- {key} - - ({value.type}) - - {getLocalizedDescription(value.description, value.description_zh) && ( -
- {getLocalizedDescription(value.description, value.description_zh)} -
- )} -
- ))} -
-
- ) : null; - })()} - {tool.initParams && tool.initParams.length > 0 && ( -
-
- {t("space.detail.parameters", "Parameters")}: -
-
- {tool.initParams.map((param: any, idx: number) => ( -
- {param.name} - {param.required && ( - - {t("common.required", "Required")} - - )} - - ({param.type}) - - {getLocalizedDescription(param.description, param.description_zh) && ( -
- {getLocalizedDescription(param.description, param.description_zh)} -
- )} -
- ))} -
-
- )} -
- )) - ) : ( - - )} -
- ), - }, - { - key: "subAgents", - label: ( - - - {t("space.detail.tabs.subAgents", "Sub Agents")} ( - {agentDetails?.sub_agent_id_list?.length || 0}) - - ), - children: ( -
- {agentDetails?.sub_agent_id_list && agentDetails.sub_agent_id_list.length > 0 ? ( -
- {agentDetails.sub_agent_id_list.map((subAgentId: string) => ( -
-
- - {t("space.detail.subAgentId", "Sub Agent ID")}: - {subAgentId} -
-
- ))} -
- ) : ( - - )} -
- ), - }, - ]; - - return ( - - - - {agentDetails?.display_name?.charAt(0)?.toUpperCase() || agentDetails?.name?.charAt(0)?.toUpperCase() || "A"} - - -
-
- {agentDetails?.display_name || agentDetails?.name || t("space.detail.title", "Agent Details")} -
-
- {t("space.detail.subtitle", "Detailed configuration and information")} -
-
-
- } - open={visible} - onCancel={onClose} - footer={null} - width={800} - style={{ top: 20, maxHeight: 'calc(100vh - 40px)' }} - styles={{ body: { maxHeight: 'calc(100vh - 180px)', overflowY: 'auto' } }} - className="agent-detail-modal" - > -
- {loading ? ( -
-
-
- ) : ( - - )} -
- - ); -} - diff --git a/frontend/app/[locale]/agent-repository/components/AgentRepositoryCard.tsx b/frontend/app/[locale]/agent-space/components/AgentRepositoryCard.tsx similarity index 89% rename from frontend/app/[locale]/agent-repository/components/AgentRepositoryCard.tsx rename to frontend/app/[locale]/agent-space/components/AgentRepositoryCard.tsx index f6103b404..70e2efed4 100644 --- a/frontend/app/[locale]/agent-repository/components/AgentRepositoryCard.tsx +++ b/frontend/app/[locale]/agent-space/components/AgentRepositoryCard.tsx @@ -7,14 +7,23 @@ import type { AgentRepositoryListingItem } from "@/types/agentRepository"; interface AgentRepositoryCardProps { listing: AgentRepositoryListingItem; + categoryName?: string | null; onDetailClick?: (listing: AgentRepositoryListingItem) => void; } -export function AgentRepositoryCard({ listing, onDetailClick }: AgentRepositoryCardProps) { +export function AgentRepositoryCard({ + listing, + categoryName, + onDetailClick, +}: AgentRepositoryCardProps) { const { t } = useTranslation("common"); const title = listing.display_name?.trim() || listing.name?.trim() || t("agentRepository.card.untitled"); + const author = listing.author?.trim(); + const category = + categoryName?.trim() || t("agentRepository.review.unknownCategory"); + const subtitle = author ? `${author} · ${category}` : category; const tags = listing.tags?.filter((tag) => tag.trim()) ?? []; const toolCount = listing.tool_count ?? 0; const versionText = listing.version_label; @@ -37,11 +46,9 @@ export function AgentRepositoryCard({ listing, onDetailClick }: AgentRepositoryC

{title}

- {listing.author ? ( -

- {listing.author} -

- ) : null} +

+ {subtitle} +

diff --git a/frontend/app/[locale]/agent-repository/components/AgentRepositoryDetailModal.tsx b/frontend/app/[locale]/agent-space/components/AgentRepositoryDetailModal.tsx similarity index 100% rename from frontend/app/[locale]/agent-repository/components/AgentRepositoryDetailModal.tsx rename to frontend/app/[locale]/agent-space/components/AgentRepositoryDetailModal.tsx diff --git a/frontend/app/[locale]/agent-repository/components/MineAgentsView.tsx b/frontend/app/[locale]/agent-space/components/MineAgentsView.tsx similarity index 78% rename from frontend/app/[locale]/agent-repository/components/MineAgentsView.tsx rename to frontend/app/[locale]/agent-space/components/MineAgentsView.tsx index 4fc002b10..6c147a698 100644 --- a/frontend/app/[locale]/agent-repository/components/MineAgentsView.tsx +++ b/frontend/app/[locale]/agent-space/components/MineAgentsView.tsx @@ -11,14 +11,17 @@ import { } from "@/hooks/agentRepository/useAgentRepositoryListings"; import { isCancelableRepositoryStatus, + isTakeDownableRepositoryStatus, pickReviewDisplayRepositoryInfo, } from "@/lib/agentRepositoryMine"; import type { + AgentRepositoryListingCreatePayload, MineOwnershipFilter, MyAgentRepositoryInfoItem, MyEditableAgentItem, MyEditableAgentOwnershipCounts, } from "@/types/agentRepository"; +import { MineApplyListingModal } from "./MineApplyListingModal"; import { MineReviewStatusModal } from "./MineReviewStatusModal"; import { MyAgentCard } from "./MyAgentCard"; @@ -64,6 +67,9 @@ export function MineAgentsView({ "review" | "reviewUpdate" >("review"); const [applyingAgentId, setApplyingAgentId] = useState(null); + const [applyModalOpen, setApplyModalOpen] = useState(false); + const [applyModalAgent, setApplyModalAgent] = + useState(null); const createListingMutation = useCreateAgentRepositoryListing(); const updateStatusMutation = useUpdateAgentRepositoryStatus(); @@ -90,23 +96,47 @@ export function MineAgentsView({ setReviewModalInfo(null); }; - const handleApplyListing = async (agent: MyEditableAgentItem) => { + const handleApplyListing = (agent: MyEditableAgentItem) => { const versionNo = agent.current_version_no ?? 0; if (versionNo <= 0) { return; } + setApplyModalAgent(agent); + setApplyModalOpen(true); + }; + + const closeApplyModal = () => { + setApplyModalOpen(false); + setApplyModalAgent(null); + }; - setApplyingAgentId(agent.agent_id); + const handleSubmitApplyListing = async ( + payload: AgentRepositoryListingCreatePayload + ) => { + if (!applyModalAgent) { + return; + } + + const versionNo = applyModalAgent.current_version_no ?? 0; + if (versionNo <= 0) { + return; + } + + setApplyingAgentId(applyModalAgent.agent_id); try { await createListingMutation.mutateAsync({ - agentId: agent.agent_id, + agentId: applyModalAgent.agent_id, versionNo, + payload, }); message.success( t("agentRepository.mine.applySuccess", { - name: agent.name?.trim() || t("agentRepository.card.untitled"), + name: + applyModalAgent.name?.trim() || + t("agentRepository.card.untitled"), }) ); + closeApplyModal(); } catch { message.error(t("agentRepository.mine.applyError")); } finally { @@ -130,20 +160,38 @@ export function MineAgentsView({ setReviewModalOpen(true); }; - const handleCancelApply = async () => { - if (!reviewModalInfo || !isCancelableRepositoryStatus(reviewModalInfo.status)) { + 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(t("agentRepository.mine.cancelApplySuccess")); + message.success( + wasShared + ? t("agentRepository.mine.takeDownSuccess") + : t("agentRepository.mine.cancelApplySuccess") + ); closeReviewModal(); } catch { - message.error(t("agentRepository.mine.cancelApplyError")); + message.error( + wasShared + ? t("agentRepository.mine.takeDownError") + : t("agentRepository.mine.cancelApplyError") + ); + throw new Error("Update repository status failed"); } }; @@ -236,14 +284,22 @@ export function MineAgentsView({ )} + + ); 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 000000000..deeec2313 --- /dev/null +++ b/frontend/app/[locale]/agent-space/components/MineApplyListingModal.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { App, Button, Modal, Select, Spin } from "antd"; +import { Share2 } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { useAgentRepositoryOptions } from "@/hooks/agentRepository/useAgentRepositoryListings"; +import type { + AgentRepositoryListingCreatePayload, + MyEditableAgentItem, +} from "@/types/agentRepository"; + +const MAX_TAGS = 5; +const MAX_TAG_LENGTH = 20; + +interface MineApplyListingModalProps { + open: boolean; + agent: MyEditableAgentItem | null; + isSubmitting?: boolean; + onClose: () => void; + onSubmit: (payload: AgentRepositoryListingCreatePayload) => void; +} + +export function MineApplyListingModal({ + open, + agent, + isSubmitting = false, + onClose, + onSubmit, +}: MineApplyListingModalProps) { + const { t } = useTranslation("common"); + const { message } = App.useApp(); + + const { data: icons = [], isLoading: isIconsLoading } = + useAgentRepositoryOptions("icons", open); + const { data: categories = [], isLoading: isCategoriesLoading } = + useAgentRepositoryOptions("categories", open); + const { data: presetTags = [], isLoading: isTagsLoading } = + useAgentRepositoryOptions("tags", open); + + const [selectedIcon, setSelectedIcon] = useState(null); + const [selectedCategoryId, setSelectedCategoryId] = useState( + null + ); + const [selectedTags, setSelectedTags] = useState([]); + + const isOptionsLoading = + isIconsLoading || isCategoriesLoading || isTagsLoading; + + const tagOptions = useMemo( + () => + presetTags.map((tag) => ({ + label: tag, + value: tag, + })), + [presetTags] + ); + + useEffect(() => { + if (!open) { + return; + } + setSelectedIcon(icons[0] ?? null); + setSelectedCategoryId(categories[0]?.id ?? null); + setSelectedTags([]); + }, [open, icons, categories]); + + if (!agent) { + return null; + } + + const title = agent.name?.trim() || t("agentRepository.card.untitled"); + + const normalizeTags = (tags: string[]) => { + const normalized: string[] = []; + const seen = new Set(); + for (const rawTag of tags) { + const tag = rawTag.trim(); + if (!tag || seen.has(tag)) { + continue; + } + seen.add(tag); + normalized.push(tag); + } + return normalized; + }; + + const handleSubmit = () => { + if (!selectedIcon) { + message.warning(t("agentRepository.mine.applyModal.validation.icon")); + return; + } + if (selectedCategoryId == null) { + message.warning(t("agentRepository.mine.applyModal.validation.category")); + return; + } + + const tags = normalizeTags(selectedTags); + if (tags.length === 0) { + message.warning(t("agentRepository.mine.applyModal.validation.tags")); + return; + } + if (tags.length > MAX_TAGS) { + message.warning( + t("agentRepository.mine.applyModal.validation.tagsMax", { + count: MAX_TAGS, + }) + ); + return; + } + if (tags.some((tag) => tag.length > MAX_TAG_LENGTH)) { + message.warning( + t("agentRepository.mine.applyModal.validation.tagLength", { + count: MAX_TAG_LENGTH, + }) + ); + return; + } + + onSubmit({ + icon: selectedIcon, + category_id: selectedCategoryId, + tags, + }); + }; + + return ( + + + {t("agentRepository.mine.applyModal.title")} + + } + footer={ +
+ + +
+ } + > +

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

+ + {isOptionsLoading ? ( +
+ +
+ ) : ( +
+
+

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

+
+ {icons.map((icon) => { + const isSelected = selectedIcon === icon; + return ( + + ); + })} +
+
+ +
+

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

+ +

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

+
+
+ )} +
+ ); +} diff --git a/frontend/app/[locale]/agent-repository/components/MineReviewStatusModal.tsx b/frontend/app/[locale]/agent-space/components/MineReviewStatusModal.tsx similarity index 70% rename from frontend/app/[locale]/agent-repository/components/MineReviewStatusModal.tsx rename to frontend/app/[locale]/agent-space/components/MineReviewStatusModal.tsx index 984350f2a..f7bd67a76 100644 --- a/frontend/app/[locale]/agent-repository/components/MineReviewStatusModal.tsx +++ b/frontend/app/[locale]/agent-space/components/MineReviewStatusModal.tsx @@ -1,12 +1,13 @@ "use client"; import { Button, Modal } from "antd"; -import { CheckCircle2, Clock, Store, XCircle } from "lucide-react"; +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, @@ -18,9 +19,9 @@ interface MineReviewStatusModalProps { agent: MyEditableAgentItem | null; repositoryInfo: MyAgentRepositoryInfoItem | null; mode: "review" | "reviewUpdate"; - isCancelling?: boolean; + isUpdatingStatus?: boolean; onClose: () => void; - onCancelApply: () => void; + onSetNotShared: () => Promise; } export function MineReviewStatusModal({ @@ -28,9 +29,9 @@ export function MineReviewStatusModal({ agent, repositoryInfo, mode, - isCancelling = false, + isUpdatingStatus = false, onClose, - onCancelApply, + onSetNotShared, }: MineReviewStatusModalProps) { const { t } = useTranslation("common"); @@ -42,6 +43,7 @@ export function MineReviewStatusModal({ 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); @@ -78,23 +80,73 @@ export function MineReviewStatusModal({ ? t("agentRepository.mine.reviewModal.reviewUpdateTitle") : t("agentRepository.mine.reviewModal.title"); + const confirmCancelApply = () => { + Modal.confirm({ + title: t("agentRepository.mine.reviewModal.confirmCancelApplyTitle"), + content: t("agentRepository.mine.reviewModal.confirmCancelApplyContent", { + name: title, + }), + okText: t("agentRepository.mine.reviewModal.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("agentRepository.mine.reviewModal.confirmTakeDownTitle"), + content: t("agentRepository.mine.reviewModal.confirmTakeDownContent", { + name: title, + }), + okText: t("agentRepository.mine.reviewModal.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={ diff --git a/frontend/app/[locale]/agent-repository/components/MyAgentCard.tsx b/frontend/app/[locale]/agent-space/components/MyAgentCard.tsx similarity index 100% rename from frontend/app/[locale]/agent-repository/components/MyAgentCard.tsx rename to frontend/app/[locale]/agent-space/components/MyAgentCard.tsx diff --git a/frontend/app/[locale]/agent-space/page.tsx b/frontend/app/[locale]/agent-space/page.tsx index 5ed29870e..f8a1f2f8c 100644 --- a/frontend/app/[locale]/agent-space/page.tsx +++ b/frontend/app/[locale]/agent-space/page.tsx @@ -1,17 +1,603 @@ -"use client"; +"use client"; -import { useEffect } from "react"; -import { useRouter } from "next/navigation"; +import { useMemo, useState } from "react"; +import { + App, + Button, + Card, + ConfigProvider, + Empty, + Input, + Modal, + Segmented, + Spin, +} from "antd"; +import { useTranslation } from "react-i18next"; +import { motion } from "framer-motion"; +import { Bot, Check, Clock, Inbox, Search, ShieldCheck, User, X } from "lucide-react"; +import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; +import { USER_ROLES } from "@/const/auth"; +import { useSetupFlow } from "@/hooks/useSetupFlow"; +import { + useAgentRepositoryOptions, + useAgentRepositoryListingDetail, + useAgentRepositoryListings, + useMyEditableAgents, + useUpdateAgentRepositoryStatus, +} from "@/hooks/agentRepository/useAgentRepositoryListings"; +import type { AgentRepositoryCategoryItem, AgentRepositoryListingItem, MineOwnershipFilter } from "@/types/agentRepository"; +import { AgentRepositoryCard } from "./components/AgentRepositoryCard"; +import { AgentRepositoryDetailModal } from "./components/AgentRepositoryDetailModal"; +import { MineAgentsView } from "./components/MineAgentsView"; -/** - * Legacy Agent Space route — redirects to Agent Repository. - */ -export default function AgentSpaceRedirectPage() { - const router = useRouter(); +enum AgentRepositoryTab { + REPOSITORY = "repository", + MINE = "mine", + REVIEW = "review", +} + +const agentRepositoryTheme = { + token: { colorPrimary: "#2563eb", colorInfo: "#3b82f6" }, +}; + +export default function AgentRepositoryPage() { + const { t } = useTranslation("common"); + const { pageVariants, pageTransition } = useSetupFlow(); + const { user } = useAuthorizationContext(); + const isAdmin = user?.role === USER_ROLES.ADMIN; + + const [tab, setTab] = useState(AgentRepositoryTab.REPOSITORY); + const [searchQuery, setSearchQuery] = useState(""); + const [selectedCategoryId, setSelectedCategoryId] = useState(null); + const [mineOwnership, setMineOwnership] = useState("all"); + const [detailOpen, setDetailOpen] = useState(false); + const [selectedRepositoryId, setSelectedRepositoryId] = useState(null); + + const isRepositoryTab = tab === AgentRepositoryTab.REPOSITORY; + const isReviewTab = tab === AgentRepositoryTab.REVIEW; + const isMineTab = tab === AgentRepositoryTab.MINE; + + const { data: categories = [] } = useAgentRepositoryOptions( + "categories", + isRepositoryTab || isReviewTab + ); + + const categoryNameById = useMemo( + () => new Map(categories.map((item) => [item.id, item.name])), + [categories] + ); + + const listingParams = { + status: "shared" as const, + ...(selectedCategoryId == null ? {} : { category_id: selectedCategoryId }), + }; + + const { data, isLoading, isError, refetch, isFetching } = + useAgentRepositoryListings(listingParams, isRepositoryTab); + + const { + data: mineData, + isLoading: isMineLoading, + isError: isMineError, + isFetching: isMineFetching, + refetch: refetchMine, + } = useMyEditableAgents(mineOwnership, isMineTab); + + const { + data: reviewData, + isLoading: isReviewLoading, + isError: isReviewError, + isFetching: isReviewFetching, + refetch: refetchReview, + } = useAgentRepositoryListings( + { status: "pending_review", deduplicate_by_agent_id: false }, + isAdmin && isReviewTab + ); + + const updateStatusMutation = useUpdateAgentRepositoryStatus(); + + const { + data: detail, + isLoading: isDetailLoading, + isError: isDetailError, + isFetching: isDetailFetching, + refetch: refetchDetail, + } = useAgentRepositoryListingDetail(selectedRepositoryId, detailOpen); + + const handleDetailClick = (listing: AgentRepositoryListingItem) => { + setSelectedRepositoryId(listing.agent_repository_id); + setDetailOpen(true); + }; + + const handleDetailClose = () => { + setDetailOpen(false); + setSelectedRepositoryId(null); + }; + + const listings = data?.items ?? []; + const reviewListings = reviewData?.items ?? []; + const mineAgents = mineData?.items ?? []; + const mineCounts = mineData?.counts ?? { all: 0, created: 0, others: 0 }; + const pendingReviewCount = reviewListings.length; + + const normalizedQuery = searchQuery.trim().toLowerCase(); + const filteredListings = normalizedQuery + ? listings.filter((item) => { + const title = (item.display_name || item.name || "").toLowerCase(); + const author = (item.author || "").toLowerCase(); + const description = (item.description || "").toLowerCase(); + const tags = (item.tags || []) + .map((tag) => tag.toLowerCase()) + .join(" "); + return ( + title.includes(normalizedQuery) || + author.includes(normalizedQuery) || + description.includes(normalizedQuery) || + tags.includes(normalizedQuery) + ); + }) + : listings; + + const tabOptions = [ + { + value: AgentRepositoryTab.REPOSITORY, + label: ( + + + {t("agentRepository.page.tab.repository")} + + ), + }, + { + value: AgentRepositoryTab.MINE, + label: ( + + + {t("agentRepository.page.tab.mine")} + + ), + }, + ...(isAdmin + ? [ + { + value: AgentRepositoryTab.REVIEW, + label: ( + + + {t("agentRepository.page.tab.review")} + {pendingReviewCount > 0 ? ( + + {pendingReviewCount} + + ) : null} + + ), + }, + ] + : []), + ]; + + return ( + +
+
+ +
+
+
+
+ +
+
+

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

+

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

+
+
+
+ +
+ setTab(value as AgentRepositoryTab)} + options={tabOptions} + className="h-9 w-full max-w-md rounded-md border border-slate-200 bg-slate-100 p-[2px] text-sm shadow-sm sm:w-auto" + /> + {isRepositoryTab ? ( + + {t("agentRepository.page.resultCount", { + count: filteredListings.length, + })} + + ) : isMineTab ? ( + + {t("agentRepository.mine.resultCount", { + count: mineCounts[mineOwnership], + })} + + ) : null} +
+ + {isRepositoryTab ? ( + refetch()} + listings={filteredListings} + onDetailClick={handleDetailClick} + /> + ) : isReviewTab ? ( + refetchReview()} + onDetailClick={handleDetailClick} + updatingRepositoryId={ + updateStatusMutation.isPending + ? updateStatusMutation.variables?.agentRepositoryId ?? null + : null + } + onApprove={(listing) => + updateStatusMutation.mutateAsync({ + agentRepositoryId: listing.agent_repository_id, + status: "shared", + }) + } + onReject={(listing) => + updateStatusMutation.mutateAsync({ + agentRepositoryId: listing.agent_repository_id, + status: "rejected", + }) + } + /> + ) : isMineTab ? ( + refetchMine()} + /> + ) : null} +
+
+
+
+ refetchDetail()} + /> +
+ ); +} + +function RepositoryView({ + searchQuery, + onSearchChange, + categories, + categoryNameById, + selectedCategoryId, + onCategoryChange, + isLoading, + isError, + isFetching, + onRetry, + listings, + onDetailClick, +}: { + searchQuery: string; + onSearchChange: (value: string) => void; + categories: AgentRepositoryCategoryItem[]; + categoryNameById: Map; + selectedCategoryId: number | null; + onCategoryChange: (categoryId: number | null) => void; + isLoading: boolean; + isError: boolean; + isFetching: boolean; + onRetry: () => void; + listings: AgentRepositoryListingItem[]; + onDetailClick: (listing: AgentRepositoryListingItem) => void; +}) { + const { t } = useTranslation("common"); + + return ( +
+
+ + onSearchChange(e.target.value)} + placeholder={t("agentRepository.page.searchPlaceholder")} + className="h-11 rounded-xl pl-10" + allowClear + /> +
+ +
+ + {categories.map((category) => ( + + ))} +
+ +

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

+ + {isLoading ? ( +
+ +
+ ) : isError ? ( +
+

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

+ +
+ ) : listings.length === 0 ? ( + + ) : ( +
+ {listings.map((listing) => ( + + ))} +
+ )} +
+ ); +} + +function ReviewCenterView({ + listings, + categoryNameById, + isLoading, + isError, + isFetching, + onRetry, + onDetailClick, + updatingRepositoryId, + onApprove, + onReject, +}: { + listings: AgentRepositoryListingItem[]; + categoryNameById: Map; + isLoading: boolean; + isError: boolean; + isFetching: boolean; + onRetry: () => void; + onDetailClick: (listing: AgentRepositoryListingItem) => void; + updatingRepositoryId: number | null; + onApprove: (listing: AgentRepositoryListingItem) => Promise; + onReject: (listing: AgentRepositoryListingItem) => Promise; +}) { + const { t } = useTranslation("common"); + const { message } = App.useApp(); + + const getListingTitle = (listing: AgentRepositoryListingItem) => + listing.display_name?.trim() || + listing.name?.trim() || + t("agentRepository.card.untitled"); + + const confirmReviewAction = ( + listing: AgentRepositoryListingItem, + action: "approve" | "reject" + ) => { + const title = getListingTitle(listing); + const isApprove = action === "approve"; + + Modal.confirm({ + title: isApprove + ? t("agentRepository.review.confirmApproveTitle") + : t("agentRepository.review.confirmRejectTitle"), + content: isApprove + ? t("agentRepository.review.confirmApproveContent", { name: title }) + : t("agentRepository.review.confirmRejectContent", { name: title }), + okText: isApprove + ? t("agentRepository.review.approve") + : t("agentRepository.review.reject"), + cancelText: t("common.cancel"), + okButtonProps: isApprove + ? undefined + : { danger: true }, + onOk: async () => { + try { + await (isApprove ? onApprove(listing) : onReject(listing)); + message.success( + isApprove + ? t("agentRepository.review.approveSuccess", { name: title }) + : t("agentRepository.review.rejectSuccess", { name: title }) + ); + } catch { + message.error( + isApprove + ? t("agentRepository.review.approveError") + : t("agentRepository.review.rejectError") + ); + throw new Error("Review action failed"); + } + }, + }); + }; + + return ( +
+ +
+ +

+ {t("agentRepository.review.title")} +

+ + {t("agentRepository.review.pendingCount", { count: listings.length })} + +
+

+ {t("agentRepository.review.description")} +

+
- useEffect(() => { - router.replace("/agent-repository"); - }, [router]); + {isLoading ? ( +
+ +
+ ) : isError ? ( +
+

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

+ +
+ ) : listings.length === 0 ? ( + + ) : ( +
+ {listings.map((listing) => { + const title = getListingTitle(listing); + const isUpdating = + updatingRepositoryId === listing.agent_repository_id; + const submitter = + listing.submitted_by?.trim() || + t("agentRepository.review.unknownSubmitter"); + const categoryName = + listing.category_id != null + ? categoryNameById.get(listing.category_id) ?? + t("agentRepository.review.unknownCategory") + : t("agentRepository.review.unknownCategory"); - return null; + return ( + +
+
+
+ {listing.icon?.trim() ? ( + {listing.icon.trim()} + ) : ( + + )} +
+
+
+

+ {title} +

+ + + {t("agentRepository.detail.status.pending_review")} + +
+

+ {listing.description?.trim() || + t("agentRepository.card.noDescription")} +

+

+ {t("agentRepository.review.submitter", { name: submitter })} + {" 路 "} + {categoryName} +

+
+
+
+ + + +
+
+
+ ); + })} +
+ )} +
+ ); } diff --git a/frontend/components/navigation/SideNavigation.tsx b/frontend/components/navigation/SideNavigation.tsx index 6dd8084f2..102cfa4f6 100644 --- a/frontend/components/navigation/SideNavigation.tsx +++ b/frontend/components/navigation/SideNavigation.tsx @@ -114,7 +114,7 @@ const ROUTE_CONFIG: RouteConfig[] = [ parentKey: null, }, { - path: "/agent-repository", + path: "/agent-space", Icon: Bot, labelKey: "sidebar.agentSpace", order: 8, diff --git a/frontend/hooks/agentRepository/useAgentRepositoryListings.ts b/frontend/hooks/agentRepository/useAgentRepositoryListings.ts index 7763ec41a..4887d6ef7 100644 --- a/frontend/hooks/agentRepository/useAgentRepositoryListings.ts +++ b/frontend/hooks/agentRepository/useAgentRepositoryListings.ts @@ -2,12 +2,14 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import agentRepositoryService from "@/services/agentRepositoryService"; import type { AgentRepositoryListingListParams, + AgentRepositoryListingCreatePayload, AgentRepositoryListingStatus, + AgentRepositoryOptionField, MineOwnershipFilter, } from "@/types/agentRepository"; const QUERY_KEY = "agentRepositoryListings"; -const CATEGORIES_QUERY_KEY = "agentRepositoryCategories"; +const OPTIONS_QUERY_KEY = "agentRepositoryOptions"; const DETAIL_QUERY_KEY = "agentRepositoryListingDetail"; const MY_EDITABLE_AGENTS_QUERY_KEY = "myEditableAgents"; @@ -23,10 +25,13 @@ export function useAgentRepositoryListings( }); } -export function useAgentRepositoryCategories(enabled = true) { +export function useAgentRepositoryOptions( + field: F, + enabled = true +) { return useQuery({ - queryKey: [CATEGORIES_QUERY_KEY], - queryFn: () => agentRepositoryService.fetchAgentRepositoryCategories(), + queryKey: [OPTIONS_QUERY_KEY, field], + queryFn: () => agentRepositoryService.fetchAgentRepositoryOptions(field), staleTime: 300_000, enabled, }); @@ -88,11 +93,17 @@ export function useCreateAgentRepositoryListing() { mutationFn: ({ agentId, versionNo, + payload, }: { agentId: number; versionNo: number; + payload: AgentRepositoryListingCreatePayload; }) => - agentRepositoryService.createAgentRepositoryListing(agentId, versionNo), + agentRepositoryService.createAgentRepositoryListing( + agentId, + versionNo, + payload + ), onSuccess: () => { queryClient.invalidateQueries({ queryKey: [QUERY_KEY] }); queryClient.invalidateQueries({ queryKey: [MY_EDITABLE_AGENTS_QUERY_KEY] }); diff --git a/frontend/lib/agentRepositoryMine.ts b/frontend/lib/agentRepositoryMine.ts index dab1f5ab7..91980ea8e 100644 --- a/frontend/lib/agentRepositoryMine.ts +++ b/frontend/lib/agentRepositoryMine.ts @@ -84,6 +84,12 @@ export function isCancelableRepositoryStatus( return status === "pending_review" || status === "rejected"; } +export function isTakeDownableRepositoryStatus( + status: MyAgentRepositoryInfoItem["status"] +): boolean { + return status === "shared"; +} + export function getMineCardMenuActions( agent: MyEditableAgentItem ): MineCardMenuAction[] { diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index b931180e1..1ed4b5c25 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -1687,10 +1687,31 @@ "agentRepository.mine.reviewModal.version": "Review version", "agentRepository.mine.reviewModal.submittedAt": "Submitted at", "agentRepository.mine.reviewModal.cancelApply": "Cancel listing request", + "agentRepository.mine.reviewModal.takeDown": "Take down", + "agentRepository.mine.reviewModal.confirmCancelApplyTitle": "Cancel listing request?", + "agentRepository.mine.reviewModal.confirmCancelApplyContent": "Cancel the listing request for \"{{name}}\"?", + "agentRepository.mine.reviewModal.confirmTakeDownTitle": "Take down from repository?", + "agentRepository.mine.reviewModal.confirmTakeDownContent": "Take down \"{{name}}\" from the repository? Teammates will no longer be able to copy it.", + "agentRepository.mine.applyModal.title": "Apply to list", + "agentRepository.mine.applyModal.agentName": "Apply to list \"{{name}}\"", + "agentRepository.mine.applyModal.icon": "Agent icon", + "agentRepository.mine.applyModal.category": "Category", + "agentRepository.mine.applyModal.categoryPlaceholder": "Select a category", + "agentRepository.mine.applyModal.tags": "Tags", + "agentRepository.mine.applyModal.tagsPlaceholder": "Select or enter tags", + "agentRepository.mine.applyModal.tagsHint": "Choose up to {{count}} tags. Custom tags are allowed.", + "agentRepository.mine.applyModal.submit": "Submit request", + "agentRepository.mine.applyModal.validation.icon": "Please select an agent icon", + "agentRepository.mine.applyModal.validation.category": "Please select a category", + "agentRepository.mine.applyModal.validation.tags": "Please add at least one tag", + "agentRepository.mine.applyModal.validation.tagsMax": "You can select at most {{count}} tags", + "agentRepository.mine.applyModal.validation.tagLength": "Each tag must be at most {{count}} characters", "agentRepository.mine.applySuccess": "Listing request for \"{{name}}\" submitted. Waiting for admin review.", "agentRepository.mine.applyError": "Failed to submit listing request. Please try again later.", "agentRepository.mine.cancelApplySuccess": "Listing request cancelled", "agentRepository.mine.cancelApplyError": "Failed to cancel listing request. Please try again later.", + "agentRepository.mine.takeDownSuccess": "Agent taken down from repository", + "agentRepository.mine.takeDownError": "Failed to take down. Please try again later.", "agentRepository.mine.resultCount": "{{count}} agents", "agentRepository.card.untitled": "Untitled agent", "agentRepository.card.noDescription": "No description", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index 7bdaa9e6f..c4a65102c 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -1655,10 +1655,31 @@ "agentRepository.mine.reviewModal.version": "审核版本", "agentRepository.mine.reviewModal.submittedAt": "提交时间", "agentRepository.mine.reviewModal.cancelApply": "取消申请上架", + "agentRepository.mine.reviewModal.takeDown": "下架", + "agentRepository.mine.reviewModal.confirmCancelApplyTitle": "确认取消上架申请", + "agentRepository.mine.reviewModal.confirmCancelApplyContent": "确定要取消「{{name}}」的上架申请吗?", + "agentRepository.mine.reviewModal.confirmTakeDownTitle": "确认下架", + "agentRepository.mine.reviewModal.confirmTakeDownContent": "确定要将「{{name}}」从智能体仓库下架吗?下架后同租户成员将无法复制该智能体。", + "agentRepository.mine.applyModal.title": "申请上架", + "agentRepository.mine.applyModal.agentName": "为「{{name}}」申请上架", + "agentRepository.mine.applyModal.icon": "智能体图标", + "agentRepository.mine.applyModal.category": "智能体类别", + "agentRepository.mine.applyModal.categoryPlaceholder": "请选择类别", + "agentRepository.mine.applyModal.tags": "智能体标签", + "agentRepository.mine.applyModal.tagsPlaceholder": "选择或输入标签", + "agentRepository.mine.applyModal.tagsHint": "最多选择 {{count}} 个标签,可输入自定义标签", + "agentRepository.mine.applyModal.submit": "提交申请", + "agentRepository.mine.applyModal.validation.icon": "请选择智能体图标", + "agentRepository.mine.applyModal.validation.category": "请选择智能体类别", + "agentRepository.mine.applyModal.validation.tags": "请至少选择一个标签", + "agentRepository.mine.applyModal.validation.tagsMax": "最多只能选择 {{count}} 个标签", + "agentRepository.mine.applyModal.validation.tagLength": "单个标签不能超过 {{count}} 个字符", "agentRepository.mine.applySuccess": "已提交「{{name}}」的上架申请,等待管理员审核", "agentRepository.mine.applyError": "提交上架申请失败,请稍后重试", "agentRepository.mine.cancelApplySuccess": "已取消上架申请", "agentRepository.mine.cancelApplyError": "取消上架申请失败,请稍后重试", + "agentRepository.mine.takeDownSuccess": "已将智能体从仓库下架", + "agentRepository.mine.takeDownError": "下架失败,请稍后重试", "agentRepository.mine.resultCount": "共 {{count}} 个智能体", "agentRepository.card.untitled": "未命名智能体", "agentRepository.card.noDescription": "暂无描述", diff --git a/frontend/services/agentRepositoryService.ts b/frontend/services/agentRepositoryService.ts index 742e7b67e..38678ade2 100644 --- a/frontend/services/agentRepositoryService.ts +++ b/frontend/services/agentRepositoryService.ts @@ -6,12 +6,14 @@ import { API_ENDPOINTS, fetchWithErrorHandling } from "./api"; import { getAuthHeaders } from "@/lib/auth"; import log from "@/lib/logger"; import type { - AgentRepositoryCategoryItem, + AgentRepositoryListingCreatePayload, AgentRepositoryListingDetail, AgentRepositoryListingItem, AgentRepositoryListingListParams, AgentRepositoryListingListResponse, AgentRepositoryListingStatus, + AgentRepositoryOptionField, + AgentRepositoryOptionResultMap, MyEditableAgentListParams, MyEditableAgentListResponse, } from "@/types/agentRepository"; @@ -39,12 +41,12 @@ export async function fetchAgentRepositoryListings( } } -export async function fetchAgentRepositoryCategories(): Promise< - AgentRepositoryCategoryItem[] -> { +export async function fetchAgentRepositoryOptions< + F extends AgentRepositoryOptionField, +>(field: F): Promise { try { const response = await fetchWithErrorHandling( - API_ENDPOINTS.agentRepository.categories, + API_ENDPOINTS.agentRepository.options(field), { method: "GET", headers: getAuthHeaders(), @@ -53,13 +55,13 @@ export async function fetchAgentRepositoryCategories(): Promise< if (!response.ok) { throw new Error( - `Failed to fetch agent repository categories: ${response.statusText}` + `Failed to fetch agent repository options: ${response.statusText}` ); } return response.json(); } catch (error) { - log.error("Error fetching agent repository categories:", error); + log.error("Error fetching agent repository options:", error); throw error; } } @@ -114,7 +116,8 @@ export async function fetchMyEditableAgents( export async function createAgentRepositoryListing( agentId: number, - versionNo: number + versionNo: number, + payload: AgentRepositoryListingCreatePayload ): Promise { try { const response = await fetchWithErrorHandling( @@ -125,7 +128,7 @@ export async function createAgentRepositoryListing( ...getAuthHeaders(), "Content-Type": "application/json", }, - body: JSON.stringify({}), + body: JSON.stringify(payload), } ); @@ -174,7 +177,7 @@ export async function updateAgentRepositoryStatus( const agentRepositoryService = { fetchAgentRepositoryListings, - fetchAgentRepositoryCategories, + fetchAgentRepositoryOptions, fetchAgentRepositoryListingDetail, fetchMyEditableAgents, createAgentRepositoryListing, diff --git a/frontend/services/api.ts b/frontend/services/api.ts index 28e517079..e19697818 100644 --- a/frontend/services/api.ts +++ b/frontend/services/api.ts @@ -2,7 +2,11 @@ import { STATUS_CODES } from "@/const/auth"; import { ErrorCode } from "@/const/errorCode"; import { handleSessionExpired } from "@/lib/session"; import log from "@/lib/logger"; -import type { AgentRepositoryListingListParams, MyEditableAgentListParams } from "@/types/agentRepository"; +import type { + AgentRepositoryListingListParams, + AgentRepositoryOptionField, + MyEditableAgentListParams, +} from "@/types/agentRepository"; import type { MarketAgentListParams } from "@/types/market"; const API_BASE_URL = "/api"; @@ -377,7 +381,8 @@ export const API_ENDPOINTS = { const queryString = queryParams.toString(); return `${API_BASE_URL}/repository/agent${queryString ? `?${queryString}` : ""}`; }, - categories: `${API_BASE_URL}/repository/agent/categories`, + options: (field: AgentRepositoryOptionField) => + `${API_BASE_URL}/repository/agent/options?field=${field}`, mineAgents: (params?: MyEditableAgentListParams) => { const queryParams = new URLSearchParams(); if (params?.ownership) { diff --git a/frontend/types/agentRepository.ts b/frontend/types/agentRepository.ts index c903f9aed..17565344f 100644 --- a/frontend/types/agentRepository.ts +++ b/frontend/types/agentRepository.ts @@ -41,6 +41,14 @@ export interface AgentRepositoryCategoryItem { name: string; } +export type AgentRepositoryOptionField = "categories" | "icons" | "tags"; + +export type AgentRepositoryOptionResultMap = { + categories: AgentRepositoryCategoryItem[]; + icons: string[]; + tags: string[]; +}; + export interface AgentRepositoryListingDetail { agent_repository_id: number; agent_id?: number | null; @@ -95,3 +103,15 @@ export interface MyEditableAgentListResponse { items: MyEditableAgentItem[]; counts: MyEditableAgentOwnershipCounts; } + +export interface AgentRepositoryListingCreatePayload { + icon: string; + category_id: number; + tags: string[]; +} + +export interface AgentRepositoryListingCreatePayload { + icon: string; + category_id: number; + tags: string[]; +} diff --git a/k8s/helm/nexent/charts/nexent-common/files/init.sql b/k8s/helm/nexent/charts/nexent-common/files/init.sql index b8bb1c02d..fa55ba9c5 100644 --- a/k8s/helm/nexent/charts/nexent-common/files/init.sql +++ b/k8s/helm/nexent/charts/nexent-common/files/init.sql @@ -1089,7 +1089,7 @@ INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_ (1109, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/agents', '/agent-dev'), (1110, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/memory', '/agent-dev'); INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_category, permission_type, permission_subtype, parent_key) VALUES -(1111, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-repository', '/resource-space'), +(1111, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-space', '/resource-space'), (1112, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/mcp-space', '/resource-space'), (1113, 'ADMIN', 'VISIBILITY', 'LEFT_NAV_MENU', '/skill-space', '/resource-space'); @@ -1106,7 +1106,7 @@ INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_ (1208, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/agents', '/agent-dev'), (1209, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/memory', '/agent-dev'); INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_category, permission_type, permission_subtype, parent_key) VALUES -(1210, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-repository', '/resource-space'), +(1210, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-space', '/resource-space'), (1211, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/mcp-space', '/resource-space'), (1212, 'DEV', 'VISIBILITY', 'LEFT_NAV_MENU', '/skill-space', '/resource-space'); @@ -1130,7 +1130,7 @@ INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_ (1408, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/agents', '/agent-dev'), (1409, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/memory', '/agent-dev'); INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_category, permission_type, permission_subtype, parent_key) VALUES -(1410, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-repository', '/resource-space'), +(1410, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-space', '/resource-space'), (1411, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/mcp-space', '/resource-space'), (1412, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/skill-space', '/resource-space'); @@ -1146,7 +1146,7 @@ INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_ (1507, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/knowledges', '/agent-dev'), (1508, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/agents', '/agent-dev'); INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_category, permission_type, permission_subtype, parent_key) VALUES -(1509, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-repository', '/resource-space'), +(1509, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-space', '/resource-space'), (1510, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/mcp-space', '/resource-space'), (1511, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/skill-space', '/resource-space'); diff --git a/test/backend/app/test_agent_repository_app.py b/test/backend/app/test_agent_repository_app.py index 03d6f6325..fdbdeb0f0 100644 --- a/test/backend/app/test_agent_repository_app.py +++ b/test/backend/app/test_agent_repository_app.py @@ -2,11 +2,15 @@ import os import sys +import types +from enum import Enum +from typing import List, Optional from unittest.mock import AsyncMock, MagicMock import pytest from fastapi import FastAPI from fastapi.testclient import TestClient +from pydantic import BaseModel, Field current_dir = os.path.dirname(os.path.abspath(__file__)) backend_dir = os.path.abspath(os.path.join(current_dir, "../../../backend")) @@ -15,6 +19,27 @@ sys.modules.setdefault("services.agent_repository_service", MagicMock()) sys.modules.setdefault("utils.auth_utils", MagicMock()) +consts_model = types.ModuleType("consts.model") + + +class _AgentRepositoryListingCreateRequest(BaseModel): + icon: Optional[str] = None + downloads: int = Field(0, ge=0) + tags: Optional[List[str]] = None + category_id: Optional[int] = 0 + tool_count: Optional[int] = Field(None, ge=0) + + +class _AgentRepositoryOptionField(str, Enum): + CATEGORIES = "categories" + ICONS = "icons" + TAGS = "tags" + + +consts_model.AgentRepositoryListingCreateRequest = _AgentRepositoryListingCreateRequest +consts_model.AgentRepositoryOptionField = _AgentRepositoryOptionField +sys.modules["consts.model"] = consts_model + from apps.agent_repository_app import agent_repository_router app = FastAPI() @@ -50,6 +75,7 @@ def test_list_agent_repository_listings_api_defaults_dedupe_without_agent_id( status=None, agent_id=None, deduplicate_by_agent_id=True, + category_id=None, ) @@ -78,6 +104,7 @@ def test_list_agent_repository_listings_api_disables_dedupe_for_agent_id( status=None, agent_id=123, deduplicate_by_agent_id=False, + category_id=None, ) @@ -106,6 +133,7 @@ def test_list_agent_repository_listings_api_passes_explicit_dedupe( status=None, agent_id=123, deduplicate_by_agent_id=True, + category_id=None, ) @@ -139,6 +167,7 @@ def test_create_agent_repository_listing_api_success(mocker, mock_auth_header): tenant_id="test_tenant_id", user_id="test_user_id", version_no=1, + card_fields=None, ) assert response.json()["agent_repository_id"] == 42 assert response.json()["is_updated"] is False @@ -173,6 +202,7 @@ def test_create_agent_repository_listing_api_draft_version(mocker, mock_auth_hea tenant_id="test_tenant_id", user_id="test_user_id", version_no=0, + card_fields=None, ) assert response.json()["version_no"] == 0 @@ -322,22 +352,25 @@ def test_update_agent_repository_status_api_bad_request(mocker, mock_auth_header assert response.json()["detail"] == "Invalid status transition" -def test_list_agent_repository_categories_api_success(mocker, mock_auth_header): - """Test categories API returns hardcoded category array.""" +def test_list_agent_repository_options_api_returns_categories(mocker, mock_auth_header): + """Test options API returns category list when field is categories.""" mock_get_user_id = mocker.patch( "apps.agent_repository_app.get_current_user_id" ) - mock_list_categories = mocker.patch( - "apps.agent_repository_app.list_agent_repository_categories_impl", + mock_list_options = mocker.patch( + "apps.agent_repository_app.list_agent_repository_options_impl", ) mock_get_user_id.return_value = ("test_user_id", "test_tenant_id") - mock_list_categories.return_value = [ + mock_list_options.return_value = [ {"id": 1, "name": "写作助手"}, {"id": 1000, "name": "其它"}, ] - response = client.get("/repository/agent/categories", headers=mock_auth_header) + response = client.get( + "/repository/agent/options?field=categories", + headers=mock_auth_header, + ) assert response.status_code == 200 assert response.json() == [ @@ -345,11 +378,40 @@ def test_list_agent_repository_categories_api_success(mocker, mock_auth_header): {"id": 1000, "name": "其它"}, ] mock_get_user_id.assert_called_once_with(mock_auth_header["Authorization"]) - mock_list_categories.assert_called_once_with() + mock_list_options.assert_called_once_with("categories") -def test_list_agent_repository_categories_api_unauthorized(mocker, mock_auth_header): - """Test categories API maps UnauthorizedError to 401.""" +def test_list_agent_repository_options_api_returns_icons(mocker, mock_auth_header): + """Test options API returns icon list when field is icons.""" + mock_get_user_id = mocker.patch( + "apps.agent_repository_app.get_current_user_id" + ) + mock_list_options = mocker.patch( + "apps.agent_repository_app.list_agent_repository_options_impl", + ) + + mock_get_user_id.return_value = ("test_user_id", "test_tenant_id") + mock_list_options.return_value = ["🤖", "📊"] + + response = client.get( + "/repository/agent/options?field=icons", + headers=mock_auth_header, + ) + + assert response.status_code == 200 + assert response.json() == ["🤖", "📊"] + mock_list_options.assert_called_once_with("icons") + + +def test_list_agent_repository_options_api_requires_field(mock_auth_header): + """Test options API requires field query parameter.""" + response = client.get("/repository/agent/options", headers=mock_auth_header) + + assert response.status_code == 422 + + +def test_list_agent_repository_options_api_unauthorized(mocker, mock_auth_header): + """Test options API maps UnauthorizedError to 401.""" from consts.exceptions import UnauthorizedError mock_get_user_id = mocker.patch( @@ -357,12 +419,55 @@ def test_list_agent_repository_categories_api_unauthorized(mocker, mock_auth_hea ) mock_get_user_id.side_effect = UnauthorizedError("Not authorized") - response = client.get("/repository/agent/categories", headers=mock_auth_header) + response = client.get( + "/repository/agent/options?field=categories", + headers=mock_auth_header, + ) assert response.status_code == 401 assert response.json()["detail"] == "Not authorized" +def test_create_agent_repository_listing_api_passes_card_fields(mocker, mock_auth_header): + """Test create listing API forwards card_fields from request body.""" + mock_get_user_id = mocker.patch( + "apps.agent_repository_app.get_current_user_id" + ) + mock_create_listing = mocker.patch( + "apps.agent_repository_app.create_agent_repository_listing_impl", + new_callable=AsyncMock, + ) + + mock_get_user_id.return_value = ("test_user_id", "test_tenant_id") + mock_create_listing.return_value = { + "agent_repository_id": 42, + "agent_id": 123, + "version_no": 1, + "is_updated": False, + } + + payload = { + "icon": "🤖", + "category_id": 2, + "tags": ["代码审查", "自定义"], + "downloads": 0, + } + response = client.post( + "/repository/agent/123/versions/1", + headers=mock_auth_header, + json=payload, + ) + + assert response.status_code == 200 + mock_create_listing.assert_awaited_once_with( + agent_id=123, + tenant_id="test_tenant_id", + user_id="test_user_id", + version_no=1, + card_fields=payload, + ) + + def test_list_my_editable_agents_api_success_default_ownership( mocker, mock_auth_header, diff --git a/test/backend/services/test_agent_repository_service.py b/test/backend/services/test_agent_repository_service.py index ec68f2b15..216eca470 100644 --- a/test/backend/services/test_agent_repository_service.py +++ b/test/backend/services/test_agent_repository_service.py @@ -237,8 +237,8 @@ def test_list_repository_listings_rejects_invalid_status_with_agent_id(): mock_list.assert_not_called() -def test_list_agent_repository_categories_impl_returns_hardcoded_categories(): - result = ars.list_agent_repository_categories_impl() +def test_list_agent_repository_options_impl_returns_categories(): + result = ars.list_agent_repository_options_impl("categories") assert len(result) == 7 assert result[0] == {"id": 1, "name": "写作助手"} @@ -246,6 +246,67 @@ def test_list_agent_repository_categories_impl_returns_hardcoded_categories(): assert [item["id"] for item in result] == [1, 2, 3, 4, 5, 6, 0] +def test_list_agent_repository_options_impl_returns_icons(): + result = ars.list_agent_repository_options_impl("icons") + + assert result[0] == "🤖" + assert "📊" in result + assert len(result) == 10 + + +def test_list_agent_repository_options_impl_returns_tags(): + result = ars.list_agent_repository_options_impl("tags") + + assert "营销" in result + assert "代码审查" in result + assert "数据" in result + + +def test_list_agent_repository_options_impl_rejects_unknown_field(): + with pytest.raises(ValueError, match="Unsupported option field"): + ars.list_agent_repository_options_impl("unknown") + + +def test_normalize_listing_tags_trims_dedupes_and_limits(): + assert ars._normalize_listing_tags([" 营销 ", "营销", "数据"]) == ["营销", "数据"] + + with pytest.raises(ValueError, match="at least one"): + ars._normalize_listing_tags([" ", ""]) + + with pytest.raises(ValueError, match="at most 5"): + ars._normalize_listing_tags(["a", "b", "c", "d", "e", "f"]) + + +def test_validate_card_fields_requires_supported_values(): + base = { + "agent_id": 1, + "version_no": 1, + "name": "agent_one", + "agent_info_json": { + "agent_id": 1, + "agent_info": {"1": {"agent_id": 1}}, + "mcp_info": [], + }, + } + + with pytest.raises(ValueError, match="icon is required"): + ars._validate_create_payload(base) + + with pytest.raises(ValueError, match="category_id is required"): + ars._validate_create_payload({**base, "icon": "🤖"}) + + with pytest.raises(ValueError, match="tags is required"): + ars._validate_create_payload({**base, "icon": "🤖", "category_id": 1}) + + with pytest.raises(ValueError, match="supported marketplace icon"): + ars._validate_create_payload({ + **base, + "icon": "invalid", + "category_id": 1, + "tags": ["营销"], + }) + + def _editable_agent_record( *, agent_id: int = 1, @@ -764,6 +825,43 @@ def test_resolve_submitter_email_uses_user_tenant_email(): assert ars._resolve_submitter_email("user_a") == "dev@example.com" +@pytest.mark.asyncio +async def test_build_repository_data_from_agent_merges_card_fields(): + card_fields = { + "icon": "📊", + "category_id": 3, + "tags": [" 数据 ", "数据", "自定义标签"], + "downloads": 10, + } + with patch.object( + ars, "search_agent_info_by_agent_id", return_value={"name": "agent_one", "author": "author@example.com"} + ), patch.object( + ars, "_validate_create_listing_permission" + ), patch.object( + ars, "_build_agent_info_json", new_callable=AsyncMock, return_value={ + "agent_id": 1, + "agent_info": {"1": {"agent_id": 1}}, + "mcp_info": [], + } + ), patch.object( + ars, "search_version_by_version_no", return_value={"version_name": "v1"} + ), patch.object( + ars, "_resolve_submitter_email", return_value="submitter@example.com" + ): + repository_data = await ars._build_repository_data_from_agent( + agent_id=1, + tenant_id="tenant_a", + user_id="user_a", + version_no=1, + card_fields=card_fields, + ) + + assert repository_data["icon"] == "📊" + assert repository_data["category_id"] == 3 + assert repository_data["tags"] == ["数据", "自定义标签"] + assert repository_data["downloads"] == 10 + + @pytest.mark.asyncio async def test_build_repository_data_from_agent_sets_submitted_by(): with patch.object( @@ -817,6 +915,9 @@ async def test_create_agent_repository_listing_impl_success(): "name": "agent_one", "agent_info_json": agent_info_json, "status": "pending_review", + "icon": "🤖", + "category_id": 1, + "tags": ["营销"], } mock_get_by_agent_id.return_value = None mock_insert.return_value = 42 @@ -872,6 +973,9 @@ async def test_create_agent_repository_listing_impl_updates_existing(): "name": "agent_one", "agent_info_json": agent_info_json, "status": "pending_review", + "icon": "🤖", + "category_id": 1, + "tags": ["营销"], } mock_get_by_agent_id.return_value = {"agent_repository_id": 42} mock_update.return_value = 1 @@ -901,6 +1005,9 @@ async def test_create_agent_repository_listing_impl_updates_existing(): publisher_tenant_id="tenant_a", user_id="user_a", updates={ + "category_id": 1, + "tags": ["营销"], + "icon": "🤖", "version_no": 2, "agent_info_json": agent_info_json, "status": "pending_review", @@ -936,6 +1043,9 @@ async def test_create_agent_repository_listing_impl_accepts_draft_version(): "name": "agent_one", "agent_info_json": agent_info_json, "status": "pending_review", + "icon": "🤖", + "category_id": 1, + "tags": ["营销"], } mock_get_by_agent_id.return_value = None mock_insert.return_value = 42 @@ -1066,18 +1176,21 @@ async def test_create_listing_impl_rejects_unauthorized_before_export(): def test_validate_create_payload_requires_agent_info_json(): + base = { + "agent_id": 1, + "version_no": 1, + "name": "agent_one", + "icon": "🤖", + "category_id": 1, + "tags": ["营销"], + } + with pytest.raises(ValueError, match="agent_info_json"): - ars._validate_create_payload({ - "agent_id": 1, - "version_no": 1, - "name": "agent_one", - }) + ars._validate_create_payload(base) with pytest.raises(ValueError, match="agent_info_json must contain"): ars._validate_create_payload({ - "agent_id": 1, - "version_no": 1, - "name": "agent_one", + **base, "agent_info_json": {"agent_id": 1}, }) From 0fab324d7f6b1115fd319a842ea4d4d5eca65ff8 Mon Sep 17 00:00:00 2001 From: Chenlifeng <174292121+Lifeng-Chen@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:53:36 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=E2=9C=A8=20Feature:=20add=20agent=20reposi?= =?UTF-8?q?tory=20page=20and=20APIs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce Agent Repository backend APIs, database/service support, frontend views, client services, and tests. Migrate Agent Space navigation and permissions to /agent-repository with updated SQL and localization. --- backend/apps/agent_repository_app.py | 33 +--- backend/consts/model.py | 8 +- backend/database/agent_repository_db.py | 62 ------- backend/services/agent_repository_service.py | 49 +---- .../v2.2.1_0605_add_ag_agent_repository_t.sql | 4 +- .../components/AgentRepositoryCard.tsx | 124 +++++++------ .../components/MineApplyListingModal.tsx | 169 +++++++++--------- frontend/app/[locale]/agent-space/page.tsx | 49 ++--- frontend/const/agentRepository.ts | 61 +++++++ .../useAgentRepositoryListings.ts | 14 -- frontend/lib/agentRepositoryLabels.test.ts | 45 +++++ frontend/lib/agentRepositoryLabels.ts | 158 ++++++++++++++++ frontend/public/locales/en/common.json | 29 +++ frontend/public/locales/zh/common.json | 29 +++ frontend/services/agentRepositoryService.ts | 28 --- frontend/services/api.ts | 3 - frontend/types/agentRepository.ts | 12 +- .../charts/nexent-common/files/init.sql | 4 +- test/backend/app/test_agent_repository_app.py | 84 --------- .../services/test_agent_repository_service.py | 45 ++--- 20 files changed, 526 insertions(+), 484 deletions(-) create mode 100644 frontend/const/agentRepository.ts create mode 100644 frontend/lib/agentRepositoryLabels.test.ts create mode 100644 frontend/lib/agentRepositoryLabels.ts diff --git a/backend/apps/agent_repository_app.py b/backend/apps/agent_repository_app.py index 460f350fb..b7d7955bb 100644 --- a/backend/apps/agent_repository_app.py +++ b/backend/apps/agent_repository_app.py @@ -6,16 +6,12 @@ from starlette.responses import JSONResponse from consts.exceptions import SkillDuplicateError, UnauthorizedError -from consts.model import ( - AgentRepositoryListingCreateRequest, - AgentRepositoryOptionField, -) +from consts.model import AgentRepositoryListingCreateRequest from services.agent_repository_service import ( create_agent_repository_listing_impl, get_agent_repository_listing_detail_impl, import_agent_from_repository_impl, list_agent_repository_listings_impl, - list_agent_repository_options_impl, list_my_editable_agents_impl, update_agent_repository_status_impl, ) @@ -66,33 +62,6 @@ async def list_agent_repository_listings_api( ) -@agent_repository_router.get("/options") -async def list_agent_repository_options_api( - field: AgentRepositoryOptionField = Query( - ..., - description="Option group to return: categories / icons / tags", - ), - authorization: str = Header(None), -): - """List hardcoded marketplace listing presets for one option group.""" - try: - get_current_user_id(authorization) - return JSONResponse( - status_code=HTTPStatus.OK, - content=list_agent_repository_options_impl(field.value), - ) - except UnauthorizedError as e: - raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) - except ValueError as e: - raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) - except Exception as e: - logger.error(f"List agent repository options error: {str(e)}") - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="List agent repository options error.", - ) - - @agent_repository_router.get("/mine") async def list_my_editable_agents_api( ownership: Optional[str] = Query( diff --git a/backend/consts/model.py b/backend/consts/model.py index ad6ef932d..f5ab26af2 100644 --- a/backend/consts/model.py +++ b/backend/consts/model.py @@ -641,16 +641,10 @@ class AgentRepositoryListingCreateRequest(BaseModel): class AgentRepositoryCategoryItem(BaseModel): """Marketplace category option for agent repository filtering.""" id: int + key: str name: str -class AgentRepositoryOptionField(str, Enum): - """Selectable option groups for agent repository listing presets.""" - CATEGORIES = "categories" - ICONS = "icons" - TAGS = "tags" - - class AgentRepositoryListingDetailResponse(BaseModel): """Detailed marketplace listing payload for repository detail view.""" agent_repository_id: int diff --git a/backend/database/agent_repository_db.py b/backend/database/agent_repository_db.py index 3f5099b8e..67681085a 100644 --- a/backend/database/agent_repository_db.py +++ b/backend/database/agent_repository_db.py @@ -242,68 +242,6 @@ def list_agent_repository_summaries( ] -def query_agent_repository_list( - *, - page: int = 1, - page_size: int = 20, - search: Optional[str] = None, - tag: Optional[str] = None, - category_id: Optional[int] = None, - status: Optional[str] = STATUS_SHARED, - publisher_tenant_id: Optional[str] = None, -) -> Dict[str, Any]: - """Query repository listings with offset pagination.""" - page = max(page, 1) - page_size = max(min(page_size, 100), 1) - offset = (page - 1) * page_size - - with get_db_session() as session: - query = session.query(AgentRepository).filter( - AgentRepository.delete_flag != "Y", - ) - - if status: - query = query.filter(AgentRepository.status == status) - if publisher_tenant_id: - query = query.filter( - AgentRepository.publisher_tenant_id == publisher_tenant_id - ) - if category_id is not None: - query = query.filter(AgentRepository.category_id == category_id) - if tag: - query = query.filter(AgentRepository.tags.any(tag)) - if search: - keyword = f"%{search}%" - query = query.filter( - or_( - AgentRepository.name.ilike(keyword), - AgentRepository.display_name.ilike(keyword), - AgentRepository.description.ilike(keyword), - AgentRepository.author.ilike(keyword), - func.array_to_string(AgentRepository.tags, ",").ilike(keyword), - ) - ) - - total = query.count() - rows = ( - query.order_by(AgentRepository.agent_repository_id.desc()) - .offset(offset) - .limit(page_size) - .all() - ) - - total_pages = math.ceil(total / page_size) if total else 0 - return { - "items": [as_dict(row) for row in rows], - "pagination": { - "page": page, - "page_size": page_size, - "total": total, - "total_pages": total_pages, - }, - } - - def update_agent_repository_by_id( *, repository_id: int, diff --git a/backend/services/agent_repository_service.py b/backend/services/agent_repository_service.py index ff0b167f5..3811a8806 100644 --- a/backend/services/agent_repository_service.py +++ b/backend/services/agent_repository_service.py @@ -65,32 +65,9 @@ STATUS_NOT_SHARED: 1, } -_AGENT_REPOSITORY_CATEGORIES: List[Dict[str, Any]] = [ - {"id": 1, "name": "写作助手"}, - {"id": 2, "name": "编程开发"}, - {"id": 3, "name": "数据分析"}, - {"id": 4, "name": "客户服务"}, - {"id": 5, "name": "效率工具"}, - {"id": 6, "name": "创意设计"}, - {"id": 0, "name": "其它"}, -] - -_AGENT_REPOSITORY_ICONS: List[str] = [ - "🤖", "✍️", "🔍", "📊", "💬", "📝", "🎨", "⚡", "🔧", "📚", -] - -_AGENT_REPOSITORY_TAGS: List[str] = [ - "营销", "文案", "内容创作", "代码审查", "质量", "DevOps", - "数据", "可视化", "BI", "客服", "工单", "自动化", - "会议", "纪要", "效率", "设计", "配色", "灵感", "表格", "办公", -] - -_VALID_REPOSITORY_CATEGORY_IDS: FrozenSet[int] = frozenset( - category["id"] for category in _AGENT_REPOSITORY_CATEGORIES -) -_VALID_REPOSITORY_ICONS: FrozenSet[str] = frozenset(_AGENT_REPOSITORY_ICONS) _MAX_LISTING_TAGS = 5 _MAX_LISTING_TAG_LENGTH = 20 +_MAX_LISTING_ICON_LENGTH = 32 _UPDATE_SNAPSHOT_FIELDS = ( "display_name", @@ -184,18 +161,6 @@ def list_agent_repository_listings_impl( return {"items": [_to_summary_item(record) for record in records]} -def list_agent_repository_options_impl(field: str) -> List[Any]: - """Return hardcoded marketplace listing option presets for one field.""" - options_by_field = { - "categories": list(_AGENT_REPOSITORY_CATEGORIES), - "icons": list(_AGENT_REPOSITORY_ICONS), - "tags": list(_AGENT_REPOSITORY_TAGS), - } - if field not in options_by_field: - raise ValueError(f"Unsupported option field: {field}") - return options_by_field[field] - - def _normalize_listing_tags(tags: Any) -> List[str]: """Trim, deduplicate, and validate marketplace listing tags.""" if not isinstance(tags, list): @@ -228,12 +193,16 @@ def _normalize_listing_tags(tags: Any) -> List[str]: def _validate_card_fields(repository_data: Dict[str, Any]) -> None: """Validate marketplace card fields required for listing submission.""" icon = repository_data.get("icon") - if not icon or not isinstance(icon, str) or icon not in _VALID_REPOSITORY_ICONS: - raise ValueError("icon is required and must be a supported marketplace icon") + if not icon or not isinstance(icon, str) or not icon.strip(): + raise ValueError("icon is required and must be a non-empty string") + if len(icon.strip()) > _MAX_LISTING_ICON_LENGTH: + raise ValueError( + f"icon must be at most {_MAX_LISTING_ICON_LENGTH} characters" + ) category_id = repository_data.get("category_id") - if category_id is None or category_id not in _VALID_REPOSITORY_CATEGORY_IDS: - raise ValueError("category_id is required and must be a supported category") + if category_id is None or not isinstance(category_id, int): + raise ValueError("category_id is required and must be an integer") tags = repository_data.get("tags") if tags is None: diff --git a/docker/sql/v2.2.1_0605_add_ag_agent_repository_t.sql b/docker/sql/v2.2.1_0605_add_ag_agent_repository_t.sql index d719fc5aa..a42e3f98b 100644 --- a/docker/sql/v2.2.1_0605_add_ag_agent_repository_t.sql +++ b/docker/sql/v2.2.1_0605_add_ag_agent_repository_t.sql @@ -23,7 +23,7 @@ CREATE TABLE IF NOT EXISTS nexent.ag_agent_repository_t ( tool_count INTEGER, version_label VARCHAR(100), agent_info_json JSONB NOT NULL, - status VARCHAR(30) DEFAULT 'NOT_SHARED', + status VARCHAR(30) DEFAULT 'not_shared', create_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, update_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, created_by VARCHAR(100), @@ -52,7 +52,7 @@ COMMENT ON COLUMN nexent.ag_agent_repository_t.tags IS 'Marketplace tags'; COMMENT ON COLUMN nexent.ag_agent_repository_t.tool_count IS 'Total tool count across all agents in the bundle (display only)'; COMMENT ON COLUMN nexent.ag_agent_repository_t.version_label IS 'Repository entry version label for display (e.g. v1.0)'; COMMENT ON COLUMN nexent.ag_agent_repository_t.agent_info_json IS 'Frozen ExportAndImportDataFormat snapshot with optional skills'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.status IS 'Listing status: NOT_SHARED (未共享) / PENDING_REVIEW (待审核) / REJECTED (审核驳回) / SHARED (已共享)'; +COMMENT ON COLUMN nexent.ag_agent_repository_t.status IS 'Listing status: not_shared (未共享) / pending_review (待审核) / rejected (审核驳回) / shared (已共享)'; COMMENT ON COLUMN nexent.ag_agent_repository_t.create_time IS 'Creation time'; COMMENT ON COLUMN nexent.ag_agent_repository_t.update_time IS 'Update time'; COMMENT ON COLUMN nexent.ag_agent_repository_t.created_by IS 'Creator ID'; diff --git a/frontend/app/[locale]/agent-space/components/AgentRepositoryCard.tsx b/frontend/app/[locale]/agent-space/components/AgentRepositoryCard.tsx index 70e2efed4..def916f55 100644 --- a/frontend/app/[locale]/agent-space/components/AgentRepositoryCard.tsx +++ b/frontend/app/[locale]/agent-space/components/AgentRepositoryCard.tsx @@ -3,6 +3,7 @@ import { Button, Card } from "antd"; import { Bot, Copy, Download, Eye } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { getAgentRepositoryTagLabel } from "@/lib/agentRepositoryLabels"; import type { AgentRepositoryListingItem } from "@/types/agentRepository"; interface AgentRepositoryCardProps { @@ -28,72 +29,79 @@ export function AgentRepositoryCard({ const toolCount = listing.tool_count ?? 0; const versionText = listing.version_label; const downloads = listing.downloads ?? 0; - const showMetaRow = versionText != null || downloads > 0; const showTagsRow = tags.length > 0 || toolCount > 0; return ( - -
-
-
- {listing.icon?.trim() ? ( - {listing.icon.trim()} - ) : ( - - )} -
-
-

- {title} -

-

- {subtitle} -

-
+ +
+
+ {listing.icon?.trim() ? ( + {listing.icon.trim()} + ) : ( + + )}
+
+

+ {title} +

+

+ {subtitle} +

+
+
-

- {listing.description?.trim() || t("agentRepository.card.noDescription")} -

+

+ {listing.description?.trim() || t("agentRepository.card.noDescription")} +

- {showTagsRow ? ( -
- {tags.map((tag) => ( - - {tag} - - ))} - {toolCount > 0 ? ( - - {t("agentRepository.card.toolCount", { count: toolCount })} - - ) : null} -
- ) : null} + {showTagsRow ? ( +
+ {tags.map((tag) => ( + + {getAgentRepositoryTagLabel(tag, t)} + + ))} + {toolCount > 0 ? ( + + {t("agentRepository.card.toolCount", { count: toolCount })} + + ) : null} +
+ ) : null} - {showMetaRow ? ( -
- {versionText ? ( - - - {versionText} - - ) : ( - - )} - {downloads > 0 ? ( - - - {downloads.toLocaleString()} - - ) : null} -
- ) : null} +
+
+ {versionText ? ( + + + {versionText} + + ) : ( + + )} + {downloads > 0 ? ( + + + {downloads.toLocaleString()} + + ) : null} +
-
+
-
@@ -156,76 +153,70 @@ export function MineApplyListingModal({ {t("agentRepository.mine.applyModal.agentName", { name: title })}

- {isOptionsLoading ? ( -
- -
- ) : ( -
-
-

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

-
- {icons.map((icon) => { - const isSelected = selectedIcon === icon; - return ( - - ); - })} -
-
- -
-

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

- -

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

-
-
- )} +
+
+

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

+
+ {icons.map((icon) => { + const isSelected = selectedIcon === icon; + return ( + + ); + })} +
+
+ +
+

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

+ +

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

+
+
); } diff --git a/frontend/app/[locale]/agent-space/page.tsx b/frontend/app/[locale]/agent-space/page.tsx index f8a1f2f8c..21c45a94b 100644 --- a/frontend/app/[locale]/agent-space/page.tsx +++ b/frontend/app/[locale]/agent-space/page.tsx @@ -19,13 +19,17 @@ import { useAuthorizationContext } from "@/components/providers/AuthorizationPro import { USER_ROLES } from "@/const/auth"; import { useSetupFlow } from "@/hooks/useSetupFlow"; import { - useAgentRepositoryOptions, useAgentRepositoryListingDetail, useAgentRepositoryListings, useMyEditableAgents, useUpdateAgentRepositoryStatus, } from "@/hooks/agentRepository/useAgentRepositoryListings"; +import { AGENT_REPOSITORY_CATEGORIES } from "@/const/agentRepository"; import type { AgentRepositoryCategoryItem, AgentRepositoryListingItem, MineOwnershipFilter } from "@/types/agentRepository"; +import { + getAgentRepositoryCategoryLabel, + getAgentRepositoryTagSearchText, +} from "@/lib/agentRepositoryLabels"; import { AgentRepositoryCard } from "./components/AgentRepositoryCard"; import { AgentRepositoryDetailModal } from "./components/AgentRepositoryDetailModal"; import { MineAgentsView } from "./components/MineAgentsView"; @@ -57,14 +61,17 @@ export default function AgentRepositoryPage() { const isReviewTab = tab === AgentRepositoryTab.REVIEW; const isMineTab = tab === AgentRepositoryTab.MINE; - const { data: categories = [] } = useAgentRepositoryOptions( - "categories", - isRepositoryTab || isReviewTab - ); + const categories = AGENT_REPOSITORY_CATEGORIES; const categoryNameById = useMemo( - () => new Map(categories.map((item) => [item.id, item.name])), - [categories] + () => + new Map( + categories.map((item) => [ + item.id, + getAgentRepositoryCategoryLabel(item, t), + ]) + ), + [categories, t] ); const listingParams = { @@ -127,7 +134,7 @@ export default function AgentRepositoryPage() { const author = (item.author || "").toLowerCase(); const description = (item.description || "").toLowerCase(); const tags = (item.tags || []) - .map((tag) => tag.toLowerCase()) + .map((tag) => getAgentRepositoryTagSearchText(tag, t)) .join(" "); return ( title.includes(normalizedQuery) || @@ -364,7 +371,8 @@ function RepositoryView({ : "bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700" }`} > - {category.name} + {categoryNameById.get(category.id) ?? + getAgentRepositoryCategoryLabel(category, t)} ))}
@@ -392,18 +400,19 @@ function RepositoryView({ description={t("agentRepository.page.empty")} /> ) : ( -
+
{listings.map((listing) => ( - +
+ +
))}
)} diff --git a/frontend/const/agentRepository.ts b/frontend/const/agentRepository.ts new file mode 100644 index 000000000..162c8af6f --- /dev/null +++ b/frontend/const/agentRepository.ts @@ -0,0 +1,61 @@ +/** + * Agent repository listing presets (categories, icons, preset tags). + * Display labels are resolved via i18n in agentRepositoryLabels.ts. + */ + +export interface AgentRepositoryCategoryPreset { + id: number; + key: string; +} + +export const AGENT_REPOSITORY_CATEGORIES: AgentRepositoryCategoryPreset[] = [ + { id: 1, key: "writing_assistant" }, + { id: 2, key: "programming" }, + { id: 3, key: "data_analysis" }, + { id: 4, key: "customer_service" }, + { id: 5, key: "productivity" }, + { id: 6, key: "creative_design" }, + { id: 0, key: "other" }, +]; + +export const AGENT_REPOSITORY_ICONS = [ + "🤖", + "✍️", + "🔍", + "📊", + "💬", + "📝", + "🎨", + "⚡", + "🔧", + "📚", +] as const; + +export const AGENT_REPOSITORY_PRESET_TAGS = [ + "marketing", + "copywriting", + "content_creation", + "code_review", + "quality", + "devops", + "data", + "visualization", + "bi", + "customer_service", + "ticket", + "automation", + "meeting", + "minutes", + "productivity", + "design", + "color_scheme", + "inspiration", + "spreadsheet", + "office", +] as const; + +/** Map category id to stable key for label resolution. */ +export const AGENT_REPOSITORY_CATEGORY_ID_TO_KEY: Record = + Object.fromEntries( + AGENT_REPOSITORY_CATEGORIES.map((category) => [category.id, category.key]) + ); diff --git a/frontend/hooks/agentRepository/useAgentRepositoryListings.ts b/frontend/hooks/agentRepository/useAgentRepositoryListings.ts index 4887d6ef7..614ea9597 100644 --- a/frontend/hooks/agentRepository/useAgentRepositoryListings.ts +++ b/frontend/hooks/agentRepository/useAgentRepositoryListings.ts @@ -4,12 +4,10 @@ import type { AgentRepositoryListingListParams, AgentRepositoryListingCreatePayload, AgentRepositoryListingStatus, - AgentRepositoryOptionField, MineOwnershipFilter, } from "@/types/agentRepository"; const QUERY_KEY = "agentRepositoryListings"; -const OPTIONS_QUERY_KEY = "agentRepositoryOptions"; const DETAIL_QUERY_KEY = "agentRepositoryListingDetail"; const MY_EDITABLE_AGENTS_QUERY_KEY = "myEditableAgents"; @@ -25,18 +23,6 @@ export function useAgentRepositoryListings( }); } -export function useAgentRepositoryOptions( - field: F, - enabled = true -) { - return useQuery({ - queryKey: [OPTIONS_QUERY_KEY, field], - queryFn: () => agentRepositoryService.fetchAgentRepositoryOptions(field), - staleTime: 300_000, - enabled, - }); -} - export function useMyEditableAgents( ownership: MineOwnershipFilter = "all", enabled = true diff --git a/frontend/lib/agentRepositoryLabels.test.ts b/frontend/lib/agentRepositoryLabels.test.ts new file mode 100644 index 000000000..e366f5216 --- /dev/null +++ b/frontend/lib/agentRepositoryLabels.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from "vitest"; +import { + getAgentRepositoryCategoryLabel, + getAgentRepositoryTagLabel, + getAgentRepositoryTagSearchText, +} from "./agentRepositoryLabels"; + +const t = vi.fn((key: string) => { + const translations: Record = { + "agentRepository.category.writingAssistant": "Writing Assistant", + "agentRepository.category.other": "Other", + "agentRepository.tag.marketing": "Marketing", + "agentRepository.tag.codeReview": "Code Review", + "agentRepository.review.unknownCategory": "Uncategorized", + }; + return translations[key] ?? key; +}); + +describe("agentRepositoryLabels", () => { + it("localizes category by stable key", () => { + const label = getAgentRepositoryCategoryLabel( + { id: 1, key: "writing_assistant", name: "写作助手" }, + t + ); + expect(label).toBe("Writing Assistant"); + }); + + it("localizes preset tag keys", () => { + expect(getAgentRepositoryTagLabel("marketing", t)).toBe("Marketing"); + }); + + it("localizes legacy Chinese tag values", () => { + expect(getAgentRepositoryTagLabel("代码审查", t)).toBe("Code Review"); + }); + + it("returns custom tags unchanged", () => { + expect(getAgentRepositoryTagLabel("my-custom-tag", t)).toBe("my-custom-tag"); + }); + + it("includes localized text in tag search text", () => { + const searchText = getAgentRepositoryTagSearchText("marketing", t); + expect(searchText).toContain("marketing"); + expect(searchText).toContain("Marketing"); + }); +}); diff --git a/frontend/lib/agentRepositoryLabels.ts b/frontend/lib/agentRepositoryLabels.ts new file mode 100644 index 000000000..f390eaaaa --- /dev/null +++ b/frontend/lib/agentRepositoryLabels.ts @@ -0,0 +1,158 @@ +/** + * Label resolvers for agent repository categories and preset tags. + * Presets live in const/agentRepository.ts; localized labels come from i18n. + */ + +import type { TFunction } from "i18next"; +import { + AGENT_REPOSITORY_CATEGORY_ID_TO_KEY, + AGENT_REPOSITORY_PRESET_TAGS, +} from "@/const/agentRepository"; +import type { AgentRepositoryCategoryItem } from "@/types/agentRepository"; + +/** Map stable category key to i18n key suffix under agentRepository.category.* */ +const CATEGORY_KEY_TO_I18N: Record = { + writing_assistant: "writingAssistant", + programming: "programming", + data_analysis: "dataAnalysis", + customer_service: "customerService", + productivity: "productivity", + creative_design: "creativeDesign", + other: "other", +}; + +/** Legacy Chinese category names from older API responses. */ +const LEGACY_CATEGORY_NAME_TO_KEY: Record = { + 写作助手: "writing_assistant", + 编程开发: "programming", + 数据分析: "data_analysis", + 客户服务: "customer_service", + 效率工具: "productivity", + 创意设计: "creative_design", + 其它: "other", +}; + +/** Map preset tag key to i18n key suffix under agentRepository.tag.* */ +const TAG_KEY_TO_I18N: Record = Object.fromEntries( + AGENT_REPOSITORY_PRESET_TAGS.map((tag) => [ + tag, + tag + .split("_") + .map((part, index) => + index === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1) + ) + .join(""), + ]) +); + +/** Legacy Chinese preset tag values stored before stable keys were introduced. */ +const LEGACY_TAG_VALUE_TO_KEY: Record = { + 营销: "marketing", + 文案: "copywriting", + 内容创作: "content_creation", + 代码审查: "code_review", + 质量: "quality", + DevOps: "devops", + 数据: "data", + 可视化: "visualization", + BI: "bi", + 客服: "customer_service", + 工单: "ticket", + 自动化: "automation", + 会议: "meeting", + 纪要: "minutes", + 效率: "productivity", + 设计: "design", + 配色: "color_scheme", + 灵感: "inspiration", + 表格: "spreadsheet", + 办公: "office", +}; + +function resolveCategoryKey(category: AgentRepositoryCategoryItem): string | null { + if (category.key?.trim()) { + return category.key.trim(); + } + if (category.id in AGENT_REPOSITORY_CATEGORY_ID_TO_KEY) { + return AGENT_REPOSITORY_CATEGORY_ID_TO_KEY[category.id]; + } + const legacyKey = LEGACY_CATEGORY_NAME_TO_KEY[category.name?.trim() ?? ""]; + return legacyKey ?? null; +} + +function resolveTagKey(tag: string): string | null { + const trimmed = tag.trim(); + if (!trimmed) { + return null; + } + if (trimmed in TAG_KEY_TO_I18N) { + return trimmed; + } + return LEGACY_TAG_VALUE_TO_KEY[trimmed] ?? null; +} + +/** + * Get localized label for a repository category option. + */ +export function getAgentRepositoryCategoryLabel( + category: AgentRepositoryCategoryItem, + t: TFunction +): string { + const stableKey = resolveCategoryKey(category); + if (stableKey) { + const i18nSuffix = CATEGORY_KEY_TO_I18N[stableKey]; + if (i18nSuffix) { + const i18nKey = `agentRepository.category.${i18nSuffix}`; + const translated = t(i18nKey); + if (translated !== i18nKey) { + return translated; + } + } + } + return category.name?.trim() || t("agentRepository.review.unknownCategory"); +} + +/** + * Get localized label for a category id using a prebuilt category list. + */ +export function getAgentRepositoryCategoryLabelById( + categoryId: number | null | undefined, + categories: AgentRepositoryCategoryItem[], + t: TFunction +): string { + if (categoryId == null) { + return t("agentRepository.review.unknownCategory"); + } + const category = categories.find((item) => item.id === categoryId); + if (!category) { + return t("agentRepository.review.unknownCategory"); + } + return getAgentRepositoryCategoryLabel(category, t); +} + +/** + * Get localized label for a repository tag (preset key or legacy Chinese value). + * Custom tags are returned unchanged. + */ +export function getAgentRepositoryTagLabel(tag: string, t: TFunction): string { + const stableKey = resolveTagKey(tag); + if (stableKey) { + const i18nSuffix = TAG_KEY_TO_I18N[stableKey]; + if (i18nSuffix) { + const i18nKey = `agentRepository.tag.${i18nSuffix}`; + const translated = t(i18nKey); + if (translated !== i18nKey) { + return translated; + } + } + } + return tag.trim(); +} + +/** + * Build searchable text for a tag (raw value + localized label). + */ +export function getAgentRepositoryTagSearchText(tag: string, t: TFunction): string { + const label = getAgentRepositoryTagLabel(tag, t); + return `${tag} ${label}`.toLowerCase(); +} diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 1ed4b5c25..e787896cc 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -1750,6 +1750,35 @@ "agentRepository.review.approveError": "Failed to approve. Please try again later.", "agentRepository.review.rejectError": "Failed to reject. Please try again later.", + "agentRepository.category.writingAssistant": "Writing Assistant", + "agentRepository.category.programming": "Programming", + "agentRepository.category.dataAnalysis": "Data Analysis", + "agentRepository.category.customerService": "Customer Service", + "agentRepository.category.productivity": "Productivity", + "agentRepository.category.creativeDesign": "Creative Design", + "agentRepository.category.other": "Other", + + "agentRepository.tag.marketing": "Marketing", + "agentRepository.tag.copywriting": "Copywriting", + "agentRepository.tag.contentCreation": "Content Creation", + "agentRepository.tag.codeReview": "Code Review", + "agentRepository.tag.quality": "Quality", + "agentRepository.tag.devops": "DevOps", + "agentRepository.tag.data": "Data", + "agentRepository.tag.visualization": "Visualization", + "agentRepository.tag.bi": "BI", + "agentRepository.tag.customerService": "Customer Support", + "agentRepository.tag.ticket": "Ticketing", + "agentRepository.tag.automation": "Automation", + "agentRepository.tag.meeting": "Meeting", + "agentRepository.tag.minutes": "Minutes", + "agentRepository.tag.productivity": "Productivity", + "agentRepository.tag.design": "Design", + "agentRepository.tag.colorScheme": "Color Scheme", + "agentRepository.tag.inspiration": "Inspiration", + "agentRepository.tag.spreadsheet": "Spreadsheet", + "agentRepository.tag.office": "Office", + "tenantResources.create": "Create", "tenantResources.subtitle": "Manage tenants, users, groups and resources", "tenantResources.title": "Tenant Resource Management", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index c4a65102c..e8a48f2ab 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -1718,6 +1718,35 @@ "agentRepository.review.approveError": "通过审核失败,请稍后重试", "agentRepository.review.rejectError": "驳回审核失败,请稍后重试", + "agentRepository.category.writingAssistant": "写作助手", + "agentRepository.category.programming": "编程开发", + "agentRepository.category.dataAnalysis": "数据分析", + "agentRepository.category.customerService": "客户服务", + "agentRepository.category.productivity": "效率工具", + "agentRepository.category.creativeDesign": "创意设计", + "agentRepository.category.other": "其它", + + "agentRepository.tag.marketing": "营销", + "agentRepository.tag.copywriting": "文案", + "agentRepository.tag.contentCreation": "内容创作", + "agentRepository.tag.codeReview": "代码审查", + "agentRepository.tag.quality": "质量", + "agentRepository.tag.devops": "DevOps", + "agentRepository.tag.data": "数据", + "agentRepository.tag.visualization": "可视化", + "agentRepository.tag.bi": "BI", + "agentRepository.tag.customerService": "客服", + "agentRepository.tag.ticket": "工单", + "agentRepository.tag.automation": "自动化", + "agentRepository.tag.meeting": "会议", + "agentRepository.tag.minutes": "纪要", + "agentRepository.tag.productivity": "效率", + "agentRepository.tag.design": "设计", + "agentRepository.tag.colorScheme": "配色", + "agentRepository.tag.inspiration": "灵感", + "agentRepository.tag.spreadsheet": "表格", + "agentRepository.tag.office": "办公", + "tenantResources.create": "创建", "tenantResources.subtitle": "管理租户、用户、用户组和资源", "tenantResources.title": "租户资源管理", diff --git a/frontend/services/agentRepositoryService.ts b/frontend/services/agentRepositoryService.ts index 38678ade2..a4070ad32 100644 --- a/frontend/services/agentRepositoryService.ts +++ b/frontend/services/agentRepositoryService.ts @@ -12,8 +12,6 @@ import type { AgentRepositoryListingListParams, AgentRepositoryListingListResponse, AgentRepositoryListingStatus, - AgentRepositoryOptionField, - AgentRepositoryOptionResultMap, MyEditableAgentListParams, MyEditableAgentListResponse, } from "@/types/agentRepository"; @@ -41,31 +39,6 @@ export async function fetchAgentRepositoryListings( } } -export async function fetchAgentRepositoryOptions< - F extends AgentRepositoryOptionField, ->(field: F): Promise { - try { - const response = await fetchWithErrorHandling( - API_ENDPOINTS.agentRepository.options(field), - { - method: "GET", - headers: getAuthHeaders(), - } - ); - - if (!response.ok) { - throw new Error( - `Failed to fetch agent repository options: ${response.statusText}` - ); - } - - return response.json(); - } catch (error) { - log.error("Error fetching agent repository options:", error); - throw error; - } -} - export async function fetchAgentRepositoryListingDetail( agentRepositoryId: number ): Promise { @@ -177,7 +150,6 @@ export async function updateAgentRepositoryStatus( const agentRepositoryService = { fetchAgentRepositoryListings, - fetchAgentRepositoryOptions, fetchAgentRepositoryListingDetail, fetchMyEditableAgents, createAgentRepositoryListing, diff --git a/frontend/services/api.ts b/frontend/services/api.ts index e19697818..3c8e3ea26 100644 --- a/frontend/services/api.ts +++ b/frontend/services/api.ts @@ -4,7 +4,6 @@ import { handleSessionExpired } from "@/lib/session"; import log from "@/lib/logger"; import type { AgentRepositoryListingListParams, - AgentRepositoryOptionField, MyEditableAgentListParams, } from "@/types/agentRepository"; import type { MarketAgentListParams } from "@/types/market"; @@ -381,8 +380,6 @@ export const API_ENDPOINTS = { const queryString = queryParams.toString(); return `${API_BASE_URL}/repository/agent${queryString ? `?${queryString}` : ""}`; }, - options: (field: AgentRepositoryOptionField) => - `${API_BASE_URL}/repository/agent/options?field=${field}`, mineAgents: (params?: MyEditableAgentListParams) => { const queryParams = new URLSearchParams(); if (params?.ownership) { diff --git a/frontend/types/agentRepository.ts b/frontend/types/agentRepository.ts index 17565344f..110063e8d 100644 --- a/frontend/types/agentRepository.ts +++ b/frontend/types/agentRepository.ts @@ -38,17 +38,11 @@ export interface AgentRepositoryListingListParams { export interface AgentRepositoryCategoryItem { id: number; - name: string; + key: string; + /** Legacy fallback when resolving labels from old API payloads. */ + name?: string; } -export type AgentRepositoryOptionField = "categories" | "icons" | "tags"; - -export type AgentRepositoryOptionResultMap = { - categories: AgentRepositoryCategoryItem[]; - icons: string[]; - tags: string[]; -}; - export interface AgentRepositoryListingDetail { agent_repository_id: number; agent_id?: number | null; diff --git a/k8s/helm/nexent/charts/nexent-common/files/init.sql b/k8s/helm/nexent/charts/nexent-common/files/init.sql index fa55ba9c5..87a6ed130 100644 --- a/k8s/helm/nexent/charts/nexent-common/files/init.sql +++ b/k8s/helm/nexent/charts/nexent-common/files/init.sql @@ -2055,7 +2055,7 @@ CREATE TABLE IF NOT EXISTS nexent.ag_agent_repository_t ( tool_count INTEGER, version_label VARCHAR(100), agent_info_json JSONB NOT NULL, - status VARCHAR(30) DEFAULT 'NOT_SHARED', + status VARCHAR(30) DEFAULT 'not_shared', create_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, update_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, created_by VARCHAR(100), @@ -2084,7 +2084,7 @@ COMMENT ON COLUMN nexent.ag_agent_repository_t.tags IS 'Marketplace tags'; COMMENT ON COLUMN nexent.ag_agent_repository_t.tool_count IS 'Total tool count across all agents in the bundle (display only)'; COMMENT ON COLUMN nexent.ag_agent_repository_t.version_label IS 'Repository entry version label for display (e.g. v1.0)'; COMMENT ON COLUMN nexent.ag_agent_repository_t.agent_info_json IS 'Frozen ExportAndImportDataFormat snapshot with optional skills'; -COMMENT ON COLUMN nexent.ag_agent_repository_t.status IS 'Listing status: NOT_SHARED (未共享) / PENDING_REVIEW (待审核) / REJECTED (审核驳回) / SHARED (已共享)'; +COMMENT ON COLUMN nexent.ag_agent_repository_t.status IS 'Listing status: not_shared (未共享) / pending_review (待审核) / rejected (审核驳回) / shared (已共享)'; COMMENT ON COLUMN nexent.ag_agent_repository_t.create_time IS 'Creation time'; COMMENT ON COLUMN nexent.ag_agent_repository_t.update_time IS 'Update time'; COMMENT ON COLUMN nexent.ag_agent_repository_t.created_by IS 'Creator ID'; diff --git a/test/backend/app/test_agent_repository_app.py b/test/backend/app/test_agent_repository_app.py index fdbdeb0f0..6424997b2 100644 --- a/test/backend/app/test_agent_repository_app.py +++ b/test/backend/app/test_agent_repository_app.py @@ -3,7 +3,6 @@ import os import sys import types -from enum import Enum from typing import List, Optional from unittest.mock import AsyncMock, MagicMock @@ -30,14 +29,7 @@ class _AgentRepositoryListingCreateRequest(BaseModel): tool_count: Optional[int] = Field(None, ge=0) -class _AgentRepositoryOptionField(str, Enum): - CATEGORIES = "categories" - ICONS = "icons" - TAGS = "tags" - - consts_model.AgentRepositoryListingCreateRequest = _AgentRepositoryListingCreateRequest -consts_model.AgentRepositoryOptionField = _AgentRepositoryOptionField sys.modules["consts.model"] = consts_model from apps.agent_repository_app import agent_repository_router @@ -352,82 +344,6 @@ def test_update_agent_repository_status_api_bad_request(mocker, mock_auth_header assert response.json()["detail"] == "Invalid status transition" -def test_list_agent_repository_options_api_returns_categories(mocker, mock_auth_header): - """Test options API returns category list when field is categories.""" - mock_get_user_id = mocker.patch( - "apps.agent_repository_app.get_current_user_id" - ) - mock_list_options = mocker.patch( - "apps.agent_repository_app.list_agent_repository_options_impl", - ) - - mock_get_user_id.return_value = ("test_user_id", "test_tenant_id") - mock_list_options.return_value = [ - {"id": 1, "name": "写作助手"}, - {"id": 1000, "name": "其它"}, - ] - - response = client.get( - "/repository/agent/options?field=categories", - headers=mock_auth_header, - ) - - assert response.status_code == 200 - assert response.json() == [ - {"id": 1, "name": "写作助手"}, - {"id": 1000, "name": "其它"}, - ] - mock_get_user_id.assert_called_once_with(mock_auth_header["Authorization"]) - mock_list_options.assert_called_once_with("categories") - - -def test_list_agent_repository_options_api_returns_icons(mocker, mock_auth_header): - """Test options API returns icon list when field is icons.""" - mock_get_user_id = mocker.patch( - "apps.agent_repository_app.get_current_user_id" - ) - mock_list_options = mocker.patch( - "apps.agent_repository_app.list_agent_repository_options_impl", - ) - - mock_get_user_id.return_value = ("test_user_id", "test_tenant_id") - mock_list_options.return_value = ["🤖", "📊"] - - response = client.get( - "/repository/agent/options?field=icons", - headers=mock_auth_header, - ) - - assert response.status_code == 200 - assert response.json() == ["🤖", "📊"] - mock_list_options.assert_called_once_with("icons") - - -def test_list_agent_repository_options_api_requires_field(mock_auth_header): - """Test options API requires field query parameter.""" - response = client.get("/repository/agent/options", headers=mock_auth_header) - - assert response.status_code == 422 - - -def test_list_agent_repository_options_api_unauthorized(mocker, mock_auth_header): - """Test options API maps UnauthorizedError to 401.""" - from consts.exceptions import UnauthorizedError - - mock_get_user_id = mocker.patch( - "apps.agent_repository_app.get_current_user_id" - ) - mock_get_user_id.side_effect = UnauthorizedError("Not authorized") - - response = client.get( - "/repository/agent/options?field=categories", - headers=mock_auth_header, - ) - - assert response.status_code == 401 - assert response.json()["detail"] == "Not authorized" - - def test_create_agent_repository_listing_api_passes_card_fields(mocker, mock_auth_header): """Test create listing API forwards card_fields from request body.""" mock_get_user_id = mocker.patch( diff --git a/test/backend/services/test_agent_repository_service.py b/test/backend/services/test_agent_repository_service.py index 216eca470..d1a4091b0 100644 --- a/test/backend/services/test_agent_repository_service.py +++ b/test/backend/services/test_agent_repository_service.py @@ -237,36 +237,6 @@ def test_list_repository_listings_rejects_invalid_status_with_agent_id(): mock_list.assert_not_called() -def test_list_agent_repository_options_impl_returns_categories(): - result = ars.list_agent_repository_options_impl("categories") - - assert len(result) == 7 - assert result[0] == {"id": 1, "name": "写作助手"} - assert result[-1] == {"id": 0, "name": "其它"} - assert [item["id"] for item in result] == [1, 2, 3, 4, 5, 6, 0] - - -def test_list_agent_repository_options_impl_returns_icons(): - result = ars.list_agent_repository_options_impl("icons") - - assert result[0] == "🤖" - assert "📊" in result - assert len(result) == 10 - - -def test_list_agent_repository_options_impl_returns_tags(): - result = ars.list_agent_repository_options_impl("tags") - - assert "营销" in result - assert "代码审查" in result - assert "数据" in result - - -def test_list_agent_repository_options_impl_rejects_unknown_field(): - with pytest.raises(ValueError, match="Unsupported option field"): - ars.list_agent_repository_options_impl("unknown") - - def test_normalize_listing_tags_trims_dedupes_and_limits(): assert ars._normalize_listing_tags([" 营销 ", "营销", "数据"]) == ["营销", "数据"] @@ -277,7 +247,7 @@ def test_normalize_listing_tags_trims_dedupes_and_limits(): ars._normalize_listing_tags(["a", "b", "c", "d", "e", "f"]) -def test_validate_card_fields_requires_supported_values(): +def test_validate_card_fields_requires_structural_values(): base = { "agent_id": 1, "version_no": 1, @@ -298,14 +268,21 @@ def test_validate_card_fields_requires_supported_values(): with pytest.raises(ValueError, match="tags is required"): ars._validate_create_payload({**base, "icon": "🤖", "category_id": 1}) - with pytest.raises(ValueError, match="supported marketplace icon"): + with pytest.raises(ValueError, match="non-empty string"): ars._validate_create_payload({ **base, - "icon": "invalid", + "icon": " ", "category_id": 1, - "tags": ["营销"], + "tags": ["marketing"], }) + ars._validate_create_payload({ + **base, + "icon": "🤖", + "category_id": 99, + "tags": ["marketing"], + }) + def _editable_agent_record( *, From c48738bdab3c02875dc1359281ee7ea355fff967 Mon Sep 17 00:00:00 2001 From: Chenlifeng <174292121+Lifeng-Chen@users.noreply.github.com> Date: Wed, 24 Jun 2026 16:01:55 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=E2=9C=A8=20Feature:=20add=20agent=20reposi?= =?UTF-8?q?tory=20page=20and=20APIs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce Agent Repository backend APIs, database/service support, frontend views, client services, and tests. Migrate Agent Space navigation and permissions to /agent-repository with updated SQL and localization. --- backend/apps/agent_repository_app.py | 38 ------------------- test/backend/app/test_agent_repository_app.py | 14 +++---- 2 files changed, 6 insertions(+), 46 deletions(-) diff --git a/backend/apps/agent_repository_app.py b/backend/apps/agent_repository_app.py index b7d7955bb..c5c54928a 100644 --- a/backend/apps/agent_repository_app.py +++ b/backend/apps/agent_repository_app.py @@ -1,4 +1,3 @@ -import logging from http import HTTPStatus from typing import Optional @@ -18,7 +17,6 @@ from utils.auth_utils import get_current_user_id agent_repository_router = APIRouter(prefix="/repository/agent") -logger = logging.getLogger("agent_repository_app") @agent_repository_router.get("") @@ -54,12 +52,6 @@ async def list_agent_repository_listings_api( raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) except ValueError as e: raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) - except Exception as e: - logger.error(f"List agent repository listings error: {str(e)}") - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="List agent repository listings error.", - ) @agent_repository_router.get("/mine") @@ -83,12 +75,6 @@ async def list_my_editable_agents_api( raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) except ValueError as e: raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) - except Exception as e: - logger.error(f"List my editable agents error: {str(e)}") - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="List my editable agents error.", - ) @agent_repository_router.get("/{agent_repository_id}") @@ -105,12 +91,6 @@ async def get_agent_repository_listing_detail_api( raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) except ValueError as e: raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e)) - except Exception as e: - logger.error(f"Get agent repository listing detail error: {str(e)}") - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Get agent repository listing detail error.", - ) @agent_repository_router.patch("/{agent_repository_id}/status") @@ -140,12 +120,6 @@ async def update_agent_repository_status_api( raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) except ValueError as e: raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) - except Exception as e: - logger.error(f"Update agent repository status error: {str(e)}") - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Update agent repository status error.", - ) @agent_repository_router.post("/{agent_id}/versions/{version_no}") @@ -171,12 +145,6 @@ async def create_agent_repository_listing_api( raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) except ValueError as e: raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) - except Exception as e: - logger.error(f"Create agent repository listing error: {str(e)}") - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Create agent repository listing error.", - ) @agent_repository_router.post("/{agent_repository_id}/import") @@ -203,9 +171,3 @@ async def import_agent_from_repository_api( ) except ValueError as e: raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e)) - except Exception as e: - logger.error(f"Import agent from repository error: {str(e)}") - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Import agent from repository error.", - ) diff --git a/test/backend/app/test_agent_repository_app.py b/test/backend/app/test_agent_repository_app.py index 6424997b2..4ca5d4770 100644 --- a/test/backend/app/test_agent_repository_app.py +++ b/test/backend/app/test_agent_repository_app.py @@ -244,7 +244,7 @@ def test_create_agent_repository_listing_api_rejects_asset_owner(mocker, mock_au def test_create_agent_repository_listing_api_exception(mocker, mock_auth_header): - """Test create_agent_repository_listing_api with general exception.""" + """Test create_agent_repository_listing_api propagates unknown exceptions.""" mock_get_user_id = mocker.patch( "apps.agent_repository_app.get_current_user_id" ) @@ -256,13 +256,11 @@ def test_create_agent_repository_listing_api_exception(mocker, mock_auth_header) mock_get_user_id.return_value = ("test_user_id", "test_tenant_id") mock_create_listing.side_effect = Exception("Database error") - response = client.post( - "/repository/agent/123/versions/1", - headers=mock_auth_header, - ) - - assert response.status_code == 500 - assert "Create agent repository listing error." in response.json()["detail"] + with pytest.raises(Exception, match="Database error"): + client.post( + "/repository/agent/123/versions/1", + headers=mock_auth_header, + ) def test_update_agent_repository_status_api_success(mocker, mock_auth_header): From 3b5e39a7328df5cb88f08b8e6ba782bda186b9b5 Mon Sep 17 00:00:00 2001 From: Chenlifeng <174292121+Lifeng-Chen@users.noreply.github.com> Date: Wed, 24 Jun 2026 16:33:13 +0800 Subject: [PATCH 5/5] =?UTF-8?q?=E2=9C=A8=20Feature:=20add=20agent=20reposi?= =?UTF-8?q?tory=20page=20and=20APIs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce Agent Repository backend APIs, database/service support, frontend views, client services, and tests. Migrate Agent Space navigation and permissions to /agent-repository with updated SQL and localization. --- backend/apps/agent_repository_app.py | 12 +- backend/database/agent_repository_db.py | 31 +++++- backend/services/agent_repository_service.py | 45 ++++++-- frontend/lib/agentRepositoryLabels.test.ts | 20 ++-- test/backend/app/test_agent_repository_app.py | 56 ++++++++++ .../services/test_agent_repository_service.py | 103 +++++++++++++++--- 6 files changed, 226 insertions(+), 41 deletions(-) diff --git a/backend/apps/agent_repository_app.py b/backend/apps/agent_repository_app.py index c5c54928a..f538d5bf7 100644 --- a/backend/apps/agent_repository_app.py +++ b/backend/apps/agent_repository_app.py @@ -35,13 +35,14 @@ async def list_agent_repository_listings_api( ): """List all marketplace repository listings with optional status filter.""" try: - get_current_user_id(authorization) + _, tenant_id = get_current_user_id(authorization) should_deduplicate = ( agent_id is None if deduplicate_by_agent_id is None else deduplicate_by_agent_id ) result = list_agent_repository_listings_impl( + tenant_id, status=status, agent_id=agent_id, deduplicate_by_agent_id=should_deduplicate, @@ -84,8 +85,11 @@ async def get_agent_repository_listing_detail_api( ): """Get detailed marketplace repository listing by primary key.""" try: - get_current_user_id(authorization) - result = get_agent_repository_listing_detail_impl(agent_repository_id) + _, tenant_id = get_current_user_id(authorization) + result = get_agent_repository_listing_detail_impl( + agent_repository_id, + tenant_id, + ) return JSONResponse(status_code=HTTPStatus.OK, content=result) except UnauthorizedError as e: raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) @@ -154,8 +158,10 @@ async def import_agent_from_repository_api( ): """Import an agent tree from a marketplace repository listing into the current tenant.""" try: + _, tenant_id = get_current_user_id(authorization) await import_agent_from_repository_impl( agent_repository_id=agent_repository_id, + tenant_id=tenant_id, authorization=authorization, ) return JSONResponse(status_code=HTTPStatus.OK, content={}) diff --git a/backend/database/agent_repository_db.py b/backend/database/agent_repository_db.py index 67681085a..3f1b8c9dc 100644 --- a/backend/database/agent_repository_db.py +++ b/backend/database/agent_repository_db.py @@ -113,6 +113,8 @@ def get_agent_repository_by_id_and_publisher( def get_agent_repository_by_agent_id( agent_id: int, version_no: Optional[int] = None, + *, + publisher_tenant_id: Optional[str] = None, ) -> Optional[dict]: """Fetch an active repository listing by root agent_id and optional version.""" with get_db_session() as session: @@ -120,6 +122,10 @@ def get_agent_repository_by_agent_id( AgentRepository.agent_id == agent_id, AgentRepository.delete_flag != "Y", ) + if publisher_tenant_id is not None: + query = query.filter( + AgentRepository.publisher_tenant_id == publisher_tenant_id, + ) if version_no is not None: query = query.filter( AgentRepository.version_no == version_no @@ -147,7 +153,10 @@ def upsert_agent_repository_record( if agent_id is None: raise ValueError("agent_id is required for repository upsert") - existing = get_agent_repository_by_agent_id(int(agent_id)) + existing = get_agent_repository_by_agent_id( + int(agent_id), + publisher_tenant_id=publisher_tenant_id, + ) if not existing: repository_id = insert_agent_repository_record( repository_data=repository_data, @@ -189,12 +198,13 @@ def upsert_agent_repository_record( def list_agent_repository_summaries( + publisher_tenant_id: str, *, status: Optional[str] = None, agent_id: Optional[int] = None, category_id: Optional[int] = None, ) -> List[dict]: - """List all active repository summaries without heavy JSON blobs.""" + """List active repository summaries for a publisher tenant without heavy JSON blobs.""" with get_db_session() as session: query = session.query( AgentRepository.agent_repository_id, @@ -213,6 +223,7 @@ def list_agent_repository_summaries( AgentRepository.downloads, ).filter( AgentRepository.delete_flag != "Y", + AgentRepository.publisher_tenant_id == publisher_tenant_id, ) if status: query = query.filter(AgentRepository.status == status) @@ -293,6 +304,7 @@ def update_agent_repository_status_by_id( repository_id: int, status: str, user_id: str, + filter_publisher_tenant_id: Optional[str] = None, publisher_tenant_id: Optional[str] = None, publisher_user_id: Optional[str] = None, submitted_by: Optional[str] = None, @@ -310,12 +322,17 @@ def update_agent_repository_status_by_id( update_values["submitted_by"] = submitted_by with get_db_session() as session: + where_clauses = [ + AgentRepository.agent_repository_id == repository_id, + AgentRepository.delete_flag != "Y", + ] + if filter_publisher_tenant_id is not None: + where_clauses.append( + AgentRepository.publisher_tenant_id == filter_publisher_tenant_id + ) result = session.execute( update(AgentRepository) - .where( - AgentRepository.agent_repository_id == repository_id, - AgentRepository.delete_flag != "Y", - ) + .where(*where_clauses) .values(**update_values) ) return int(result.rowcount or 0) @@ -326,6 +343,7 @@ def reset_agent_repository_status( agent_repository_id: int, agent_id: int, status: str, + publisher_tenant_id: str, ) -> int: """Set other active listings with the same agent and status to not_shared.""" with get_db_session() as session: @@ -335,6 +353,7 @@ def reset_agent_repository_status( AgentRepository.agent_id == agent_id, AgentRepository.status == status, AgentRepository.agent_repository_id != agent_repository_id, + AgentRepository.publisher_tenant_id == publisher_tenant_id, AgentRepository.delete_flag != "Y", ) .values(status=STATUS_NOT_SHARED) diff --git a/backend/services/agent_repository_service.py b/backend/services/agent_repository_service.py index 3811a8806..1c1b29426 100644 --- a/backend/services/agent_repository_service.py +++ b/backend/services/agent_repository_service.py @@ -15,7 +15,7 @@ VALID_REPOSITORY_STATUSES, count_editable_agents_by_ownership, get_agent_repository_by_agent_id, - get_agent_repository_by_id, + get_agent_repository_by_id_and_publisher, insert_agent_repository_record, list_agent_repository_by_agent_ids, list_agent_repository_summaries, @@ -139,19 +139,21 @@ def _repository_summary_rank(record: Dict[str, Any]) -> Tuple[int, int]: def list_agent_repository_listings_impl( + tenant_id: str, *, status: Optional[str] = None, agent_id: Optional[int] = None, deduplicate_by_agent_id: bool = True, category_id: Optional[int] = None, ) -> Dict[str, Any]: - """List all repository listings with optional status filter.""" + """List repository listings for the caller tenant with optional status filter.""" if status is not None and status not in VALID_REPOSITORY_STATUSES: raise ValueError( f"Invalid status '{status}'; must be one of: " f"{', '.join(sorted(VALID_REPOSITORY_STATUSES))}" ) records = list_agent_repository_summaries( + publisher_tenant_id=tenant_id, status=status, agent_id=agent_id, category_id=category_id, @@ -222,18 +224,21 @@ def _reset_repository_peer_statuses( agent_repository_id: int, agent_id: int, status: str, + publisher_tenant_id: str, ) -> None: """Reset peer listings with the same status; also clear rejected when submitting.""" reset_agent_repository_status( agent_repository_id=agent_repository_id, agent_id=agent_id, status=status, + publisher_tenant_id=publisher_tenant_id, ) if status == STATUS_PENDING_REVIEW: reset_agent_repository_status( agent_repository_id=agent_repository_id, agent_id=agent_id, status=STATUS_REJECTED, + publisher_tenant_id=publisher_tenant_id, ) @@ -358,9 +363,13 @@ def _serialize_created_at(create_time: Any) -> Optional[str]: def get_agent_repository_listing_detail_impl( agent_repository_id: int, + tenant_id: str, ) -> Dict[str, Any]: """Load a repository listing and return a detail payload for the UI.""" - record = get_agent_repository_by_id(agent_repository_id) + record = get_agent_repository_by_id_and_publisher( + agent_repository_id, + tenant_id, + ) if not record: raise ValueError("Repository listing not found") @@ -481,7 +490,10 @@ def update_agent_repository_status_impl( f"{', '.join(sorted(VALID_REPOSITORY_STATUSES))}" ) - record = get_agent_repository_by_id(agent_repository_id) + record = get_agent_repository_by_id_and_publisher( + agent_repository_id, + tenant_id, + ) if not record: raise ValueError("Repository listing not found") @@ -505,6 +517,7 @@ def update_agent_repository_status_impl( repository_id=agent_repository_id, status=status, user_id=user_id, + filter_publisher_tenant_id=tenant_id, publisher_tenant_id=( publisher_updates["publisher_tenant_id"] if publisher_updates @@ -524,9 +537,13 @@ def update_agent_repository_status_impl( agent_repository_id=agent_repository_id, agent_id=record["agent_id"], status=status, + publisher_tenant_id=tenant_id, ) - updated = get_agent_repository_by_id(agent_repository_id) + updated = get_agent_repository_by_id_and_publisher( + agent_repository_id, + tenant_id, + ) if not updated: raise ValueError("Failed to load repository listing after update") return _to_summary_item(updated) @@ -700,7 +717,11 @@ async def create_agent_repository_listing_impl( ) _validate_create_payload(repository_data) - existing = get_agent_repository_by_agent_id(agent_id, version_no) + 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, @@ -725,23 +746,31 @@ async def create_agent_repository_listing_impl( raise ValueError("Failed to update repository listing") is_updated = True - record = get_agent_repository_by_id(repository_id) + record = get_agent_repository_by_id_and_publisher( + 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, ) return _to_detail_item(record, is_updated=is_updated) async def import_agent_from_repository_impl( agent_repository_id: int, + tenant_id: str, authorization: str, ) -> Dict[int, int]: """Import an agent tree from a marketplace repository listing into the current tenant.""" - record = get_agent_repository_by_id(agent_repository_id) + record = get_agent_repository_by_id_and_publisher( + agent_repository_id, + tenant_id, + ) if not record: raise ValueError("Repository listing not found") diff --git a/frontend/lib/agentRepositoryLabels.test.ts b/frontend/lib/agentRepositoryLabels.test.ts index e366f5216..262a6e635 100644 --- a/frontend/lib/agentRepositoryLabels.test.ts +++ b/frontend/lib/agentRepositoryLabels.test.ts @@ -1,11 +1,13 @@ -import { describe, expect, it, vi } from "vitest"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { TFunction } from "i18next"; import { getAgentRepositoryCategoryLabel, getAgentRepositoryTagLabel, getAgentRepositoryTagSearchText, } from "./agentRepositoryLabels"; -const t = vi.fn((key: string) => { +const t = ((key: string) => { const translations: Record = { "agentRepository.category.writingAssistant": "Writing Assistant", "agentRepository.category.other": "Other", @@ -14,7 +16,7 @@ const t = vi.fn((key: string) => { "agentRepository.review.unknownCategory": "Uncategorized", }; return translations[key] ?? key; -}); +}) as TFunction; describe("agentRepositoryLabels", () => { it("localizes category by stable key", () => { @@ -22,24 +24,24 @@ describe("agentRepositoryLabels", () => { { id: 1, key: "writing_assistant", name: "写作助手" }, t ); - expect(label).toBe("Writing Assistant"); + assert.equal(label, "Writing Assistant"); }); it("localizes preset tag keys", () => { - expect(getAgentRepositoryTagLabel("marketing", t)).toBe("Marketing"); + assert.equal(getAgentRepositoryTagLabel("marketing", t), "Marketing"); }); it("localizes legacy Chinese tag values", () => { - expect(getAgentRepositoryTagLabel("代码审查", t)).toBe("Code Review"); + assert.equal(getAgentRepositoryTagLabel("代码审查", t), "Code Review"); }); it("returns custom tags unchanged", () => { - expect(getAgentRepositoryTagLabel("my-custom-tag", t)).toBe("my-custom-tag"); + assert.equal(getAgentRepositoryTagLabel("my-custom-tag", t), "my-custom-tag"); }); it("includes localized text in tag search text", () => { const searchText = getAgentRepositoryTagSearchText("marketing", t); - expect(searchText).toContain("marketing"); - expect(searchText).toContain("Marketing"); + assert.match(searchText, /marketing/); + assert.match(searchText, /Marketing/); }); }); diff --git a/test/backend/app/test_agent_repository_app.py b/test/backend/app/test_agent_repository_app.py index 4ca5d4770..9d65e9433 100644 --- a/test/backend/app/test_agent_repository_app.py +++ b/test/backend/app/test_agent_repository_app.py @@ -64,6 +64,7 @@ def test_list_agent_repository_listings_api_defaults_dedupe_without_agent_id( assert response.status_code == 200 mock_get_user_id.assert_called_once_with(mock_auth_header["Authorization"]) mock_list.assert_called_once_with( + "test_tenant_id", status=None, agent_id=None, deduplicate_by_agent_id=True, @@ -93,6 +94,7 @@ def test_list_agent_repository_listings_api_disables_dedupe_for_agent_id( assert response.status_code == 200 mock_list.assert_called_once_with( + "test_tenant_id", status=None, agent_id=123, deduplicate_by_agent_id=False, @@ -122,6 +124,7 @@ def test_list_agent_repository_listings_api_passes_explicit_dedupe( assert response.status_code == 200 mock_list.assert_called_once_with( + "test_tenant_id", status=None, agent_id=123, deduplicate_by_agent_id=True, @@ -462,3 +465,56 @@ def test_list_my_editable_agents_api_bad_request(mocker, mock_auth_header): assert response.status_code == 400 assert response.json()["detail"] == "Invalid ownership filter: bad" + + +def test_get_agent_repository_listing_detail_api_passes_tenant_id( + mocker, + mock_auth_header, +): + """Test detail API forwards caller tenant_id to service.""" + mock_get_user_id = mocker.patch( + "apps.agent_repository_app.get_current_user_id" + ) + mock_get_detail = mocker.patch( + "apps.agent_repository_app.get_agent_repository_listing_detail_impl", + ) + + mock_get_user_id.return_value = ("test_user_id", "test_tenant_id") + mock_get_detail.return_value = { + "agent_repository_id": 42, + "name": "agent_one", + } + + response = client.get("/repository/agent/42", headers=mock_auth_header) + + assert response.status_code == 200 + mock_get_detail.assert_called_once_with(42, "test_tenant_id") + + +def test_import_agent_from_repository_api_passes_tenant_id( + mocker, + mock_auth_header, +): + """Test import API forwards caller tenant_id to service.""" + mock_get_user_id = mocker.patch( + "apps.agent_repository_app.get_current_user_id" + ) + mock_import = mocker.patch( + "apps.agent_repository_app.import_agent_from_repository_impl", + new_callable=AsyncMock, + ) + + mock_get_user_id.return_value = ("test_user_id", "test_tenant_id") + mock_import.return_value = {} + + response = client.post( + "/repository/agent/42/import", + headers=mock_auth_header, + ) + + assert response.status_code == 200 + mock_import.assert_awaited_once_with( + agent_repository_id=42, + tenant_id="test_tenant_id", + authorization=mock_auth_header["Authorization"], + ) diff --git a/test/backend/services/test_agent_repository_service.py b/test/backend/services/test_agent_repository_service.py index d1a4091b0..e1e1f1cbe 100644 --- a/test/backend/services/test_agent_repository_service.py +++ b/test/backend/services/test_agent_repository_service.py @@ -33,6 +33,7 @@ "others", }) _agent_repo_db_mock.get_agent_repository_by_id = MagicMock() +_agent_repo_db_mock.get_agent_repository_by_id_and_publisher = MagicMock() _agent_repo_db_mock.get_agent_repository_by_agent_id = MagicMock() _agent_repo_db_mock.insert_agent_repository_record = MagicMock() _agent_repo_db_mock.update_agent_repository_by_id = MagicMock() @@ -134,17 +135,20 @@ def _pending_review_reset_calls( *, agent_repository_id: int = 1, agent_id: int = 10, + publisher_tenant_id: str = "tenant_a", ) -> list: return [ call( agent_repository_id=agent_repository_id, agent_id=agent_id, status="pending_review", + publisher_tenant_id=publisher_tenant_id, ), call( agent_repository_id=agent_repository_id, agent_id=agent_id, status="rejected", + publisher_tenant_id=publisher_tenant_id, ), ] @@ -169,7 +173,7 @@ def test_list_repository_listings_deduplicates_by_agent_id_by_default(): ] with patch.object(ars, "list_agent_repository_summaries", return_value=records): - result = ars.list_agent_repository_listings_impl() + result = ars.list_agent_repository_listings_impl("tenant_a") assert [item["agent_repository_id"] for item in result["items"]] == [90, 80] assert result["items"][0]["status"] == "shared" @@ -184,6 +188,7 @@ def test_list_repository_listings_can_skip_agent_id_deduplication(): with patch.object(ars, "list_agent_repository_summaries", return_value=records): result = ars.list_agent_repository_listings_impl( + "tenant_a", deduplicate_by_agent_id=False, ) @@ -205,7 +210,7 @@ def test_list_repository_listings_uses_newest_repository_for_status_tie(): ] with patch.object(ars, "list_agent_repository_summaries", return_value=records): - result = ars.list_agent_repository_listings_impl() + result = ars.list_agent_repository_listings_impl("tenant_a") assert [item["agent_repository_id"] for item in result["items"]] == [11] @@ -217,12 +222,18 @@ def test_list_repository_listings_passes_agent_id_to_db(): return_value=[_repository_record(agent_repository_id=1, agent_id=123)], ) as mock_list: result = ars.list_agent_repository_listings_impl( + "tenant_a", status="shared", agent_id=123, deduplicate_by_agent_id=False, ) - mock_list.assert_called_once_with(status="shared", agent_id=123, category_id=None) + mock_list.assert_called_once_with( + publisher_tenant_id="tenant_a", + status="shared", + agent_id=123, + category_id=None, + ) assert [item["agent_repository_id"] for item in result["items"]] == [1] @@ -230,6 +241,7 @@ def test_list_repository_listings_rejects_invalid_status_with_agent_id(): with patch.object(ars, "list_agent_repository_summaries") as mock_list: with pytest.raises(ValueError, match="Invalid status"): ars.list_agent_repository_listings_impl( + "tenant_a", status="invalid", agent_id=123, ) @@ -415,7 +427,7 @@ def test_list_my_editable_agents_impl_rejects_invalid_ownership(): @pytest.fixture def mock_status_update_deps(): with patch.object(ars, "get_user_tenant_by_user_id") as mock_get_role, patch.object( - ars, "get_agent_repository_by_id" + ars, "get_agent_repository_by_id_and_publisher" ) as mock_get_by_id, patch.object( ars, "update_agent_repository_status_by_id" ) as mock_update_status, patch.object( @@ -435,6 +447,7 @@ def test_reset_repository_peer_statuses_pending_review_also_clears_rejected(): agent_repository_id=1, agent_id=10, status="pending_review", + publisher_tenant_id="tenant_a", ) mock_reset.assert_has_calls(_pending_review_reset_calls()) @@ -446,12 +459,14 @@ def test_reset_repository_peer_statuses_non_pending_single_reset(): agent_repository_id=1, agent_id=10, status="shared", + publisher_tenant_id="tenant_a", ) mock_reset.assert_called_once_with( agent_repository_id=1, agent_id=10, status="shared", + publisher_tenant_id="tenant_a", ) @@ -466,7 +481,7 @@ def test_update_status_su_pending_review_to_shared(mock_status_update_deps): agent_repository_id=1, status="shared", user_id="su_user", - tenant_id="any_tenant", + tenant_id="tenant_a", ) assert result["status"] == "shared" @@ -474,6 +489,7 @@ def test_update_status_su_pending_review_to_shared(mock_status_update_deps): repository_id=1, status="shared", user_id="su_user", + filter_publisher_tenant_id="tenant_a", publisher_tenant_id=None, publisher_user_id=None, submitted_by=None, @@ -482,6 +498,7 @@ def test_update_status_su_pending_review_to_shared(mock_status_update_deps): agent_repository_id=1, agent_id=10, status="shared", + publisher_tenant_id="tenant_a", ) @@ -496,7 +513,7 @@ def test_update_status_su_pending_review_to_rejected(mock_status_update_deps): agent_repository_id=1, status="rejected", user_id="su_user", - tenant_id="any_tenant", + tenant_id="tenant_a", ) assert result["status"] == "rejected" @@ -513,7 +530,7 @@ def test_update_status_su_shared_to_not_shared(mock_status_update_deps): agent_repository_id=1, status="not_shared", user_id="su_user", - tenant_id="any_tenant", + tenant_id="tenant_a", ) assert result["status"] == "not_shared" @@ -529,7 +546,7 @@ def test_update_status_su_invalid_transition(mock_status_update_deps): agent_repository_id=1, status="shared", user_id="su_user", - tenant_id="any_tenant", + tenant_id="tenant_a", ) @@ -570,6 +587,7 @@ def test_update_status_admin_not_shared_to_pending_review(mock_status_update_dep repository_id=1, status="pending_review", user_id="admin_user", + filter_publisher_tenant_id="tenant_a", publisher_tenant_id="tenant_a", publisher_user_id="admin_user", submitted_by="admin@example.com", @@ -597,6 +615,7 @@ def test_update_status_admin_rejected_to_pending_review(mock_status_update_deps) repository_id=1, status="pending_review", user_id="admin_user", + filter_publisher_tenant_id="tenant_a", publisher_tenant_id="tenant_a", publisher_user_id="admin_user", submitted_by="admin@example.com", @@ -626,6 +645,7 @@ def test_update_status_admin_pending_review_to_shared(mock_status_update_deps): repository_id=1, status="shared", user_id="admin_user", + filter_publisher_tenant_id="tenant_a", publisher_tenant_id=None, publisher_user_id=None, submitted_by=None, @@ -688,6 +708,7 @@ def test_update_status_admin_pending_review_to_not_shared(mock_status_update_dep repository_id=1, status="not_shared", user_id="admin_user", + filter_publisher_tenant_id="tenant_a", publisher_tenant_id=None, publisher_user_id=None, submitted_by=None, @@ -764,6 +785,7 @@ def test_update_status_same_status_noop(mock_status_update_deps): repository_id=1, status="shared", user_id="any_user", + filter_publisher_tenant_id="tenant_a", publisher_tenant_id=None, publisher_user_id=None, submitted_by=None, @@ -772,6 +794,7 @@ def test_update_status_same_status_noop(mock_status_update_deps): agent_repository_id=1, agent_id=10, status="shared", + publisher_tenant_id="tenant_a", ) @@ -788,11 +811,49 @@ def test_list_repository_listings_includes_submitted_by(): ] with patch.object(ars, "list_agent_repository_summaries", return_value=records): - result = ars.list_agent_repository_listings_impl(status="pending_review") + result = ars.list_agent_repository_listings_impl( + "tenant_a", + status="pending_review", + ) assert result["items"][0]["submitted_by"] == "reviewer@example.com" +def test_get_agent_repository_listing_detail_impl_scopes_by_tenant(): + record = { + **_repository_record(agent_repository_id=42), + "agent_info_json": { + "agent_id": 10, + "agent_info": {"10": {"model_name": "gpt", "duty_prompt": "help", "tools": []}}, + "mcp_info": [], + }, + "icon": "🤖", + "version_name": "v1", + "downloads": 0, + "create_time": None, + } + + with patch.object( + ars, + "get_agent_repository_by_id_and_publisher", + return_value=record, + ) as mock_get: + result = ars.get_agent_repository_listing_detail_impl(42, "tenant_a") + + mock_get.assert_called_once_with(42, "tenant_a") + assert result["agent_repository_id"] == 42 + + +def test_get_agent_repository_listing_detail_impl_not_found_for_other_tenant(): + with patch.object( + ars, + "get_agent_repository_by_id_and_publisher", + return_value=None, + ): + with pytest.raises(ValueError, match="Repository listing not found"): + ars.get_agent_repository_listing_detail_impl(42, "tenant_a") + + def test_resolve_submitter_email_uses_user_tenant_email(): with patch.object( ars, @@ -882,7 +943,7 @@ async def test_create_agent_repository_listing_impl_success(): ) as mock_get_by_agent_id, patch.object( ars, "insert_agent_repository_record" ) as mock_insert, patch.object( - ars, "get_agent_repository_by_id" + ars, "get_agent_repository_by_id_and_publisher" ) as mock_get_by_id, patch.object( ars, "reset_agent_repository_status" ) as mock_reset_status: @@ -919,7 +980,11 @@ async def test_create_agent_repository_listing_impl_success(): assert result["agent_info_json"] == agent_info_json assert result["is_updated"] is False mock_insert.assert_called_once() - mock_get_by_agent_id.assert_called_once_with(1, 1) + mock_get_by_agent_id.assert_called_once_with( + 1, + 1, + publisher_tenant_id="tenant_a", + ) mock_reset_status.assert_has_calls( _pending_review_reset_calls(agent_repository_id=42, agent_id=1) ) @@ -940,7 +1005,7 @@ async def test_create_agent_repository_listing_impl_updates_existing(): ) as mock_get_by_agent_id, patch.object( ars, "update_agent_repository_by_id" ) as mock_update, patch.object( - ars, "get_agent_repository_by_id" + ars, "get_agent_repository_by_id_and_publisher" ) as mock_get_by_id, patch.object( ars, "reset_agent_repository_status" ) as mock_reset_status: @@ -975,7 +1040,11 @@ async def test_create_agent_repository_listing_impl_updates_existing(): assert result["agent_repository_id"] == 42 assert result["is_updated"] is True - mock_get_by_agent_id.assert_called_once_with(1, 2) + mock_get_by_agent_id.assert_called_once_with( + 1, + 2, + publisher_tenant_id="tenant_a", + ) mock_update.assert_called_once() mock_update.assert_called_with( repository_id=42, @@ -1010,7 +1079,7 @@ async def test_create_agent_repository_listing_impl_accepts_draft_version(): ) as mock_get_by_agent_id, patch.object( ars, "insert_agent_repository_record" ) as mock_insert, patch.object( - ars, "get_agent_repository_by_id" + ars, "get_agent_repository_by_id_and_publisher" ) as mock_get_by_id, patch.object( ars, "reset_agent_repository_status" ) as mock_reset_status: @@ -1046,7 +1115,11 @@ async def test_create_agent_repository_listing_impl_accepts_draft_version(): assert result["agent_repository_id"] == 42 assert result["version_no"] == 0 mock_build_data.assert_awaited_once_with(1, "tenant_a", "user_a", 0, card_fields=None) - mock_get_by_agent_id.assert_called_once_with(1, 0) + mock_get_by_agent_id.assert_called_once_with( + 1, + 0, + publisher_tenant_id="tenant_a", + ) mock_reset_status.assert_has_calls( _pending_review_reset_calls(agent_repository_id=42, agent_id=1) )