"""Console API for workspace-level Skill Management.""" from __future__ import annotations import io from flask import request, send_file from flask_restx import Resource from pydantic import BaseModel, ConfigDict, Field, ValidationError from controllers.common.fields import BinaryFileResponse from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console import console_ns from controllers.console.wraps import ( account_initialization_required, edit_permission_required, setup_required, with_current_tenant_id, with_current_user, ) from fields.base import ResponseModel from libs import helper from libs.helper import dump_response from libs.login import login_required from models.account import Account from services.skill_management_service import ( SkillAssistMessagePayload, SkillCreatePayload, SkillDraftFileOperationPayload, SkillDraftTreePayload, SkillImportPayload, SkillManagementService, SkillManagementServiceError, SkillMetadataPayload, SkillPublishPayload, SkillRestorePayload, SkillVersionUpdatePayload, ) _FILE_UPLOAD_PARAMS = { "file": { "description": "Skill draft file payload", "in": "formData", "type": "file", "required": True, }, } class WorkspaceSkillsQuery(BaseModel): keyword: str | None = Field(default=None, description="Search keyword matching skill name or description.") page: int = Field(default=1, ge=1, le=99999, description="Page number.") limit: int = Field(default=20, ge=1, le=100, description="Number of items per page.") tag: list[str] = Field( default_factory=list, description="Skill tag filters. Repeat the parameter for multiple tags.", ) class SkillDeletePayload(BaseModel): model_config = ConfigDict(extra="forbid") confirmation_name: str | None = Field( default=None, description="Required when deleting a referenced Skill. Must match the Skill name.", ) class AgentSkillBindingsPayload(BaseModel): model_config = ConfigDict(extra="forbid") skill_ids: list[str] = Field(default_factory=list, description="Ordered Skill IDs bound to the Agent.") class SkillFileQuery(BaseModel): path: str = Field(description="Skill file path relative to the Skill root.") version_id: str | None = Field(default=None, description="Optional published version ID. Omit for current draft.") class SkillResponse(ResponseModel): id: str name: str display_name: str icon: str description: str tags: list[str] = Field(default_factory=list) name_manually_edited: bool = False visibility: str latest_published_version_id: str | None = None reference_count: int = 0 created_by: str | None = None created_by_name: str | None = None updated_by: str | None = None updated_by_name: str | None = None created_at: int updated_at: int class SkillFileResponse(ResponseModel): id: str | None = None path: str kind: str storage: str | None = None mime_type: str | None = None content: str | None = None tool_file_id: str | None = None size: int | None = None hash: str | None = None class SkillFilePreviewResponse(ResponseModel): path: str mime_type: str content: str size: int hash: str class SkillFileUploadResponse(ResponseModel): id: str name: str mime_type: str size: int hash: str class SkillDetailResponse(SkillResponse): files: list[SkillFileResponse] = Field(default_factory=list) class SkillListResponse(ResponseModel): data: list[SkillResponse] = Field(default_factory=list) has_more: bool = False limit: int = 20 page: int = 1 total: int = 0 class SkillTagResponse(ResponseModel): tag: str count: int class SkillTagListResponse(ResponseModel): data: list[SkillTagResponse] = Field(default_factory=list) class SkillVersionResponse(ResponseModel): id: str skill_id: str version_number: int version_name: str publish_note: str hash_code: str archive_size: int published_by: str | None = None published_by_name: str | None = None is_latest: bool = False created_at: int class SkillVersionListResponse(ResponseModel): data: list[SkillVersionResponse] = Field(default_factory=list) class SkillVersionDetailResponse(SkillVersionResponse): files: list[SkillFileResponse] = Field(default_factory=list) class SkillVersionDeleteResponse(ResponseModel): id: str deleted: bool latest_published_version_id: str | None = None class SkillReferenceResponse(ResponseModel): type: str agent_id: str agent_icon: str | None = None agent_icon_background: str | None = None agent_icon_type: str | None = None app_id: str | None = None name: str display_name: str workflow_id: str | None = None workflow_name: str | None = None workflow_icon: str | None = None workflow_icon_background: str | None = None workflow_icon_type: str | None = None workflow_version: str | None = None node_id: str | None = None node_name: str | None = None class SkillReferenceListResponse(ResponseModel): data: list[SkillReferenceResponse] = Field(default_factory=list) class SkillDeleteResponse(ResponseModel): id: str deleted: bool class AgentSkillBindingItemResponse(ResponseModel): id: str priority: int name: str display_name: str icon: str description: str tags: list[str] = Field(default_factory=list) status: str file_count: int latest_published_version_id: str | None = None latest_published_at: int | None = None updated_at: int class AgentSkillBindingsResponse(ResponseModel): agent_id: str skill_ids: list[str] = Field(default_factory=list) data: list[AgentSkillBindingItemResponse] = Field(default_factory=list) register_schema_models( console_ns, WorkspaceSkillsQuery, SkillCreatePayload, SkillAssistMessagePayload, SkillMetadataPayload, SkillDraftFileOperationPayload, SkillDraftTreePayload, SkillPublishPayload, SkillRestorePayload, SkillVersionUpdatePayload, SkillDeletePayload, SkillFileQuery, AgentSkillBindingsPayload, ) register_response_schema_models( console_ns, SkillResponse, SkillFileResponse, SkillFilePreviewResponse, SkillFileUploadResponse, SkillDetailResponse, SkillListResponse, SkillTagResponse, SkillTagListResponse, SkillVersionResponse, SkillVersionListResponse, SkillVersionDetailResponse, SkillVersionDeleteResponse, SkillReferenceResponse, SkillReferenceListResponse, SkillDeleteResponse, AgentSkillBindingItemResponse, AgentSkillBindingsResponse, BinaryFileResponse, ) def _error_response(exc: SkillManagementServiceError) -> tuple[dict[str, object], int]: body: dict[str, object] = {"code": exc.code, "message": exc.message} if exc.details: body["details"] = exc.details return body, exc.status_code @console_ns.route("/workspaces/current/skills") class WorkspaceSkillsApi(Resource): @console_ns.doc(params=query_params_from_model(WorkspaceSkillsQuery)) @console_ns.response(200, "Workspace skills", console_ns.models[SkillListResponse.__name__]) @setup_required @login_required @account_initialization_required @with_current_tenant_id def get(self, current_tenant_id: str): query_input: dict[str, object] = { "keyword": request.args.get("keyword"), "tag": request.args.getlist("tag"), } if "limit" in request.args: query_input["limit"] = request.args.get("limit") if "page" in request.args: query_input["page"] = request.args.get("page") query = WorkspaceSkillsQuery.model_validate(query_input) result = SkillManagementService().list_skills( tenant_id=current_tenant_id, keyword=query.keyword, page=query.page, limit=query.limit, tags=[tag for tag in query.tag if tag], ) return dump_response(SkillListResponse, result) @console_ns.expect(console_ns.models[SkillCreatePayload.__name__]) @console_ns.response(201, "Skill created", console_ns.models[SkillDetailResponse.__name__]) @setup_required @login_required @account_initialization_required @edit_permission_required @with_current_user @with_current_tenant_id def post(self, current_tenant_id: str, current_user: Account): try: payload = SkillCreatePayload.model_validate(console_ns.payload or {}) result = SkillManagementService().create_skill( tenant_id=current_tenant_id, user_id=current_user.id, payload=payload, ) return dump_response(SkillDetailResponse, result), 201 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/upload") class WorkspaceSkillFileUploadApi(Resource): @console_ns.doc(consumes=["multipart/form-data"], params=_FILE_UPLOAD_PARAMS) @console_ns.response(201, "Skill draft file uploaded", console_ns.models[SkillFileUploadResponse.__name__]) @setup_required @login_required @account_initialization_required @edit_permission_required @with_current_user @with_current_tenant_id def post(self, current_tenant_id: str, current_user: Account): if "file" not in request.files: return {"code": "no_file_uploaded", "message": "no file uploaded"}, 400 file = request.files["file"] if not file.filename: return {"code": "filename_missing", "message": "filename is required"}, 400 result = SkillManagementService().upload_file( tenant_id=current_tenant_id, user_id=current_user.id, filename=file.filename, content=file.stream.read(), mime_type=file.mimetype, ) return dump_response(SkillFileUploadResponse, result), 201 @console_ns.route("/workspaces/current/skills/tags") class WorkspaceSkillTagsApi(Resource): @console_ns.response(200, "Workspace Skill tags", console_ns.models[SkillTagListResponse.__name__]) @setup_required @login_required @account_initialization_required @with_current_tenant_id def get(self, current_tenant_id: str): result = SkillManagementService().list_tags(tenant_id=current_tenant_id) return dump_response(SkillTagListResponse, result) @console_ns.route("/workspaces/current/skills/import") class WorkspaceSkillImportApi(Resource): @console_ns.doc(description="Import a Skill zip package from multipart form field `file`.") @console_ns.response(201, "Skill imported", console_ns.models[SkillDetailResponse.__name__]) @setup_required @login_required @account_initialization_required @edit_permission_required @with_current_user @with_current_tenant_id def post(self, current_tenant_id: str, current_user: Account): upload = request.files.get("file") if upload is None: return {"code": "invalid_request", "message": "file is required"}, 400 try: payload = SkillImportPayload(content=upload.read(), filename=upload.filename or "skill.zip") result = SkillManagementService().import_skill( tenant_id=current_tenant_id, user_id=current_user.id, payload=payload, ) return dump_response(SkillDetailResponse, result), 201 except (ValidationError, 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/") class WorkspaceSkillApi(Resource): @console_ns.response(200, "Skill detail", console_ns.models[SkillDetailResponse.__name__]) @setup_required @login_required @account_initialization_required @with_current_tenant_id def get(self, current_tenant_id: str, skill_id: str): try: result = SkillManagementService().get_skill(tenant_id=current_tenant_id, skill_id=skill_id) return dump_response(SkillDetailResponse, result) except SkillManagementServiceError as exc: return _error_response(exc) @console_ns.expect(console_ns.models[SkillMetadataPayload.__name__]) @console_ns.response(200, "Skill updated", console_ns.models[SkillResponse.__name__]) @setup_required @login_required @account_initialization_required @edit_permission_required @with_current_user @with_current_tenant_id def patch(self, current_tenant_id: str, current_user: Account, skill_id: str): try: payload = SkillMetadataPayload.model_validate(console_ns.payload or {}) result = SkillManagementService().update_metadata( tenant_id=current_tenant_id, user_id=current_user.id, skill_id=skill_id, payload=payload, ) return dump_response(SkillResponse, 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.expect(console_ns.models[SkillDeletePayload.__name__]) @console_ns.response(200, "Skill deleted", console_ns.models[SkillDeleteResponse.__name__]) @setup_required @login_required @account_initialization_required @edit_permission_required @with_current_tenant_id def delete(self, current_tenant_id: str, skill_id: str): try: payload = SkillDeletePayload.model_validate(console_ns.payload or {}) result = SkillManagementService().delete_skill( tenant_id=current_tenant_id, skill_id=skill_id, confirmation_name=payload.confirmation_name, ) return dump_response(SkillDeleteResponse, result) except ValidationError as exc: return {"code": "invalid_request", "message": str(exc)}, 400 except SkillManagementServiceError as exc: return _error_response(exc) @console_ns.route("/workspaces/current/skills//duplicate") class WorkspaceSkillDuplicateApi(Resource): @console_ns.response(201, "Skill duplicated", console_ns.models[SkillDetailResponse.__name__]) @setup_required @login_required @account_initialization_required @edit_permission_required @with_current_user @with_current_tenant_id def post(self, current_tenant_id: str, current_user: Account, skill_id: str): try: result = SkillManagementService().duplicate_skill( tenant_id=current_tenant_id, user_id=current_user.id, skill_id=skill_id, ) return dump_response(SkillDetailResponse, result), 201 except SkillManagementServiceError as exc: return _error_response(exc) @console_ns.route("/workspaces/current/skills//export") class WorkspaceSkillExportApi(Resource): @console_ns.response(200, "Published Skill zip archive") @setup_required @login_required @account_initialization_required @with_current_tenant_id def get(self, current_tenant_id: str, skill_id: str): try: result = SkillManagementService().pull_published_archive(tenant_id=current_tenant_id, skill_id=skill_id) return send_file( io.BytesIO(result.payload), mimetype=result.mime_type, as_attachment=True, download_name=result.filename, ) except SkillManagementServiceError as exc: return _error_response(exc) @console_ns.route("/workspaces/current/skills//assist/messages") class WorkspaceSkillAssistMessageApi(Resource): """Stream read-only Skill Authoring suggestions from the default workspace model.""" @console_ns.expect(console_ns.models[SkillAssistMessagePayload.__name__]) @console_ns.response(200, "Skill Authoring assistant event stream") @setup_required @login_required @account_initialization_required @with_current_user @with_current_tenant_id def post(self, current_tenant_id: str, current_user: Account, skill_id: str): try: payload = SkillAssistMessagePayload.model_validate(console_ns.payload or {}) response = SkillManagementService().create_assistant_action_stream( tenant_id=current_tenant_id, skill_id=skill_id, user_id=current_user.id, message=payload.message, attachments=payload.attachments, model_payload=payload.model, target_path=payload.target_path, ) except ValidationError as exc: return {"code": "invalid_request", "message": str(exc)}, 400 except SkillManagementServiceError as exc: return _error_response(exc) return helper.compact_generate_response(response) @console_ns.route("/workspaces/current/skills//files") class WorkspaceSkillFilesApi(Resource): @console_ns.expect(console_ns.models[SkillDraftFileOperationPayload.__name__]) @console_ns.response(200, "Draft file operation applied", console_ns.models[SkillDetailResponse.__name__]) @setup_required @login_required @account_initialization_required @edit_permission_required @with_current_user @with_current_tenant_id def patch(self, current_tenant_id: str, current_user: Account, skill_id: str): try: payload = SkillDraftFileOperationPayload.model_validate(console_ns.payload or {}) result = SkillManagementService().apply_draft_file_operation( tenant_id=current_tenant_id, user_id=current_user.id, skill_id=skill_id, payload=payload, ) return dump_response(SkillDetailResponse, 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.expect(console_ns.models[SkillDraftTreePayload.__name__]) @console_ns.response(200, "Draft files replaced", console_ns.models[SkillDetailResponse.__name__]) @setup_required @login_required @account_initialization_required @edit_permission_required @with_current_user @with_current_tenant_id def put(self, current_tenant_id: str, current_user: Account, skill_id: str): try: payload = SkillDraftTreePayload.model_validate(console_ns.payload or {}) result = SkillManagementService().replace_draft_tree( tenant_id=current_tenant_id, user_id=current_user.id, skill_id=skill_id, payload=payload, ) return dump_response(SkillDetailResponse, 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/preview") class WorkspaceSkillFilePreviewApi(Resource): @console_ns.doc(params=query_params_from_model(SkillFileQuery)) @console_ns.response(200, "Skill file text preview", console_ns.models[SkillFilePreviewResponse.__name__]) @setup_required @login_required @account_initialization_required @with_current_tenant_id def get(self, current_tenant_id: str, skill_id: str): try: query = SkillFileQuery.model_validate( { "path": request.args.get("path"), "version_id": request.args.get("version_id"), } ) result = SkillManagementService().preview_file( tenant_id=current_tenant_id, skill_id=skill_id, path=query.path, version_id=query.version_id, ) return dump_response(SkillFilePreviewResponse, 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/content") class WorkspaceSkillFileContentApi(Resource): @console_ns.doc(params={**query_params_from_model(SkillFileQuery), "download": "Return as an attachment when 1."}) @console_ns.response(200, "Skill file content", console_ns.models[BinaryFileResponse.__name__]) @setup_required @login_required @account_initialization_required @with_current_tenant_id def get(self, current_tenant_id: str, skill_id: str): try: query = SkillFileQuery.model_validate( { "path": request.args.get("path"), "version_id": request.args.get("version_id"), } ) result = SkillManagementService().pull_file( tenant_id=current_tenant_id, skill_id=skill_id, path=query.path, version_id=query.version_id, ) return send_file( io.BytesIO(result.payload), mimetype=result.mime_type, as_attachment=request.args.get("download") == "1", download_name=result.filename, ) 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//publish") class WorkspaceSkillPublishApi(Resource): @console_ns.expect(console_ns.models[SkillPublishPayload.__name__]) @console_ns.response(200, "Skill published", console_ns.models[SkillVersionResponse.__name__]) @setup_required @login_required @account_initialization_required @edit_permission_required @with_current_user @with_current_tenant_id def post(self, current_tenant_id: str, current_user: Account, skill_id: str): try: payload = SkillPublishPayload.model_validate(console_ns.payload or {}) result = SkillManagementService().publish_skill( tenant_id=current_tenant_id, user_id=current_user.id, skill_id=skill_id, payload=payload, ) return dump_response(SkillVersionResponse, result) except ValidationError as exc: return {"code": "invalid_request", "message": str(exc)}, 400 except SkillManagementServiceError as exc: return _error_response(exc) @console_ns.route("/workspaces/current/skills//restore") class WorkspaceSkillRestoreApi(Resource): @console_ns.expect(console_ns.models[SkillRestorePayload.__name__]) @console_ns.response(200, "Skill version restored", console_ns.models[SkillVersionResponse.__name__]) @setup_required @login_required @account_initialization_required @edit_permission_required @with_current_user @with_current_tenant_id def post(self, current_tenant_id: str, current_user: Account, skill_id: str): try: payload = SkillRestorePayload.model_validate(console_ns.payload or {}) result = SkillManagementService().restore_version( tenant_id=current_tenant_id, user_id=current_user.id, skill_id=skill_id, payload=payload, ) return dump_response(SkillVersionResponse, result) except ValidationError as exc: return {"code": "invalid_request", "message": str(exc)}, 400 except SkillManagementServiceError as exc: return _error_response(exc) @console_ns.route("/workspaces/current/skills//references") class WorkspaceSkillReferencesApi(Resource): @console_ns.response(200, "Skill references", console_ns.models[SkillReferenceListResponse.__name__]) @setup_required @login_required @account_initialization_required @with_current_tenant_id def get(self, current_tenant_id: str, skill_id: str): try: result = SkillManagementService().list_skill_references(tenant_id=current_tenant_id, skill_id=skill_id) return dump_response(SkillReferenceListResponse, result) except SkillManagementServiceError as exc: return _error_response(exc) @console_ns.route("/workspaces/current/skills//versions") class WorkspaceSkillVersionsApi(Resource): @console_ns.response(200, "Skill versions", console_ns.models[SkillVersionListResponse.__name__]) @setup_required @login_required @account_initialization_required @with_current_tenant_id def get(self, current_tenant_id: str, skill_id: str): try: result = SkillManagementService().list_versions(tenant_id=current_tenant_id, skill_id=skill_id) return dump_response(SkillVersionListResponse, result) except SkillManagementServiceError as exc: return _error_response(exc) @console_ns.route("/workspaces/current/skills//versions/") class WorkspaceSkillVersionApi(Resource): @console_ns.response(200, "Skill version detail", console_ns.models[SkillVersionDetailResponse.__name__]) @setup_required @login_required @account_initialization_required @with_current_tenant_id def get(self, current_tenant_id: str, skill_id: str, version_id: str): try: result = SkillManagementService().get_version( tenant_id=current_tenant_id, skill_id=skill_id, version_id=version_id, ) return dump_response(SkillVersionDetailResponse, result) except SkillManagementServiceError as exc: return _error_response(exc) @console_ns.expect(console_ns.models[SkillVersionUpdatePayload.__name__]) @console_ns.response(200, "Skill version updated", console_ns.models[SkillVersionResponse.__name__]) @setup_required @login_required @account_initialization_required @edit_permission_required @with_current_tenant_id def patch(self, current_tenant_id: str, skill_id: str, version_id: str): try: payload = SkillVersionUpdatePayload.model_validate(console_ns.payload or {}) result = SkillManagementService().update_version( tenant_id=current_tenant_id, skill_id=skill_id, version_id=version_id, payload=payload, ) return dump_response(SkillVersionResponse, result) except ValidationError as exc: return {"code": "invalid_request", "message": str(exc)}, 400 except SkillManagementServiceError as exc: return _error_response(exc) @console_ns.response(200, "Skill version deleted", console_ns.models[SkillVersionDeleteResponse.__name__]) @setup_required @login_required @account_initialization_required @edit_permission_required @with_current_user @with_current_tenant_id def delete(self, current_tenant_id: str, current_user: Account, skill_id: str, version_id: str): try: result = SkillManagementService().delete_version( tenant_id=current_tenant_id, user_id=current_user.id, skill_id=skill_id, version_id=version_id, ) return dump_response(SkillVersionDeleteResponse, result) except SkillManagementServiceError as exc: return _error_response(exc) @console_ns.route("/workspaces/current/agents//skills") class WorkspaceAgentSkillBindingsApi(Resource): @console_ns.response(200, "Agent Skill bindings", console_ns.models[AgentSkillBindingsResponse.__name__]) @setup_required @login_required @account_initialization_required @with_current_tenant_id def get(self, current_tenant_id: str, agent_id: str): result = SkillManagementService().list_agent_bindings(tenant_id=current_tenant_id, agent_id=agent_id) return dump_response(AgentSkillBindingsResponse, result) @console_ns.expect(console_ns.models[AgentSkillBindingsPayload.__name__]) @console_ns.response(200, "Agent Skill bindings replaced", console_ns.models[AgentSkillBindingsResponse.__name__]) @setup_required @login_required @account_initialization_required @edit_permission_required @with_current_user @with_current_tenant_id def put(self, current_tenant_id: str, current_user: Account, agent_id: str): try: payload = AgentSkillBindingsPayload.model_validate(console_ns.payload or {}) result = SkillManagementService().replace_agent_bindings( tenant_id=current_tenant_id, user_id=current_user.id, agent_id=agent_id, skill_ids=payload.skill_ids, ) return dump_response(AgentSkillBindingsResponse, result) except ValidationError as exc: return {"code": "invalid_request", "message": str(exc)}, 400 except SkillManagementServiceError as exc: return _error_response(exc) __all__ = [ "WorkspaceAgentSkillBindingsApi", "WorkspaceSkillApi", "WorkspaceSkillDuplicateApi", "WorkspaceSkillExportApi", "WorkspaceSkillFilesApi", "WorkspaceSkillImportApi", "WorkspaceSkillPublishApi", "WorkspaceSkillReferencesApi", "WorkspaceSkillRestoreApi", "WorkspaceSkillTagsApi", "WorkspaceSkillVersionApi", "WorkspaceSkillVersionsApi", "WorkspaceSkillsApi", ]