Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions backend/apps/agent_repository_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,10 @@ async def check_repository_import_precheck_api(
@agent_repository_router.post("/{agent_repository_id}/import")
async def import_agent_from_repository_api(
agent_repository_id: int,
skip_duplicates: bool = Query(
False,
description="If True, skip duplicate skills and proceed with the remaining skills",
),
authorization: Optional[str] = Header(None),
):
"""Import an agent tree from a marketplace repository listing into the current tenant."""
Expand All @@ -255,6 +259,7 @@ async def import_agent_from_repository_api(
agent_repository_id=agent_repository_id,
tenant_id=tenant_id,
authorization=authorization,
skip_duplicates=skip_duplicates,
)
return JSONResponse(status_code=HTTPStatus.OK, content={})
except UnauthorizedError as e:
Expand Down
17 changes: 14 additions & 3 deletions backend/services/agent_repository_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
VALID_OWNERSHIP_FILTERS,
VALID_REPOSITORY_STATUSES,
)
from consts.exceptions import UnauthorizedError
from consts.exceptions import SkillDuplicateError, UnauthorizedError
from consts.model import AgentRepositorySnapshot
from consts.notification import EVENT_TYPE_REPOSITORY_REVIEW_PENDING, RESOURCE_TYPE_AGENT_REPOSITORY
from database.agent_db import search_agent_info_by_agent_id
Expand Down Expand Up @@ -1045,8 +1045,13 @@ async def import_agent_from_repository_impl(
agent_repository_id: int,
tenant_id: str,
authorization: str,
skip_duplicates: bool = False,
) -> Dict[int, int]:
"""Import an agent tree from a marketplace repository listing into the current tenant."""
"""Import an agent tree from a marketplace repository listing into the current tenant.

When skip_duplicates=True, duplicate skills are silently dropped. If all skills are
duplicates, the agent is imported without any skills (same as AgentImportWizard behavior).
"""
record = get_agent_repository_by_id(
agent_repository_id,
tenant_id,
Expand All @@ -1059,13 +1064,19 @@ async def import_agent_from_repository_impl(
raise ValueError("Repository listing has no agent snapshot")

snapshot = AgentRepositorySnapshot.model_validate(agent_info_json)
if snapshot.skills:

if snapshot.skills and not skip_duplicates:
result = await import_agent_with_skills_impl(
snapshot,
snapshot.skills,
authorization,
)
else:
if snapshot.skills and skip_duplicates:
logger.info(
"Skipping %d skills on repository import (id=%s) due to conflicts",
len(snapshot.skills), agent_repository_id,
)
result = await import_agent_impl(snapshot, authorization)

affected = increment_agent_repository_downloads(agent_repository_id)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
ExternalLink,
Plug,
RefreshCw,
SkipForward,
Sparkles,
Wrench,
X,
Expand Down Expand Up @@ -105,6 +106,14 @@
[precheck]
);

const skillConflictItems = useMemo(
() =>
abnormalItems.filter((item) => item.reason_code === "skill_duplicate"),
[abnormalItems]
);
const hasSkillConflicts = skillConflictItems.length > 0;
const skillConflictNames = skillConflictItems.map((item) => item.name);

const percent = precheck?.percent ?? 0;
const hasAbnormal = precheck?.has_abnormal ?? false;

Expand All @@ -116,14 +125,21 @@
router.push(`/${locale}${path}`);
};

