diff --git a/backend/apps/agent_repository_app.py b/backend/apps/agent_repository_app.py index e9da2fde0..f538d5bf7 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 @@ -6,38 +5,96 @@ 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_listings_impl, + list_my_editable_agents_impl, update_agent_repository_status_impl, ) 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("") 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) + _, 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, + 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)) 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") +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)) + + +@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: + _, 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)) + except ValueError as e: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e)) @agent_repository_router.patch("/{agent_repository_id}/status") @@ -47,59 +104,51 @@ 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: 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}") 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: 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") @@ -109,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={}) @@ -126,9 +177,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/backend/consts/model.py b/backend/consts/model.py index 00e5b8a0a..f5ab26af2 100644 --- a/backend/consts/model.py +++ b/backend/consts/model.py @@ -627,6 +627,42 @@ 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 + key: str + 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..3f1b8c9dc 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,27 @@ 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, + *, + 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: - record = session.query(AgentRepository).filter( + query = session.query(AgentRepository).filter( AgentRepository.agent_id == agent_id, AgentRepository.delete_flag != "Y", - ).first() + ) + 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 + ) + record = query.first() return as_dict(record) if record else None @@ -111,8 +142,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: @@ -122,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, @@ -131,8 +165,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: @@ -164,99 +198,61 @@ 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, + 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", + AgentRepository.publisher_tenant_id == publisher_tenant_id, ) 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 ] -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, @@ -269,11 +265,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,16 +304,59 @@ 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, ) -> 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: + 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(*where_clauses) + .values(**update_values) + ) + return int(result.rowcount or 0) + + +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: result = session.execute( update(AgentRepository) .where( - AgentRepository.agent_repository_id == repository_id, + 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, updated_by=user_id) + .values(status=STATUS_NOT_SHARED) ) return int(result.rowcount or 0) @@ -356,3 +398,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..1c1b29426 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, + get_agent_repository_by_id_and_publisher, 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,53 @@ 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, +} + +_MAX_LISTING_TAGS = 5 +_MAX_LISTING_TAG_LENGTH = 20 +_MAX_LISTING_ICON_LENGTH = 32 + _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,33 +90,398 @@ 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( + 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(status=status) + records = list_agent_repository_summaries( + publisher_tenant_id=tenant_id, + 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 _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 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 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: + raise ValueError("tags is required for marketplace listing submission") + repository_data["tags"] = _normalize_listing_tags(tags) + + +_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, + 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, + ) + + +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, + tenant_id: str, +) -> Dict[str, Any]: + """Load a repository listing and return a detail payload for the UI.""" + record = get_agent_repository_by_id_and_publisher( + agent_repository_id, + tenant_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: @@ -77,19 +490,60 @@ 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") + 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, + filter_publisher_tenant_id=tenant_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") - updated = get_agent_repository_by_id(agent_repository_id) + _reset_repository_peer_statuses( + agent_repository_id=agent_repository_id, + agent_id=record["agent_id"], + status=status, + publisher_tenant_id=tenant_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) @@ -105,12 +559,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 +593,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", ) @@ -156,17 +613,7 @@ 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}'") - -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("租户管理员智能体无法共享") + _validate_card_fields(repository_data) async def _build_agent_info_json( @@ -199,60 +646,82 @@ 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", "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 + 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, + publisher_tenant_id=tenant_id, + ) if not existing: repository_id = insert_agent_repository_record( repository_data=repository_data, @@ -277,18 +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/docker/init.sql b/docker/init.sql index 5b0ff025b..ad522a48a 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 @@ -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.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-repository/page.tsx b/frontend/app/[locale]/agent-repository/page.tsx new file mode 100644 index 000000000..46c60e68c --- /dev/null +++ b/frontend/app/[locale]/agent-repository/page.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; + +/** + * Legacy Agent Repository route — redirects to Agent Space. + */ +export default function AgentRepositoryRedirectPage() { + const router = useRouter(); + + useEffect(() => { + router.replace("/agent-space"); + }, [router]); + + 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-space/components/AgentRepositoryCard.tsx b/frontend/app/[locale]/agent-space/components/AgentRepositoryCard.tsx new file mode 100644 index 000000000..def916f55 --- /dev/null +++ b/frontend/app/[locale]/agent-space/components/AgentRepositoryCard.tsx @@ -0,0 +1,126 @@ +"use client"; + +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 { + listing: AgentRepositoryListingItem; + categoryName?: string | null; + onDetailClick?: (listing: AgentRepositoryListingItem) => void; +} + +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; + const downloads = listing.downloads ?? 0; + const showTagsRow = tags.length > 0 || toolCount > 0; + + return ( + +
+
+ {listing.icon?.trim() ? ( + {listing.icon.trim()} + ) : ( + + )} +
+
+

+ {title} +

+

+ {subtitle} +

+
+
+ +

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

+ + {showTagsRow ? ( +
+ {tags.map((tag) => ( + + {getAgentRepositoryTagLabel(tag, t)} + + ))} + {toolCount > 0 ? ( + + {t("agentRepository.card.toolCount", { count: toolCount })} + + ) : null} +
+ ) : null} + +
+
+ {versionText ? ( + + + {versionText} + + ) : ( + + )} + {downloads > 0 ? ( + + + {downloads.toLocaleString()} + + ) : null} +
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/app/[locale]/agent-space/components/AgentRepositoryDetailModal.tsx b/frontend/app/[locale]/agent-space/components/AgentRepositoryDetailModal.tsx new file mode 100644 index 000000000..e07683224 --- /dev/null +++ b/frontend/app/[locale]/agent-space/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-space/components/MineAgentsView.tsx b/frontend/app/[locale]/agent-space/components/MineAgentsView.tsx new file mode 100644 index 000000000..6c147a698 --- /dev/null +++ b/frontend/app/[locale]/agent-space/components/MineAgentsView.tsx @@ -0,0 +1,306 @@ +"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, + 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"; + +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 [applyModalOpen, setApplyModalOpen] = useState(false); + const [applyModalAgent, setApplyModalAgent] = + 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 = (agent: MyEditableAgentItem) => { + const versionNo = agent.current_version_no ?? 0; + if (versionNo <= 0) { + return; + } + setApplyModalAgent(agent); + setApplyModalOpen(true); + }; + + const closeApplyModal = () => { + setApplyModalOpen(false); + setApplyModalAgent(null); + }; + + const handleSubmitApplyListing = async ( + payload: AgentRepositoryListingCreatePayload + ) => { + if (!applyModalAgent) { + return; + } + + const versionNo = applyModalAgent.current_version_no ?? 0; + if (versionNo <= 0) { + return; + } + + setApplyingAgentId(applyModalAgent.agent_id); + try { + await createListingMutation.mutateAsync({ + agentId: applyModalAgent.agent_id, + versionNo, + payload, + }); + message.success( + t("agentRepository.mine.applySuccess", { + name: + applyModalAgent.name?.trim() || + t("agentRepository.card.untitled"), + }) + ); + closeApplyModal(); + } 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 handleSetNotShared = async () => { + if (!reviewModalInfo) { + return; + } + + const canUpdate = + isCancelableRepositoryStatus(reviewModalInfo.status) || + isTakeDownableRepositoryStatus(reviewModalInfo.status); + if (!canUpdate) { + return; + } + + const wasShared = reviewModalInfo.status === "shared"; + + try { + await updateStatusMutation.mutateAsync({ + agentRepositoryId: reviewModalInfo.agent_repository_id, + status: "not_shared", + }); + message.success( + wasShared + ? t("agentRepository.mine.takeDownSuccess") + : t("agentRepository.mine.cancelApplySuccess") + ); + closeReviewModal(); + } catch { + message.error( + wasShared + ? t("agentRepository.mine.takeDownError") + : t("agentRepository.mine.cancelApplyError") + ); + throw new Error("Update repository status failed"); + } + }; + + 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-space/components/MineApplyListingModal.tsx b/frontend/app/[locale]/agent-space/components/MineApplyListingModal.tsx new file mode 100644 index 000000000..20fa13d99 --- /dev/null +++ b/frontend/app/[locale]/agent-space/components/MineApplyListingModal.tsx @@ -0,0 +1,222 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { App, Button, Modal, Select } from "antd"; +import { Share2 } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { + AGENT_REPOSITORY_CATEGORIES, + AGENT_REPOSITORY_ICONS, + AGENT_REPOSITORY_PRESET_TAGS, +} from "@/const/agentRepository"; +import { + getAgentRepositoryCategoryLabel, + getAgentRepositoryTagLabel, +} from "@/lib/agentRepositoryLabels"; +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 icons = AGENT_REPOSITORY_ICONS; + const categories = AGENT_REPOSITORY_CATEGORIES; + const presetTags = AGENT_REPOSITORY_PRESET_TAGS; + + const [selectedIcon, setSelectedIcon] = useState(null); + const [selectedCategoryId, setSelectedCategoryId] = useState( + null + ); + const [selectedTags, setSelectedTags] = useState([]); + + const tagOptions = useMemo( + () => + presetTags.map((tag) => ({ + label: getAgentRepositoryTagLabel(tag, t), + value: tag, + })), + [presetTags, t] + ); + + 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 })} +

+ +
+
+

+ {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/components/MineReviewStatusModal.tsx b/frontend/app/[locale]/agent-space/components/MineReviewStatusModal.tsx new file mode 100644 index 000000000..f7bd67a76 --- /dev/null +++ b/frontend/app/[locale]/agent-space/components/MineReviewStatusModal.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { Button, Modal } from "antd"; +import { CheckCircle2, Clock, PackageX, Store, XCircle } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { + formatMineDate, + formatRepositoryVersionLabel, + isCancelableRepositoryStatus, + isTakeDownableRepositoryStatus, +} from "@/lib/agentRepositoryMine"; +import type { + MyAgentRepositoryInfoItem, + MyEditableAgentItem, +} from "@/types/agentRepository"; + +interface MineReviewStatusModalProps { + open: boolean; + agent: MyEditableAgentItem | null; + repositoryInfo: MyAgentRepositoryInfoItem | null; + mode: "review" | "reviewUpdate"; + isUpdatingStatus?: boolean; + onClose: () => void; + onSetNotShared: () => Promise; +} + +export function MineReviewStatusModal({ + open, + agent, + repositoryInfo, + mode, + isUpdatingStatus = false, + onClose, + onSetNotShared, +}: MineReviewStatusModalProps) { + const { t } = useTranslation("common"); + + if (!agent || !repositoryInfo) { + return null; + } + + const title = agent.name?.trim() || t("agentRepository.card.untitled"); + const isPending = repositoryInfo.status === "pending_review"; + const isRejected = repositoryInfo.status === "rejected"; + const canCancelApply = isCancelableRepositoryStatus(repositoryInfo.status); + const canTakeDown = isTakeDownableRepositoryStatus(repositoryInfo.status); + const versionLabel = formatRepositoryVersionLabel(repositoryInfo); + const submittedAt = formatMineDate(repositoryInfo.create_time); + + const 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"); + + 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={ + + + {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-space/components/MyAgentCard.tsx b/frontend/app/[locale]/agent-space/components/MyAgentCard.tsx new file mode 100644 index 000000000..f98600806 --- /dev/null +++ b/frontend/app/[locale]/agent-space/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-space/page.tsx b/frontend/app/[locale]/agent-space/page.tsx index ebb925e0a..21c45a94b 100644 --- a/frontend/app/[locale]/agent-space/page.tsx +++ b/frontend/app/[locale]/agent-space/page.tsx @@ -1,216 +1,612 @@ -"use client"; +"use client"; -import React, { useState } 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 { App } from "antd"; -import { Plus, RefreshCw, Upload } from "lucide-react"; - +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 { 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 - */ -export default function SpacePage() { - const router = useRouter(); + 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"; + +enum AgentRepositoryTab { + REPOSITORY = "repository", + MINE = "mine", + REVIEW = "review", +} +const agentRepositoryTheme = { + token: { colorPrimary: "#2563eb", colorInfo: "#3b82f6" }, +}; + +export default function AgentRepositoryPage() { const { t } = useTranslation("common"); - const { message } = App.useApp(); const { pageVariants, pageTransition } = useSetupFlow(); - const [isImporting, setIsImporting] = useState(false); - const { agents, isLoading, invalidate } = usePublishedAgentList(); + 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; - // Import wizard state - const [importWizardVisible, setImportWizardVisible] = useState(false); - const [importWizardData, setImportWizardData] = useState(null); + const categories = AGENT_REPOSITORY_CATEGORIES; - const handleCreateAgent = () => { - router.push("/agents?create=true"); + const categoryNameById = useMemo( + () => + new Map( + categories.map((item) => [ + item.id, + getAgentRepositoryCategoryLabel(item, t), + ]) + ), + [categories, t] + ); + + const listingParams = { + status: "shared" as const, + ...(selectedCategoryId == null ? {} : { category_id: selectedCategoryId }), }; - const onRefresh = () => { - invalidate(); + 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 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); + 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) => getAgentRepositoryTagSearchText(tag, t)) + .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 ( -
- -
- {/* Page header */} -
- -

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

-

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

-
- - {/* Refresh button */} - - - -
+ {isRepositoryTab ? ( + + {t("agentRepository.page.resultCount", { + count: filteredListings.length, + })} + + ) : isMineTab ? ( + + {t("agentRepository.mine.resultCount", { + count: mineCounts[mineOwnership], + })} + + ) : null} +
- {/* Agent cards grid */} - 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")} +

+
+ + {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 ( + -
- {/* 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!" - )} -

-
- )} + + ); + })}
- - - {/* Import Wizard Modal */} - { - setImportWizardVisible(false); - setImportWizardData(null); - }} - initialData={importWizardData} - onImportComplete={() => { - setImportWizardVisible(false); - setImportWizardData(null); - invalidate(); // Refresh the agent list - }} - /> + )} ); } diff --git a/frontend/components/navigation/SideNavigation.tsx b/frontend/components/navigation/SideNavigation.tsx index a2ce2f42f..102cfa4f6 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-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", + }, // 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/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 new file mode 100644 index 000000000..614ea9597 --- /dev/null +++ b/frontend/hooks/agentRepository/useAgentRepositoryListings.ts @@ -0,0 +1,98 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import agentRepositoryService from "@/services/agentRepositoryService"; +import type { + AgentRepositoryListingListParams, + AgentRepositoryListingCreatePayload, + AgentRepositoryListingStatus, + MineOwnershipFilter, +} from "@/types/agentRepository"; + +const QUERY_KEY = "agentRepositoryListings"; +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 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, + payload, + }: { + agentId: number; + versionNo: number; + payload: AgentRepositoryListingCreatePayload; + }) => + agentRepositoryService.createAgentRepositoryListing( + agentId, + versionNo, + payload + ), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEY] }); + queryClient.invalidateQueries({ queryKey: [MY_EDITABLE_AGENTS_QUERY_KEY] }); + }, + }); +} diff --git a/frontend/lib/agentRepositoryLabels.test.ts b/frontend/lib/agentRepositoryLabels.test.ts new file mode 100644 index 000000000..262a6e635 --- /dev/null +++ b/frontend/lib/agentRepositoryLabels.test.ts @@ -0,0 +1,47 @@ +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 = ((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; +}) as TFunction; + +describe("agentRepositoryLabels", () => { + it("localizes category by stable key", () => { + const label = getAgentRepositoryCategoryLabel( + { id: 1, key: "writing_assistant", name: "写作助手" }, + t + ); + assert.equal(label, "Writing Assistant"); + }); + + it("localizes preset tag keys", () => { + assert.equal(getAgentRepositoryTagLabel("marketing", t), "Marketing"); + }); + + it("localizes legacy Chinese tag values", () => { + assert.equal(getAgentRepositoryTagLabel("代码审查", t), "Code Review"); + }); + + it("returns custom tags unchanged", () => { + assert.equal(getAgentRepositoryTagLabel("my-custom-tag", t), "my-custom-tag"); + }); + + it("includes localized text in tag search text", () => { + const searchText = getAgentRepositoryTagSearchText("marketing", t); + assert.match(searchText, /marketing/); + assert.match(searchText, /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/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..91980ea8e --- /dev/null +++ b/frontend/lib/agentRepositoryMine.ts @@ -0,0 +1,131 @@ +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 isTakeDownableRepositoryStatus( + status: MyAgentRepositoryInfoItem["status"] +): boolean { + return status === "shared"; +} + +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..e787896cc 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,141 @@ "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.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", + "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.", + + "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 4735f22c5..e8a48f2ab 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,141 @@ "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.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": "暂无描述", + "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": "驳回审核失败,请稍后重试", + + "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 new file mode 100644 index 000000000..a4070ad32 --- /dev/null +++ b/frontend/services/agentRepositoryService.ts @@ -0,0 +1,159 @@ +/** + * 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 { + AgentRepositoryListingCreatePayload, + 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 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, + payload: AgentRepositoryListingCreatePayload +): Promise { + try { + const response = await fetchWithErrorHandling( + API_ENDPOINTS.agentRepository.createListing(agentId, versionNo), + { + method: "POST", + headers: { + ...getAuthHeaders(), + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + } + ); + + 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, + fetchAgentRepositoryListingDetail, + fetchMyEditableAgents, + createAgentRepositoryListing, + updateAgentRepositoryStatus, +}; + +export default agentRepositoryService; diff --git a/frontend/services/api.ts b/frontend/services/api.ts index e5b4ed025..3c8e3ea26 100644 --- a/frontend/services/api.ts +++ b/frontend/services/api.ts @@ -2,6 +2,10 @@ 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 +361,42 @@ 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}` : ""}`; + }, + 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..110063e8d --- /dev/null +++ b/frontend/types/agentRepository.ts @@ -0,0 +1,111 @@ +/** + * 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; + key: string; + /** Legacy fallback when resolving labels from old API payloads. */ + 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; +} + +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 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 b9b0d573a..9d65e9433 100644 --- a/test/backend/app/test_agent_repository_app.py +++ b/test/backend/app/test_agent_repository_app.py @@ -2,11 +2,14 @@ import os import sys +import types +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 +18,20 @@ 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) + + +consts_model.AgentRepositoryListingCreateRequest = _AgentRepositoryListingCreateRequest +sys.modules["consts.model"] = consts_model + from apps.agent_repository_app import agent_repository_router app = FastAPI() @@ -27,6 +44,94 @@ 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( + "test_tenant_id", + status=None, + agent_id=None, + deduplicate_by_agent_id=True, + category_id=None, + ) + + +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( + "test_tenant_id", + status=None, + agent_id=123, + deduplicate_by_agent_id=False, + category_id=None, + ) + + +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( + "test_tenant_id", + status=None, + agent_id=123, + deduplicate_by_agent_id=True, + category_id=None, + ) + + 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 +146,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, } @@ -57,6 +162,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 @@ -76,7 +182,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, } @@ -91,8 +197,9 @@ 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()["source_version_no"] == 0 + assert response.json()["version_no"] == 0 def test_create_agent_repository_listing_api_bad_request(mocker, mock_auth_header): @@ -140,7 +247,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" ) @@ -152,10 +259,262 @@ 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") + 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): + """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_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, ) - assert response.status_code == 500 - assert "Create agent repository listing error." in response.json()["detail"] + +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" + + +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 648d20385..e1e1f1cbe 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,35 @@ 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_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() +_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 +53,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 +105,829 @@ 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, + 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, + ), + ] + + +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("tenant_a") + + 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( + "tenant_a", + 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("tenant_a") + + 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( + "tenant_a", + status="shared", + agent_id=123, + deduplicate_by_agent_id=False, + ) + + 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] + + +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, + ) + + mock_list.assert_not_called() + + +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_structural_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="non-empty string"): + ars._validate_create_payload({ + **base, + "icon": " ", + "category_id": 1, + "tags": ["marketing"], + }) + + ars._validate_create_payload({ + **base, + "icon": "🤖", + "category_id": 99, + "tags": ["marketing"], + }) + + +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_and_publisher" + ) 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", + publisher_tenant_id="tenant_a", + ) + + 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", + 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", + ) + + +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="tenant_a", + ) + + assert result["status"] == "shared" + deps["update_status"].assert_called_once_with( + 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, + ) + deps["reset_status"].assert_called_once_with( + agent_repository_id=1, + agent_id=10, + status="shared", + publisher_tenant_id="tenant_a", + ) + + +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="tenant_a", + ) + + 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="tenant_a", + ) + + 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="tenant_a", + ) + + +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", + filter_publisher_tenant_id="tenant_a", + 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", + filter_publisher_tenant_id="tenant_a", + 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", + filter_publisher_tenant_id="tenant_a", + 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", + filter_publisher_tenant_id="tenant_a", + 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", + filter_publisher_tenant_id="tenant_a", + 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", + publisher_tenant_id="tenant_a", + ) + + +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( + "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, + "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_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( + 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 = { @@ -107,14 +943,19 @@ 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" - ) as mock_get_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: 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", + "icon": "🤖", + "category_id": 1, + "tags": ["营销"], } mock_get_by_agent_id.return_value = None mock_insert.return_value = 42 @@ -123,8 +964,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 +980,14 @@ 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, + publisher_tenant_id="tenant_a", + ) + mock_reset_status.assert_has_calls( + _pending_review_reset_calls(agent_repository_id=42, agent_id=1) + ) @pytest.mark.asyncio @@ -157,14 +1005,19 @@ 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" - ) as mock_get_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: 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", + "icon": "🤖", + "category_id": 1, + "tags": ["营销"], } mock_get_by_agent_id.return_value = {"agent_repository_id": 42} mock_update.return_value = 1 @@ -173,8 +1026,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 +1040,28 @@ 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, + publisher_tenant_id="tenant_a", + ) 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, + "category_id": 1, + "tags": ["营销"], + "icon": "🤖", + "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 @@ -215,14 +1079,19 @@ 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" - ) as mock_get_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: 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", + "icon": "🤖", + "category_id": 1, + "tags": ["营销"], } mock_get_by_agent_id.return_value = None mock_insert.return_value = 42 @@ -231,8 +1100,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 +1113,16 @@ 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, + publisher_tenant_id="tenant_a", + ) + mock_reset_status.assert_has_calls( + _pending_review_reset_calls(agent_repository_id=42, agent_id=1) + ) @pytest.mark.asyncio @@ -259,19 +1136,111 @@ 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(): + 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, - "source_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, - "source_version_no": 1, - "name": "agent_one", + **base, "agent_info_json": {"agent_id": 1}, }) @@ -310,44 +1279,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 +1339,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"