mirror of
https://github.com/langgenius/dify.git
synced 2026-06-24 13:01:16 +08:00
fix: support agent duplicate role and skill file preview (#37788)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
7fc8eed716
commit
a3309cd857
@ -14,7 +14,6 @@ from controllers.console.app.app import (
|
||||
)
|
||||
from controllers.console.app.app import (
|
||||
AppListQuery,
|
||||
CopyAppPayload,
|
||||
_normalize_app_list_query_args,
|
||||
)
|
||||
from controllers.console.app.app import (
|
||||
@ -110,6 +109,25 @@ class AgentAppUpdatePayload(GenericUpdateAppPayload):
|
||||
return role
|
||||
|
||||
|
||||
class AgentAppCopyPayload(BaseModel):
|
||||
name: str | None = Field(default=None, description="Name for the copied agent")
|
||||
description: str | None = Field(default=None, description="Description for the copied agent", max_length=400)
|
||||
role: str | None = Field(default=None, description="Role for the copied agent", max_length=255)
|
||||
icon_type: IconType | None = Field(default=None, description="Icon type")
|
||||
icon: str | None = Field(default=None, description="Icon")
|
||||
icon_background: str | None = Field(default=None, description="Icon background color")
|
||||
|
||||
@field_validator("role")
|
||||
@classmethod
|
||||
def validate_role(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
role = value.strip()
|
||||
if not role:
|
||||
raise ValueError("Agent role is required when provided.")
|
||||
return role
|
||||
|
||||
|
||||
class AgentApiStatusPayload(BaseModel):
|
||||
enable_api: bool = Field(..., description="Enable or disable Agent service API")
|
||||
|
||||
@ -242,8 +260,8 @@ register_schema_models(
|
||||
console_ns,
|
||||
AgentAppCreatePayload,
|
||||
AgentAppUpdatePayload,
|
||||
AgentAppCopyPayload,
|
||||
AgentApiStatusPayload,
|
||||
CopyAppPayload,
|
||||
AgentInviteOptionsQuery,
|
||||
AgentLogsQuery,
|
||||
AgentStatisticsQuery,
|
||||
@ -567,7 +585,7 @@ class AgentDebugConversationRefreshApi(Resource):
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/copy")
|
||||
class AgentAppCopyApi(Resource):
|
||||
@console_ns.expect(console_ns.models[CopyAppPayload.__name__])
|
||||
@console_ns.expect(console_ns.models[AgentAppCopyPayload.__name__])
|
||||
@console_ns.response(201, "Agent app copied successfully", console_ns.models[AgentAppDetailWithSite.__name__])
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@console_ns.response(400, "Invalid request parameters")
|
||||
@ -578,13 +596,14 @@ class AgentAppCopyApi(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
args = CopyAppPayload.model_validate(console_ns.payload or {})
|
||||
args = AgentAppCopyPayload.model_validate(console_ns.payload or {})
|
||||
copied_app = _agent_roster_service().duplicate_agent_app(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=str(agent_id),
|
||||
account=current_user,
|
||||
name=args.name,
|
||||
description=args.description,
|
||||
role=args.role,
|
||||
icon_type=args.icon_type,
|
||||
icon=args.icon,
|
||||
icon_background=args.icon_background,
|
||||
|
||||
@ -592,7 +592,7 @@ Stop a running Agent App chat message generation
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [CopyAppPayload](#copyapppayload)<br> |
|
||||
| Yes | **application/json**: [AgentAppCopyPayload](#agentappcopypayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
@ -12157,6 +12157,17 @@ Default namespace
|
||||
| validation | [ComposerValidationFindingsResponse](#composervalidationfindingsresponse) | | No |
|
||||
| variant | string | | Yes |
|
||||
|
||||
#### AgentAppCopyPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| description | string | Description for the copied agent | No |
|
||||
| icon | string | Icon | No |
|
||||
| icon_background | string | Icon background color | No |
|
||||
| icon_type | [IconType](#icontype) | Icon type | No |
|
||||
| name | string | Name for the copied agent | No |
|
||||
| role | string | Role for the copied agent | No |
|
||||
|
||||
#### AgentAppCreatePayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
|
||||
@ -633,6 +633,7 @@ class AgentRosterService:
|
||||
account: Any,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
role: str | None = None,
|
||||
icon_type: Any = None,
|
||||
icon: str | None = None,
|
||||
icon_background: str | None = None,
|
||||
@ -644,6 +645,7 @@ class AgentRosterService:
|
||||
|
||||
copied_name = name or self._next_duplicate_agent_name(tenant_id=tenant_id, base_name=source_app.name)
|
||||
copied_description = description if description is not None else source_app.description
|
||||
copied_role = role if role is not None else source_agent.role or ""
|
||||
copied_icon_type = icon_type if icon_type is not None else source_app.icon_type
|
||||
copied_icon = icon if icon is not None else source_app.icon
|
||||
copied_icon_background = icon_background if icon_background is not None else source_app.icon_background
|
||||
@ -654,7 +656,7 @@ class AgentRosterService:
|
||||
name=copied_name,
|
||||
description=copied_description,
|
||||
mode="agent",
|
||||
agent_role=source_agent.role or "",
|
||||
agent_role=copied_role,
|
||||
icon_type=self._normalize_app_icon_type(copied_icon_type),
|
||||
icon=copied_icon,
|
||||
icon_background=copied_icon_background,
|
||||
|
||||
@ -17,6 +17,8 @@ normalization.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
import posixpath
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
@ -62,7 +64,8 @@ class SkillStandardizeService:
|
||||
skill_md_bytes = self._package.read_member_bytes(content=content, member_path=manifest.entry_path)
|
||||
slug = slugify_skill_name(manifest.name)
|
||||
|
||||
# Two drive-owned ToolFiles: canonical SKILL.md + the full archive.
|
||||
# Drive-owned files: canonical SKILL.md, every inspectable archive file,
|
||||
# and the full archive for future restore/export.
|
||||
md_tool_file = self._tool_files.create_file_by_raw(
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
@ -82,6 +85,30 @@ class SkillStandardizeService:
|
||||
|
||||
skill_md_key = f"{slug}/{_SKILL_MD_NAME}"
|
||||
archive_key = f"{slug}/{_FULL_ARCHIVE_NAME}"
|
||||
member_items: list[DriveCommitItem] = []
|
||||
for member_path in sorted(set(manifest.files)):
|
||||
member_key = f"{slug}/{member_path}"
|
||||
if member_key in {skill_md_key, archive_key}:
|
||||
continue
|
||||
|
||||
member_bytes = self._package.read_member_bytes(content=content, member_path=member_path)
|
||||
mimetype = mimetypes.guess_type(member_path)[0] or "application/octet-stream"
|
||||
member_tool_file = self._tool_files.create_file_by_raw(
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
conversation_id=None,
|
||||
file_binary=member_bytes,
|
||||
mimetype=mimetype,
|
||||
filename=posixpath.basename(member_path),
|
||||
)
|
||||
member_items.append(
|
||||
DriveCommitItem(
|
||||
key=member_key,
|
||||
file_ref=DriveFileRef(kind="tool_file", id=member_tool_file.id),
|
||||
value_owned_by_drive=True,
|
||||
)
|
||||
)
|
||||
|
||||
self._drive.commit(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
@ -103,6 +130,7 @@ class SkillStandardizeService:
|
||||
file_ref=DriveFileRef(kind="tool_file", id=archive_tool_file.id),
|
||||
value_owned_by_drive=True,
|
||||
),
|
||||
*member_items,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@ -464,6 +464,7 @@ def test_agent_app_copy_uses_agent_id_and_returns_agent_detail(
|
||||
json={
|
||||
"name": "Iris copy",
|
||||
"description": "Copied",
|
||||
"role": "Copied role",
|
||||
"icon_type": "emoji",
|
||||
"icon": "sparkles",
|
||||
"icon_background": "#fff",
|
||||
@ -479,6 +480,7 @@ def test_agent_app_copy_uses_agent_id_and_returns_agent_detail(
|
||||
"account": current_user,
|
||||
"name": "Iris copy",
|
||||
"description": "Copied",
|
||||
"role": "Copied role",
|
||||
"icon_type": "emoji",
|
||||
"icon": "sparkles",
|
||||
"icon_background": "#fff",
|
||||
|
||||
@ -1883,8 +1883,11 @@ class TestAgentAppBackingAgent:
|
||||
monkeypatch.setattr(service, "_copy_agent_active_snapshot", lambda **_: None)
|
||||
monkeypatch.setattr(service, "_next_duplicate_agent_name", lambda **_: "Iris copy")
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class FakeAppService:
|
||||
def create_app(self, tenant_id: str, params, account: object) -> object:
|
||||
captured["params"] = params
|
||||
return target_app
|
||||
|
||||
access_mode_updates = []
|
||||
@ -1910,9 +1913,11 @@ class TestAgentAppBackingAgent:
|
||||
tenant_id="tenant-1",
|
||||
agent_id="source-agent",
|
||||
account=SimpleNamespace(id="account-1"),
|
||||
role="Custom Analyst",
|
||||
)
|
||||
|
||||
assert duplicated is target_app
|
||||
assert captured["params"].agent_role == "Custom Analyst"
|
||||
assert access_mode_updates == [("target-app", "private")]
|
||||
|
||||
def test_duplicate_agent_app_falls_back_to_public_access_mode(self, monkeypatch: pytest.MonkeyPatch):
|
||||
|
||||
@ -32,13 +32,14 @@ def test_slugify_skill_name():
|
||||
assert slugify_skill_name("") == "skill"
|
||||
|
||||
|
||||
def test_standardize_creates_two_drive_owned_toolfiles_and_commits():
|
||||
def test_standardize_creates_drive_owned_toolfiles_and_commits_archive_members():
|
||||
content = _zip({"SKILL.md": _SKILL_MD, "scripts/run.py": b"print('x')\n"})
|
||||
|
||||
tool_files = MagicMock()
|
||||
tool_files.create_file_by_raw.side_effect = [
|
||||
SimpleNamespace(id="md-tool-file"),
|
||||
SimpleNamespace(id="zip-tool-file"),
|
||||
SimpleNamespace(id="script-tool-file"),
|
||||
]
|
||||
drive = MagicMock()
|
||||
drive.commit.return_value = []
|
||||
@ -52,25 +53,33 @@ def test_standardize_creates_two_drive_owned_toolfiles_and_commits():
|
||||
agent_id="agent-1",
|
||||
)
|
||||
|
||||
# Two ToolFiles: SKILL.md (markdown) + full archive (zip).
|
||||
assert tool_files.create_file_by_raw.call_count == 2
|
||||
md_call, zip_call = tool_files.create_file_by_raw.call_args_list
|
||||
# ToolFiles: SKILL.md, full archive, and each inspectable package member.
|
||||
assert tool_files.create_file_by_raw.call_count == 3
|
||||
md_call, zip_call, script_call = tool_files.create_file_by_raw.call_args_list
|
||||
assert md_call.kwargs["mimetype"] == "text/markdown"
|
||||
assert md_call.kwargs["file_binary"] == _SKILL_MD
|
||||
assert zip_call.kwargs["mimetype"] == "application/zip"
|
||||
assert zip_call.kwargs["file_binary"] == content
|
||||
assert script_call.kwargs["mimetype"] in {"text/x-python", "text/plain", "application/octet-stream"}
|
||||
assert script_call.kwargs["file_binary"] == b"print('x')\n"
|
||||
assert script_call.kwargs["filename"] == "run.py"
|
||||
|
||||
# Committed as drive-owned with the standardized keys.
|
||||
commit_kwargs = drive.commit.call_args.kwargs
|
||||
assert commit_kwargs["agent_id"] == "agent-1"
|
||||
items = commit_kwargs["items"]
|
||||
assert [item.key for item in items] == ["pdf-toolkit/SKILL.md", "pdf-toolkit/.DIFY-SKILL-FULL.zip"]
|
||||
assert [item.key for item in items] == [
|
||||
"pdf-toolkit/SKILL.md",
|
||||
"pdf-toolkit/.DIFY-SKILL-FULL.zip",
|
||||
"pdf-toolkit/scripts/run.py",
|
||||
]
|
||||
assert all(item.value_owned_by_drive for item in items)
|
||||
assert [item.file_ref.id for item in items] == ["md-tool-file", "zip-tool-file"]
|
||||
assert [item.file_ref.id for item in items] == ["md-tool-file", "zip-tool-file", "script-tool-file"]
|
||||
assert items[0].is_skill is True
|
||||
assert items[0].skill_metadata.name == "PDF Toolkit"
|
||||
assert items[0].skill_metadata.manifest_files == ["SKILL.md", "scripts/run.py"]
|
||||
assert items[1].is_skill is False
|
||||
assert items[2].is_skill is False
|
||||
|
||||
# The returned skill ref carries stable drive paths + file ids.
|
||||
skill = result["skill"]
|
||||
|
||||
@ -158,12 +158,13 @@ export type AgentComposerValidateResponse = {
|
||||
warnings?: Array<ComposerValidationWarningResponse>
|
||||
}
|
||||
|
||||
export type CopyAppPayload = {
|
||||
export type AgentAppCopyPayload = {
|
||||
description?: string | null
|
||||
icon?: string | null
|
||||
icon_background?: string | null
|
||||
icon_type?: IconType | null
|
||||
name?: string | null
|
||||
role?: string | null
|
||||
}
|
||||
|
||||
export type AgentDebugConversationRefreshResponse = {
|
||||
@ -1952,7 +1953,7 @@ export type PostAgentByAgentIdComposerValidateResponse
|
||||
= PostAgentByAgentIdComposerValidateResponses[keyof PostAgentByAgentIdComposerValidateResponses]
|
||||
|
||||
export type PostAgentByAgentIdCopyData = {
|
||||
body: CopyAppPayload
|
||||
body: AgentAppCopyPayload
|
||||
path: {
|
||||
agent_id: string
|
||||
}
|
||||
|
||||
@ -170,14 +170,15 @@ export const zAgentAppUpdatePayload = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* CopyAppPayload
|
||||
* AgentAppCopyPayload
|
||||
*/
|
||||
export const zCopyAppPayload = z.object({
|
||||
export const zAgentAppCopyPayload = z.object({
|
||||
description: z.string().max(400).nullish(),
|
||||
icon: z.string().nullish(),
|
||||
icon_background: z.string().nullish(),
|
||||
icon_type: zIconType.nullish(),
|
||||
name: z.string().nullish(),
|
||||
role: z.string().max(255).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
@ -2433,7 +2434,7 @@ export const zPostAgentByAgentIdComposerValidatePath = z.object({
|
||||
*/
|
||||
export const zPostAgentByAgentIdComposerValidateResponse = zAgentComposerValidateResponse
|
||||
|
||||
export const zPostAgentByAgentIdCopyBody = zCopyAppPayload
|
||||
export const zPostAgentByAgentIdCopyBody = zAgentAppCopyPayload
|
||||
|
||||
export const zPostAgentByAgentIdCopyPath = z.object({
|
||||
agent_id: z.uuid(),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user