mirror of
https://github.com/langgenius/dify.git
synced 2026-09-08 02:43:49 +08:00
Merge branch 'skill' into deploy/dev
This commit is contained in:
commit
7a6c9934d7
@ -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__])
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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(),
|
||||
})
|
||||
|
||||
@ -16,6 +16,7 @@ export type AgentOrchestrateAddedItem =
|
||||
|
||||
export type AgentOrchestrateAddActionOptions = {
|
||||
onAdded?: (item: AgentOrchestrateAddedItem) => void
|
||||
skillSource?: 'library' | 'upload'
|
||||
}
|
||||
|
||||
export type AgentOrchestrateAddAction = (options?: AgentOrchestrateAddActionOptions) => void
|
||||
|
||||
@ -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' }))
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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')
|
||||
})
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "文件",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user