const handleCopy = async () => {
const handleCopy = async (skipDuplicates = false) => {
if (!agentRepositoryId) {
return;
}
try {
await importMutation.mutateAsync(agentRepositoryId);
await importMutation.mutateAsync({
agentRepositoryId,
skipDuplicates,
});
message.success(
t("agentRepository.copy.success", { name: listingTitle })
skipDuplicates
? t("agentRepository.copy.successWithoutSkills", {
name: listingTitle,
})
: t("agentRepository.copy.success", { name: listingTitle })
);
onOpenChange(false);
onSuccess?.();
Expand All @@ -141,7 +157,7 @@
detail?.type === "skill_duplicate" &&
Array.isArray(detail.duplicate_skills)
) {
message.error(
message.warning(
t("agentRepository.copy.skillDuplicate", {
names: detail.duplicate_skills.join(", "),
})
Expand Down Expand Up @@ -169,17 +185,49 @@
width={520}
destroyOnHidden
footer={
<div className="flex justify-end gap-2">
<Button onClick={handleClose}>{t("common.cancel")}</Button>
<Button
type="primary"
icon={<Copy className="size-4" />}
loading={importMutation.isPending}
disabled={!precheck || isLoading || isError}
onClick={handleCopy}
>
{t("agentRepository.card.copy")}
</Button>
<div className="flex flex-col gap-2">
{hasSkillConflicts ? (
<div className="flex items-center justify-between rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:bg-amber-500/10 dark:text-amber-300">
<div className="flex items-center gap-2">
<AlertCircle className="size-4 shrink-0" />
<span>
{t("agentRepository.copy.skillConflict", {
count: skillConflictItems.length,
})}
<span className="ml-1 font-medium">
{skillConflictNames.join(", ")}
</span>
</span>
</div>
<div className="flex gap-2">
<Button
size="small"
icon={<SkipForward className="size-3.5" />}
loading={importMutation.isPending}
onClick={() => handleCopy(true)}
>
{t("agentRepository.copy.skipAndCopy")}
</Button>
</div>
</div>
) : null}
<div className="flex justify-end gap-2">
<Button onClick={handleClose}>{t("common.cancel")}</Button>
<Button
type="primary"
icon={<Copy className="size-4" />}
loading={importMutation.isPending}
disabled={
!precheck ||
isLoading ||
isError ||
hasSkillConflicts
}
onClick={() => handleCopy(false)}
>
{t("agentRepository.card.copy")}
</Button>
</div>
</div>
}
styles={{
Expand Down Expand Up @@ -354,6 +402,8 @@
const abnormal = status === "abnormal";
const typeLabel = getRepositoryRequirementTypeLabel(type, t);
const activatePath = getRepositoryRequirementActivatePath(type);
const isSkillDuplicate =
items.length > 0 && items[0]?.reason_code === "skill_duplicate";

return (
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-700">
Expand All @@ -363,28 +413,33 @@
{typeLabel}
</div>
{abnormal ? (
activatePath ? (
isSkillDuplicate ? (
<span className="flex items-center gap-1 text-xs text-amber-600">
<AlertCircle className="size-3.5" />
{getRepositoryRequirementReasonLabel("skill_duplicate", t)}
</span>
) : activatePath ? (
<button
type="button"
onClick={onActivate}
className="flex items-center gap-2 text-xs"
>
<span className="flex items-center gap-1 text-amber-600">
<AlertCircle className="size-3.5" />
{t("agentRepository.copy.notActivated", { type: typeLabel })}
</span>
<span className="flex items-center gap-0.5 text-primary hover:underline">
{t("agentRepository.copy.activate")}
<ExternalLink className="size-3" />
</span>
</button>
) : (
<span className="flex items-center gap-1 text-xs text-amber-600">
<AlertCircle className="size-3.5" />
{getRepositoryRequirementReasonLabel(items[0]?.reason_code, t) ||
t("agentRepository.copy.unavailable")}
</span>
)

Check warning on line 442 in frontend/app/[locale]/agent-space/components/AgentRepositoryCopyDialog.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AaAxsTTe_zvzs-iiMkNI&open=AaAxsTTe_zvzs-iiMkNI&pullRequest=3754

Check warning on line 442 in frontend/app/[locale]/agent-space/components/AgentRepositoryCopyDialog.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AaAxsTTe_zvzs-iiMkNH&open=AaAxsTTe_zvzs-iiMkNH&pullRequest=3754
) : (
<span className="flex items-center gap-1 text-xs text-emerald-600">
<CheckCircle2 className="size-3.5" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,11 @@ export function useImportAgentFromRepository() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: (agentRepositoryId: number) =>
agentRepositoryService.importAgentFromRepository(agentRepositoryId),
mutationFn: (params: { agentRepositoryId: number; skipDuplicates?: boolean }) =>
agentRepositoryService.importAgentFromRepository(
params.agentRepositoryId,
params.skipDuplicates
),
onSuccess: async () => {
await Promise.all([
invalidateAgentRepositoryCaches(queryClient),
Expand Down
3 changes: 3 additions & 0 deletions frontend/public/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -2183,6 +2183,9 @@
"agentRepository.copy.success": "Copied \"{{name}}\". You can edit it under Mine.",
"agentRepository.copy.failed": "Copy failed. Please try again.",
"agentRepository.copy.skillDuplicate": "These skills already exist and cannot be imported: {{names}}",
"agentRepository.copy.skillConflict": "{{count}} skill name conflict(s) detected:",
"agentRepository.copy.skipAndCopy": "Skip conflicts & copy",
"agentRepository.copy.successWithoutSkills": "Copied \"{{name}}\" (conflicting skills skipped). You can edit it in \"My Agents\".",
"agentRepository.copy.type.model": "Model",
"agentRepository.copy.type.knowledgeBase": "Knowledge base",
"agentRepository.copy.type.mcp": "MCP service",
Expand Down
3 changes: 3 additions & 0 deletions frontend/public/locales/zh/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -2166,6 +2166,9 @@
"agentRepository.copy.success": "已复制「{{name}}」,可在「我的」中编辑",
"agentRepository.copy.failed": "复制失败,请稍后重试",
"agentRepository.copy.skillDuplicate": "以下 Skill 已存在,无法复制:{{names}}",
"agentRepository.copy.skillConflict": "检测到 {{count}} 个 Skill 名称冲突:",
"agentRepository.copy.skipAndCopy": "跳过冲突并复制",
"agentRepository.copy.successWithoutSkills": "已复制「{{name}}」(跳过冲突的 Skill),可在「我的」中编辑",
"agentRepository.copy.type.model": "模型",
"agentRepository.copy.type.knowledgeBase": "知识库",
"agentRepository.copy.type.mcp": "MCP 服务",
Expand Down
17 changes: 9 additions & 8 deletions frontend/services/agentRepositoryService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,16 +180,17 @@ export async function fetchRepositoryImportPrecheck(
}

export async function importAgentFromRepository(
agentRepositoryId: number
agentRepositoryId: number,
skipDuplicates?: boolean
): Promise<void> {
try {
const response = await fetch(
API_ENDPOINTS.agentRepository.import(agentRepositoryId),
{
method: "POST",
headers: getAuthHeaders(),
}
);
const url = skipDuplicates
? `${API_ENDPOINTS.agentRepository.import(agentRepositoryId)}?skip_duplicates=true`
: API_ENDPOINTS.agentRepository.import(agentRepositoryId);
const response = await fetch(url, {
method: "POST",
headers: getAuthHeaders(),
});

if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
Expand Down
31 changes: 31 additions & 0 deletions test/backend/app/test_agent_repository_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -722,6 +722,37 @@ def test_import_agent_from_repository_api_passes_tenant_id(
agent_repository_id=42,
tenant_id="test_tenant_id",
authorization=mock_auth_header["Authorization"],
skip_duplicates=False,
)


def test_import_agent_from_repository_api_skip_duplicates(
mocker,
mock_auth_header,
):
"""Test import API forwards skip_duplicates query param 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?skip_duplicates=true",
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"],
skip_duplicates=True,
)


Expand Down
Loading
Loading