Merge branch 'skill' into deploy/dev

This commit is contained in:
fatelei 2026-08-03 16:07:23 +08:00
commit 7a6c9934d7
No known key found for this signature in database
GPG Key ID: 2F91DA05646F4EED
14 changed files with 670 additions and 80 deletions

View File

@ -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/<string:skill_id>/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/<string:skill_id>/files")
class WorkspaceSkillFilesApi(Resource):
@console_ns.expect(console_ns.models[SkillDraftFileOperationPayload.__name__])

View File

@ -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)

View File

@ -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)

View File

@ -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"))

View File

@ -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

View File

@ -771,6 +771,16 @@ export type SkillDraftTreePayload = {
files?: Array<SkillDraftTreeItemPayload>
}
export type SkillDraftFileCheckPayload = {
files?: Array<SkillDraftFileCheckItemPayload>
}
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<SkillFileCheckErrorResponse>
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: {

View File

@ -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(),
})

View File

@ -16,6 +16,7 @@ export type AgentOrchestrateAddedItem =
export type AgentOrchestrateAddActionOptions = {
onAdded?: (item: AgentOrchestrateAddedItem) => void
skillSource?: 'library' | 'upload'
}
export type AgentOrchestrateAddAction = (options?: AgentOrchestrateAddActionOptions) => void

View File

@ -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(
<AgentPromptSlashMenu
view="skills"
categories={[
{
key: 'skills',
label: 'Skills',
icon: 'i-custom-vender-agent-v2-building-blocks',
},
]}
skills={[]}
files={[]}
configuredTools={[]}
knowledgeRetrievals={[]}
onAddProviderTools={vi.fn()}
onAddSkill={onAddSkill}
onBack={vi.fn()}
onOpenCategory={vi.fn()}
onInsertToken={vi.fn()}
/>,
)
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' }))
})
})

View File

@ -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' ? (
<div className="flex flex-col border-t border-divider-subtle p-1">
<AgentPromptSkillAddButton
icon="i-custom-vender-agent-v2-building-blocks"
label={t(($) => $['agentDetail.configure.skills.addMenu.workspace.label'])}
onClick={() => handleAddFromFooter('library')}
/>
<AgentPromptSkillAddButton
icon="i-ri-upload-cloud-2-line"
label={t(($) => $['agentDetail.configure.skills.addMenu.upload.label'])}
onClick={() => handleAddFromFooter('upload')}
/>
</div>
) : (
<div className="border-t border-divider-subtle p-1">
<button
type="button"
{...agentPromptSlashMenuItemProps}
className="flex h-6 w-full items-center gap-1 rounded-md pr-2 pl-3 text-left hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:outline-hidden data-agent-prompt-menu-active:bg-state-base-hover"
onClick={handleAddFromFooter}
onClick={() => handleAddFromFooter()}
>
<span aria-hidden className="i-ri-add-line size-4 shrink-0 text-text-secondary" />
<span className="system-sm-regular text-text-secondary">
{view === 'skills' && t(($) => $['agentDetail.configure.skills.add'])}
{view === 'files' && t(($) => $['agentDetail.configure.files.add'])}
{view === 'knowledge' && t(($) => $['agentDetail.configure.knowledgeRetrieval.add'])}
</span>
@ -233,6 +246,28 @@ export function AgentPromptSlashMenu({
)
}
function AgentPromptSkillAddButton({
icon,
label,
onClick,
}: {
icon: string
label: string
onClick: () => void
}) {
return (
<button
type="button"
{...agentPromptSlashMenuItemProps}
className="flex h-6 w-full items-center gap-1 rounded-md pr-2 pl-3 text-left hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:outline-hidden data-agent-prompt-menu-active:bg-state-base-hover"
onClick={onClick}
>
<span aria-hidden className={`${icon} size-4 shrink-0 text-text-secondary`} />
<span className="system-sm-regular text-text-secondary">{label}</span>
</button>
)
}
function AgentPromptSlashPanel({
className,
children,

View File

@ -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 <pre aria-label="config snapshot">{JSON.stringify(configSnapshot)}</pre>
}
function PromptSkillAddProbe() {
const actions = useAgentOrchestrateAddActions()
const [addedSkill, setAddedSkill] = useState('')
return (
<>
<button
type="button"
onClick={() =>
actions.skills?.({
skillSource: 'library',
onAdded: (item) => setAddedSkill('name' in item ? (item.name ?? '') : ''),
})
}
>
prompt add from library
</button>
<button type="button" onClick={() => actions.skills?.({ skillSource: 'upload' })}>
prompt upload skill.zip
</button>
<output aria-label="prompt added skill">{addedSkill}</output>
</>
)
}
function createWorkspaceSkill(overrides: Partial<SkillResponse> = {}): SkillResponse {
return {
id: 'workspace-skill-1',
@ -278,10 +306,13 @@ function renderAgentSkills({
<AgentConfigApiContextProvider value={apiContext}>
<AgentComposerProvider initialDraft={initialDraft}>
<AgentOrchestrateViewingVersionContext value={viewingVersion}>
<AgentOrchestrateReadOnlyContext value={readOnly}>
<AgentSkills />
<ConfigSnapshotProbe />
</AgentOrchestrateReadOnlyContext>
<AgentOrchestrateAddActionsProvider>
<AgentOrchestrateReadOnlyContext value={readOnly}>
<AgentSkills />
<ConfigSnapshotProbe />
<PromptSkillAddProbe />
</AgentOrchestrateReadOnlyContext>
</AgentOrchestrateAddActionsProvider>
</AgentOrchestrateViewingVersionContext>
</AgentComposerProvider>
</AgentConfigApiContextProvider>
@ -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) => {

View File

@ -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')
})

View File

@ -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",

View File

@ -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": "文件",