diff --git a/api/controllers/console/workspace/skills.py b/api/controllers/console/workspace/skills.py index 65199bee719..9549d56764a 100644 --- a/api/controllers/console/workspace/skills.py +++ b/api/controllers/console/workspace/skills.py @@ -26,6 +26,7 @@ from models.account import Account from services.skill_management_service import ( SkillAssistMessagePayload, SkillCreatePayload, + SkillDraftFileCheckPayload, SkillDraftFileOperationPayload, SkillDraftTreePayload, SkillImportPayload, @@ -126,6 +127,24 @@ class SkillFileUploadResponse(ResponseModel): hash: str +class SkillFileCheckErrorResponse(ResponseModel): + code: str + message: str + + +class SkillFileCheckItemResponse(ResponseModel): + path: str + filename: str + extension: str + mime_type: str + size: int + errors: list[SkillFileCheckErrorResponse] = Field(default_factory=list) + + +class SkillFileCheckResponse(ResponseModel): + data: dict[str, SkillFileCheckItemResponse] = Field(default_factory=dict) + + class SkillDetailResponse(SkillResponse): files: list[SkillFileResponse] = Field(default_factory=list) @@ -230,6 +249,7 @@ register_schema_models( SkillCreatePayload, SkillAssistMessagePayload, SkillMetadataPayload, + SkillDraftFileCheckPayload, SkillDraftFileOperationPayload, SkillDraftTreePayload, SkillPublishPayload, @@ -246,6 +266,9 @@ register_response_schema_models( SkillFileResponse, SkillFilePreviewResponse, SkillFileUploadResponse, + SkillFileCheckErrorResponse, + SkillFileCheckItemResponse, + SkillFileCheckResponse, SkillDetailResponse, SkillListResponse, SkillTagResponse, @@ -522,6 +545,32 @@ class WorkspaceSkillAssistMessageApi(Resource): return helper.compact_generate_response(response) +@console_ns.route("/workspaces/current/skills//files/check") +class WorkspaceSkillFilesCheckApi(Resource): + @console_ns.expect(console_ns.models[SkillDraftFileCheckPayload.__name__]) + @console_ns.response(200, "Draft files checked", console_ns.models[SkillFileCheckResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @with_current_tenant_id + def post(self, current_tenant_id: str, skill_id: str): + try: + payload = SkillDraftFileCheckPayload.model_validate(console_ns.payload or {}) + result = SkillManagementService().check_draft_files( + tenant_id=current_tenant_id, + skill_id=skill_id, + payload=payload, + ) + return dump_response(SkillFileCheckResponse, result) + except ValidationError as exc: + return {"code": "invalid_request", "message": str(exc)}, 400 + except ValueError as exc: + return {"code": "invalid_request", "message": str(exc)}, 400 + except SkillManagementServiceError as exc: + return _error_response(exc) + + @console_ns.route("/workspaces/current/skills//files") class WorkspaceSkillFilesApi(Resource): @console_ns.expect(console_ns.models[SkillDraftFileOperationPayload.__name__]) diff --git a/api/services/skill_management_service.py b/api/services/skill_management_service.py index 54edf22355d..eff124ba54f 100644 --- a/api/services/skill_management_service.py +++ b/api/services/skill_management_service.py @@ -84,6 +84,7 @@ _SKILL_MD = "SKILL.md" _MAX_FILE_BYTES = 512 * 1024 _MAX_SKILL_BYTES = 5 * 1024 * 1024 _MAX_FILES_PER_SKILL = 50 +_MAX_FILE_CHECK_ITEMS = 100 _MAX_SKILLS_PER_WORKSPACE = 500 _MAX_AGENT_SKILLS = 20 _MAX_TAGS = 5 @@ -98,6 +99,7 @@ _UNTITLED_SKILL_MD_BODY = """# Untitled skill Describe what this Skill does, when an Agent should use it, and any step-by-step instructions it must follow. """ _FRONTMATTER_RE = re.compile(r"\A---\n(.*?)\n---\n?", re.DOTALL) +_FILE_EXTENSION_RE = re.compile(r"\.[A-Za-z0-9][A-Za-z0-9._+-]*\Z") _SKILL_ASSISTANT_SYSTEM_PROMPT = """You are Dify's Skill Authoring assistant. Help the user create or revise the draft files of a reusable Skill. The supplied @@ -290,6 +292,21 @@ class SkillDraftFileOperationPayload(BaseModel): return self +class SkillDraftFileCheckItemPayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + filename: str = Field(min_length=1, max_length=255) + path: str | None = Field(default=None, description="Target draft path. Defaults to filename.") + size: int = Field(ge=0) + mime_type: str | None = Field(default=None, max_length=255) + + +class SkillDraftFileCheckPayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + files: list[SkillDraftFileCheckItemPayload] = Field(default_factory=list, max_length=_MAX_FILE_CHECK_ITEMS) + + class SkillPublishPayload(BaseModel): model_config = ConfigDict(extra="forbid") @@ -528,6 +545,39 @@ class SkillManagementService: "hash": hashlib.sha256(content).hexdigest(), } + def check_draft_files( + self, + *, + tenant_id: str, + skill_id: str, + payload: SkillDraftFileCheckPayload, + ) -> dict[str, Any]: + """Validate candidate draft file uploads without persisting files.""" + with session_factory.create_session() as session: + skill = self._require_skill(session, tenant_id=tenant_id, skill_id=skill_id) + existing_files = list( + session.scalars( + select(SkillDraftFile).where(SkillDraftFile.skill_id == skill.id).order_by(SkillDraftFile.path) + ) + ) + + existing_file_paths = {file.path for file in existing_files if file.kind == SkillFileKind.FILE} + batch_paths: set[str] = set() + data: dict[str, dict[str, Any]] = {} + + for item in payload.files: + item_result = self._check_draft_file_candidate( + item=item, + existing_file_paths=existing_file_paths, + batch_paths=batch_paths, + ) + batch_paths.add(item_result["path"]) + data[item.filename] = item_result + + return { + "data": data, + } + def list_skills( self, *, @@ -1977,6 +2027,111 @@ class SkillManagementService: references.sort(key=lambda item: (0 if item["type"] == "agent" else 1, str(item["display_name"]))) return {"data": references} + @classmethod + def _check_draft_file_candidate( + cls, + *, + item: SkillDraftFileCheckItemPayload, + existing_file_paths: set[str], + batch_paths: set[str], + ) -> dict[str, Any]: + raw_path = item.path or item.filename + try: + path = normalize_skill_file_path(raw_path) + except ValueError: + path = raw_path.strip().replace("\\", "/") or item.filename + path = path.lstrip("/") + return cls._build_file_check_result( + item=item, + path=path, + error={"code": "invalid_file_path", "message": "skill file path is invalid"}, + ) + + filename = posixpath.basename(path) + extension = cls._file_extension(filename) + + if not filename or filename in {".", ".."}: + return cls._build_file_check_result( + item=item, + path=path, + error={"code": "invalid_filename", "message": "filename is invalid"}, + ) + elif filename != item.filename and "/" in item.filename.replace("\\", "/"): + return cls._build_file_check_result( + item=item, + path=path, + error={"code": "invalid_filename", "message": "filename must not include path separators"}, + ) + + if extension is None: + return cls._build_file_check_result( + item=item, + path=path, + error={"code": "missing_file_extension", "message": "file extension is required"}, + ) + elif not _FILE_EXTENSION_RE.fullmatch(extension): + return cls._build_file_check_result( + item=item, + path=path, + error={"code": "invalid_file_extension", "message": "file extension is invalid"}, + ) + + if item.size > _MAX_FILE_BYTES: + return cls._build_file_check_result( + item=item, + path=path, + error={ + "code": "file_too_large", + "message": f"file exceeds {_MAX_FILE_BYTES} byte limit", + }, + ) + + duplicate_in_draft = path in existing_file_paths + duplicate_in_batch = path in batch_paths + if duplicate_in_draft: + return cls._build_file_check_result( + item=item, + path=path, + error={"code": "file_already_exists", "message": "file already exists in the draft"}, + ) + if duplicate_in_batch: + return cls._build_file_check_result( + item=item, + path=path, + error={"code": "duplicate_file_path", "message": "file path is duplicated in this batch"}, + ) + + return cls._build_file_check_result( + item=item, + path=path, + ) + + @classmethod + def _build_file_check_result( + cls, + *, + item: SkillDraftFileCheckItemPayload, + path: str, + error: dict[str, str] | None = None, + ) -> dict[str, Any]: + filename = posixpath.basename(path) + extension = cls._file_extension(filename) + return { + "errors": [error] if error else [], + "extension": extension or "", + "filename": filename, + "mime_type": item.mime_type or cls._guess_mime_type(path), + "path": path, + "size": item.size, + } + + @staticmethod + def _file_extension(filename: str) -> str | None: + if filename in {"", ".", ".."}: + return None + suffix = posixpath.splitext(filename)[1] + return suffix or None + @staticmethod def _serialize_skill( skill: Skill, @@ -3093,7 +3248,7 @@ class SkillManagementService: else: next_frontmatter = f"{frontmatter}\nmetadata:\n display-name: {escaped_display_name}" - return f"---\n{next_frontmatter}\n---\n{content[match.end():]}" + return f"---\n{next_frontmatter}\n---\n{content[match.end() :]}" def _sync_skill_md_text(self, skill: Skill, content: str) -> str: body = _FRONTMATTER_RE.sub("", content, count=1) diff --git a/api/tests/unit_tests/controllers/console/workspace/test_skills.py b/api/tests/unit_tests/controllers/console/workspace/test_skills.py index 050b2adcd11..7d4ac8fed8e 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_skills.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_skills.py @@ -17,6 +17,7 @@ from controllers.console.workspace.skills import ( WorkspaceSkillFileContentApi, WorkspaceSkillFilePreviewApi, WorkspaceSkillFilesApi, + WorkspaceSkillFilesCheckApi, WorkspaceSkillFileUploadApi, WorkspaceSkillImportApi, WorkspaceSkillPublishApi, @@ -649,6 +650,44 @@ def test_patch_skill_file_operation_returns_error_details(app: Flask, current_us } +def test_check_skill_files_validates_payload_and_returns_results(app: Flask) -> None: + api = WorkspaceSkillFilesCheckApi() + method = unwrap(api.post) + service = MagicMock() + service.check_draft_files.return_value = { + "data": { + "policy.md": { + "path": "references/policy.md", + "filename": "policy.md", + "extension": ".md", + "mime_type": "text/markdown", + "size": 12, + "errors": [], + } + }, + } + + with ( + app.test_request_context("/", method="POST"), + patch.object( + type(console_ns), + "payload", + new_callable=PropertyMock, + return_value={"files": [{"filename": "policy.md", "path": "references/policy.md", "size": 12}]}, + ), + patch("controllers.console.workspace.skills.SkillManagementService", return_value=service), + ): + payload = method(api, "tenant-1", "skill-1") + + assert payload["data"]["policy.md"]["path"] == "references/policy.md" + assert payload["data"]["policy.md"]["errors"] == [] + service.check_draft_files.assert_called_once() + call = service.check_draft_files.call_args.kwargs + assert call["tenant_id"] == "tenant-1" + assert call["skill_id"] == "skill-1" + assert call["payload"].files[0].filename == "policy.md" + + def test_replace_skill_draft_tree_validates_payload_and_returns_detail(app: Flask, current_user: Account) -> None: api = WorkspaceSkillFilesApi() method = unwrap(api.put) diff --git a/api/tests/unit_tests/services/test_skill_management_service.py b/api/tests/unit_tests/services/test_skill_management_service.py index be4eedc230a..c3fd322ebea 100644 --- a/api/tests/unit_tests/services/test_skill_management_service.py +++ b/api/tests/unit_tests/services/test_skill_management_service.py @@ -44,6 +44,7 @@ from services.skill_management_service import ( SkillAssistAttachmentPayload, SkillAssistDraftOperationPayload, SkillCreatePayload, + SkillDraftFileCheckPayload, SkillDraftFileOperation, SkillDraftFileOperationPayload, SkillDraftTreeItemPayload, @@ -2005,6 +2006,48 @@ def test_apply_draft_file_operation_rejects_duplicate_folder_name() -> None: assert exc_info.value.code == "file_path_conflict" +def test_check_draft_files_reports_batch_validation_results() -> None: + service = SkillManagementService(tool_file_manager=_FakeToolFileManager()) + created = service.create_skill(tenant_id=TENANT, user_id=USER, payload=SkillCreatePayload(name="finance-sop")) + service.apply_draft_file_operation( + tenant_id=TENANT, + user_id=USER, + skill_id=created["id"], + payload=SkillDraftFileOperationPayload( + operation="upsert_text", + path="references/policy.md", + content="Policy text.", + ), + ) + + result = service.check_draft_files( + tenant_id=TENANT, + skill_id=created["id"], + payload=SkillDraftFileCheckPayload( + files=[ + {"filename": "guide.md", "path": "references/guide.md", "size": 10}, + {"filename": "policy.md", "path": "references/policy.md", "size": 10}, + {"filename": "guide-copy.md", "path": "references/guide.md", "size": 10}, + {"filename": "README", "path": "references/README", "size": 10}, + {"filename": "big.md", "path": "references/big.md", "size": 512 * 1024 + 1}, + {"filename": "escape.md", "path": "../escape.md", "size": 10}, + ] + ), + ) + + assert set(result) == {"data"} + first_guide = result["data"]["guide.md"] + second_guide = result["data"]["guide-copy.md"] + assert first_guide["path"] == "references/guide.md" + assert first_guide["errors"] == [] + assert second_guide["path"] == "references/guide.md" + assert [error["code"] for error in second_guide["errors"]] == ["duplicate_file_path"] + assert [error["code"] for error in result["data"]["policy.md"]["errors"]] == ["file_already_exists"] + assert [error["code"] for error in result["data"]["README"]["errors"]] == ["missing_file_extension"] + assert [error["code"] for error in result["data"]["big.md"]["errors"]] == ["file_too_large"] + assert [error["code"] for error in result["data"]["escape.md"]["errors"]] == ["invalid_file_path"] + + def test_apply_draft_file_operation_updates_skill_md_frontmatter() -> None: service = SkillManagementService(tool_file_manager=_FakeToolFileManager()) created = service.create_skill(tenant_id=TENANT, user_id=USER, payload=SkillCreatePayload(name="finance-sop")) diff --git a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts index a1ca3641db9..95461117a8a 100644 --- a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts @@ -357,6 +357,9 @@ import { zPostWorkspacesCurrentSkillsBySkillIdAssistMessagesResponse, zPostWorkspacesCurrentSkillsBySkillIdDuplicatePath, zPostWorkspacesCurrentSkillsBySkillIdDuplicateResponse, + zPostWorkspacesCurrentSkillsBySkillIdFilesCheckBody, + zPostWorkspacesCurrentSkillsBySkillIdFilesCheckPath, + zPostWorkspacesCurrentSkillsBySkillIdFilesCheckResponse, zPostWorkspacesCurrentSkillsBySkillIdPublishBody, zPostWorkspacesCurrentSkillsBySkillIdPublishPath, zPostWorkspacesCurrentSkillsBySkillIdPublishResponse, @@ -3191,6 +3194,26 @@ export const export2 = { get: get61, } +export const post52 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentSkillsBySkillIdFilesCheck', + path: '/workspaces/current/skills/{skill_id}/files/check', + tags: ['console'], + }) + .input( + z.object({ + body: zPostWorkspacesCurrentSkillsBySkillIdFilesCheckBody, + params: zPostWorkspacesCurrentSkillsBySkillIdFilesCheckPath, + }), + ) + .output(zPostWorkspacesCurrentSkillsBySkillIdFilesCheckResponse) + +export const check = { + post: post52, +} + export const get62 = oc .route({ inputStructure: 'detailed', @@ -3266,11 +3289,12 @@ export const put16 = oc export const files2 = { patch: patch5, put: put16, + check, content, preview, } -export const post52 = oc +export const post53 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3287,7 +3311,7 @@ export const post52 = oc .output(zPostWorkspacesCurrentSkillsBySkillIdPublishResponse) export const publish = { - post: post52, + post: post53, } export const get64 = oc @@ -3305,7 +3329,7 @@ export const references = { get: get64, } -export const post53 = oc +export const post54 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3322,7 +3346,7 @@ export const post53 = oc .output(zPostWorkspacesCurrentSkillsBySkillIdRestoreResponse) export const restore = { - post: post53, + post: post54, } export const delete13 = oc @@ -3453,7 +3477,7 @@ export const get68 = oc .input(z.object({ query: zGetWorkspacesCurrentSkillsQuery.optional() })) .output(zGetWorkspacesCurrentSkillsResponse) -export const post54 = oc +export const post55 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3467,7 +3491,7 @@ export const post54 = oc export const skills2 = { get: get68, - post: post54, + post: post55, files, import: import_, tags, @@ -3488,7 +3512,7 @@ export const toolLabels = { get: get69, } -export const post55 = oc +export const post56 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3500,10 +3524,10 @@ export const post55 = oc .output(zPostWorkspacesCurrentToolProviderApiAddResponse) export const add = { - post: post55, + post: post56, } -export const post56 = oc +export const post57 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3515,7 +3539,7 @@ export const post56 = oc .output(zPostWorkspacesCurrentToolProviderApiDeleteResponse) export const delete15 = { - post: post56, + post: post57, } export const get70 = oc @@ -3548,7 +3572,7 @@ export const remote = { get: get72, } -export const post57 = oc +export const post58 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3560,10 +3584,10 @@ export const post57 = oc .output(zPostWorkspacesCurrentToolProviderApiSchemaResponse) export const schema = { - post: post57, + post: post58, } -export const post58 = oc +export const post59 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3575,7 +3599,7 @@ export const post58 = oc .output(zPostWorkspacesCurrentToolProviderApiTestPreResponse) export const pre = { - post: post58, + post: post59, } export const test = { @@ -3597,7 +3621,7 @@ export const tools = { get: get73, } -export const post59 = oc +export const post60 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3609,7 +3633,7 @@ export const post59 = oc .output(zPostWorkspacesCurrentToolProviderApiUpdateResponse) export const update2 = { - post: post59, + post: post60, } export const api = { @@ -3623,7 +3647,7 @@ export const api = { update: update2, } -export const post60 = oc +export const post61 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3640,7 +3664,7 @@ export const post60 = oc .output(zPostWorkspacesCurrentToolProviderBuiltinByProviderAddResponse) export const add2 = { - post: post60, + post: post61, } export const get74 = oc @@ -3715,7 +3739,7 @@ export const credentials3 = { get: get76, } -export const post61 = oc +export const post62 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3732,10 +3756,10 @@ export const post61 = oc .output(zPostWorkspacesCurrentToolProviderBuiltinByProviderDefaultCredentialResponse) export const defaultCredential = { - post: post61, + post: post62, } -export const post62 = oc +export const post63 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3752,7 +3776,7 @@ export const post62 = oc .output(zPostWorkspacesCurrentToolProviderBuiltinByProviderDeleteResponse) export const delete16 = { - post: post62, + post: post63, } export const get77 = oc @@ -3830,7 +3854,7 @@ export const get80 = oc ) .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse) -export const post63 = oc +export const post64 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3849,7 +3873,7 @@ export const post63 = oc export const customClient = { delete: delete17, get: get80, - post: post63, + post: post64, } export const oauth = { @@ -3872,7 +3896,7 @@ export const tools2 = { get: get81, } -export const post64 = oc +export const post65 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3889,7 +3913,7 @@ export const post64 = oc .output(zPostWorkspacesCurrentToolProviderBuiltinByProviderUpdateResponse) export const update3 = { - post: post64, + post: post65, } export const byProvider2 = { @@ -3909,7 +3933,7 @@ export const builtin = { byProvider: byProvider2, } -export const post65 = oc +export const post66 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3921,7 +3945,7 @@ export const post65 = oc .output(zPostWorkspacesCurrentToolProviderMcpAuthResponse) export const auth = { - post: post65, + post: post66, } export const get82 = oc @@ -3973,7 +3997,7 @@ export const delete18 = oc .input(z.object({ body: zDeleteWorkspacesCurrentToolProviderMcpBody })) .output(zDeleteWorkspacesCurrentToolProviderMcpResponse) -export const post66 = oc +export const post67 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3997,14 +4021,14 @@ export const put17 = oc export const mcp = { delete: delete18, - post: post66, + post: post67, put: put17, auth, tools: tools3, update: update4, } -export const post67 = oc +export const post68 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4016,10 +4040,10 @@ export const post67 = oc .output(zPostWorkspacesCurrentToolProviderWorkflowCreateResponse) export const create2 = { - post: post67, + post: post68, } -export const post68 = oc +export const post69 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4031,7 +4055,7 @@ export const post68 = oc .output(zPostWorkspacesCurrentToolProviderWorkflowDeleteResponse) export const delete19 = { - post: post68, + post: post69, } export const get84 = oc @@ -4064,7 +4088,7 @@ export const tools4 = { get: get86, } -export const post69 = oc +export const post70 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4076,7 +4100,7 @@ export const post69 = oc .output(zPostWorkspacesCurrentToolProviderWorkflowUpdateResponse) export const update5 = { - post: post69, + post: post70, } export const workflow = { @@ -4239,7 +4263,7 @@ export const get94 = oc /** * Configure custom OAuth client for a provider */ -export const post70 = oc +export const post71 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4259,7 +4283,7 @@ export const post70 = oc export const client = { delete: delete20, get: get94, - post: post70, + post: post71, } export const oauth2 = { @@ -4269,7 +4293,7 @@ export const oauth2 = { /** * Build a subscription instance for a trigger provider */ -export const post71 = oc +export const post72 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4291,7 +4315,7 @@ export const post71 = oc ) export const bySubscriptionBuilderId = { - post: post71, + post: post72, } export const build = { @@ -4301,7 +4325,7 @@ export const build = { /** * Add a new subscription instance for a trigger provider */ -export const post72 = oc +export const post73 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4319,7 +4343,7 @@ export const post72 = oc .output(zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderCreateResponse) export const create3 = { - post: post72, + post: post73, } /** @@ -4356,7 +4380,7 @@ export const logs = { /** * Update a subscription instance for a trigger provider */ -export const post73 = oc +export const post74 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4378,7 +4402,7 @@ export const post73 = oc ) export const bySubscriptionBuilderId3 = { - post: post73, + post: post74, } export const update6 = { @@ -4388,7 +4412,7 @@ export const update6 = { /** * Verify and update a subscription instance for a trigger provider */ -export const post74 = oc +export const post75 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4410,7 +4434,7 @@ export const post74 = oc ) export const bySubscriptionBuilderId4 = { - post: post74, + post: post75, } export const verifyAndUpdate = { @@ -4502,7 +4526,7 @@ export const oauth3 = { /** * Verify credentials for an existing subscription (edit mode only) */ -export const post75 = oc +export const post76 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4524,7 +4548,7 @@ export const post75 = oc ) export const bySubscriptionId = { - post: post75, + post: post76, } export const verify = { @@ -4548,7 +4572,7 @@ export const byProvider3 = { /** * Delete a subscription instance */ -export const post76 = oc +export const post77 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4565,13 +4589,13 @@ export const post76 = oc .output(zPostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsDeleteResponse) export const delete21 = { - post: post76, + post: post77, } /** * Update a subscription instance */ -export const post77 = oc +export const post78 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4589,7 +4613,7 @@ export const post77 = oc .output(zPostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsUpdateResponse) export const update7 = { - post: post77, + post: post78, } export const subscriptions2 = { @@ -4624,7 +4648,7 @@ export const triggers = { get: get99, } -export const post78 = oc +export const post79 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4635,7 +4659,7 @@ export const post78 = oc .output(zPostWorkspacesCurrentResponse) export const current = { - post: post78, + post: post79, agentProvider, agentProviders, agents, @@ -4658,7 +4682,7 @@ export const current = { triggers, } -export const post79 = oc +export const post80 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4671,14 +4695,14 @@ export const post79 = oc .output(zPostWorkspacesCustomConfigWebappLogoUploadResponse) export const upload3 = { - post: post79, + post: post80, } export const webappLogo = { upload: upload3, } -export const post80 = oc +export const post81 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4690,11 +4714,11 @@ export const post80 = oc .output(zPostWorkspacesCustomConfigResponse) export const customConfig = { - post: post80, + post: post81, webappLogo, } -export const post81 = oc +export const post82 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4706,10 +4730,10 @@ export const post81 = oc .output(zPostWorkspacesInfoResponse) export const info4 = { - post: post81, + post: post82, } -export const post82 = oc +export const post83 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4721,7 +4745,7 @@ export const post82 = oc .output(zPostWorkspacesSwitchResponse) export const switch3 = { - post: post82, + post: post83, } export const get100 = oc diff --git a/packages/contracts/generated/api/console/workspaces/types.gen.ts b/packages/contracts/generated/api/console/workspaces/types.gen.ts index 675861f21b4..c1df2df66cb 100644 --- a/packages/contracts/generated/api/console/workspaces/types.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/types.gen.ts @@ -771,6 +771,16 @@ export type SkillDraftTreePayload = { files?: Array } +export type SkillDraftFileCheckPayload = { + files?: Array +} + +export type SkillFileCheckResponse = { + data?: { + [key: string]: SkillFileCheckItemResponse + } +} + export type SkillFilePreviewResponse = { content: string hash: string @@ -1755,6 +1765,22 @@ export type SkillDraftTreeItemPayload = { tool_file_id?: string | null } +export type SkillDraftFileCheckItemPayload = { + filename: string + mime_type?: string | null + path?: string | null + size: number +} + +export type SkillFileCheckItemResponse = { + errors?: Array + extension: string + filename: string + mime_type: string + path: string + size: number +} + export type SkillReferenceResponse = { agent_id: string agent_icon?: string | null @@ -2254,6 +2280,11 @@ export type SkillFileKind = 'directory' | 'file' export type SkillFileStorage = 'text' | 'tool_file' +export type SkillFileCheckErrorResponse = { + code: string + message: string +} + export type ToolParameter = { auto_generate?: PluginParameterAutoGenerate | null default?: @@ -5142,6 +5173,22 @@ export type PutWorkspacesCurrentSkillsBySkillIdFilesResponses = { export type PutWorkspacesCurrentSkillsBySkillIdFilesResponse = PutWorkspacesCurrentSkillsBySkillIdFilesResponses[keyof PutWorkspacesCurrentSkillsBySkillIdFilesResponses] +export type PostWorkspacesCurrentSkillsBySkillIdFilesCheckData = { + body: SkillDraftFileCheckPayload + path: { + skill_id: string + } + query?: never + url: '/workspaces/current/skills/{skill_id}/files/check' +} + +export type PostWorkspacesCurrentSkillsBySkillIdFilesCheckResponses = { + 200: SkillFileCheckResponse +} + +export type PostWorkspacesCurrentSkillsBySkillIdFilesCheckResponse = + PostWorkspacesCurrentSkillsBySkillIdFilesCheckResponses[keyof PostWorkspacesCurrentSkillsBySkillIdFilesCheckResponses] + export type GetWorkspacesCurrentSkillsBySkillIdFilesContentData = { body?: never path: { diff --git a/packages/contracts/generated/api/console/workspaces/zod.gen.ts b/packages/contracts/generated/api/console/workspaces/zod.gen.ts index 3d0267ed7b5..a9d405967ff 100644 --- a/packages/contracts/generated/api/console/workspaces/zod.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/zod.gen.ts @@ -1561,6 +1561,23 @@ export const zSkillDraftFileOperationPayload = z.object({ tool_file_id: z.string().nullish(), }) +/** + * SkillDraftFileCheckItemPayload + */ +export const zSkillDraftFileCheckItemPayload = z.object({ + filename: z.string().min(1).max(255), + mime_type: z.string().max(255).nullish(), + path: z.string().nullish(), + size: z.int().gte(0), +}) + +/** + * SkillDraftFileCheckPayload + */ +export const zSkillDraftFileCheckPayload = z.object({ + files: z.array(zSkillDraftFileCheckItemPayload).max(100).optional(), +}) + /** * SkillReferenceResponse */ @@ -2478,6 +2495,33 @@ export const zSkillDraftTreePayload = z.object({ files: z.array(zSkillDraftTreeItemPayload).optional(), }) +/** + * SkillFileCheckErrorResponse + */ +export const zSkillFileCheckErrorResponse = z.object({ + code: z.string(), + message: z.string(), +}) + +/** + * SkillFileCheckItemResponse + */ +export const zSkillFileCheckItemResponse = z.object({ + errors: z.array(zSkillFileCheckErrorResponse).optional(), + extension: z.string(), + filename: z.string(), + mime_type: z.string(), + path: z.string(), + size: z.int(), +}) + +/** + * SkillFileCheckResponse + */ +export const zSkillFileCheckResponse = z.object({ + data: z.record(z.string(), zSkillFileCheckItemResponse).optional(), +}) + /** * Option */ @@ -5219,6 +5263,17 @@ export const zPutWorkspacesCurrentSkillsBySkillIdFilesPath = z.object({ */ export const zPutWorkspacesCurrentSkillsBySkillIdFilesResponse = zSkillDetailResponse +export const zPostWorkspacesCurrentSkillsBySkillIdFilesCheckBody = zSkillDraftFileCheckPayload + +export const zPostWorkspacesCurrentSkillsBySkillIdFilesCheckPath = z.object({ + skill_id: z.string(), +}) + +/** + * Draft files checked + */ +export const zPostWorkspacesCurrentSkillsBySkillIdFilesCheckResponse = zSkillFileCheckResponse + export const zGetWorkspacesCurrentSkillsBySkillIdFilesContentPath = z.object({ skill_id: z.string(), }) diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/add-actions-context.ts b/web/features/agent-v2/agent-detail/configure/components/orchestrate/add-actions-context.ts index 16307317715..cfd84e680d1 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/add-actions-context.ts +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/add-actions-context.ts @@ -16,6 +16,7 @@ export type AgentOrchestrateAddedItem = export type AgentOrchestrateAddActionOptions = { onAdded?: (item: AgentOrchestrateAddedItem) => void + skillSource?: 'library' | 'upload' } export type AgentOrchestrateAddAction = (options?: AgentOrchestrateAddActionOptions) => void diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/__tests__/slash.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/__tests__/slash.spec.tsx new file mode 100644 index 00000000000..a35ac10f899 --- /dev/null +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/__tests__/slash.spec.tsx @@ -0,0 +1,47 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { AgentPromptSlashMenu } from '../slash' + +describe('AgentPromptSlashMenu', () => { + it('offers library and skill.zip as separate add-skill actions', async () => { + const user = userEvent.setup() + const onAddSkill = vi.fn() + + render( + , + ) + + await user.click( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.skills.addMenu.workspace.label', + }), + ) + expect(onAddSkill).toHaveBeenLastCalledWith(expect.objectContaining({ skillSource: 'library' })) + + await user.click( + screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.skills.addMenu.upload.label', + }), + ) + expect(onAddSkill).toHaveBeenLastCalledWith(expect.objectContaining({ skillSource: 'upload' })) + }) +}) diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/slash.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/slash.tsx index 6767aa8ed9f..0c2f274c3cb 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/slash.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/slash.tsx @@ -102,9 +102,10 @@ export function AgentPromptSlashMenu({ }: AgentPromptSlashMenuProps) { const { t } = useTranslation('agentV2') const title = categories.find((category) => category.key === view)?.label - const handleAddFromFooter = () => { + const handleAddFromFooter = (skillSource?: 'library' | 'upload') => { if (view === 'skills') { onAddSkill?.({ + skillSource, onAdded: (item) => { if (isPromptReferenceItem(item)) onInsertToken(createConfigReferenceToken('skill', item.id, item.name)) @@ -212,17 +213,29 @@ export function AgentPromptSlashMenu({ : undefined } /> + ) : view === 'skills' ? ( +
+ $['agentDetail.configure.skills.addMenu.workspace.label'])} + onClick={() => handleAddFromFooter('library')} + /> + $['agentDetail.configure.skills.addMenu.upload.label'])} + onClick={() => handleAddFromFooter('upload')} + /> +
) : (
+ ) +} + function AgentPromptSlashPanel({ className, children, diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/__tests__/index.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/__tests__/index.spec.tsx index 0782c0951c4..1aa7a77d764 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/__tests__/index.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/__tests__/index.spec.tsx @@ -6,11 +6,14 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useAtomValue } from 'jotai' +import { useState } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { formStateToAgentSoulConfig } from '@/features/agent-v2/agent-composer/conversions' import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state' import { AgentComposerProvider } from '@/features/agent-v2/agent-composer/provider' import { agentComposerDraftAtom } from '@/features/agent-v2/agent-composer/store' +import { AgentOrchestrateAddActionsProvider } from '../../add-actions' +import { useAgentOrchestrateAddActions } from '../../add-actions-context' import { AgentConfigApiContextProvider } from '../../config-context' import { AgentOrchestrateReadOnlyContext, @@ -228,6 +231,31 @@ function ConfigSnapshotProbe() { return
{JSON.stringify(configSnapshot)}
} +function PromptSkillAddProbe() { + const actions = useAgentOrchestrateAddActions() + const [addedSkill, setAddedSkill] = useState('') + + return ( + <> + + + {addedSkill} + + ) +} + function createWorkspaceSkill(overrides: Partial = {}): SkillResponse { return { id: 'workspace-skill-1', @@ -278,10 +306,13 @@ function renderAgentSkills({ - - - - + + + + + + + @@ -573,6 +604,19 @@ describe('AgentSkills', () => { expect(toast.success).toHaveBeenCalled() }) + it('should open the upload flow from the prompt skill.zip action', async () => { + const user = userEvent.setup() + renderAgentSkills({ initialDraft: defaultAgentSoulConfigFormState }) + + await user.click(screen.getByRole('button', { name: 'prompt upload skill.zip' })) + + expect( + await screen.findByRole('dialog', { + name: 'agentV2.agentDetail.configure.skills.upload.title', + }), + ).toBeInTheDocument() + }) + it('should show the configured skill package size limit', async () => { const user = userEvent.setup() renderAgentSkills({ initialDraft: defaultAgentSoulConfigFormState }) @@ -695,6 +739,36 @@ describe('AgentSkills', () => { expect(snapshot.config_skills).toEqual([]) }) + it('should open the library flow from the prompt and return the selected skill', async () => { + const user = userEvent.setup() + mocks.workspaceSkillsInfiniteOptions.mockImplementation((options) => { + const { input, getNextPageParam, initialPageParam } = options as { + input: (pageParam: number) => { query?: { limit?: number } } + getNextPageParam: (lastPage: { has_more?: boolean; page?: number }) => number | undefined + initialPageParam: number + } + + return { + queryKey: ['workspace-skills', input(initialPageParam)], + queryFn: async ({ pageParam = initialPageParam }: { pageParam?: number }) => ({ + data: [createWorkspaceSkill()], + has_more: false, + limit: input(pageParam).query?.limit ?? 20, + page: pageParam, + total: 1, + }), + getNextPageParam, + initialPageParam, + } + }) + renderAgentSkills({ initialDraft: defaultAgentSoulConfigFormState }) + + await user.click(screen.getByRole('button', { name: 'prompt add from library' })) + await user.click(await screen.findByRole('button', { name: /Refund approval/ })) + + expect(await screen.findByLabelText('prompt added skill')).toHaveTextContent('Refund approval') + }) + it('should allow workflow agent nodes to bind workspace skills', async () => { const user = userEvent.setup() mocks.workspaceSkillsInfiniteOptions.mockImplementation((options) => { diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/index.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/index.tsx index 78f90acc796..647e574a4cd 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/index.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/index.tsx @@ -500,15 +500,30 @@ export function AgentSkills() { ], ) - const handleOpenUpload = useCallback((options?: AgentOrchestrateAddActionOptions) => { + const handlePromptAdd = useCallback((options?: AgentOrchestrateAddActionOptions) => { promptAddCallbackRef.current = options?.onAdded - setIsUploadOpen(true) + if (options?.skillSource === 'library') { + setAddMenuView('workspace-selector') + setAddMenuOpen(true) + return + } + + if (options?.skillSource === 'upload') { + setIsUploadOpen(true) + return + } + + setAddMenuView('menu') + setAddMenuOpen(true) }, []) - useRegisterAgentOrchestrateAddAction('skills', handleOpenUpload) + useRegisterAgentOrchestrateAddAction('skills', handlePromptAdd) const handleAddMenuOpenChange = useCallback((open: boolean) => { setAddMenuOpen(open) - if (!open) setAddMenuView('menu') + if (!open) { + setAddMenuView('menu') + promptAddCallbackRef.current = undefined + } }, []) const handleOpenWorkspaceSelector = useCallback(() => { @@ -517,8 +532,8 @@ export function AgentSkills() { const handleOpenUploadFromMenu = useCallback(() => { setAddMenuOpen(false) - handleOpenUpload() - }, [handleOpenUpload]) + setIsUploadOpen(true) + }, []) const handleUploaded = useCallback( (skill: AgentSkill) => { @@ -535,6 +550,12 @@ export function AgentSkills() { replaceWorkspaceSkillBindings([...boundSkillIds, skill.id], () => { toast.success(t(($) => $['agentDetail.configure.skills.workspaceSelector.addSuccess'])) + promptAddCallbackRef.current?.({ + description: skill.description, + id: skill.name, + name: skill.display_name, + }) + promptAddCallbackRef.current = undefined setAddMenuOpen(false) setAddMenuView('menu') }) diff --git a/web/i18n/en-US/agent-v-2.json b/web/i18n/en-US/agent-v-2.json index 6c79c38555a..df05ad8f486 100644 --- a/web/i18n/en-US/agent-v-2.json +++ b/web/i18n/en-US/agent-v-2.json @@ -205,9 +205,9 @@ "agentDetail.configure.skills.add": "Add skill", "agentDetail.configure.skills.addMenu.upload.badge": "EMBEDDED", "agentDetail.configure.skills.addMenu.upload.description": "A .zip containing SKILL.md. It's embedded in this app and won't update with the library.", - "agentDetail.configure.skills.addMenu.upload.label": "Upload package", + "agentDetail.configure.skills.addMenu.upload.label": "Upload skill.zip", "agentDetail.configure.skills.addMenu.workspace.description": "Reuses a shared skill and follows its published updates.", - "agentDetail.configure.skills.addMenu.workspace.label": "From skill library", + "agentDetail.configure.skills.addMenu.workspace.label": "Add from library", "agentDetail.configure.skills.detail.contentRegion": "Skill detail content", "agentDetail.configure.skills.detail.fileCount": "{{count}} FILES", "agentDetail.configure.skills.detail.files": "Files", diff --git a/web/i18n/zh-Hans/agent-v-2.json b/web/i18n/zh-Hans/agent-v-2.json index f19b25d25df..9de11ffa467 100644 --- a/web/i18n/zh-Hans/agent-v-2.json +++ b/web/i18n/zh-Hans/agent-v-2.json @@ -205,9 +205,9 @@ "agentDetail.configure.skills.add": "添加 Skill", "agentDetail.configure.skills.addMenu.upload.badge": "内嵌", "agentDetail.configure.skills.addMenu.upload.description": "包含 SKILL.md 的 .zip 包。它会嵌入当前应用,不会跟随 Skill 库更新。", - "agentDetail.configure.skills.addMenu.upload.label": "上传包", + "agentDetail.configure.skills.addMenu.upload.label": "上传 skill.zip", "agentDetail.configure.skills.addMenu.workspace.description": "复用共享 Skill,并跟随其发布版本更新。", - "agentDetail.configure.skills.addMenu.workspace.label": "从 Skill 库选择", + "agentDetail.configure.skills.addMenu.workspace.label": "从 Skill 库添加", "agentDetail.configure.skills.detail.contentRegion": "Skill 详情内容", "agentDetail.configure.skills.detail.fileCount": "{{count}} 个文件", "agentDetail.configure.skills.detail.files": "文件",