Compare commits
96
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bb01984ed | ||
|
|
77e41a37ee | ||
|
|
79229b7ede | ||
|
|
36ae648a63 | ||
|
|
c409d531da | ||
|
|
511ca69e4e | ||
|
|
4c7e60d7e7 | ||
|
|
a9e163403d | ||
|
|
bc136b89f9 | ||
|
|
b1bd4f9a8b | ||
|
|
06d3927f05 | ||
|
|
5c3516cae8 | ||
|
|
1c18d8ddbd | ||
|
|
253dfe9351 | ||
|
|
2d3999e984 | ||
|
|
a431cc726d | ||
|
|
08493d2429 | ||
|
|
8dd0969006 | ||
|
|
2d80d3c35c | ||
|
|
4da764904c | ||
|
|
5c4f4fd1ef | ||
|
|
54843971ac | ||
|
|
5ec5d3aeb9 | ||
|
|
137d4f3f60 | ||
|
|
d661b53e49 | ||
|
|
fd1e777f85 | ||
|
|
e5f77ce185 | ||
|
|
e723b348cf | ||
|
|
59fb603ec6 | ||
|
|
63f072ebfb | ||
|
|
0913d04d33 | ||
|
|
80ff108fc0 | ||
|
|
ea58129ebe | ||
|
|
698869460c | ||
|
|
f3f2f63110 | ||
|
|
b100cdc382 | ||
|
|
9e90b32991 | ||
|
|
b2d54cb2e9 | ||
|
|
99929d1c16 | ||
|
|
c699aa11db | ||
|
|
06cd0b56ac | ||
|
|
241a9e1fec | ||
|
|
3d79689fb5 | ||
|
|
9f050f7957 | ||
|
|
cb691d47d3 | ||
|
|
04f5ee58d0 | ||
|
|
f5cff724f4 | ||
|
|
1f4ccafdea | ||
|
|
163049f6ca | ||
|
|
65e7507ca3 | ||
|
|
ba157f9604 | ||
|
|
0dc913630e | ||
|
|
875cd30b1f | ||
|
|
aa4a32ae84 | ||
|
|
8573e14777 | ||
|
|
1855be234c | ||
|
|
9bb960ff12 | ||
|
|
1c14c7d467 | ||
|
|
61faec16ca | ||
|
|
57c836e692 | ||
|
|
626cc282b1 | ||
|
|
52624d54e3 | ||
|
|
5ce038ef92 | ||
|
|
30f4d4c0c6 | ||
|
|
510679a7d1 | ||
|
|
9237f2a14a | ||
|
|
bd178c7b29 | ||
|
|
d80947aa72 | ||
|
|
1618c37d26 | ||
|
|
701ab64462 | ||
|
|
b3298800e9 | ||
|
|
0f1c6b3f78 | ||
|
|
9b4b246aad | ||
|
|
1bd654a289 | ||
|
|
fc70329bdb | ||
|
|
cd8a82fbd4 | ||
|
|
550cb7eff5 | ||
|
|
2e748c16e9 | ||
|
|
577012b66d | ||
|
|
251c324180 | ||
|
|
865a618fd5 | ||
|
|
991116990a | ||
|
|
eb5d1da0e8 | ||
|
|
dce3b7a7fc | ||
|
|
be386aba3b | ||
|
|
02e51e7d7c | ||
|
|
96b6d4f2c0 | ||
|
|
4c84c5957d | ||
|
|
34613ecdc5 | ||
|
|
6a14245401 | ||
|
|
a758ca2aef | ||
|
|
9e60d4e213 | ||
|
|
ef29c8442c | ||
|
|
a1b45415ac | ||
|
|
7fc46d75bd | ||
|
|
953a4ef0ca |
@@ -144,6 +144,7 @@ from .workspace import (
|
||||
models,
|
||||
plugin,
|
||||
rbac,
|
||||
skills,
|
||||
snippets,
|
||||
tool_providers,
|
||||
trigger_providers,
|
||||
@@ -225,6 +226,7 @@ __all__ = [
|
||||
"saved_message",
|
||||
"setup",
|
||||
"site",
|
||||
"skills",
|
||||
"snippet_workflow",
|
||||
"snippet_workflow_draft_variable",
|
||||
"snippets",
|
||||
|
||||
@@ -59,7 +59,7 @@ class TagBindingRemovePayload(BaseModel):
|
||||
|
||||
|
||||
class TagListQueryParam(BaseModel):
|
||||
type: Literal["knowledge", "app", "snippet", ""] = Field("", description="Tag type filter")
|
||||
type: TagType | Literal[""] = Field("", description="Tag type filter")
|
||||
keyword: str | None = Field(None, description="Search keyword")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,834 @@
|
||||
"""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/<string:skill_id>")
|
||||
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/<string:skill_id>/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/<string:skill_id>/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/<string:skill_id>/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/<string:skill_id>/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/<string:skill_id>/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/<string:skill_id>/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/<string:skill_id>/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/<string:skill_id>/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/<string:skill_id>/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/<string:skill_id>/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/<string:skill_id>/versions/<string:version_id>")
|
||||
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/<string:agent_id>/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",
|
||||
]
|
||||
@@ -23,6 +23,7 @@ from .knowledge import retrieval as _knowledge_retrieval
|
||||
from .plugin import agent_config as _agent_config
|
||||
from .plugin import agent_drive as _agent_drive
|
||||
from .plugin import plugin as _plugin
|
||||
from .plugin import skills as _skills
|
||||
from .workspace import workspace as _workspace
|
||||
|
||||
api.add_namespace(inner_api_ns)
|
||||
@@ -36,6 +37,7 @@ __all__ = [
|
||||
"_mail",
|
||||
"_plugin",
|
||||
"_runtime_credentials",
|
||||
"_skills",
|
||||
"_workspace",
|
||||
"api",
|
||||
"bp",
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Inner API for published workspace Skills.
|
||||
|
||||
These endpoints are called by trusted runtime services. They expose only
|
||||
published Skill artifacts, never draft files or editable metadata.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
from flask import request, send_file
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from controllers.console.wraps import setup_required
|
||||
from controllers.inner_api import inner_api_ns
|
||||
from controllers.inner_api.wraps import plugin_inner_api_only
|
||||
from services.skill_management_service import SkillManagementService, SkillManagementServiceError
|
||||
|
||||
|
||||
class _SkillTargetQuery(BaseModel):
|
||||
tenant_id: str
|
||||
|
||||
|
||||
def _target_query_from_request() -> _SkillTargetQuery:
|
||||
return _SkillTargetQuery.model_validate({"tenant_id": request.args.get("tenant_id")})
|
||||
|
||||
|
||||
def _error_response(exc: SkillManagementServiceError) -> tuple[dict[str, str], int]:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
|
||||
|
||||
@inner_api_ns.route("/skills/<string:skill_id>/pull")
|
||||
class PublishedSkillPullApi(Resource):
|
||||
@setup_required
|
||||
@plugin_inner_api_only
|
||||
@inner_api_ns.doc("published_skill_pull")
|
||||
def get(self, skill_id: str):
|
||||
try:
|
||||
query = _target_query_from_request()
|
||||
result = SkillManagementService().pull_published_archive(tenant_id=query.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 ValidationError as exc:
|
||||
return {"code": "invalid_request", "message": str(exc)}, 400
|
||||
except SkillManagementServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
__all__ = ["PublishedSkillPullApi"]
|
||||
@@ -43,6 +43,7 @@ from core.workflow.nodes.agent_v2.runtime_request_builder import (
|
||||
build_config_layer_config,
|
||||
build_knowledge_layer_config,
|
||||
build_shell_layer_config,
|
||||
load_runtime_agent_skill_configs,
|
||||
)
|
||||
from models.agent_config_entities import AgentSoulConfig, AgentSoulToolsConfig
|
||||
from models.provider_ids import ModelProviderID
|
||||
@@ -125,14 +126,22 @@ class AgentAppRuntimeRequestBuilder:
|
||||
"cli_tool_count": len(agent_soul.tools.cli_tools),
|
||||
}
|
||||
|
||||
runtime_config_skills = load_runtime_agent_skill_configs(
|
||||
tenant_id=context.dify_context.tenant_id,
|
||||
agent_id=context.agent_id,
|
||||
)
|
||||
config_layer_config, config_warnings = build_config_layer_config(
|
||||
agent_soul,
|
||||
agent_id=context.agent_id,
|
||||
config_version_id=context.agent_config_snapshot_id,
|
||||
config_version_kind=context.agent_config_version_kind,
|
||||
runtime_config_skills=runtime_config_skills,
|
||||
)
|
||||
append_runtime_warnings(metadata, config_warnings)
|
||||
soul_prompt_resolver = build_config_aware_soul_mention_resolver(agent_soul)
|
||||
soul_prompt_resolver = build_config_aware_soul_mention_resolver(
|
||||
agent_soul,
|
||||
runtime_config_skills=runtime_config_skills,
|
||||
)
|
||||
knowledge_config = build_knowledge_layer_config(agent_soul)
|
||||
|
||||
request = self._request_builder.build_for_agent_app(
|
||||
|
||||
@@ -38,6 +38,7 @@ from dify_agent.layers.shell import (
|
||||
)
|
||||
from dify_agent.protocol import CreateRunRequest, DeferredToolResultsPayload
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
from clients.agent_backend import (
|
||||
AgentBackendModelConfig,
|
||||
@@ -206,14 +207,22 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
"cli_tool_count": len(agent_soul.tools.cli_tools),
|
||||
}
|
||||
|
||||
runtime_config_skills = load_runtime_agent_skill_configs(
|
||||
tenant_id=context.dify_context.tenant_id,
|
||||
agent_id=context.agent.id,
|
||||
)
|
||||
config_layer_config, config_warnings = build_config_layer_config(
|
||||
agent_soul,
|
||||
agent_id=context.agent.id,
|
||||
config_version_id=context.snapshot.id,
|
||||
config_version_kind="snapshot",
|
||||
runtime_config_skills=runtime_config_skills,
|
||||
)
|
||||
append_runtime_warnings(metadata, config_warnings)
|
||||
soul_prompt_resolver = build_config_aware_soul_mention_resolver(agent_soul)
|
||||
soul_prompt_resolver = build_config_aware_soul_mention_resolver(
|
||||
agent_soul,
|
||||
runtime_config_skills=runtime_config_skills,
|
||||
)
|
||||
soul_prompt = expand_prompt_mentions(agent_soul.prompt.system_prompt, soul_prompt_resolver).strip()
|
||||
knowledge_config = build_knowledge_layer_config(agent_soul)
|
||||
|
||||
@@ -883,11 +892,16 @@ def append_runtime_warnings(metadata: dict[str, Any], warnings: list[dict[str, s
|
||||
existing.extend(warnings)
|
||||
|
||||
|
||||
def build_config_aware_soul_mention_resolver(agent_soul: AgentSoulConfig):
|
||||
def build_config_aware_soul_mention_resolver(
|
||||
agent_soul: AgentSoulConfig,
|
||||
*,
|
||||
runtime_config_skills: Sequence[DifyConfigSkillConfig] = (),
|
||||
):
|
||||
"""Resolve config skill/file mentions and delegate the rest to Agent Soul."""
|
||||
|
||||
base_resolver = build_soul_mention_resolver(agent_soul)
|
||||
skill_names = {item.name for item in agent_soul.config_skills if not item.is_missing}
|
||||
skill_names.update(item.name for item in runtime_config_skills)
|
||||
file_names = {item.name for item in agent_soul.config_files if not item.is_missing}
|
||||
|
||||
def _resolve(mention: object) -> str | None:
|
||||
@@ -905,12 +919,34 @@ def build_config_aware_soul_mention_resolver(agent_soul: AgentSoulConfig):
|
||||
return _resolve
|
||||
|
||||
|
||||
def load_runtime_agent_skill_configs(*, tenant_id: str, agent_id: str) -> list[DifyConfigSkillConfig]:
|
||||
"""Return workspace-bound Skills as prompt-safe runtime config skills."""
|
||||
from services.skill_management_service import SkillManagementService
|
||||
|
||||
try:
|
||||
runtime_skills = SkillManagementService().list_runtime_agent_skills(tenant_id=tenant_id, agent_id=agent_id)
|
||||
except OperationalError as exc:
|
||||
if "no such table: agent_skill_bindings" not in str(exc.orig):
|
||||
raise
|
||||
runtime_skills = []
|
||||
return [
|
||||
DifyConfigSkillConfig(
|
||||
name=str(item["name"]),
|
||||
description=str(item.get("description") or ""),
|
||||
size=cast(int | None, item.get("size")),
|
||||
mime_type=cast(str | None, item.get("mime_type")),
|
||||
)
|
||||
for item in runtime_skills
|
||||
]
|
||||
|
||||
|
||||
def build_config_layer_config(
|
||||
agent_soul: AgentSoulConfig,
|
||||
*,
|
||||
agent_id: str | None = None,
|
||||
config_version_id: str | None = None,
|
||||
config_version_kind: Literal["snapshot", "draft", "build_draft"] = "snapshot",
|
||||
runtime_config_skills: Sequence[DifyConfigSkillConfig] = (),
|
||||
) -> tuple[DifyConfigLayerConfig, list[dict[str, str]]]:
|
||||
"""Build the always-present Agent config layer from Agent Soul state.
|
||||
|
||||
@@ -927,8 +963,23 @@ def build_config_layer_config(
|
||||
)
|
||||
)
|
||||
available_skills = [skill for skill in agent_soul.config_skills if not skill.is_missing]
|
||||
skill_configs = [
|
||||
DifyConfigSkillConfig(
|
||||
name=skill.name,
|
||||
description=skill.description,
|
||||
size=skill.size,
|
||||
mime_type=skill.mime_type,
|
||||
)
|
||||
for skill in available_skills
|
||||
]
|
||||
seen_skill_names = {skill.name for skill in skill_configs}
|
||||
for skill in runtime_config_skills:
|
||||
if skill.name in seen_skill_names:
|
||||
continue
|
||||
seen_skill_names.add(skill.name)
|
||||
skill_configs.append(skill)
|
||||
available_files = [file_ref for file_ref in agent_soul.config_files if not file_ref.is_missing]
|
||||
skill_names = {skill.name for skill in available_skills}
|
||||
skill_names = {skill.name for skill in skill_configs}
|
||||
file_names = {file_ref.name for file_ref in available_files}
|
||||
warnings: list[dict[str, str]] = [
|
||||
{
|
||||
@@ -965,15 +1016,7 @@ def build_config_layer_config(
|
||||
kind=config_version_kind,
|
||||
writable=config_version_kind == "build_draft",
|
||||
),
|
||||
skills=[
|
||||
DifyConfigSkillConfig(
|
||||
name=skill.name,
|
||||
description=skill.description,
|
||||
size=skill.size,
|
||||
mime_type=skill.mime_type,
|
||||
)
|
||||
for skill in available_skills
|
||||
],
|
||||
skills=skill_configs,
|
||||
files=[
|
||||
DifyConfigFileConfig(
|
||||
name=file_ref.name,
|
||||
|
||||
+5
-1
@@ -289,7 +289,11 @@ UUIDStr = Annotated[str, AfterValidator(_strict_uuid)]
|
||||
|
||||
def alphanumeric(value: str):
|
||||
# check if the value is alphanumeric and underlined
|
||||
if re.match(r"^[a-zA-Z0-9_]+$", value):
|
||||
# Use re.fullmatch instead of re.match to reject trailing newlines.
|
||||
# In Python, '$' matches at end-of-string OR just before a trailing newline,
|
||||
# so re.match accepts "tool_name\n". re.fullmatch requires the entire
|
||||
# string to match. Regression for #39666 (sibling of #39234 / #39548).
|
||||
if re.fullmatch(r"^[a-zA-Z0-9_]+$", value):
|
||||
return value
|
||||
|
||||
raise ValueError(f"{value} is not a valid alphanumeric value")
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
"""add workspace skill management
|
||||
|
||||
Revision ID: a4f8d2c9e1b0
|
||||
Revises: 6f5a9c2d8e1b
|
||||
Create Date: 2026-07-09 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
from models.types import StringUUID
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "a4f8d2c9e1b0"
|
||||
down_revision = "6f5a9c2d8e1b"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _uuid_column(name: str, *, nullable: bool = False) -> sa.Column:
|
||||
return sa.Column(name, StringUUID(), nullable=nullable)
|
||||
|
||||
|
||||
def _long_text() -> sa.types.TypeEngine:
|
||||
return sa.Text().with_variant(mysql.LONGTEXT(), "mysql")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"skills",
|
||||
_uuid_column("id"),
|
||||
_uuid_column("tenant_id"),
|
||||
sa.Column("name", sa.String(length=64), nullable=False),
|
||||
sa.Column("display_name", sa.String(length=128), nullable=False),
|
||||
sa.Column("icon", sa.String(length=16), nullable=False, server_default="📄"),
|
||||
sa.Column("description", sa.String(length=1024), nullable=False, server_default=""),
|
||||
sa.Column("name_manually_edited", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("visibility", sa.String(length=32), nullable=False, server_default="workspace"),
|
||||
_uuid_column("latest_published_version_id", nullable=True),
|
||||
_uuid_column("created_by", nullable=True),
|
||||
_uuid_column("updated_by", nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
|
||||
sa.PrimaryKeyConstraint("id", name="skill_pkey"),
|
||||
sa.UniqueConstraint("tenant_id", "name", name="skill_tenant_name_unique"),
|
||||
)
|
||||
op.create_index("skills_tenant_updated_at_idx", "skills", ["tenant_id", "updated_at"])
|
||||
|
||||
op.create_table(
|
||||
"skill_draft_files",
|
||||
_uuid_column("id"),
|
||||
_uuid_column("skill_id"),
|
||||
sa.Column("path", sa.String(length=512), nullable=False),
|
||||
sa.Column("kind", sa.String(length=32), nullable=False),
|
||||
sa.Column("storage", sa.String(length=32), nullable=True),
|
||||
sa.Column("mime_type", sa.String(length=255), nullable=True),
|
||||
sa.Column("content_text", _long_text(), nullable=True),
|
||||
_uuid_column("tool_file_id", nullable=True),
|
||||
sa.Column("size", sa.BigInteger(), nullable=True),
|
||||
sa.Column("hash", sa.String(length=255), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
|
||||
sa.PrimaryKeyConstraint("id", name="skill_draft_file_pkey"),
|
||||
sa.UniqueConstraint("skill_id", "path", name="skill_draft_file_skill_path_unique"),
|
||||
)
|
||||
op.create_index("skill_draft_files_skill_path_idx", "skill_draft_files", ["skill_id", "path"])
|
||||
|
||||
op.create_table(
|
||||
"skill_versions",
|
||||
_uuid_column("id"),
|
||||
_uuid_column("skill_id"),
|
||||
sa.Column("version_number", sa.Integer(), nullable=False),
|
||||
sa.Column("version_name", sa.String(length=128), nullable=False, server_default=""),
|
||||
sa.Column("publish_note", sa.String(length=1024), nullable=False, server_default=""),
|
||||
sa.Column("manifest", _long_text(), nullable=False),
|
||||
_uuid_column("archive_tool_file_id"),
|
||||
sa.Column("hash_code", sa.String(length=255), nullable=False),
|
||||
sa.Column("archive_size", sa.BigInteger(), nullable=False),
|
||||
_uuid_column("published_by", nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
|
||||
sa.PrimaryKeyConstraint("id", name="skill_version_pkey"),
|
||||
sa.UniqueConstraint("skill_id", "version_number", name="skill_version_skill_number_unique"),
|
||||
)
|
||||
op.create_index("skill_versions_skill_created_at_idx", "skill_versions", ["skill_id", "created_at"])
|
||||
|
||||
op.create_table(
|
||||
"agent_skill_bindings",
|
||||
_uuid_column("id"),
|
||||
_uuid_column("tenant_id"),
|
||||
_uuid_column("agent_id"),
|
||||
_uuid_column("skill_id"),
|
||||
sa.Column("priority", sa.Integer(), nullable=False),
|
||||
_uuid_column("created_by", nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
|
||||
sa.PrimaryKeyConstraint("id", name="agent_skill_binding_pkey"),
|
||||
sa.UniqueConstraint("tenant_id", "agent_id", "skill_id", name="agent_skill_binding_unique"),
|
||||
sa.UniqueConstraint("tenant_id", "agent_id", "priority", name="agent_skill_binding_priority_unique"),
|
||||
)
|
||||
op.create_index("agent_skill_bindings_skill_idx", "agent_skill_bindings", ["tenant_id", "skill_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("agent_skill_bindings_skill_idx", table_name="agent_skill_bindings")
|
||||
op.drop_table("agent_skill_bindings")
|
||||
op.drop_index("skill_versions_skill_created_at_idx", table_name="skill_versions")
|
||||
op.drop_table("skill_versions")
|
||||
op.drop_index("skill_draft_files_skill_path_idx", table_name="skill_draft_files")
|
||||
op.drop_table("skill_draft_files")
|
||||
op.drop_index("skills_tenant_updated_at_idx", table_name="skills")
|
||||
op.drop_table("skills")
|
||||
+110
-23
@@ -18,31 +18,109 @@ branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _is_pg(conn) -> bool:
|
||||
return conn.dialect.name == "postgresql"
|
||||
|
||||
|
||||
def _uuid_column(name: str, *, nullable: bool = False, primary_key: bool = False) -> sa.Column:
|
||||
kwargs = {"nullable": nullable, "primary_key": primary_key}
|
||||
if primary_key and _is_pg(op.get_bind()):
|
||||
kwargs["server_default"] = sa.text("uuidv7()")
|
||||
return sa.Column(name, models.types.StringUUID(), **kwargs)
|
||||
|
||||
|
||||
def _has_table(table_name: str) -> bool:
|
||||
return sa.inspect(op.get_bind()).has_table(table_name)
|
||||
|
||||
|
||||
def _has_column(table_name: str, column_name: str) -> bool:
|
||||
return any(
|
||||
column["name"] == column_name for column in sa.inspect(op.get_bind()).get_columns(table_name)
|
||||
)
|
||||
|
||||
|
||||
def _has_unique_constraint(table_name: str, constraint_name: str) -> bool:
|
||||
return any(
|
||||
constraint["name"] == constraint_name
|
||||
for constraint in sa.inspect(op.get_bind()).get_unique_constraints(table_name)
|
||||
)
|
||||
|
||||
|
||||
def upgrade():
|
||||
if not _has_table("agent_debug_conversations"):
|
||||
op.create_table(
|
||||
"agent_debug_conversations",
|
||||
_uuid_column("id", primary_key=True),
|
||||
sa.Column("tenant_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("agent_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("app_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("account_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("conversation_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column(
|
||||
"draft_type",
|
||||
sa.String(length=32),
|
||||
nullable=False,
|
||||
server_default=sa.text("'debug_build'"),
|
||||
),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("agent_debug_conversation_pkey")),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"agent_id",
|
||||
"account_id",
|
||||
"draft_type",
|
||||
name=op.f("agent_debug_conversation_agent_account_draft_type_unique"),
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"agent_debug_conversation_conversation_idx",
|
||||
"agent_debug_conversations",
|
||||
["conversation_id"],
|
||||
)
|
||||
op.create_index(
|
||||
"agent_debug_conversation_account_idx",
|
||||
"agent_debug_conversations",
|
||||
["tenant_id", "account_id"],
|
||||
)
|
||||
return
|
||||
|
||||
# Existing pointers have always represented Build chat because the Agent
|
||||
# detail API exposes them as ``debug_conversation_id`` for that surface.
|
||||
op.add_column(
|
||||
if not _has_column("agent_debug_conversations", "draft_type"):
|
||||
op.add_column(
|
||||
"agent_debug_conversations",
|
||||
sa.Column(
|
||||
"draft_type",
|
||||
sa.String(length=32),
|
||||
nullable=False,
|
||||
server_default=sa.text("'debug_build'"),
|
||||
),
|
||||
)
|
||||
if _has_unique_constraint(
|
||||
"agent_debug_conversations",
|
||||
sa.Column(
|
||||
"draft_type",
|
||||
sa.String(length=32),
|
||||
nullable=False,
|
||||
server_default=sa.text("'debug_build'"),
|
||||
),
|
||||
)
|
||||
op.drop_constraint(
|
||||
"agent_debug_conversation_agent_account_unique",
|
||||
):
|
||||
op.drop_constraint(
|
||||
"agent_debug_conversation_agent_account_unique",
|
||||
"agent_debug_conversations",
|
||||
type_="unique",
|
||||
)
|
||||
if not _has_unique_constraint(
|
||||
"agent_debug_conversations",
|
||||
type_="unique",
|
||||
)
|
||||
op.create_unique_constraint(
|
||||
"agent_debug_conversation_agent_account_draft_type_unique",
|
||||
"agent_debug_conversations",
|
||||
["tenant_id", "agent_id", "account_id", "draft_type"],
|
||||
)
|
||||
):
|
||||
op.create_unique_constraint(
|
||||
"agent_debug_conversation_agent_account_draft_type_unique",
|
||||
"agent_debug_conversations",
|
||||
["tenant_id", "agent_id", "account_id", "draft_type"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
if not _has_table("agent_debug_conversations"):
|
||||
return
|
||||
|
||||
debug_conversations = sa.table(
|
||||
"agent_debug_conversations",
|
||||
sa.column("tenant_id", models.types.StringUUID()),
|
||||
@@ -64,14 +142,23 @@ def downgrade():
|
||||
),
|
||||
)
|
||||
)
|
||||
op.drop_constraint(
|
||||
if _has_unique_constraint(
|
||||
"agent_debug_conversations",
|
||||
"agent_debug_conversation_agent_account_draft_type_unique",
|
||||
):
|
||||
op.drop_constraint(
|
||||
"agent_debug_conversation_agent_account_draft_type_unique",
|
||||
"agent_debug_conversations",
|
||||
type_="unique",
|
||||
)
|
||||
if not _has_unique_constraint(
|
||||
"agent_debug_conversations",
|
||||
type_="unique",
|
||||
)
|
||||
op.create_unique_constraint(
|
||||
"agent_debug_conversation_agent_account_unique",
|
||||
"agent_debug_conversations",
|
||||
["tenant_id", "agent_id", "account_id"],
|
||||
)
|
||||
op.drop_column("agent_debug_conversations", "draft_type")
|
||||
):
|
||||
op.create_unique_constraint(
|
||||
"agent_debug_conversation_agent_account_unique",
|
||||
"agent_debug_conversations",
|
||||
["tenant_id", "agent_id", "account_id"],
|
||||
)
|
||||
if _has_column("agent_debug_conversations", "draft_type"):
|
||||
op.drop_column("agent_debug_conversations", "draft_type")
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
"""merge skill and agent debug conversation heads
|
||||
|
||||
Revision ID: e9f4a1b2c3d5
|
||||
Revises: a4f8d2c9e1b0, d2825e7b9c10
|
||||
Create Date: 2026-07-23 15:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "e9f4a1b2c3d5"
|
||||
down_revision = ("a4f8d2c9e1b0", "d2825e7b9c10")
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
pass
|
||||
|
||||
|
||||
def downgrade():
|
||||
pass
|
||||
@@ -113,6 +113,7 @@ from .provider import (
|
||||
TenantDefaultModel,
|
||||
TenantPreferredModelProvider,
|
||||
)
|
||||
from .skill import AgentSkillBinding, Skill, SkillDraftFile, SkillFileKind, SkillFileStorage, SkillVersion
|
||||
from .snippet import CustomizedSnippet, SnippetType
|
||||
from .source import DataSourceApiKeyAuthBinding, DataSourceOauthBinding
|
||||
from .task import CeleryTask, CeleryTaskSet
|
||||
@@ -173,6 +174,7 @@ __all__ = [
|
||||
"AgentRuntimeSessionOwnerType",
|
||||
"AgentRuntimeSessionStatus",
|
||||
"AgentScope",
|
||||
"AgentSkillBinding",
|
||||
"AgentSource",
|
||||
"AgentStatus",
|
||||
"ApiRequest",
|
||||
@@ -246,6 +248,11 @@ __all__ = [
|
||||
"RecommendedApp",
|
||||
"SavedMessage",
|
||||
"Site",
|
||||
"Skill",
|
||||
"SkillDraftFile",
|
||||
"SkillFileKind",
|
||||
"SkillFileStorage",
|
||||
"SkillVersion",
|
||||
"SnippetType",
|
||||
"Tag",
|
||||
"TagBinding",
|
||||
|
||||
@@ -249,6 +249,7 @@ class TagType(StrEnum):
|
||||
KNOWLEDGE = "knowledge"
|
||||
APP = "app"
|
||||
SNIPPET = "snippet"
|
||||
SKILL = "skill"
|
||||
|
||||
|
||||
class DatasetMetadataType(StrEnum):
|
||||
|
||||
+1
-1
@@ -2667,7 +2667,7 @@ class Tag(TypeBase):
|
||||
sa.Index("tag_name_idx", "name"),
|
||||
)
|
||||
|
||||
TAG_TYPE_LIST = ["knowledge", "app", "snippet"]
|
||||
TAG_TYPE_LIST = ["knowledge", "app", "snippet", "skill"]
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
StringUUID, insert_default=lambda: str(uuid4()), default_factory=lambda: str(uuid4()), init=False
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Workspace-level Skill Management models.
|
||||
|
||||
These tables are the source of truth for reusable workspace Skills. Agent Soul
|
||||
``config_skills`` and Agent Drive skill rows remain per-agent runtime/config
|
||||
assets; they may consume a published Skill snapshot but do not own the Skill's
|
||||
draft, metadata, version history, or Agent binding priority.
|
||||
"""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
import sqlalchemy as sa
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import Index, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from models.base import Base, DefaultFieldsMixin
|
||||
from models.types import EnumText, JSONModelColumn, LongText, StringUUID
|
||||
|
||||
|
||||
class SkillFileKind(StrEnum):
|
||||
"""Draft file entry kind."""
|
||||
|
||||
FILE = "file"
|
||||
DIRECTORY = "directory"
|
||||
|
||||
|
||||
class SkillFileStorage(StrEnum):
|
||||
"""How a draft file's content is stored."""
|
||||
|
||||
TEXT = "text"
|
||||
TOOL_FILE = "tool_file"
|
||||
|
||||
|
||||
class SkillVersionManifestFile(BaseModel):
|
||||
"""One file entry captured in a published Skill snapshot manifest."""
|
||||
|
||||
path: str
|
||||
mime_type: str | None = None
|
||||
size: int
|
||||
hash: str
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class SkillVersionManifest(BaseModel):
|
||||
"""Published Skill snapshot file index."""
|
||||
|
||||
files: list[SkillVersionManifestFile]
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class Skill(DefaultFieldsMixin, Base):
|
||||
"""Workspace-level reusable Skill metadata and draft status."""
|
||||
|
||||
__tablename__ = "skills"
|
||||
__table_args__ = (
|
||||
sa.PrimaryKeyConstraint("id", name="skill_pkey"),
|
||||
UniqueConstraint("tenant_id", "name", name="skill_tenant_name_unique"),
|
||||
Index("skills_tenant_updated_at_idx", "tenant_id", "updated_at"),
|
||||
)
|
||||
|
||||
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
name: Mapped[str] = mapped_column(sa.String(64), nullable=False)
|
||||
display_name: Mapped[str] = mapped_column(sa.String(128), nullable=False)
|
||||
icon: Mapped[str] = mapped_column(sa.String(16), nullable=False, default="📄", server_default="📄")
|
||||
description: Mapped[str] = mapped_column(sa.String(1024), nullable=False, default="", server_default="")
|
||||
name_manually_edited: Mapped[bool] = mapped_column(
|
||||
sa.Boolean,
|
||||
nullable=False,
|
||||
default=False,
|
||||
server_default=sa.false(),
|
||||
)
|
||||
visibility: Mapped[str] = mapped_column(
|
||||
sa.String(32),
|
||||
nullable=False,
|
||||
default="workspace",
|
||||
server_default="workspace",
|
||||
)
|
||||
latest_published_version_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
updated_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
|
||||
|
||||
class SkillDraftFile(DefaultFieldsMixin, Base):
|
||||
"""One draft file or directory in a workspace Skill."""
|
||||
|
||||
__tablename__ = "skill_draft_files"
|
||||
__table_args__ = (
|
||||
sa.PrimaryKeyConstraint("id", name="skill_draft_file_pkey"),
|
||||
UniqueConstraint("skill_id", "path", name="skill_draft_file_skill_path_unique"),
|
||||
Index("skill_draft_files_skill_path_idx", "skill_id", "path"),
|
||||
)
|
||||
|
||||
skill_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
path: Mapped[str] = mapped_column(sa.String(512), nullable=False)
|
||||
kind: Mapped[SkillFileKind] = mapped_column(EnumText(SkillFileKind, length=32), nullable=False)
|
||||
storage: Mapped[SkillFileStorage | None] = mapped_column(EnumText(SkillFileStorage, length=32), nullable=True)
|
||||
mime_type: Mapped[str | None] = mapped_column(sa.String(255), nullable=True)
|
||||
content_text: Mapped[str | None] = mapped_column(LongText, nullable=True)
|
||||
tool_file_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
size: Mapped[int | None] = mapped_column(sa.BigInteger, nullable=True)
|
||||
hash: Mapped[str | None] = mapped_column(sa.String(255), nullable=True)
|
||||
|
||||
|
||||
class SkillVersion(DefaultFieldsMixin, Base):
|
||||
"""Immutable published Skill snapshot.
|
||||
|
||||
``hash_code`` uniquely identifies a published version for downstream
|
||||
execution audit. It includes Skill identity, version number, and archive
|
||||
content digest instead of being only the archive content hash.
|
||||
"""
|
||||
|
||||
__tablename__ = "skill_versions"
|
||||
__table_args__ = (
|
||||
sa.PrimaryKeyConstraint("id", name="skill_version_pkey"),
|
||||
UniqueConstraint("skill_id", "version_number", name="skill_version_skill_number_unique"),
|
||||
Index("skill_versions_skill_created_at_idx", "skill_id", "created_at"),
|
||||
)
|
||||
|
||||
skill_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
version_number: Mapped[int] = mapped_column(sa.Integer, nullable=False)
|
||||
version_name: Mapped[str] = mapped_column(sa.String(128), nullable=False, default="", server_default="")
|
||||
publish_note: Mapped[str] = mapped_column(sa.String(1024), nullable=False, default="", server_default="")
|
||||
manifest: Mapped[SkillVersionManifest] = mapped_column(JSONModelColumn(SkillVersionManifest), nullable=False)
|
||||
archive_tool_file_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
hash_code: Mapped[str] = mapped_column(sa.String(255), nullable=False)
|
||||
archive_size: Mapped[int] = mapped_column(sa.BigInteger, nullable=False)
|
||||
published_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
|
||||
|
||||
class AgentSkillBinding(DefaultFieldsMixin, Base):
|
||||
"""Direct Agent-to-workspace-Skill binding.
|
||||
|
||||
``priority`` is retained as an internal ordering column for the current
|
||||
schema constraints. Runtime Skill selection is Agent-driven and must not
|
||||
treat it as a matching priority.
|
||||
"""
|
||||
|
||||
__tablename__ = "agent_skill_bindings"
|
||||
__table_args__ = (
|
||||
sa.PrimaryKeyConstraint("id", name="agent_skill_binding_pkey"),
|
||||
UniqueConstraint("tenant_id", "agent_id", "skill_id", name="agent_skill_binding_unique"),
|
||||
UniqueConstraint("tenant_id", "agent_id", "priority", name="agent_skill_binding_priority_unique"),
|
||||
Index("agent_skill_bindings_skill_idx", "tenant_id", "skill_id"),
|
||||
)
|
||||
|
||||
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
agent_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
skill_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
priority: Mapped[int] = mapped_column(sa.Integer, nullable=False)
|
||||
created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentSkillBinding",
|
||||
"Skill",
|
||||
"SkillDraftFile",
|
||||
"SkillFileKind",
|
||||
"SkillFileStorage",
|
||||
"SkillVersion",
|
||||
"SkillVersionManifest",
|
||||
"SkillVersionManifestFile",
|
||||
]
|
||||
@@ -9551,7 +9551,7 @@ Remove one or more tag bindings from a target.
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| keyword | query | Search keyword | No | string |
|
||||
| type | query | Tag type filter | No | string, <br>**Available values:** "", "app", "knowledge", "snippet" |
|
||||
| type | query | Tag type filter | No | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
@@ -10087,6 +10087,38 @@ Get list of available agent providers
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Success | **application/json**: [AgentProviderListResponse](#agentproviderlistresponse)<br> |
|
||||
|
||||
### [GET] /workspaces/current/agents/{agent_id}/skills
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| agent_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Agent Skill bindings | **application/json**: [AgentSkillBindingsResponse](#agentskillbindingsresponse)<br> |
|
||||
|
||||
### [PUT] /workspaces/current/agents/{agent_id}/skills
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| agent_id | path | | Yes | string |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [AgentSkillBindingsPayload](#agentskillbindingspayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Agent Skill bindings replaced | **application/json**: [AgentSkillBindingsResponse](#agentskillbindingsresponse)<br> |
|
||||
|
||||
### [GET] /workspaces/current/customized-snippets
|
||||
**List customized snippets with pagination and search**
|
||||
|
||||
@@ -11988,6 +12020,341 @@ Returns permission flags that control workspace features like member invitations
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Success | **application/json**: [WorkspaceAccessMatrix](#workspaceaccessmatrix)<br> |
|
||||
|
||||
### [GET] /workspaces/current/skills
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| keyword | query | Search keyword matching skill name or description. | No | string |
|
||||
| limit | query | Number of items per page. | No | integer, <br>**Default:** 20 |
|
||||
| page | query | Page number. | No | integer, <br>**Default:** 1 |
|
||||
| tag | query | Skill tag filters. Repeat the parameter for multiple tags. | No | [ string ] |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Workspace skills | **application/json**: [SkillListResponse](#skilllistresponse)<br> |
|
||||
|
||||
### [POST] /workspaces/current/skills
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [SkillCreatePayload](#skillcreatepayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 201 | Skill created | **application/json**: [SkillDetailResponse](#skilldetailresponse)<br> |
|
||||
|
||||
### [POST] /workspaces/current/skills/files/upload
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **multipart/form-data**: { **"file"**: binary }<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 201 | Skill draft file uploaded | **application/json**: [SkillFileUploadResponse](#skillfileuploadresponse)<br> |
|
||||
|
||||
### [POST] /workspaces/current/skills/import
|
||||
Import a Skill zip package from multipart form field `file`.
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 201 | Skill imported | **application/json**: [SkillDetailResponse](#skilldetailresponse)<br> |
|
||||
|
||||
### [GET] /workspaces/current/skills/tags
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Workspace Skill tags | **application/json**: [SkillTagListResponse](#skilltaglistresponse)<br> |
|
||||
|
||||
### [DELETE] /workspaces/current/skills/{skill_id}
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| skill_id | path | | Yes | string |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [SkillDeletePayload](#skilldeletepayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Skill deleted | **application/json**: [SkillDeleteResponse](#skilldeleteresponse)<br> |
|
||||
|
||||
### [GET] /workspaces/current/skills/{skill_id}
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| skill_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Skill detail | **application/json**: [SkillDetailResponse](#skilldetailresponse)<br> |
|
||||
|
||||
### [PATCH] /workspaces/current/skills/{skill_id}
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| skill_id | path | | Yes | string |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [SkillMetadataPayload](#skillmetadatapayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Skill updated | **application/json**: [SkillResponse](#skillresponse)<br> |
|
||||
|
||||
### [POST] /workspaces/current/skills/{skill_id}/assist/messages
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| skill_id | path | | Yes | string |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [SkillAssistMessagePayload](#skillassistmessagepayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 200 | Skill Authoring assistant event stream |
|
||||
|
||||
### [POST] /workspaces/current/skills/{skill_id}/duplicate
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| skill_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 201 | Skill duplicated | **application/json**: [SkillDetailResponse](#skilldetailresponse)<br> |
|
||||
|
||||
### [GET] /workspaces/current/skills/{skill_id}/export
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| skill_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 200 | Published Skill zip archive |
|
||||
|
||||
### [PATCH] /workspaces/current/skills/{skill_id}/files
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| skill_id | path | | Yes | string |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [SkillDraftFileOperationPayload](#skilldraftfileoperationpayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Draft file operation applied | **application/json**: [SkillDetailResponse](#skilldetailresponse)<br> |
|
||||
|
||||
### [PUT] /workspaces/current/skills/{skill_id}/files
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| skill_id | path | | Yes | string |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [SkillDraftTreePayload](#skilldrafttreepayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Draft files replaced | **application/json**: [SkillDetailResponse](#skilldetailresponse)<br> |
|
||||
|
||||
### [GET] /workspaces/current/skills/{skill_id}/files/content
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| download | query | Return as an attachment when 1. | No | string |
|
||||
| path | query | Skill file path relative to the Skill root. | Yes | string |
|
||||
| version_id | query | Optional published version ID. Omit for current draft. | No | string |
|
||||
| skill_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Skill file content | **application/json**: [BinaryFileResponse](#binaryfileresponse)<br> |
|
||||
|
||||
### [GET] /workspaces/current/skills/{skill_id}/files/preview
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| path | query | Skill file path relative to the Skill root. | Yes | string |
|
||||
| version_id | query | Optional published version ID. Omit for current draft. | No | string |
|
||||
| skill_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Skill file text preview | **application/json**: [SkillFilePreviewResponse](#skillfilepreviewresponse)<br> |
|
||||
|
||||
### [POST] /workspaces/current/skills/{skill_id}/publish
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| skill_id | path | | Yes | string |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [SkillPublishPayload](#skillpublishpayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Skill published | **application/json**: [SkillVersionResponse](#skillversionresponse)<br> |
|
||||
|
||||
### [GET] /workspaces/current/skills/{skill_id}/references
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| skill_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Skill references | **application/json**: [SkillReferenceListResponse](#skillreferencelistresponse)<br> |
|
||||
|
||||
### [POST] /workspaces/current/skills/{skill_id}/restore
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| skill_id | path | | Yes | string |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [SkillRestorePayload](#skillrestorepayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Skill version restored | **application/json**: [SkillVersionResponse](#skillversionresponse)<br> |
|
||||
|
||||
### [GET] /workspaces/current/skills/{skill_id}/versions
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| skill_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Skill versions | **application/json**: [SkillVersionListResponse](#skillversionlistresponse)<br> |
|
||||
|
||||
### [DELETE] /workspaces/current/skills/{skill_id}/versions/{version_id}
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| skill_id | path | | Yes | string |
|
||||
| version_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Skill version deleted | **application/json**: [SkillVersionDeleteResponse](#skillversiondeleteresponse)<br> |
|
||||
|
||||
### [GET] /workspaces/current/skills/{skill_id}/versions/{version_id}
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| skill_id | path | | Yes | string |
|
||||
| version_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Skill version detail | **application/json**: [SkillVersionDetailResponse](#skillversiondetailresponse)<br> |
|
||||
|
||||
### [PATCH] /workspaces/current/skills/{skill_id}/versions/{version_id}
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| skill_id | path | | Yes | string |
|
||||
| version_id | path | | Yes | string |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [SkillVersionUpdatePayload](#skillversionupdatepayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Skill version updated | **application/json**: [SkillVersionResponse](#skillversionresponse)<br> |
|
||||
|
||||
### [GET] /workspaces/current/tool-labels
|
||||
#### Responses
|
||||
|
||||
@@ -14709,6 +15076,37 @@ Visibility and lifecycle scope of an Agent record.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| result | string | | Yes |
|
||||
|
||||
#### AgentSkillBindingItemResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| description | string | | Yes |
|
||||
| display_name | string | | Yes |
|
||||
| file_count | integer | | Yes |
|
||||
| icon | string | | Yes |
|
||||
| id | string | | Yes |
|
||||
| latest_published_at | integer | | No |
|
||||
| latest_published_version_id | string | | No |
|
||||
| name | string | | Yes |
|
||||
| priority | integer | | Yes |
|
||||
| status | string | | Yes |
|
||||
| tags | [ string ] | | No |
|
||||
| updated_at | integer | | Yes |
|
||||
|
||||
#### AgentSkillBindingsPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| skill_ids | [ string ] | Ordered Skill IDs bound to the Agent. | No |
|
||||
|
||||
#### AgentSkillBindingsResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| agent_id | string | | Yes |
|
||||
| data | [ [AgentSkillBindingItemResponse](#agentskillbindingitemresponse) ] | | No |
|
||||
| skill_ids | [ string ] | | No |
|
||||
|
||||
#### AgentSkillRefConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -21665,6 +22063,186 @@ Simple provider entity response.
|
||||
| title | string | | Yes |
|
||||
| use_icon_as_answer_icon | boolean | | Yes |
|
||||
|
||||
#### SkillAssistAttachmentPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| mime_type | string | | No |
|
||||
| name | string | | Yes |
|
||||
| size | integer | | No |
|
||||
| tool_file_id | string | | Yes |
|
||||
|
||||
#### SkillAssistMessagePayload
|
||||
|
||||
One user message and optional uploaded context for the read-only Skill Authoring assistant.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| attachments | [ [SkillAssistAttachmentPayload](#skillassistattachmentpayload) ] | | No |
|
||||
| message | string | | Yes |
|
||||
| model | [SkillAssistModelPayload](#skillassistmodelpayload) | | No |
|
||||
|
||||
#### SkillAssistModelPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| model | string | | Yes |
|
||||
| model_settings | object | | No |
|
||||
| plugin_id | string | | No |
|
||||
| provider | string | | Yes |
|
||||
|
||||
#### SkillCreatePayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| description | string | | No |
|
||||
| display_name | string | | No |
|
||||
| icon | string, <br>**Default:** 📄 | | No |
|
||||
| name | string | | No |
|
||||
| tags | [ string ] | | No |
|
||||
|
||||
#### SkillDeletePayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| confirmation_name | string | Required when deleting a referenced Skill. Must match the Skill name. | No |
|
||||
|
||||
#### SkillDeleteResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| deleted | boolean | | Yes |
|
||||
| id | string | | Yes |
|
||||
|
||||
#### SkillDetailResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| created_at | integer | | Yes |
|
||||
| created_by | string | | No |
|
||||
| created_by_name | string | | No |
|
||||
| description | string | | Yes |
|
||||
| display_name | string | | Yes |
|
||||
| files | [ [SkillFileResponse](#skillfileresponse) ] | | No |
|
||||
| icon | string | | Yes |
|
||||
| id | string | | Yes |
|
||||
| latest_published_version_id | string | | No |
|
||||
| name | string | | Yes |
|
||||
| name_manually_edited | boolean | | No |
|
||||
| reference_count | integer | | No |
|
||||
| tags | [ string ] | | No |
|
||||
| updated_at | integer | | Yes |
|
||||
| updated_by | string | | No |
|
||||
| updated_by_name | string | | No |
|
||||
| visibility | string | | Yes |
|
||||
|
||||
#### SkillDraftFileOperation
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| SkillDraftFileOperation | string | | |
|
||||
|
||||
#### SkillDraftFileOperationPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| content | string | | No |
|
||||
| expected_updated_at | integer | | No |
|
||||
| hash | string | | No |
|
||||
| mime_type | string | | No |
|
||||
| operation | [SkillDraftFileOperation](#skilldraftfileoperation) | | Yes |
|
||||
| path | string | | Yes |
|
||||
| size | integer | | No |
|
||||
| target_path | string | | No |
|
||||
| tool_file_id | string | | No |
|
||||
|
||||
#### SkillDraftTreeItemPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| content | string | | No |
|
||||
| hash | string | | No |
|
||||
| kind | [SkillFileKind](#skillfilekind) | | No |
|
||||
| mime_type | string | | No |
|
||||
| path | string | | Yes |
|
||||
| size | integer | | No |
|
||||
| storage | [SkillFileStorage](#skillfilestorage) | | No |
|
||||
| tool_file_id | string | | No |
|
||||
|
||||
#### SkillDraftTreePayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| expected_updated_at | integer | | No |
|
||||
| files | [ [SkillDraftTreeItemPayload](#skilldrafttreeitempayload) ] | | No |
|
||||
|
||||
#### SkillFileKind
|
||||
|
||||
Draft file entry kind.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| SkillFileKind | string | Draft file entry kind. | |
|
||||
|
||||
#### SkillFilePreviewResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| content | string | | Yes |
|
||||
| hash | string | | Yes |
|
||||
| mime_type | string | | Yes |
|
||||
| path | string | | Yes |
|
||||
| size | integer | | Yes |
|
||||
|
||||
#### SkillFileQuery
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| path | string | Skill file path relative to the Skill root. | Yes |
|
||||
| version_id | string | Optional published version ID. Omit for current draft. | No |
|
||||
|
||||
#### SkillFileResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| content | string | | No |
|
||||
| hash | string | | No |
|
||||
| id | string | | No |
|
||||
| kind | string | | Yes |
|
||||
| mime_type | string | | No |
|
||||
| path | string | | Yes |
|
||||
| size | integer | | No |
|
||||
| storage | string | | No |
|
||||
| tool_file_id | string | | No |
|
||||
|
||||
#### SkillFileStorage
|
||||
|
||||
How a draft file's content is stored.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| SkillFileStorage | string | How a draft file's content is stored. | |
|
||||
|
||||
#### SkillFileUploadResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| hash | string | | Yes |
|
||||
| id | string | | Yes |
|
||||
| mime_type | string | | Yes |
|
||||
| name | string | | Yes |
|
||||
| size | integer | | Yes |
|
||||
|
||||
#### SkillListResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| data | [ [SkillResponse](#skillresponse) ] | | No |
|
||||
| has_more | boolean | | No |
|
||||
| limit | integer, <br>**Default:** 20 | | No |
|
||||
| page | integer, <br>**Default:** 1 | | No |
|
||||
| total | integer | | No |
|
||||
|
||||
#### SkillManifest
|
||||
|
||||
Validated metadata extracted from a Skill package.
|
||||
@@ -21678,6 +22256,91 @@ Validated metadata extracted from a Skill package.
|
||||
| name | string | | Yes |
|
||||
| size | integer | | Yes |
|
||||
|
||||
#### SkillMetadataPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| display_name | string | | No |
|
||||
| expected_updated_at | integer | | No |
|
||||
| icon | string | | No |
|
||||
| tags | [ string ] | | No |
|
||||
|
||||
#### SkillPublishPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| publish_note | string | | No |
|
||||
| version_name | string | | No |
|
||||
|
||||
#### SkillReferenceListResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| data | [ [SkillReferenceResponse](#skillreferenceresponse) ] | | No |
|
||||
|
||||
#### SkillReferenceResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| agent_icon | string | | No |
|
||||
| agent_icon_background | string | | No |
|
||||
| agent_icon_type | string | | No |
|
||||
| agent_id | string | | Yes |
|
||||
| app_id | string | | No |
|
||||
| display_name | string | | Yes |
|
||||
| name | string | | Yes |
|
||||
| node_id | string | | No |
|
||||
| node_name | string | | No |
|
||||
| type | string | | Yes |
|
||||
| workflow_icon | string | | No |
|
||||
| workflow_icon_background | string | | No |
|
||||
| workflow_icon_type | string | | No |
|
||||
| workflow_id | string | | No |
|
||||
| workflow_name | string | | No |
|
||||
| workflow_version | string | | No |
|
||||
|
||||
#### SkillResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| created_at | integer | | Yes |
|
||||
| created_by | string | | No |
|
||||
| created_by_name | string | | No |
|
||||
| description | string | | Yes |
|
||||
| display_name | string | | Yes |
|
||||
| icon | string | | Yes |
|
||||
| id | string | | Yes |
|
||||
| latest_published_version_id | string | | No |
|
||||
| name | string | | Yes |
|
||||
| name_manually_edited | boolean | | No |
|
||||
| reference_count | integer | | No |
|
||||
| tags | [ string ] | | No |
|
||||
| updated_at | integer | | Yes |
|
||||
| updated_by | string | | No |
|
||||
| updated_by_name | string | | No |
|
||||
| visibility | string | | Yes |
|
||||
|
||||
#### SkillRestorePayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| publish_note | string | | No |
|
||||
| version_id | string | | Yes |
|
||||
| version_name | string | | No |
|
||||
|
||||
#### SkillTagListResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| data | [ [SkillTagResponse](#skilltagresponse) ] | | No |
|
||||
|
||||
#### SkillTagResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| count | integer | | Yes |
|
||||
| tag | string | | Yes |
|
||||
|
||||
#### SkillToolInferenceResult
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -21686,6 +22349,60 @@ Validated metadata extracted from a Skill package.
|
||||
| inferable | boolean | | Yes |
|
||||
| reason | string | | No |
|
||||
|
||||
#### SkillVersionDeleteResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| deleted | boolean | | Yes |
|
||||
| id | string | | Yes |
|
||||
| latest_published_version_id | string | | No |
|
||||
|
||||
#### SkillVersionDetailResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| archive_size | integer | | Yes |
|
||||
| created_at | integer | | Yes |
|
||||
| files | [ [SkillFileResponse](#skillfileresponse) ] | | No |
|
||||
| hash_code | string | | Yes |
|
||||
| id | string | | Yes |
|
||||
| is_latest | boolean | | No |
|
||||
| publish_note | string | | Yes |
|
||||
| published_by | string | | No |
|
||||
| published_by_name | string | | No |
|
||||
| skill_id | string | | Yes |
|
||||
| version_name | string | | Yes |
|
||||
| version_number | integer | | Yes |
|
||||
|
||||
#### SkillVersionListResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| data | [ [SkillVersionResponse](#skillversionresponse) ] | | No |
|
||||
|
||||
#### SkillVersionResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| archive_size | integer | | Yes |
|
||||
| created_at | integer | | Yes |
|
||||
| hash_code | string | | Yes |
|
||||
| id | string | | Yes |
|
||||
| is_latest | boolean | | No |
|
||||
| publish_note | string | | Yes |
|
||||
| published_by | string | | No |
|
||||
| published_by_name | string | | No |
|
||||
| skill_id | string | | Yes |
|
||||
| version_name | string | | Yes |
|
||||
| version_number | integer | | Yes |
|
||||
|
||||
#### SkillVersionUpdatePayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| publish_note | string | | No |
|
||||
| version_name | string | | No |
|
||||
|
||||
#### SnippetDependencyCheckResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -22153,7 +22870,7 @@ Non-sensitive bootstrap snapshot exposed before Console or Web authentication.
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| keyword | string | Search keyword | No |
|
||||
| type | string, <br>**Available values:** "", "app", "knowledge", "snippet" | Tag type filter<br>*Enum:* `""`, `"app"`, `"knowledge"`, `"snippet"` | No |
|
||||
| type | [TagType](#tagtype)<br>string | Tag type filter | No |
|
||||
|
||||
#### TagListResponse
|
||||
|
||||
@@ -24339,6 +25056,15 @@ Workflow tool configuration
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| permission_keys | [ string ] | | No |
|
||||
|
||||
#### WorkspaceSkillsQuery
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| keyword | string | Search keyword matching skill name or description. | No |
|
||||
| limit | integer, <br>**Default:** 20 | Number of items per page. | No |
|
||||
| page | integer, <br>**Default:** 1 | Page number. | No |
|
||||
| tag | [ string ] | Skill tag filters. Repeat the parameter for multiple tags. | No |
|
||||
|
||||
#### WorkspaceTenantResultResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
|
||||
+21
-12
@@ -1,3 +1,5 @@
|
||||
"""Unit tests for Aliyun trace utility transformations and database lookups."""
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, cast
|
||||
@@ -25,11 +27,13 @@ from dify_trace_aliyun.utils import (
|
||||
serialize_json_data,
|
||||
)
|
||||
from opentelemetry.trace import Link, StatusCode
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.rag.models.document import Document
|
||||
from graphon.entities import WorkflowNodeExecution
|
||||
from graphon.enums import WorkflowNodeExecutionStatus
|
||||
from models import EndUser
|
||||
from models.enums import EndUserType
|
||||
|
||||
|
||||
def test_get_user_id_from_message_data_no_end_user(monkeypatch: pytest.MonkeyPatch):
|
||||
@@ -40,35 +44,40 @@ def test_get_user_id_from_message_data_no_end_user(monkeypatch: pytest.MonkeyPat
|
||||
assert get_user_id_from_message_data(message_data) == "account_id"
|
||||
|
||||
|
||||
def test_get_user_id_from_message_data_with_end_user(monkeypatch: pytest.MonkeyPatch):
|
||||
@pytest.mark.parametrize("sqlite3_session", [(EndUser,)], indirect=True)
|
||||
def test_get_user_id_from_message_data_with_end_user(monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session) -> None:
|
||||
message_data = MagicMock()
|
||||
message_data.from_account_id = "account_id"
|
||||
message_data.from_end_user_id = "end_user_id"
|
||||
|
||||
end_user_data = MagicMock(spec=EndUser)
|
||||
end_user_data.session_id = "session_id"
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.get.return_value = end_user_data
|
||||
end_user_data = EndUser(
|
||||
id="end_user_id",
|
||||
tenant_id="tenant_id",
|
||||
app_id="app_id",
|
||||
type=EndUserType.BROWSER,
|
||||
session_id="session_id",
|
||||
)
|
||||
sqlite3_session.add(end_user_data)
|
||||
sqlite3_session.commit()
|
||||
|
||||
from dify_trace_aliyun.utils import db
|
||||
|
||||
monkeypatch.setattr(db, "session", mock_session)
|
||||
monkeypatch.setattr(db, "session", sqlite3_session)
|
||||
|
||||
assert get_user_id_from_message_data(message_data) == "session_id"
|
||||
|
||||
|
||||
def test_get_user_id_from_message_data_end_user_not_found(monkeypatch: pytest.MonkeyPatch):
|
||||
@pytest.mark.parametrize("sqlite3_session", [(EndUser,)], indirect=True)
|
||||
def test_get_user_id_from_message_data_end_user_not_found(
|
||||
monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session
|
||||
) -> None:
|
||||
message_data = MagicMock()
|
||||
message_data.from_account_id = "account_id"
|
||||
message_data.from_end_user_id = "end_user_id"
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.get.return_value = None
|
||||
|
||||
from dify_trace_aliyun.utils import db
|
||||
|
||||
monkeypatch.setattr(db, "session", mock_session)
|
||||
monkeypatch.setattr(db, "session", sqlite3_session)
|
||||
|
||||
assert get_user_id_from_message_data(message_data) == "account_id"
|
||||
|
||||
|
||||
+67
-29
@@ -1,5 +1,8 @@
|
||||
"""Unit tests for LangSmith trace translation with SQLite-backed lookups."""
|
||||
|
||||
import collections
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from typing import override
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
@@ -11,6 +14,7 @@ from dify_trace_langsmith.entities.langsmith_trace_entity import (
|
||||
LangSmithRunUpdateModel,
|
||||
)
|
||||
from dify_trace_langsmith.langsmith_trace import LangSmithDataTrace
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.ops.entities.trace_entity import (
|
||||
DatasetRetrievalTraceInfo,
|
||||
@@ -24,6 +28,7 @@ from core.ops.entities.trace_entity import (
|
||||
)
|
||||
from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey
|
||||
from models import EndUser
|
||||
from models.enums import EndUserType
|
||||
|
||||
|
||||
def _dt() -> datetime:
|
||||
@@ -108,7 +113,8 @@ def test_trace_dispatch(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
mocks["generate_name_trace"].assert_called_once_with(info)
|
||||
|
||||
|
||||
def test_workflow_trace(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
|
||||
def test_workflow_trace(trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session) -> None:
|
||||
# Setup trace info
|
||||
workflow_data = MagicMock()
|
||||
workflow_data.created_at = _dt()
|
||||
@@ -137,10 +143,10 @@ def test_workflow_trace(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
workflow_data=workflow_data,
|
||||
)
|
||||
|
||||
# Mock dependencies
|
||||
mock_session = MagicMock()
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: mock_session)
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine"))
|
||||
monkeypatch.setattr(
|
||||
"dify_trace_langsmith.langsmith_trace.db",
|
||||
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
|
||||
)
|
||||
|
||||
# Mock node executions
|
||||
node_llm = MagicMock()
|
||||
@@ -228,7 +234,10 @@ def test_workflow_trace(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
assert call_args[4].run_type == LangSmithRunType.retriever
|
||||
|
||||
|
||||
def test_workflow_trace_no_start_time(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
|
||||
def test_workflow_trace_no_start_time(
|
||||
trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session
|
||||
) -> None:
|
||||
workflow_data = MagicMock()
|
||||
workflow_data.created_at = _dt()
|
||||
workflow_data.finished_at = _dt() + timedelta(seconds=1)
|
||||
@@ -256,9 +265,10 @@ def test_workflow_trace_no_start_time(trace_instance, monkeypatch: pytest.Monkey
|
||||
workflow_data=workflow_data,
|
||||
)
|
||||
|
||||
mock_session = MagicMock()
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: mock_session)
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine"))
|
||||
monkeypatch.setattr(
|
||||
"dify_trace_langsmith.langsmith_trace.db",
|
||||
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
|
||||
)
|
||||
repo = MagicMock()
|
||||
repo.get_by_workflow_execution.return_value = []
|
||||
mock_factory = MagicMock()
|
||||
@@ -271,7 +281,10 @@ def test_workflow_trace_no_start_time(trace_instance, monkeypatch: pytest.Monkey
|
||||
assert trace_instance.add_run.called
|
||||
|
||||
|
||||
def test_workflow_trace_missing_app_id(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
|
||||
def test_workflow_trace_missing_app_id(
|
||||
trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session
|
||||
) -> None:
|
||||
trace_info = MagicMock(spec=WorkflowTraceInfo)
|
||||
trace_info.trace_id = "trace-1"
|
||||
trace_info.message_id = None
|
||||
@@ -287,15 +300,17 @@ def test_workflow_trace_missing_app_id(trace_instance, monkeypatch: pytest.Monke
|
||||
trace_info.workflow_run_outputs = {}
|
||||
trace_info.error = ""
|
||||
|
||||
mock_session = MagicMock()
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: mock_session)
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine"))
|
||||
monkeypatch.setattr(
|
||||
"dify_trace_langsmith.langsmith_trace.db",
|
||||
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="No app_id found in trace_info metadata"):
|
||||
trace_instance.workflow_trace(trace_info)
|
||||
|
||||
|
||||
def test_message_trace(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
@pytest.mark.parametrize("sqlite3_session", [(EndUser,)], indirect=True)
|
||||
def test_message_trace(trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session) -> None:
|
||||
message_data = MagicMock()
|
||||
message_data.id = "msg-1"
|
||||
message_data.from_account_id = "acc-1"
|
||||
@@ -321,10 +336,19 @@ def test_message_trace(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
message_file_data=MagicMock(url="file-url"),
|
||||
)
|
||||
|
||||
# Mock EndUser lookup
|
||||
mock_end_user = MagicMock(spec=EndUser)
|
||||
mock_end_user.session_id = "session-id-123"
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db.session.get", lambda model, pk: mock_end_user)
|
||||
end_user = EndUser(
|
||||
id="end-user-1",
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
type=EndUserType.BROWSER,
|
||||
session_id="session-id-123",
|
||||
)
|
||||
sqlite3_session.add(end_user)
|
||||
sqlite3_session.commit()
|
||||
monkeypatch.setattr(
|
||||
"dify_trace_langsmith.langsmith_trace.db",
|
||||
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
|
||||
)
|
||||
|
||||
trace_instance.add_run = MagicMock()
|
||||
|
||||
@@ -521,9 +545,13 @@ def test_update_run_error(trace_instance):
|
||||
trace_instance.update_run(update_data)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
|
||||
def test_workflow_trace_usage_extraction_error(
|
||||
trace_instance, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
):
|
||||
trace_instance,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
sqlite3_session: Session,
|
||||
) -> None:
|
||||
workflow_data = MagicMock()
|
||||
workflow_data.created_at = _dt()
|
||||
workflow_data.finished_at = _dt() + timedelta(seconds=1)
|
||||
@@ -576,8 +604,10 @@ def test_workflow_trace_usage_extraction_error(
|
||||
mock_factory = MagicMock()
|
||||
mock_factory.create_workflow_node_execution_repository.return_value = repo
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.DifyCoreRepositoryFactory", mock_factory)
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: MagicMock())
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine"))
|
||||
monkeypatch.setattr(
|
||||
"dify_trace_langsmith.langsmith_trace.db",
|
||||
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
|
||||
)
|
||||
monkeypatch.setattr(trace_instance, "get_service_account_with_tenant", lambda app_id: MagicMock())
|
||||
|
||||
trace_instance.add_run = MagicMock()
|
||||
@@ -644,9 +674,11 @@ def _make_workflow_trace_info(
|
||||
)
|
||||
|
||||
|
||||
def _patch_workflow_trace_deps(monkeypatch, trace_instance):
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: MagicMock())
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine"))
|
||||
def _patch_workflow_trace_deps(monkeypatch, trace_instance, sqlite3_session: Session) -> None:
|
||||
monkeypatch.setattr(
|
||||
"dify_trace_langsmith.langsmith_trace.db",
|
||||
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
|
||||
)
|
||||
repo = MagicMock()
|
||||
repo.get_by_workflow_execution.return_value = []
|
||||
factory = MagicMock()
|
||||
@@ -656,14 +688,17 @@ def _patch_workflow_trace_deps(monkeypatch, trace_instance):
|
||||
trace_instance.add_run = MagicMock()
|
||||
|
||||
|
||||
def test_workflow_trace_id_uses_message_id_not_external(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
|
||||
def test_workflow_trace_id_uses_message_id_not_external(
|
||||
trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session
|
||||
) -> None:
|
||||
"""Chatflow with external trace_id: LangSmith trace_id must be message_id, not external."""
|
||||
trace_info = _make_workflow_trace_info(
|
||||
message_id="msg-abc",
|
||||
workflow_run_id="run-xyz",
|
||||
trace_id="external-999",
|
||||
)
|
||||
_patch_workflow_trace_deps(monkeypatch, trace_instance)
|
||||
_patch_workflow_trace_deps(monkeypatch, trace_instance, sqlite3_session)
|
||||
|
||||
trace_instance.workflow_trace(trace_info)
|
||||
|
||||
@@ -677,14 +712,17 @@ def test_workflow_trace_id_uses_message_id_not_external(trace_instance, monkeypa
|
||||
assert trace_info.metadata.get("external_trace_id") == "external-999"
|
||||
|
||||
|
||||
def test_workflow_trace_id_pure_workflow_uses_run_id(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
|
||||
def test_workflow_trace_id_pure_workflow_uses_run_id(
|
||||
trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session
|
||||
) -> None:
|
||||
"""Pure workflow (no message_id) with external trace_id: trace_id must be workflow_run_id."""
|
||||
trace_info = _make_workflow_trace_info(
|
||||
message_id=None,
|
||||
workflow_run_id="run-xyz",
|
||||
trace_id="external-999",
|
||||
)
|
||||
_patch_workflow_trace_deps(monkeypatch, trace_instance)
|
||||
_patch_workflow_trace_deps(monkeypatch, trace_instance, sqlite3_session)
|
||||
|
||||
trace_instance.workflow_trace(trace_info)
|
||||
|
||||
|
||||
@@ -1599,7 +1599,9 @@ class TenantService:
|
||||
return updated_accounts
|
||||
|
||||
@staticmethod
|
||||
def iter_member_account_id_batches(tenant_id: str, batch_size: int, *, session: Session) -> Iterator[list[str]]:
|
||||
def iter_member_account_id_batches(
|
||||
tenant_id: str, batch_size: int, *, session: Session
|
||||
) -> Iterator[list[str]]:
|
||||
"""Yield workspace member account ids in bounded, ordered batches."""
|
||||
offset = 0
|
||||
while True:
|
||||
|
||||
@@ -19,12 +19,11 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import io
|
||||
import posixpath
|
||||
import re
|
||||
import zipfile
|
||||
import zlib
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field, ValidationError, field_validator
|
||||
|
||||
# Bounds — generous but finite so a hostile upload can't exhaust memory/disk.
|
||||
_MAX_ARCHIVE_BYTES = 50 * 1024 * 1024
|
||||
@@ -33,7 +32,8 @@ _MAX_SKILL_MD_BYTES = 1 * 1024 * 1024
|
||||
_MAX_ENTRIES = 5000
|
||||
_ALLOWED_EXTENSIONS = (".zip", ".skill")
|
||||
_SKILL_MD_NAME = "SKILL.md"
|
||||
_HEADING_RE = re.compile(r"^\s*#\s+(.+?)\s*$", re.MULTILINE)
|
||||
_SKILL_NAME_PATTERN = r"^[a-z0-9]+(?:-[a-z0-9]+)*$"
|
||||
_MAX_SKILL_DESCRIPTION_LENGTH = 1024
|
||||
|
||||
|
||||
class SkillPackageError(Exception):
|
||||
@@ -53,13 +53,18 @@ class SkillPackageError(Exception):
|
||||
class SkillManifest(BaseModel):
|
||||
"""Validated metadata extracted from a Skill package."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
name: str = Field(min_length=1, max_length=64, pattern=_SKILL_NAME_PATTERN)
|
||||
description: str = Field(min_length=1, max_length=_MAX_SKILL_DESCRIPTION_LENGTH)
|
||||
entry_path: str # path of SKILL.md inside the archive
|
||||
files: list[str] # all (safe) file paths inside the archive
|
||||
size: int # total uncompressed bytes
|
||||
hash: str # sha256 of the archive bytes
|
||||
|
||||
@field_validator("name", "description", mode="before")
|
||||
@classmethod
|
||||
def _strip_required_string(cls, value: object) -> object:
|
||||
return value.strip() if isinstance(value, str) else value
|
||||
|
||||
|
||||
class NormalizedSkillPackage(BaseModel):
|
||||
"""Canonical skill package bytes and metadata ready to store in agent drive."""
|
||||
@@ -108,14 +113,17 @@ class SkillPackageService:
|
||||
normalized_size = sum(max(info.file_size, 0) for info in normalized_members.values())
|
||||
|
||||
name, description = self._parse_skill_md(skill_md)
|
||||
manifest = SkillManifest(
|
||||
name=name,
|
||||
description=description,
|
||||
entry_path=_SKILL_MD_NAME,
|
||||
files=sorted(normalized_members),
|
||||
size=normalized_size,
|
||||
hash=hashlib.sha256(normalized_archive_bytes).hexdigest(),
|
||||
)
|
||||
try:
|
||||
manifest = SkillManifest(
|
||||
name=name,
|
||||
description=description,
|
||||
entry_path=_SKILL_MD_NAME,
|
||||
files=sorted(normalized_members),
|
||||
size=normalized_size,
|
||||
hash=hashlib.sha256(normalized_archive_bytes).hexdigest(),
|
||||
)
|
||||
except ValidationError as exc:
|
||||
raise self._manifest_validation_error(exc) from exc
|
||||
return NormalizedSkillPackage(
|
||||
manifest=manifest,
|
||||
archive_bytes=normalized_archive_bytes,
|
||||
@@ -123,6 +131,31 @@ class SkillPackageService:
|
||||
strip_prefix=strip_prefix,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _manifest_validation_error(exc: ValidationError) -> SkillPackageError:
|
||||
first_error = exc.errors()[0]
|
||||
loc = first_error["loc"]
|
||||
field = loc[0] if loc else "manifest"
|
||||
error_type = first_error["type"]
|
||||
if field == "name":
|
||||
code = "missing_skill_name" if error_type == "string_too_short" else "invalid_skill_name"
|
||||
message = (
|
||||
"SKILL.md frontmatter name is required"
|
||||
if code == "missing_skill_name"
|
||||
else "SKILL.md frontmatter name must be lowercase letters, numbers, and hyphens only, "
|
||||
"must not start or end with a hyphen, and must be at most 64 characters"
|
||||
)
|
||||
return SkillPackageError(code, message, status_code=400)
|
||||
if field == "description":
|
||||
code = "missing_skill_description" if error_type == "string_too_short" else "invalid_skill_description"
|
||||
message = (
|
||||
"SKILL.md frontmatter description is required"
|
||||
if code == "missing_skill_description"
|
||||
else f"SKILL.md frontmatter description must be at most {_MAX_SKILL_DESCRIPTION_LENGTH} characters"
|
||||
)
|
||||
return SkillPackageError(code, message, status_code=400)
|
||||
return SkillPackageError("invalid_skill_manifest", "SKILL.md frontmatter is invalid", status_code=400)
|
||||
|
||||
def _open_archive(self, *, content: bytes, filename: str) -> zipfile.ZipFile:
|
||||
self._check_extension(filename)
|
||||
if not content:
|
||||
@@ -280,13 +313,6 @@ class SkillPackageService:
|
||||
frontmatter = cls._parse_frontmatter(content)
|
||||
name = str(frontmatter.get("name") or "").strip()
|
||||
description = str(frontmatter.get("description") or "").strip()
|
||||
if not name:
|
||||
heading = _HEADING_RE.search(content)
|
||||
name = heading.group(1).strip() if heading else ""
|
||||
if not name:
|
||||
raise SkillPackageError(
|
||||
"missing_skill_name", "SKILL.md must declare a name (frontmatter or top heading)", status_code=400
|
||||
)
|
||||
return name, description
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -46,6 +46,7 @@ from models.tools import ToolFile
|
||||
from services.agent.config_skill_normalize_service import ConfigSkillNormalizeService
|
||||
from services.agent.skill_package_service import SkillPackageError
|
||||
from services.agent_drive_service import DriveFileRef
|
||||
from services.skill_management_service import SkillManagementService, SkillManagementServiceError
|
||||
|
||||
|
||||
class AgentConfigVersionKind(StrEnum):
|
||||
@@ -98,6 +99,7 @@ class ConfigPushPayload(BaseModel):
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AgentConfigTarget:
|
||||
tenant_id: str
|
||||
agent_id: str
|
||||
version_id: str
|
||||
kind: AgentConfigVersionKind
|
||||
@@ -146,6 +148,7 @@ class AgentConfigService:
|
||||
user_id=user_id,
|
||||
)
|
||||
return AgentConfigTarget(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=target.agent_id,
|
||||
version_id=target.version_id,
|
||||
kind=target.kind,
|
||||
@@ -191,7 +194,7 @@ class AgentConfigService:
|
||||
return {
|
||||
"agent_id": target.agent_id,
|
||||
"config_version": self._config_version_payload(target),
|
||||
"items": [self._serialize_skill_item(skill) for skill in target.agent_soul.config_skills],
|
||||
"items": self._skill_items_for_target(target),
|
||||
}
|
||||
|
||||
def list_files(
|
||||
@@ -233,10 +236,27 @@ class AgentConfigService:
|
||||
config_version_kind=config_version_kind,
|
||||
user_id=user_id,
|
||||
)
|
||||
skill = self._require_skill(target.agent_soul, name=name)
|
||||
file_id = self._available_skill_file_id(skill)
|
||||
payload, mime_type = self._load_tool_file_bytes(tenant_id=tenant_id, file_id=file_id)
|
||||
return ConfigDownload(filename=f"{skill.name}.zip", mime_type=mime_type or "application/zip", payload=payload)
|
||||
try:
|
||||
skill = self._require_skill(target.agent_soul, name=name)
|
||||
file_id = self._available_skill_file_id(skill)
|
||||
payload, mime_type = self._load_tool_file_bytes(tenant_id=tenant_id, file_id=file_id)
|
||||
return ConfigDownload(
|
||||
filename=f"{skill.name}.zip",
|
||||
mime_type=mime_type or "application/zip",
|
||||
payload=payload,
|
||||
)
|
||||
except AgentConfigServiceError as exc:
|
||||
if exc.code != "config_skill_not_found":
|
||||
raise
|
||||
try:
|
||||
result = SkillManagementService().pull_runtime_agent_skill(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
name=name,
|
||||
)
|
||||
return ConfigDownload(filename=result.filename, mime_type=result.mime_type, payload=result.payload)
|
||||
except SkillManagementServiceError as exc:
|
||||
raise AgentConfigServiceError("config_skill_not_found", "config skill not found", status_code=404) from exc
|
||||
|
||||
def download_skill_url(
|
||||
self,
|
||||
@@ -279,9 +299,45 @@ class AgentConfigService:
|
||||
config_version_kind=config_version_kind,
|
||||
user_id=user_id,
|
||||
)
|
||||
skill = self._require_skill(target.agent_soul, name=name)
|
||||
file_id = self._available_skill_file_id(skill)
|
||||
archive_bytes, _mime_type = self._load_tool_file_bytes(tenant_id=tenant_id, file_id=file_id)
|
||||
try:
|
||||
skill = self._require_skill(target.agent_soul, name=name)
|
||||
file_id = self._available_skill_file_id(skill)
|
||||
archive_bytes, _mime_type = self._load_tool_file_bytes(tenant_id=tenant_id, file_id=file_id)
|
||||
skill_item = self._serialize_skill_item(skill)
|
||||
except AgentConfigServiceError as exc:
|
||||
if exc.code != "config_skill_not_found":
|
||||
raise
|
||||
try:
|
||||
workspace_archive = SkillManagementService().pull_runtime_agent_skill(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
name=name,
|
||||
)
|
||||
except SkillManagementServiceError as skill_exc:
|
||||
raise AgentConfigServiceError(
|
||||
"config_skill_not_found",
|
||||
"config skill not found",
|
||||
status_code=404,
|
||||
) from skill_exc
|
||||
archive_bytes = workspace_archive.payload
|
||||
skill_item = next(
|
||||
(
|
||||
item
|
||||
for item in SkillManagementService().list_runtime_agent_skills(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
)
|
||||
if item["name"] == name
|
||||
),
|
||||
{
|
||||
"id": name,
|
||||
"name": name,
|
||||
"description": "",
|
||||
"size": None,
|
||||
"hash": None,
|
||||
"mime_type": "application/zip",
|
||||
},
|
||||
)
|
||||
try:
|
||||
archive_items, skill_md = self._inspect_skill_archive(archive_bytes)
|
||||
except (OSError, ValueError, zipfile.BadZipFile) as exc:
|
||||
@@ -291,7 +347,7 @@ class AgentConfigService:
|
||||
status_code=500,
|
||||
) from exc
|
||||
return {
|
||||
**self._serialize_skill_item(skill),
|
||||
**skill_item,
|
||||
"source": "config_skill_zip",
|
||||
"files": archive_items,
|
||||
"skill_md": skill_md,
|
||||
@@ -839,6 +895,7 @@ class AgentConfigService:
|
||||
status_code=404,
|
||||
)
|
||||
return AgentConfigTarget(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
version_id=version.id,
|
||||
kind=config_version_kind,
|
||||
@@ -1133,9 +1190,7 @@ class AgentConfigService:
|
||||
return {
|
||||
"agent_id": target.agent_id,
|
||||
"config_version": AgentConfigService._config_version_payload(target),
|
||||
"skills": {
|
||||
"items": [AgentConfigService._serialize_skill_item(skill) for skill in target.agent_soul.config_skills]
|
||||
},
|
||||
"skills": {"items": AgentConfigService._skill_items_for_target(target)},
|
||||
"files": {
|
||||
"items": [
|
||||
AgentConfigService._serialize_file_item(file_ref) for file_ref in target.agent_soul.config_files
|
||||
@@ -1145,6 +1200,20 @@ class AgentConfigService:
|
||||
"note": target.agent_soul.config_note,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _skill_items_for_target(target: AgentConfigTarget) -> list[dict[str, object]]:
|
||||
items = [AgentConfigService._serialize_skill_item(skill) for skill in target.agent_soul.config_skills]
|
||||
seen_names = {str(item["name"]) for item in items}
|
||||
for item in SkillManagementService().list_runtime_agent_skills(
|
||||
tenant_id=target.tenant_id,
|
||||
agent_id=target.agent_id,
|
||||
):
|
||||
if item["name"] in seen_names:
|
||||
continue
|
||||
seen_names.add(str(item["name"]))
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
@staticmethod
|
||||
def _config_version_payload(target: AgentConfigTarget) -> dict[str, object]:
|
||||
return {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ from werkzeug.exceptions import NotFound
|
||||
from models.dataset import Dataset
|
||||
from models.enums import TagType
|
||||
from models.model import App, Tag, TagBinding
|
||||
from models.skill import Skill
|
||||
from models.snippet import CustomizedSnippet
|
||||
|
||||
type _TagTypeLike = TagType | str
|
||||
@@ -282,5 +283,13 @@ class TagService:
|
||||
)
|
||||
if not snippet:
|
||||
raise NotFound("Snippet not found")
|
||||
elif type == "skill":
|
||||
skill = session.scalar(
|
||||
select(Skill)
|
||||
.where(Skill.tenant_id == current_user.current_tenant_id, Skill.id == target_id)
|
||||
.limit(1)
|
||||
)
|
||||
if not skill:
|
||||
raise NotFound("Skill not found")
|
||||
else:
|
||||
raise NotFound("Invalid binding type")
|
||||
|
||||
@@ -3,7 +3,6 @@ from __future__ import annotations
|
||||
import json
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@@ -12,13 +11,13 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from graphon.enums import WorkflowExecutionStatus
|
||||
from models import EndUser, Workflow, WorkflowAppLog, WorkflowArchiveLog, WorkflowRun
|
||||
from models.enums import AppTriggerType, CreatorUserRole, EndUserType, WorkflowRunTriggeredFrom
|
||||
from models.enums import CreatorUserRole, EndUserType, WorkflowRunTriggeredFrom
|
||||
from models.workflow import WorkflowAppLogCreatedFrom
|
||||
from services.account_service import AccountService, TenantService
|
||||
|
||||
# Delay import of AppService to avoid circular dependency
|
||||
# from services.app_service import AppService, CreateAppParams
|
||||
from services.workflow_app_service import LogView, WorkflowAppService
|
||||
from services.workflow_app_service import WorkflowAppService
|
||||
from tests.test_containers_integration_tests.helpers import generate_valid_password
|
||||
|
||||
|
||||
@@ -1627,73 +1626,3 @@ class TestWorkflowAppService:
|
||||
end_user_item = next(d for d in result["data"] if d["created_by_end_user"] is not None)
|
||||
assert account_item["created_by_account"].id == account.id
|
||||
assert end_user_item["created_by_end_user"].id == end_user.id
|
||||
|
||||
|
||||
class TestLogView:
|
||||
def test_details_and_proxy_attributes(self):
|
||||
log = SimpleNamespace(id="log-1", status="succeeded")
|
||||
view = LogView(log=log, details={"trigger_metadata": {"type": "plugin"}})
|
||||
|
||||
assert view.details == {"trigger_metadata": {"type": "plugin"}}
|
||||
assert view.status == "succeeded"
|
||||
|
||||
|
||||
class TestHandleTriggerMetadata:
|
||||
def test_returns_empty_dict_when_metadata_missing(self):
|
||||
service = WorkflowAppService()
|
||||
assert service.handle_trigger_metadata("tenant-1", None) == {}
|
||||
|
||||
def test_enriches_plugin_icons(self):
|
||||
service = WorkflowAppService()
|
||||
meta = {
|
||||
"type": AppTriggerType.TRIGGER_PLUGIN.value,
|
||||
"icon_filename": "light.png",
|
||||
"icon_dark_filename": "dark.png",
|
||||
}
|
||||
with patch(
|
||||
"services.workflow_app_service.PluginService.get_plugin_icon_url",
|
||||
side_effect=["https://cdn/light.png", "https://cdn/dark.png"],
|
||||
) as mock_icon:
|
||||
result = service.handle_trigger_metadata("tenant-1", json.dumps(meta))
|
||||
|
||||
assert result["icon"] == "https://cdn/light.png"
|
||||
assert result["icon_dark"] == "https://cdn/dark.png"
|
||||
assert mock_icon.call_count == 2
|
||||
|
||||
def test_non_plugin_metadata_without_icon_lookup(self):
|
||||
service = WorkflowAppService()
|
||||
meta = {"type": AppTriggerType.TRIGGER_WEBHOOK.value}
|
||||
with patch("services.workflow_app_service.PluginService.get_plugin_icon_url") as mock_icon:
|
||||
result = service.handle_trigger_metadata("tenant-1", json.dumps(meta))
|
||||
|
||||
assert result["type"] == AppTriggerType.TRIGGER_WEBHOOK.value
|
||||
mock_icon.assert_not_called()
|
||||
|
||||
|
||||
class TestSafeJsonLoads:
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
(None, None),
|
||||
("", None),
|
||||
('{"k":"v"}', {"k": "v"}),
|
||||
("not-json", None),
|
||||
({"raw": True}, {"raw": True}),
|
||||
],
|
||||
)
|
||||
def test_handles_various_inputs(self, value, expected):
|
||||
assert WorkflowAppService._safe_json_loads(value) == expected
|
||||
|
||||
|
||||
class TestSafeParseUuid:
|
||||
def test_returns_none_for_short_or_invalid_values(self):
|
||||
service = WorkflowAppService()
|
||||
assert service._safe_parse_uuid("short") is None
|
||||
assert service._safe_parse_uuid("x" * 40) is None
|
||||
|
||||
def test_returns_uuid_for_valid_string(self):
|
||||
service = WorkflowAppService()
|
||||
raw = str(uuid.uuid4())
|
||||
result = service._safe_parse_uuid(raw)
|
||||
assert result is not None
|
||||
assert str(result) == raw
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from inspect import unwrap
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.workspace.skills import (
|
||||
WorkspaceAgentSkillBindingsApi,
|
||||
WorkspaceSkillAssistMessageApi,
|
||||
WorkspaceSkillFilesApi,
|
||||
WorkspaceSkillsApi,
|
||||
WorkspaceSkillTagsApi,
|
||||
WorkspaceSkillVersionApi,
|
||||
)
|
||||
from models.account import Account
|
||||
from services.skill_management_service import SkillAssistAttachmentPayload, SkillManagementServiceError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app() -> Flask:
|
||||
flask_app = Flask("test_workspace_skills")
|
||||
flask_app.config["TESTING"] = True
|
||||
return flask_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def current_user() -> Account:
|
||||
user = Account(name="Test User", email="test@example.com")
|
||||
user.id = "user-1"
|
||||
return user
|
||||
|
||||
|
||||
def _skill_detail() -> dict:
|
||||
return {
|
||||
"id": "skill-1",
|
||||
"name": "finance-sop",
|
||||
"display_name": "Finance SOP",
|
||||
"icon": "📄",
|
||||
"description": "",
|
||||
"tags": [],
|
||||
"name_manually_edited": False,
|
||||
"visibility": "workspace",
|
||||
"latest_published_version_id": None,
|
||||
"reference_count": 0,
|
||||
"created_by": "user-1",
|
||||
"created_by_name": "Test User",
|
||||
"updated_by": "user-1",
|
||||
"updated_by_name": "Test User",
|
||||
"created_at": 1,
|
||||
"updated_at": 1,
|
||||
"files": [
|
||||
{
|
||||
"id": "file-1",
|
||||
"path": "SKILL.md",
|
||||
"kind": "file",
|
||||
"storage": "text",
|
||||
"mime_type": "text/markdown",
|
||||
"content": "---\nname: finance-sop\n---\n# Body",
|
||||
"tool_file_id": None,
|
||||
"size": 32,
|
||||
"hash": "hash",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_create_skill_validates_payload_and_returns_detail(app: Flask, current_user: Account) -> None:
|
||||
api = WorkspaceSkillsApi()
|
||||
method = unwrap(api.post)
|
||||
service = MagicMock()
|
||||
service.create_skill.return_value = _skill_detail()
|
||||
|
||||
with (
|
||||
app.test_request_context("/", method="POST"),
|
||||
patch.object(type(console_ns), "payload", new_callable=PropertyMock, return_value={}),
|
||||
patch("controllers.console.workspace.skills.SkillManagementService", return_value=service),
|
||||
):
|
||||
payload, status = method(api, "tenant-1", current_user)
|
||||
|
||||
assert status == 201
|
||||
assert payload["id"] == "skill-1"
|
||||
assert payload["files"][0]["path"] == "SKILL.md"
|
||||
service.create_skill.assert_called_once()
|
||||
assert service.create_skill.call_args.kwargs["tenant_id"] == "tenant-1"
|
||||
assert service.create_skill.call_args.kwargs["user_id"] == "user-1"
|
||||
|
||||
|
||||
def test_list_skills_uses_default_pagination_when_query_omits_page_and_limit(app: Flask) -> None:
|
||||
api = WorkspaceSkillsApi()
|
||||
method = unwrap(api.get)
|
||||
service = MagicMock()
|
||||
service.list_skills.return_value = {
|
||||
"data": [],
|
||||
"has_more": False,
|
||||
"limit": 20,
|
||||
"page": 1,
|
||||
"total": 0,
|
||||
}
|
||||
|
||||
with (
|
||||
app.test_request_context("/?keyword=finance&tag=ops&tag=", method="GET"),
|
||||
patch("controllers.console.workspace.skills.SkillManagementService", return_value=service),
|
||||
):
|
||||
payload = method(api, "tenant-1")
|
||||
|
||||
assert payload == {
|
||||
"data": [],
|
||||
"has_more": False,
|
||||
"limit": 20,
|
||||
"page": 1,
|
||||
"total": 0,
|
||||
}
|
||||
service.list_skills.assert_called_once_with(
|
||||
tenant_id="tenant-1",
|
||||
keyword="finance",
|
||||
page=1,
|
||||
limit=20,
|
||||
tags=["ops"],
|
||||
)
|
||||
|
||||
|
||||
def test_get_agent_skill_bindings_returns_card_data(app: Flask) -> None:
|
||||
api = WorkspaceAgentSkillBindingsApi()
|
||||
method = unwrap(api.get)
|
||||
service = MagicMock()
|
||||
service.list_agent_bindings.return_value = {
|
||||
"agent_id": "agent-1",
|
||||
"skill_ids": ["skill-1"],
|
||||
"data": [
|
||||
{
|
||||
"id": "skill-1",
|
||||
"priority": 0,
|
||||
"name": "finance-sop",
|
||||
"display_name": "Finance SOP",
|
||||
"icon": "📄",
|
||||
"description": "Handle finance.",
|
||||
"tags": ["Finance"],
|
||||
"status": "published",
|
||||
"file_count": 2,
|
||||
"latest_published_version_id": "version-1",
|
||||
"latest_published_at": 123,
|
||||
"updated_at": 124,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
with (
|
||||
app.test_request_context("/", method="GET"),
|
||||
patch("controllers.console.workspace.skills.SkillManagementService", return_value=service),
|
||||
):
|
||||
payload = method(api, "tenant-1", "agent-1")
|
||||
|
||||
assert payload["skill_ids"] == ["skill-1"]
|
||||
assert payload["data"][0]["display_name"] == "Finance SOP"
|
||||
assert payload["data"][0]["file_count"] == 2
|
||||
service.list_agent_bindings.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1")
|
||||
|
||||
|
||||
def test_patch_skill_file_operation_validates_payload_and_returns_detail(app: Flask, current_user: Account) -> None:
|
||||
api = WorkspaceSkillFilesApi()
|
||||
method = unwrap(api.patch)
|
||||
service = MagicMock()
|
||||
service.apply_draft_file_operation.return_value = _skill_detail()
|
||||
request_payload = {
|
||||
"operation": "upsert_text",
|
||||
"path": "references/policy.md",
|
||||
"content": "Policy",
|
||||
}
|
||||
|
||||
with (
|
||||
app.test_request_context("/", method="PATCH"),
|
||||
patch.object(type(console_ns), "payload", new_callable=PropertyMock, return_value=request_payload),
|
||||
patch("controllers.console.workspace.skills.SkillManagementService", return_value=service),
|
||||
):
|
||||
payload = method(api, "tenant-1", current_user, "skill-1")
|
||||
|
||||
assert payload["id"] == "skill-1"
|
||||
service.apply_draft_file_operation.assert_called_once()
|
||||
call = service.apply_draft_file_operation.call_args.kwargs
|
||||
assert call["tenant_id"] == "tenant-1"
|
||||
assert call["user_id"] == "user-1"
|
||||
assert call["skill_id"] == "skill-1"
|
||||
assert call["payload"].operation == "upsert_text"
|
||||
|
||||
|
||||
def test_patch_skill_file_operation_returns_error_details(app: Flask, current_user: Account) -> None:
|
||||
api = WorkspaceSkillFilesApi()
|
||||
method = unwrap(api.patch)
|
||||
service = MagicMock()
|
||||
service.apply_draft_file_operation.side_effect = SkillManagementServiceError(
|
||||
"missing_skill_name",
|
||||
"SKILL.md frontmatter name is required",
|
||||
details={"path": "SKILL.md", "field": "name", "line": 2},
|
||||
)
|
||||
|
||||
with (
|
||||
app.test_request_context("/", method="PATCH"),
|
||||
patch.object(
|
||||
type(console_ns),
|
||||
"payload",
|
||||
new_callable=PropertyMock,
|
||||
return_value={"operation": "delete", "path": "SKILL.md"},
|
||||
),
|
||||
patch("controllers.console.workspace.skills.SkillManagementService", return_value=service),
|
||||
):
|
||||
payload, status = method(api, "tenant-1", current_user, "skill-1")
|
||||
|
||||
assert status == 400
|
||||
assert payload == {
|
||||
"code": "missing_skill_name",
|
||||
"message": "SKILL.md frontmatter name is required",
|
||||
"details": {"path": "SKILL.md", "field": "name", "line": 2},
|
||||
}
|
||||
|
||||
|
||||
def test_list_skill_tags_returns_filter_options(app: Flask) -> None:
|
||||
api = WorkspaceSkillTagsApi()
|
||||
method = unwrap(api.get)
|
||||
service = MagicMock()
|
||||
service.list_tags.return_value = {"data": [{"tag": "finance", "count": 2}]}
|
||||
|
||||
with (
|
||||
app.test_request_context("/", method="GET"),
|
||||
patch("controllers.console.workspace.skills.SkillManagementService", return_value=service),
|
||||
):
|
||||
payload = method(api, "tenant-1")
|
||||
|
||||
assert payload == {"data": [{"tag": "finance", "count": 2}]}
|
||||
service.list_tags.assert_called_once_with(tenant_id="tenant-1")
|
||||
|
||||
|
||||
def test_get_skill_version_returns_version_detail(app: Flask) -> None:
|
||||
api = WorkspaceSkillVersionApi()
|
||||
method = unwrap(api.get)
|
||||
service = MagicMock()
|
||||
service.get_version.return_value = {
|
||||
"id": "version-1",
|
||||
"skill_id": "skill-1",
|
||||
"version_number": 1,
|
||||
"version_name": "Initial finance policy",
|
||||
"publish_note": "Initial finance policy",
|
||||
"hash_code": "hash-code",
|
||||
"archive_size": 123,
|
||||
"published_by": "user-1",
|
||||
"published_by_name": "Li Wei",
|
||||
"is_latest": True,
|
||||
"created_at": 1,
|
||||
"files": [
|
||||
{
|
||||
"id": None,
|
||||
"path": "SKILL.md",
|
||||
"kind": "file",
|
||||
"storage": "text",
|
||||
"mime_type": "text/markdown",
|
||||
"content": "# Version",
|
||||
"tool_file_id": None,
|
||||
"size": 9,
|
||||
"hash": "file-hash",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
with (
|
||||
app.test_request_context("/", method="GET"),
|
||||
patch("controllers.console.workspace.skills.SkillManagementService", return_value=service),
|
||||
):
|
||||
payload = method(api, "tenant-1", "skill-1", "version-1")
|
||||
|
||||
assert payload["files"][0]["content"] == "# Version"
|
||||
service.get_version.assert_called_once_with(
|
||||
tenant_id="tenant-1",
|
||||
skill_id="skill-1",
|
||||
version_id="version-1",
|
||||
)
|
||||
|
||||
|
||||
def test_patch_skill_version_renames_version(app: Flask) -> None:
|
||||
api = WorkspaceSkillVersionApi()
|
||||
method = unwrap(api.patch)
|
||||
service = MagicMock()
|
||||
service.update_version.return_value = {
|
||||
"id": "version-1",
|
||||
"skill_id": "skill-1",
|
||||
"version_number": 1,
|
||||
"version_name": "Approval threshold",
|
||||
"publish_note": "",
|
||||
"hash_code": "hash-code",
|
||||
"archive_size": 123,
|
||||
"published_by": "user-1",
|
||||
"published_by_name": "Li Wei",
|
||||
"is_latest": True,
|
||||
"created_at": 1,
|
||||
}
|
||||
|
||||
with (
|
||||
app.test_request_context("/", method="PATCH"),
|
||||
patch.object(
|
||||
type(console_ns),
|
||||
"payload",
|
||||
new_callable=PropertyMock,
|
||||
return_value={"version_name": "Approval threshold"},
|
||||
),
|
||||
patch("controllers.console.workspace.skills.SkillManagementService", return_value=service),
|
||||
):
|
||||
payload = method(api, "tenant-1", "skill-1", "version-1")
|
||||
|
||||
assert payload["version_name"] == "Approval threshold"
|
||||
service.update_version.assert_called_once()
|
||||
assert service.update_version.call_args.kwargs["payload"].version_name == "Approval threshold"
|
||||
|
||||
|
||||
def test_delete_skill_version_returns_new_latest(app: Flask, current_user: Account) -> None:
|
||||
api = WorkspaceSkillVersionApi()
|
||||
method = unwrap(api.delete)
|
||||
service = MagicMock()
|
||||
service.delete_version.return_value = {
|
||||
"id": "version-2",
|
||||
"deleted": True,
|
||||
"latest_published_version_id": "version-1",
|
||||
}
|
||||
|
||||
with (
|
||||
app.test_request_context("/", method="DELETE"),
|
||||
patch("controllers.console.workspace.skills.SkillManagementService", return_value=service),
|
||||
):
|
||||
payload = method(api, "tenant-1", current_user, "skill-1", "version-2")
|
||||
|
||||
assert payload == {"id": "version-2", "deleted": True, "latest_published_version_id": "version-1"}
|
||||
service.delete_version.assert_called_once_with(
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
skill_id="skill-1",
|
||||
version_id="version-2",
|
||||
)
|
||||
|
||||
|
||||
def test_skill_assistant_runs_agent_app_stream(app: Flask, current_user: Account) -> None:
|
||||
api = WorkspaceSkillAssistMessageApi()
|
||||
method = unwrap(api.post)
|
||||
service = MagicMock()
|
||||
assistant_app = MagicMock()
|
||||
assistant_app.id = "assistant-app-1"
|
||||
service.get_or_create_assistant_app.return_value = (assistant_app, "<skill_draft>draft</skill_draft>")
|
||||
app_model = MagicMock()
|
||||
app_response = MagicMock()
|
||||
compact_response = MagicMock()
|
||||
|
||||
with (
|
||||
app.test_request_context("/", method="POST"),
|
||||
patch.object(
|
||||
type(console_ns),
|
||||
"payload",
|
||||
new_callable=PropertyMock,
|
||||
return_value={
|
||||
"attachments": [
|
||||
{
|
||||
"tool_file_id": "tool-file-1",
|
||||
"name": "requirements.md",
|
||||
"mime_type": "text/markdown",
|
||||
"size": 128,
|
||||
}
|
||||
],
|
||||
"message": "Create an approval checklist.",
|
||||
},
|
||||
),
|
||||
patch("controllers.console.workspace.skills.SkillManagementService", return_value=service),
|
||||
patch(
|
||||
"controllers.console.workspace.skills.db.session",
|
||||
return_value=MagicMock(get=MagicMock(return_value=app_model)),
|
||||
),
|
||||
patch("controllers.console.workspace.skills.AppGenerateService.generate", return_value=app_response),
|
||||
patch("controllers.console.workspace.skills.helper.compact_generate_response", return_value=compact_response),
|
||||
):
|
||||
response = method(api, "tenant-1", current_user, "skill-1")
|
||||
|
||||
assert response is compact_response
|
||||
service.get_or_create_assistant_app.assert_called_once_with(
|
||||
tenant_id="tenant-1",
|
||||
skill_id="skill-1",
|
||||
user_id="user-1",
|
||||
attachments=[
|
||||
SkillAssistAttachmentPayload(
|
||||
tool_file_id="tool-file-1",
|
||||
name="requirements.md",
|
||||
mime_type="text/markdown",
|
||||
size=128,
|
||||
)
|
||||
],
|
||||
message="Create an approval checklist.",
|
||||
model_payload=None,
|
||||
)
|
||||
@@ -7,6 +7,7 @@ from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from dify_agent.layers.config import DifyConfigSkillConfig
|
||||
from dify_agent.layers.dify_core_tools import DifyCoreToolConfig, DifyCoreToolsLayerConfig
|
||||
from dify_agent.layers.dify_plugin import DifyPluginToolConfig, DifyPluginToolsLayerConfig
|
||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
@@ -29,6 +30,14 @@ from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_runtime_agent_skills(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.agent_app.runtime_request_builder.load_runtime_agent_skill_configs",
|
||||
lambda *, tenant_id, agent_id: [],
|
||||
)
|
||||
|
||||
|
||||
def _exec_ctx() -> DifyExecutionContextLayerConfig:
|
||||
return DifyExecutionContextLayerConfig(
|
||||
tenant_id="tenant-1",
|
||||
@@ -514,6 +523,33 @@ class TestAgentAppConfigLayer:
|
||||
"mentioned_file_names": [],
|
||||
}
|
||||
|
||||
def test_config_layer_includes_bound_workspace_skills(self, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.agent_app.runtime_request_builder.load_runtime_agent_skill_configs",
|
||||
lambda *, tenant_id, agent_id: [
|
||||
DifyConfigSkillConfig(
|
||||
name="workspace-skill",
|
||||
description="Bound workspace skill.",
|
||||
size=123,
|
||||
mime_type="application/zip",
|
||||
)
|
||||
],
|
||||
)
|
||||
soul = _soul_with_model()
|
||||
soul.prompt.system_prompt = "Use [§skill:workspace-skill:Workspace Skill§]."
|
||||
builder = AgentAppRuntimeRequestBuilder(
|
||||
credentials_provider=_FakeCredentialsProvider(),
|
||||
dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
result = builder.build(_ctx(soul))
|
||||
|
||||
config = next(layer for layer in result.request.composition.layers if layer.name == DIFY_CONFIG_LAYER_ID)
|
||||
assert [skill.name for skill in config.config.skills] == ["workspace-skill"]
|
||||
assert config.config.mentioned_skill_names == ["workspace-skill"]
|
||||
prompt_layer = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt")
|
||||
assert prompt_layer.config.prefix == "Use workspace-skill."
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("system_prompt", "expected_prefix"),
|
||||
[
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.app.app_config.entities import AppAdditionalFeatures, WorkflowUIBasedAppConfig
|
||||
from core.app.apps.workflow.generate_task_pipeline import WorkflowAppGenerateTaskPipeline
|
||||
@@ -54,6 +54,7 @@ from graphon.runtime import GraphRuntimeState, VariablePool
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.enums import CreatorUserRole
|
||||
from models.model import AppMode, EndUser
|
||||
from models.workflow import WorkflowAppLog
|
||||
from tests.workflow_test_utils import build_test_variable_pool
|
||||
|
||||
|
||||
@@ -193,7 +194,7 @@ class TestWorkflowGenerateTaskPipeline:
|
||||
|
||||
assert isinstance(responses[0], ValueError)
|
||||
|
||||
def test_handle_workflow_started_event_sets_run_id(self, monkeypatch: pytest.MonkeyPatch):
|
||||
def test_handle_workflow_started_event_sets_run_id(self, monkeypatch: pytest.MonkeyPatch, sqlite_engine):
|
||||
pipeline = _make_pipeline()
|
||||
pipeline._graph_runtime_state = GraphRuntimeState(
|
||||
variable_pool=build_test_variable_pool(variables=build_system_variables(workflow_execution_id="run-id")),
|
||||
@@ -201,11 +202,10 @@ class TestWorkflowGenerateTaskPipeline:
|
||||
)
|
||||
pipeline._workflow_response_converter.workflow_start_to_stream_response = lambda **kwargs: "started"
|
||||
|
||||
@contextmanager
|
||||
def _fake_session():
|
||||
yield SimpleNamespace()
|
||||
|
||||
monkeypatch.setattr(pipeline, "_database_session", _fake_session)
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.workflow.generate_task_pipeline.db",
|
||||
SimpleNamespace(engine=sqlite_engine),
|
||||
)
|
||||
monkeypatch.setattr(pipeline, "_save_workflow_app_log", lambda **kwargs: None)
|
||||
|
||||
responses = list(pipeline._handle_workflow_started_event(QueueWorkflowStartedEvent()))
|
||||
@@ -339,19 +339,18 @@ class TestWorkflowGenerateTaskPipeline:
|
||||
|
||||
assert responses == ["finish"]
|
||||
|
||||
def test_save_workflow_app_log_created_from(self):
|
||||
@pytest.mark.parametrize("sqlite_session", [(WorkflowAppLog,)], indirect=True)
|
||||
def test_save_workflow_app_log_created_from(self, sqlite_session: Session):
|
||||
pipeline = _make_pipeline()
|
||||
pipeline._application_generate_entity.invoke_from = InvokeFrom.SERVICE_API
|
||||
pipeline._user_id = "user"
|
||||
added: list[object] = []
|
||||
pipeline._save_workflow_app_log(session=sqlite_session, workflow_run_id="run-id")
|
||||
sqlite_session.flush()
|
||||
|
||||
class _Session:
|
||||
def add(self, item):
|
||||
added.append(item)
|
||||
|
||||
pipeline._save_workflow_app_log(session=_Session(), workflow_run_id="run-id")
|
||||
|
||||
assert added
|
||||
saved_log = sqlite_session.scalar(select(WorkflowAppLog))
|
||||
assert saved_log is not None
|
||||
assert saved_log.workflow_run_id == "run-id"
|
||||
assert saved_log.created_from == "service-api"
|
||||
|
||||
def test_iteration_loop_and_human_input_handlers(self):
|
||||
pipeline = _make_pipeline()
|
||||
@@ -674,35 +673,29 @@ class TestWorkflowGenerateTaskPipeline:
|
||||
assert "Fails to get audio trunk, task_id: task" in caplog.messages
|
||||
assert any(isinstance(item, MessageAudioEndStreamResponse) for item in responses)
|
||||
|
||||
def test_database_session_rolls_back_on_error(self, monkeypatch: pytest.MonkeyPatch):
|
||||
@pytest.mark.parametrize("sqlite_session", [(WorkflowAppLog,)], indirect=True)
|
||||
def test_database_session_rolls_back_on_error(
|
||||
self, monkeypatch: pytest.MonkeyPatch, sqlite_engine, sqlite_session: Session
|
||||
):
|
||||
pipeline = _make_pipeline()
|
||||
calls = {"enter": 0, "exit_exc": None}
|
||||
pipeline._application_generate_entity.invoke_from = InvokeFrom.SERVICE_API
|
||||
pipeline._user_id = "user"
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.workflow.generate_task_pipeline.db",
|
||||
SimpleNamespace(engine=sqlite_engine),
|
||||
)
|
||||
|
||||
class _BeginContext:
|
||||
def __enter__(self):
|
||||
calls["enter"] += 1
|
||||
return MagicMock()
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
calls["exit_exc"] = exc_type
|
||||
return False
|
||||
|
||||
class _Sessionmaker:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def begin(self):
|
||||
return _BeginContext()
|
||||
|
||||
monkeypatch.setattr("core.app.apps.workflow.generate_task_pipeline.sessionmaker", _Sessionmaker)
|
||||
monkeypatch.setattr("core.app.apps.workflow.generate_task_pipeline.db", SimpleNamespace(engine=object()))
|
||||
|
||||
with pytest.raises(RuntimeError, match="db error"):
|
||||
with pipeline._database_session():
|
||||
def persist_then_fail() -> None:
|
||||
with pipeline._database_session() as session:
|
||||
pipeline._save_workflow_app_log(session=session, workflow_run_id="run-id")
|
||||
session.flush()
|
||||
raise RuntimeError("db error")
|
||||
|
||||
assert calls["enter"] == 1
|
||||
assert calls["exit_exc"] is RuntimeError
|
||||
with pytest.raises(RuntimeError, match="db error"):
|
||||
persist_then_fail()
|
||||
|
||||
sqlite_session.expire_all()
|
||||
assert sqlite_session.scalar(select(WorkflowAppLog)) is None
|
||||
|
||||
def test_node_retry_and_started_handlers_cover_none_and_value(self):
|
||||
pipeline = _make_pipeline()
|
||||
@@ -862,31 +855,30 @@ class TestWorkflowGenerateTaskPipeline:
|
||||
pipeline._handle_workflow_failed_and_stop_events = lambda event, **kwargs: iter(["stopped"])
|
||||
assert list(pipeline._process_stream_response()) == ["stopped"]
|
||||
|
||||
def test_save_workflow_app_log_covers_invoke_from_variants(self):
|
||||
@pytest.mark.parametrize("sqlite_session", [(WorkflowAppLog,)], indirect=True)
|
||||
def test_save_workflow_app_log_covers_invoke_from_variants(self, sqlite_session: Session):
|
||||
pipeline = _make_pipeline()
|
||||
pipeline._user_id = "user-id"
|
||||
added: list[object] = []
|
||||
|
||||
class _Session:
|
||||
def add(self, item):
|
||||
added.append(item)
|
||||
|
||||
pipeline._application_generate_entity.invoke_from = InvokeFrom.EXPLORE
|
||||
pipeline._save_workflow_app_log(session=_Session(), workflow_run_id="run-id")
|
||||
assert added[-1].created_from == "installed-app"
|
||||
pipeline._save_workflow_app_log(session=sqlite_session, workflow_run_id="run-id")
|
||||
|
||||
pipeline._application_generate_entity.invoke_from = InvokeFrom.WEB_APP
|
||||
pipeline._save_workflow_app_log(session=_Session(), workflow_run_id="run-id")
|
||||
assert added[-1].created_from == "web-app"
|
||||
pipeline._save_workflow_app_log(session=sqlite_session, workflow_run_id="run-id-2")
|
||||
sqlite_session.flush()
|
||||
saved_logs = sqlite_session.scalars(select(WorkflowAppLog).order_by(WorkflowAppLog.workflow_run_id)).all()
|
||||
assert [log.created_from for log in saved_logs] == ["installed-app", "web-app"]
|
||||
|
||||
count_before = len(added)
|
||||
count_before = len(saved_logs)
|
||||
pipeline._application_generate_entity.invoke_from = InvokeFrom.DEBUGGER
|
||||
pipeline._save_workflow_app_log(session=_Session(), workflow_run_id="run-id")
|
||||
assert len(added) == count_before
|
||||
pipeline._save_workflow_app_log(session=sqlite_session, workflow_run_id="run-id-3")
|
||||
sqlite_session.flush()
|
||||
assert len(sqlite_session.scalars(select(WorkflowAppLog)).all()) == count_before
|
||||
|
||||
pipeline._application_generate_entity.invoke_from = InvokeFrom.WEB_APP
|
||||
pipeline._save_workflow_app_log(session=_Session(), workflow_run_id=None)
|
||||
assert len(added) == count_before
|
||||
pipeline._save_workflow_app_log(session=sqlite_session, workflow_run_id=None)
|
||||
sqlite_session.flush()
|
||||
assert len(sqlite_session.scalars(select(WorkflowAppLog)).all()) == count_before
|
||||
|
||||
def test_save_output_for_event_writes_draft_variables(self):
|
||||
pipeline = _make_pipeline()
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import logging
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import Engine, event
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.app.layers.trigger_post_layer import TriggerPostLayer
|
||||
from core.workflow.system_variables import build_system_variables
|
||||
@@ -13,19 +17,63 @@ from graphon.graph_events import (
|
||||
GraphRunSucceededEvent,
|
||||
)
|
||||
from graphon.runtime import VariablePool
|
||||
from models.enums import WorkflowTriggerStatus
|
||||
from models.enums import AppTriggerType, CreatorUserRole, WorkflowTriggerStatus
|
||||
from models.trigger import WorkflowTriggerLog
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TriggerDatabase:
|
||||
session: Session
|
||||
statements: list[str]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def trigger_database(monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> Iterator[TriggerDatabase]:
|
||||
"""Create the trigger-log table and bind layer-owned sessions to SQLite."""
|
||||
WorkflowTriggerLog.metadata.create_all(sqlite_engine, tables=[WorkflowTriggerLog.__table__])
|
||||
sqlite_session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
monkeypatch.setattr("core.db.session_factory._session_maker", sqlite_session_maker)
|
||||
statements: list[str] = []
|
||||
|
||||
def record_statement(_connection, _cursor, statement, _parameters, _context, _executemany) -> None:
|
||||
statements.append(statement)
|
||||
|
||||
event.listen(sqlite_engine, "before_cursor_execute", record_statement)
|
||||
with sqlite_session_maker() as session:
|
||||
try:
|
||||
yield TriggerDatabase(session=session, statements=statements)
|
||||
finally:
|
||||
event.remove(sqlite_engine, "before_cursor_execute", record_statement)
|
||||
|
||||
|
||||
def _persist_trigger_log(database: TriggerDatabase, *, trigger_log_id: str = "log-1") -> WorkflowTriggerLog:
|
||||
trigger_log = WorkflowTriggerLog(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
workflow_id="workflow-1",
|
||||
workflow_run_id=None,
|
||||
root_node_id=None,
|
||||
trigger_metadata="{}",
|
||||
trigger_type=AppTriggerType.TRIGGER_WEBHOOK,
|
||||
trigger_data="{}",
|
||||
inputs="{}",
|
||||
outputs=None,
|
||||
status=WorkflowTriggerStatus.RUNNING,
|
||||
error=None,
|
||||
queue_name="workflow",
|
||||
celery_task_id=None,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by="account-1",
|
||||
)
|
||||
trigger_log.id = trigger_log_id
|
||||
database.session.add(trigger_log)
|
||||
database.session.commit()
|
||||
return trigger_log
|
||||
|
||||
|
||||
class TestTriggerPostLayer:
|
||||
def test_on_event_updates_trigger_log(self):
|
||||
trigger_log = SimpleNamespace(
|
||||
status=None,
|
||||
workflow_run_id=None,
|
||||
outputs=None,
|
||||
elapsed_time=None,
|
||||
total_tokens=None,
|
||||
finished_at=None,
|
||||
)
|
||||
def test_on_event_updates_trigger_log(self, trigger_database: TriggerDatabase):
|
||||
trigger_log = _persist_trigger_log(trigger_database)
|
||||
runtime_state = SimpleNamespace(
|
||||
outputs={"answer": "ok"},
|
||||
variable_pool=VariablePool.from_bootstrap(
|
||||
@@ -35,19 +83,10 @@ class TestTriggerPostLayer:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("core.app.layers.trigger_post_layer.session_factory") as mock_session_factory,
|
||||
patch("core.app.layers.trigger_post_layer.SQLAlchemyWorkflowTriggerLogRepository") as mock_repo_cls,
|
||||
patch("core.app.layers.trigger_post_layer.datetime") as mock_datetime,
|
||||
):
|
||||
mock_datetime.now.return_value = datetime(2026, 2, 20, tzinfo=UTC)
|
||||
|
||||
session = Mock()
|
||||
mock_session_factory.create_session.return_value.__enter__.return_value = session
|
||||
|
||||
repo = Mock()
|
||||
repo.get_by_id.return_value = trigger_log
|
||||
mock_repo_cls.return_value = repo
|
||||
|
||||
layer = TriggerPostLayer(
|
||||
cfs_plan_scheduler_entity=Mock(),
|
||||
start_time=datetime(2026, 2, 20, tzinfo=UTC) - timedelta(seconds=10),
|
||||
@@ -57,25 +96,18 @@ class TestTriggerPostLayer:
|
||||
|
||||
layer.on_event(GraphRunSucceededEvent())
|
||||
|
||||
assert trigger_log.status == WorkflowTriggerStatus.SUCCEEDED
|
||||
assert trigger_log.workflow_run_id == "run-1"
|
||||
assert trigger_log.outputs is not None
|
||||
assert trigger_log.elapsed_time is not None
|
||||
assert trigger_log.total_tokens == 12
|
||||
assert trigger_log.finished_at is not None
|
||||
repo.update.assert_called_once_with(trigger_log)
|
||||
session.commit.assert_called_once()
|
||||
trigger_database.session.expire_all()
|
||||
persisted_log = trigger_database.session.get(WorkflowTriggerLog, trigger_log.id)
|
||||
assert persisted_log is not None
|
||||
assert persisted_log.status == WorkflowTriggerStatus.SUCCEEDED
|
||||
assert persisted_log.workflow_run_id == "run-1"
|
||||
assert persisted_log.outputs == '{"answer":"ok"}'
|
||||
assert persisted_log.elapsed_time == 10
|
||||
assert persisted_log.total_tokens == 12
|
||||
assert persisted_log.finished_at is not None
|
||||
|
||||
def test_on_event_updates_trigger_log_for_aborted_event(self):
|
||||
trigger_log = SimpleNamespace(
|
||||
status=None,
|
||||
workflow_run_id=None,
|
||||
outputs=None,
|
||||
error=None,
|
||||
elapsed_time=None,
|
||||
total_tokens=None,
|
||||
finished_at=None,
|
||||
)
|
||||
def test_on_event_updates_trigger_log_for_aborted_event(self, trigger_database: TriggerDatabase):
|
||||
trigger_log = _persist_trigger_log(trigger_database)
|
||||
runtime_state = SimpleNamespace(
|
||||
outputs={"partial": "ok"},
|
||||
variable_pool=VariablePool.from_bootstrap(
|
||||
@@ -85,19 +117,10 @@ class TestTriggerPostLayer:
|
||||
)
|
||||
|
||||
with (
|
||||
patch("core.app.layers.trigger_post_layer.session_factory") as mock_session_factory,
|
||||
patch("core.app.layers.trigger_post_layer.SQLAlchemyWorkflowTriggerLogRepository") as mock_repo_cls,
|
||||
patch("core.app.layers.trigger_post_layer.datetime") as mock_datetime,
|
||||
):
|
||||
mock_datetime.now.return_value = datetime(2026, 2, 20, tzinfo=UTC)
|
||||
|
||||
session = Mock()
|
||||
mock_session_factory.create_session.return_value.__enter__.return_value = session
|
||||
|
||||
repo = Mock()
|
||||
repo.get_by_id.return_value = trigger_log
|
||||
mock_repo_cls.return_value = repo
|
||||
|
||||
layer = TriggerPostLayer(
|
||||
cfs_plan_scheduler_entity=Mock(),
|
||||
start_time=datetime(2026, 2, 20, tzinfo=UTC) - timedelta(seconds=10),
|
||||
@@ -107,17 +130,22 @@ class TestTriggerPostLayer:
|
||||
|
||||
layer.on_event(GraphRunAbortedEvent(reason="timeout"))
|
||||
|
||||
assert trigger_log.status == WorkflowTriggerStatus.FAILED
|
||||
assert trigger_log.workflow_run_id == "run-1"
|
||||
assert trigger_log.outputs is not None
|
||||
assert trigger_log.error == "timeout"
|
||||
assert trigger_log.elapsed_time is not None
|
||||
assert trigger_log.total_tokens == 7
|
||||
assert trigger_log.finished_at is not None
|
||||
repo.update.assert_called_once_with(trigger_log)
|
||||
session.commit.assert_called_once()
|
||||
trigger_database.session.expire_all()
|
||||
persisted_log = trigger_database.session.get(WorkflowTriggerLog, trigger_log.id)
|
||||
assert persisted_log is not None
|
||||
assert persisted_log.status == WorkflowTriggerStatus.FAILED
|
||||
assert persisted_log.workflow_run_id == "run-1"
|
||||
assert persisted_log.outputs == '{"partial":"ok"}'
|
||||
assert persisted_log.error == "timeout"
|
||||
assert persisted_log.elapsed_time == 10
|
||||
assert persisted_log.total_tokens == 7
|
||||
assert persisted_log.finished_at is not None
|
||||
|
||||
def test_on_event_handles_missing_trigger_log(self, caplog: pytest.LogCaptureFixture):
|
||||
def test_on_event_handles_missing_trigger_log(
|
||||
self,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
trigger_database: TriggerDatabase,
|
||||
):
|
||||
runtime_state = SimpleNamespace(
|
||||
outputs={},
|
||||
variable_pool=VariablePool.from_bootstrap(
|
||||
@@ -126,31 +154,20 @@ class TestTriggerPostLayer:
|
||||
total_tokens=0,
|
||||
)
|
||||
|
||||
with (
|
||||
patch("core.app.layers.trigger_post_layer.session_factory") as mock_session_factory,
|
||||
patch("core.app.layers.trigger_post_layer.SQLAlchemyWorkflowTriggerLogRepository") as mock_repo_cls,
|
||||
):
|
||||
session = Mock()
|
||||
mock_session_factory.create_session.return_value.__enter__.return_value = session
|
||||
layer = TriggerPostLayer(
|
||||
cfs_plan_scheduler_entity=Mock(),
|
||||
start_time=datetime(2026, 2, 20, tzinfo=UTC),
|
||||
trigger_log_id="missing",
|
||||
)
|
||||
layer.initialize(runtime_state, Mock())
|
||||
|
||||
repo = Mock()
|
||||
repo.get_by_id.return_value = None
|
||||
mock_repo_cls.return_value = repo
|
||||
|
||||
layer = TriggerPostLayer(
|
||||
cfs_plan_scheduler_entity=Mock(),
|
||||
start_time=datetime(2026, 2, 20, tzinfo=UTC),
|
||||
trigger_log_id="missing",
|
||||
)
|
||||
layer.initialize(runtime_state, Mock())
|
||||
|
||||
with caplog.at_level(logging.ERROR, logger="core.app.layers.trigger_post_layer"):
|
||||
layer.on_event(GraphRunFailedEvent(error="boom"))
|
||||
with caplog.at_level(logging.ERROR, logger="core.app.layers.trigger_post_layer"):
|
||||
layer.on_event(GraphRunFailedEvent(error="boom"))
|
||||
|
||||
assert any(record.levelno == logging.ERROR for record in caplog.records)
|
||||
session.commit.assert_not_called()
|
||||
assert trigger_database.session.get(WorkflowTriggerLog, "missing") is None
|
||||
|
||||
def test_on_event_ignores_non_status_events(self):
|
||||
def test_on_event_ignores_non_status_events(self, trigger_database: TriggerDatabase):
|
||||
runtime_state = SimpleNamespace(
|
||||
outputs={},
|
||||
variable_pool=VariablePool.from_bootstrap(
|
||||
@@ -159,14 +176,14 @@ class TestTriggerPostLayer:
|
||||
total_tokens=0,
|
||||
)
|
||||
|
||||
with patch("core.app.layers.trigger_post_layer.session_factory") as mock_session_factory:
|
||||
layer = TriggerPostLayer(
|
||||
cfs_plan_scheduler_entity=Mock(),
|
||||
start_time=datetime(2026, 2, 20, tzinfo=UTC),
|
||||
trigger_log_id="log-1",
|
||||
)
|
||||
layer.initialize(runtime_state, Mock())
|
||||
layer = TriggerPostLayer(
|
||||
cfs_plan_scheduler_entity=Mock(),
|
||||
start_time=datetime(2026, 2, 20, tzinfo=UTC),
|
||||
trigger_log_id="log-1",
|
||||
)
|
||||
layer.initialize(runtime_state, Mock())
|
||||
|
||||
layer.on_event(Mock())
|
||||
trigger_database.statements.clear()
|
||||
layer.on_event(Mock())
|
||||
|
||||
mock_session_factory.create_session.assert_not_called()
|
||||
assert trigger_database.statements == []
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import types
|
||||
from collections.abc import Generator
|
||||
from collections.abc import Generator, Iterator
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from contexts.wrapper import RecyclableContextVar
|
||||
from core.datasource import datasource_manager as datasource_manager_module
|
||||
from core.datasource.datasource_manager import DatasourceManager
|
||||
from core.datasource.entities.datasource_entities import DatasourceMessage, DatasourceProviderType
|
||||
from core.datasource.errors import DatasourceProviderNotFoundError
|
||||
@@ -12,6 +15,34 @@ from core.workflow.file_reference import parse_file_reference
|
||||
from graphon.enums import WorkflowNodeExecutionStatus
|
||||
from graphon.file import File, FileTransferMethod, FileType
|
||||
from graphon.node_events import StreamChunkEvent, StreamCompletedEvent
|
||||
from models.base import TypeBase
|
||||
from models.tools import ToolFile
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tool_file_session(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> Iterator[Session]:
|
||||
"""Bind datasource-owned lookups to a SQLite ToolFile table."""
|
||||
TypeBase.metadata.create_all(sqlite_engine, tables=[TypeBase.metadata.tables[ToolFile.__tablename__]])
|
||||
session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
monkeypatch.setattr(datasource_manager_module.session_factory, "create_session", session_maker)
|
||||
with session_maker() as session:
|
||||
yield session
|
||||
|
||||
|
||||
def _persist_tool_file(session: Session, *, file_id: str, tenant_id: str) -> ToolFile:
|
||||
tool_file = ToolFile(
|
||||
user_id="user-1",
|
||||
tenant_id=tenant_id,
|
||||
conversation_id=None,
|
||||
file_key="files/image.png",
|
||||
mimetype="image/png",
|
||||
name="image.png",
|
||||
size=10,
|
||||
)
|
||||
tool_file.id = file_id
|
||||
session.add(tool_file)
|
||||
session.commit()
|
||||
return tool_file
|
||||
|
||||
|
||||
def _gen_messages_text_only(text: str) -> Generator[DatasourceMessage, None, None]:
|
||||
@@ -373,7 +404,8 @@ def test_stream_node_events_emits_events_online_document(mocker: MockerFixture):
|
||||
assert events[-1].node_run_result.status == WorkflowNodeExecutionStatus.SUCCEEDED
|
||||
|
||||
|
||||
def test_stream_node_events_builds_file_and_variables_from_messages(mocker: MockerFixture):
|
||||
def test_stream_node_events_builds_file_and_variables_from_messages(mocker: MockerFixture, tool_file_session: Session):
|
||||
_persist_tool_file(tool_file_session, file_id="tool_file_1", tenant_id="t1")
|
||||
mocker.patch.object(DatasourceManager, "stream_online_results", return_value=_gen_messages_text_only("ignored"))
|
||||
|
||||
def _transformed(**_kwargs):
|
||||
@@ -418,19 +450,6 @@ def test_stream_node_events_builds_file_and_variables_from_messages(mocker: Mock
|
||||
side_effect=_transformed,
|
||||
)
|
||||
|
||||
fake_tool_file = types.SimpleNamespace(mimetype="image/png")
|
||||
|
||||
class _Session:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def scalar(self, _stmt):
|
||||
return fake_tool_file
|
||||
|
||||
mocker.patch("core.datasource.datasource_manager.session_factory.create_session", return_value=_Session())
|
||||
mocker.patch("core.datasource.datasource_manager.get_file_type_by_mime_type", return_value=FileType.IMAGE)
|
||||
built = File(
|
||||
file_type=FileType.IMAGE,
|
||||
@@ -481,7 +500,8 @@ def test_stream_node_events_builds_file_and_variables_from_messages(mocker: Mock
|
||||
assert events[-1].node_run_result.outputs["x"] == 1
|
||||
|
||||
|
||||
def test_stream_node_events_raises_when_toolfile_missing(mocker: MockerFixture):
|
||||
def test_stream_node_events_raises_when_toolfile_missing(mocker: MockerFixture, tool_file_session: Session):
|
||||
_persist_tool_file(tool_file_session, file_id="missing", tenant_id="other-tenant")
|
||||
mocker.patch.object(DatasourceManager, "stream_online_results", return_value=_gen_messages_text_only("ignored"))
|
||||
|
||||
def _transformed(**_kwargs):
|
||||
@@ -496,18 +516,6 @@ def test_stream_node_events_raises_when_toolfile_missing(mocker: MockerFixture):
|
||||
side_effect=_transformed,
|
||||
)
|
||||
|
||||
class _Session:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def scalar(self, _stmt):
|
||||
return None
|
||||
|
||||
mocker.patch("core.datasource.datasource_manager.session_factory.create_session", return_value=_Session())
|
||||
|
||||
with pytest.raises(ValueError, match="ToolFile not found for file_id=missing, tenant_id=t1"):
|
||||
list(
|
||||
DatasourceManager.stream_node_events(
|
||||
|
||||
@@ -14,18 +14,64 @@ Tests follow the Arrange-Act-Assert pattern for clarity.
|
||||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from unittest.mock import Mock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.datasource.entities.datasource_entities import DatasourceProviderType
|
||||
from core.datasource.online_document.online_document_provider import (
|
||||
OnlineDocumentDatasourcePluginProviderController,
|
||||
)
|
||||
from core.rag.extractor import notion_extractor as notion_extractor_module
|
||||
from core.rag.extractor.notion_extractor import NotionExtractor
|
||||
from core.rag.models.document import Document
|
||||
from models.base import TypeBase
|
||||
from models.dataset import Document as DocumentModel
|
||||
from models.enums import DataSourceType, DocumentCreatedFrom
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Database:
|
||||
"""Expose the real SQLite session used by the extractor update."""
|
||||
|
||||
session: Session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def database(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> Iterator[_Database]:
|
||||
"""Bind a real session for Notion document metadata persistence."""
|
||||
|
||||
TypeBase.metadata.create_all(sqlite_engine, tables=[DocumentModel.__table__])
|
||||
with Session(sqlite_engine, expire_on_commit=False) as session:
|
||||
database = _Database(session)
|
||||
monkeypatch.setattr(notion_extractor_module, "db", database)
|
||||
yield database
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def persisted_document(database: _Database) -> DocumentModel:
|
||||
document = DocumentModel(
|
||||
id=str(uuid4()),
|
||||
tenant_id=str(uuid4()),
|
||||
dataset_id=str(uuid4()),
|
||||
position=1,
|
||||
data_source_type=DataSourceType.NOTION_IMPORT,
|
||||
data_source_info=json.dumps({"last_edited_time": "2024-01-01T00:00:00.000Z"}),
|
||||
batch="batch",
|
||||
name="Notion page",
|
||||
created_from=DocumentCreatedFrom.WEB,
|
||||
created_by=str(uuid4()),
|
||||
)
|
||||
database.session.add(document)
|
||||
database.session.commit()
|
||||
return document
|
||||
|
||||
|
||||
class TestNotionExtractorAuthentication:
|
||||
@@ -763,9 +809,14 @@ class TestNotionExtractorLastEditedTime:
|
||||
call_args = mock_request.call_args
|
||||
assert "databases/database-789" in call_args[0][1]
|
||||
|
||||
@patch("core.rag.extractor.notion_extractor.db")
|
||||
@patch("httpx.request")
|
||||
def test_update_last_edited_time(self, mock_request, mock_db, extractor_page, mock_document_model):
|
||||
def test_update_last_edited_time(
|
||||
self,
|
||||
mock_request: Mock,
|
||||
extractor_page: NotionExtractor,
|
||||
database: _Database,
|
||||
persisted_document: DocumentModel,
|
||||
):
|
||||
"""Test updating document model with last edited time."""
|
||||
# Arrange
|
||||
mock_response = Mock()
|
||||
@@ -777,11 +828,11 @@ class TestNotionExtractorLastEditedTime:
|
||||
mock_request.return_value = mock_response
|
||||
|
||||
# Act
|
||||
extractor_page.update_last_edited_time(mock_document_model)
|
||||
extractor_page.update_last_edited_time(persisted_document)
|
||||
|
||||
# Assert
|
||||
assert mock_document_model.data_source_info_dict["last_edited_time"] == "2024-11-27T18:00:00.000Z"
|
||||
mock_db.session.commit.assert_called_once()
|
||||
database.session.expire(persisted_document)
|
||||
assert persisted_document.data_source_info_dict["last_edited_time"] == "2024-11-27T18:00:00.000Z"
|
||||
|
||||
def test_update_last_edited_time_no_document(self, extractor_page):
|
||||
"""Test update_last_edited_time with None document model."""
|
||||
@@ -807,9 +858,10 @@ class TestNotionExtractorIntegration:
|
||||
mock_doc.data_source_info_dict = {"last_edited_time": "2024-01-01T00:00:00.000Z"}
|
||||
return mock_doc
|
||||
|
||||
@patch("core.rag.extractor.notion_extractor.db")
|
||||
@patch("httpx.request")
|
||||
def test_extract_page_complete_workflow(self, mock_request, mock_db, mock_document_model):
|
||||
def test_extract_page_complete_workflow(
|
||||
self, mock_request: Mock, database: _Database, persisted_document: DocumentModel
|
||||
):
|
||||
"""Test complete page extraction workflow."""
|
||||
# Arrange
|
||||
extractor = NotionExtractor(
|
||||
@@ -818,7 +870,7 @@ class TestNotionExtractorIntegration:
|
||||
notion_page_type="page",
|
||||
tenant_id="tenant-789",
|
||||
notion_access_token="test-token",
|
||||
document_model=mock_document_model,
|
||||
document_model=persisted_document,
|
||||
)
|
||||
|
||||
# Mock last edited time request
|
||||
@@ -869,11 +921,18 @@ class TestNotionExtractorIntegration:
|
||||
assert isinstance(documents[0], Document)
|
||||
assert "# Test Page" in documents[0].page_content
|
||||
assert "Test content" in documents[0].page_content
|
||||
database.session.expire(persisted_document)
|
||||
assert persisted_document.data_source_info_dict["last_edited_time"] == "2024-11-27T20:00:00.000Z"
|
||||
|
||||
@patch("core.rag.extractor.notion_extractor.db")
|
||||
@patch("httpx.post")
|
||||
@patch("httpx.request")
|
||||
def test_extract_database_complete_workflow(self, mock_request, mock_post, mock_db, mock_document_model):
|
||||
def test_extract_database_complete_workflow(
|
||||
self,
|
||||
mock_request: Mock,
|
||||
mock_post: Mock,
|
||||
database: _Database,
|
||||
persisted_document: DocumentModel,
|
||||
):
|
||||
"""Test complete database extraction workflow."""
|
||||
# Arrange
|
||||
extractor = NotionExtractor(
|
||||
@@ -882,7 +941,7 @@ class TestNotionExtractorIntegration:
|
||||
notion_page_type="database",
|
||||
tenant_id="tenant-789",
|
||||
notion_access_token="test-token",
|
||||
document_model=mock_document_model,
|
||||
document_model=persisted_document,
|
||||
)
|
||||
|
||||
# Mock last edited time request
|
||||
@@ -921,6 +980,8 @@ class TestNotionExtractorIntegration:
|
||||
assert isinstance(documents[0], Document)
|
||||
assert "Name:Item 1" in documents[0].page_content
|
||||
assert "Status:Active" in documents[0].page_content
|
||||
database.session.expire(persisted_document)
|
||||
assert persisted_document.data_source_info_dict["last_edited_time"] == "2024-11-27T20:00:00.000Z"
|
||||
|
||||
def test_extract_invalid_page_type(self):
|
||||
"""Test extract with invalid page type."""
|
||||
|
||||
@@ -2,9 +2,21 @@ from types import SimpleNamespace
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from sqlalchemy import Engine, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
import core.rag.extractor.excel_extractor as excel_module
|
||||
from core.rag.extractor.excel_extractor import ExcelExtractor
|
||||
from models.base import TypeBase
|
||||
from models.model import UploadFile
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def database_session_maker(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> sessionmaker[Session]:
|
||||
TypeBase.metadata.create_all(sqlite_engine, tables=[UploadFile.__table__])
|
||||
session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
monkeypatch.setattr(excel_module.session_factory, "create_session", session_maker)
|
||||
return session_maker
|
||||
|
||||
|
||||
class _FakeCell:
|
||||
@@ -58,82 +70,22 @@ class _FakeImage:
|
||||
return self._raw_data
|
||||
|
||||
|
||||
class _FieldExpression:
|
||||
def __eq__(self, other):
|
||||
return ("eq", other)
|
||||
|
||||
def in_(self, values):
|
||||
return ("in", tuple(values))
|
||||
|
||||
|
||||
class _SelectStub:
|
||||
def where(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
|
||||
class _FakeUploadFile:
|
||||
tenant_id = _FieldExpression()
|
||||
key = _FieldExpression()
|
||||
_i = 0
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
type(self)._i += 1
|
||||
self.id = f"u{self._i}"
|
||||
self.key = kwargs["key"]
|
||||
|
||||
|
||||
class _PersistentSession:
|
||||
def __init__(self, persisted):
|
||||
self._persisted = persisted
|
||||
self.added = []
|
||||
self.commit_count = 0
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def scalars(self, _stmt):
|
||||
return SimpleNamespace(all=lambda: list(self._persisted.values()))
|
||||
|
||||
def add_all(self, objects) -> None:
|
||||
self.added.extend(objects)
|
||||
|
||||
def commit(self) -> None:
|
||||
self.commit_count += 1
|
||||
for upload_file in self.added:
|
||||
self._persisted[upload_file.key] = upload_file
|
||||
self.added.clear()
|
||||
|
||||
|
||||
class _PersistentSessionFactory:
|
||||
def __init__(self):
|
||||
self.persisted = {}
|
||||
self.sessions = []
|
||||
|
||||
def create_session(self):
|
||||
session = _PersistentSession(self.persisted)
|
||||
self.sessions.append(session)
|
||||
return session
|
||||
|
||||
|
||||
def _patch_image_persistence(monkeypatch: pytest.MonkeyPatch):
|
||||
saves: list[tuple[str, bytes]] = []
|
||||
session_factory = _PersistentSessionFactory()
|
||||
|
||||
def save(key: str, data: bytes) -> None:
|
||||
saves.append((key, data))
|
||||
|
||||
_FakeUploadFile._i = 0
|
||||
monkeypatch.setattr(excel_module, "storage", SimpleNamespace(save=save))
|
||||
monkeypatch.setattr(excel_module, "session_factory", session_factory)
|
||||
monkeypatch.setattr(excel_module, "select", lambda *args, **kwargs: _SelectStub())
|
||||
monkeypatch.setattr(excel_module, "UploadFile", _FakeUploadFile)
|
||||
monkeypatch.setattr(excel_module.storage, "save", save)
|
||||
monkeypatch.setattr(excel_module.dify_config, "FILES_URL", "http://files.local", raising=False)
|
||||
monkeypatch.setattr(excel_module.dify_config, "STORAGE_TYPE", "local", raising=False)
|
||||
|
||||
return saves, session_factory
|
||||
return saves
|
||||
|
||||
|
||||
def _get_upload_files(session_maker: sessionmaker[Session]) -> list[UploadFile]:
|
||||
with session_maker() as session:
|
||||
return list(session.scalars(select(UploadFile)).all())
|
||||
|
||||
|
||||
class TestExcelExtractor:
|
||||
@@ -160,7 +112,11 @@ class TestExcelExtractor:
|
||||
assert docs[1].page_content == '"Name":"";"Link":"123"'
|
||||
assert all(doc.metadata["source"] == "/tmp/sample.xlsx" for doc in docs)
|
||||
|
||||
def test_extract_xlsx_turns_embedded_images_into_markdown_links(self, monkeypatch: pytest.MonkeyPatch):
|
||||
def test_extract_xlsx_turns_embedded_images_into_markdown_links(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
database_session_maker: sessionmaker[Session],
|
||||
):
|
||||
image_bytes = b"\x89PNG\r\n\x1a\nexcel-image"
|
||||
sheet = _FakeSheet(
|
||||
header_rows=[("Question", "Answer", "Image")],
|
||||
@@ -175,7 +131,7 @@ class TestExcelExtractor:
|
||||
)
|
||||
workbook = _FakeWorkbook({"Data": sheet})
|
||||
monkeypatch.setattr(excel_module, "load_workbook", lambda *args, **kwargs: workbook)
|
||||
saves, session_factory = _patch_image_persistence(monkeypatch)
|
||||
saves = _patch_image_persistence(monkeypatch)
|
||||
|
||||
extractor = ExcelExtractor(
|
||||
"/tmp/sample.xlsx",
|
||||
@@ -184,23 +140,30 @@ class TestExcelExtractor:
|
||||
source_file_id="source-file-1",
|
||||
)
|
||||
docs = extractor.extract()
|
||||
upload_files = _get_upload_files(database_session_maker)
|
||||
|
||||
assert workbook.closed is True
|
||||
assert len(docs) == 2
|
||||
assert len(upload_files) == 1
|
||||
assert docs[0].page_content == (
|
||||
'"Question":"Q1";"Answer":"A1";'
|
||||
'"Image":" '
|
||||
'"'
|
||||
f'"Image":" '
|
||||
f'"'
|
||||
)
|
||||
assert docs[1].page_content == '"Question":"Q2";"Answer":"A2";"Image":""'
|
||||
assert len(saves) == 1
|
||||
assert saves[0][0].startswith("image_files/tenant-1/source-file-1/")
|
||||
assert saves[0][0].endswith(".png")
|
||||
assert saves[0][1] == image_bytes
|
||||
assert len(session_factory.persisted) == 1
|
||||
assert [session.commit_count for session in session_factory.sessions] == [1]
|
||||
assert upload_files[0].tenant_id == "tenant-1"
|
||||
assert upload_files[0].key == saves[0][0]
|
||||
assert upload_files[0].used is True
|
||||
|
||||
def test_extract_xlsx_keeps_rows_with_only_embedded_images(self, monkeypatch: pytest.MonkeyPatch):
|
||||
def test_extract_xlsx_keeps_rows_with_only_embedded_images(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
database_session_maker: sessionmaker[Session],
|
||||
):
|
||||
image_bytes = b"\x89PNG\r\n\x1a\nimage-only-row"
|
||||
sheet = _FakeSheet(
|
||||
header_rows=[("Question", "Answer", "Image")],
|
||||
@@ -212,7 +175,7 @@ class TestExcelExtractor:
|
||||
)
|
||||
workbook = _FakeWorkbook({"Data": sheet})
|
||||
monkeypatch.setattr(excel_module, "load_workbook", lambda *args, **kwargs: workbook)
|
||||
saves, session_factory = _patch_image_persistence(monkeypatch)
|
||||
saves = _patch_image_persistence(monkeypatch)
|
||||
|
||||
extractor = ExcelExtractor(
|
||||
"/tmp/sample.xlsx",
|
||||
@@ -221,17 +184,21 @@ class TestExcelExtractor:
|
||||
source_file_id="source-file-1",
|
||||
)
|
||||
docs = extractor.extract()
|
||||
upload_files = _get_upload_files(database_session_maker)
|
||||
|
||||
assert workbook.closed is True
|
||||
assert len(docs) == 1
|
||||
assert len(upload_files) == 1
|
||||
assert docs[0].page_content == (
|
||||
'"Question":"";"Answer":"";"Image":""'
|
||||
f'"Question":"";"Answer":"";"Image":""'
|
||||
)
|
||||
assert len(saves) == 1
|
||||
assert len(session_factory.persisted) == 1
|
||||
assert [session.commit_count for session in session_factory.sessions] == [1]
|
||||
|
||||
def test_extract_xlsx_reuses_existing_embedded_image_uploads_on_retry(self, monkeypatch: pytest.MonkeyPatch):
|
||||
def test_extract_xlsx_reuses_existing_embedded_image_uploads_on_retry(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
database_session_maker: sessionmaker[Session],
|
||||
):
|
||||
image_bytes = b"\x89PNG\r\n\x1a\nretry-safe-image"
|
||||
workbooks = [
|
||||
_FakeWorkbook(
|
||||
@@ -254,7 +221,7 @@ class TestExcelExtractor:
|
||||
),
|
||||
]
|
||||
monkeypatch.setattr(excel_module, "load_workbook", lambda *args, **kwargs: workbooks.pop(0))
|
||||
saves, session_factory = _patch_image_persistence(monkeypatch)
|
||||
saves = _patch_image_persistence(monkeypatch)
|
||||
|
||||
extractor = ExcelExtractor(
|
||||
"/tmp/sample.xlsx",
|
||||
@@ -264,16 +231,17 @@ class TestExcelExtractor:
|
||||
)
|
||||
first_docs = extractor.extract()
|
||||
second_docs = extractor.extract()
|
||||
upload_files = _get_upload_files(database_session_maker)
|
||||
assert len(upload_files) == 1
|
||||
|
||||
expected_page_content = (
|
||||
'"Question":"Q1";"Answer":"A1";"Image":""'
|
||||
'"Question":"Q1";"Answer":"A1";'
|
||||
f'"Image":""'
|
||||
)
|
||||
|
||||
assert first_docs[0].page_content == expected_page_content
|
||||
assert second_docs[0].page_content == expected_page_content
|
||||
assert len(saves) == 1
|
||||
assert len(session_factory.persisted) == 1
|
||||
assert [session.commit_count for session in session_factory.sessions] == [1, 0]
|
||||
|
||||
def test_extract_xls_path(self, monkeypatch: pytest.MonkeyPatch):
|
||||
class FakeExcelFile:
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import cast
|
||||
|
||||
import pytest
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
from dify_agent.layers.config import DifyConfigSkillConfig
|
||||
from dify_agent.layers.dify_core_tools import DifyCoreToolConfig, DifyCoreToolsLayerConfig
|
||||
from dify_agent.layers.dify_plugin import DifyPluginToolConfig, DifyPluginToolsLayerConfig
|
||||
from dify_agent.protocol import DIFY_AGENT_HISTORY_LAYER_ID, DIFY_AGENT_MODEL_LAYER_ID
|
||||
@@ -40,6 +41,14 @@ from models.agent_config_entities import (
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_runtime_agent_skills(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.load_runtime_agent_skill_configs",
|
||||
lambda *, tenant_id, agent_id: [],
|
||||
)
|
||||
|
||||
|
||||
class FakeCredentialsProvider:
|
||||
def fetch(self, provider_name: str, model_name: str) -> dict[str, object]:
|
||||
assert provider_name == "openai"
|
||||
@@ -1420,6 +1429,30 @@ def test_build_config_layer_config_includes_soul_context_and_mentions():
|
||||
assert warnings == []
|
||||
|
||||
|
||||
def test_build_config_layer_config_includes_runtime_agent_skills():
|
||||
from core.workflow.nodes.agent_v2.runtime_request_builder import build_config_layer_config
|
||||
|
||||
soul = AgentSoulConfig(
|
||||
prompt={"system_prompt": "Use [§skill:workspace-skill:Workspace Skill§]."},
|
||||
model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"),
|
||||
)
|
||||
config, warnings = build_config_layer_config(
|
||||
soul,
|
||||
runtime_config_skills=[
|
||||
DifyConfigSkillConfig(
|
||||
name="workspace-skill",
|
||||
description="Bound workspace skill.",
|
||||
size=123,
|
||||
mime_type="application/zip",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
assert [skill.name for skill in config.skills] == ["workspace-skill"]
|
||||
assert config.mentioned_skill_names == ["workspace-skill"]
|
||||
assert warnings == []
|
||||
|
||||
|
||||
def test_build_config_layer_config_returns_empty_config_for_empty_agent_soul():
|
||||
from core.workflow.nodes.agent_v2.runtime_request_builder import build_config_layer_config
|
||||
|
||||
@@ -1505,6 +1538,33 @@ def test_workflow_run_request_contains_config_layer():
|
||||
assert any(spec.name == DIFY_CONFIG_LAYER_ID and spec.type == "dify.config" for spec in specs)
|
||||
|
||||
|
||||
def test_workflow_run_request_includes_bound_workspace_skills(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.load_runtime_agent_skill_configs",
|
||||
lambda *, tenant_id, agent_id: [
|
||||
DifyConfigSkillConfig(
|
||||
name="workspace-skill",
|
||||
description="Bound workspace skill.",
|
||||
size=123,
|
||||
mime_type="application/zip",
|
||||
)
|
||||
],
|
||||
)
|
||||
context = _context()
|
||||
context.snapshot.config_snapshot = AgentSoulConfig(
|
||||
prompt={"system_prompt": "Use [§skill:workspace-skill:Workspace Skill§]."},
|
||||
model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"),
|
||||
)
|
||||
|
||||
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context)
|
||||
|
||||
config = next(layer for layer in result.request.composition.layers if layer.name == DIFY_CONFIG_LAYER_ID)
|
||||
assert [skill.name for skill in config.config.skills] == ["workspace-skill"]
|
||||
assert config.config.mentioned_skill_names == ["workspace-skill"]
|
||||
soul_prompt = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt")
|
||||
assert soul_prompt.config.prefix == "Use workspace-skill."
|
||||
|
||||
|
||||
def test_workflow_runtime_expands_config_mentions_in_agent_soul_prompt():
|
||||
context = _context()
|
||||
context.snapshot.config_snapshot = _soul_with_config_assets()
|
||||
|
||||
@@ -2,7 +2,7 @@ from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from libs.helper import OptionalTimestampField, email, escape_like_pattern, extract_tenant_id
|
||||
from libs.helper import OptionalTimestampField, alphanumeric, email, escape_like_pattern, extract_tenant_id
|
||||
from models.account import Account
|
||||
from models.model import EndUser
|
||||
|
||||
@@ -153,3 +153,47 @@ class TestEmailValidator:
|
||||
def test_invalid_email_rejected(self):
|
||||
with pytest.raises(ValueError, match="not a valid email"):
|
||||
email("not-an-email")
|
||||
|
||||
|
||||
class TestAlphanumericValidator:
|
||||
"""Tests for the alphanumeric() validator — regression for #39666."""
|
||||
|
||||
def test_valid_alphanumeric_accepted(self):
|
||||
assert alphanumeric("tool_name") == "tool_name"
|
||||
assert alphanumeric("Tool123") == "Tool123"
|
||||
assert alphanumeric("_underscore_start") == "_underscore_start"
|
||||
assert alphanumeric("a") == "a"
|
||||
|
||||
def test_trailing_newline_rejected(self):
|
||||
# re.match with $ accepts a trailing \n in Python; re.fullmatch does not.
|
||||
# This was the pre-fix behaviour: alphanumeric("tool\n") returned "tool\n".
|
||||
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
|
||||
alphanumeric("tool_name\n")
|
||||
|
||||
def test_trailing_carriage_return_rejected(self):
|
||||
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
|
||||
alphanumeric("tool_name\r")
|
||||
|
||||
def test_trailing_crlf_rejected(self):
|
||||
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
|
||||
alphanumeric("tool_name\r\n")
|
||||
|
||||
def test_leading_newline_rejected(self):
|
||||
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
|
||||
alphanumeric("\ntool_name")
|
||||
|
||||
def test_embedded_whitespace_rejected(self):
|
||||
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
|
||||
alphanumeric("tool name")
|
||||
|
||||
def test_empty_string_rejected(self):
|
||||
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
|
||||
alphanumeric("")
|
||||
|
||||
def test_special_characters_rejected(self):
|
||||
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
|
||||
alphanumeric("tool-name")
|
||||
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
|
||||
alphanumeric("tool.name")
|
||||
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
|
||||
alphanumeric("tool/name")
|
||||
|
||||
@@ -13,7 +13,7 @@ from services.agent import skill_package_service as skill_package_service_module
|
||||
from services.agent.skill_package_service import NormalizedSkillPackage, SkillPackageError, SkillPackageService
|
||||
|
||||
_SKILL_MD = """---
|
||||
name: PDF Toolkit
|
||||
name: pdf-toolkit
|
||||
description: Tools for working with PDF files.
|
||||
---
|
||||
|
||||
@@ -43,7 +43,7 @@ def _archive_members(content: bytes) -> list[str]:
|
||||
def test_valid_skill_normalizes_manifest():
|
||||
manifest = _normalize({"SKILL.md": _SKILL_MD.encode(), "scripts/run.py": b"print('hi')\n"}).manifest
|
||||
|
||||
assert manifest.name == "PDF Toolkit"
|
||||
assert manifest.name == "pdf-toolkit"
|
||||
assert manifest.description == "Tools for working with PDF files."
|
||||
assert manifest.entry_path == "SKILL.md"
|
||||
assert set(manifest.files) == {"SKILL.md", "scripts/run.py"}
|
||||
@@ -51,10 +51,10 @@ def test_valid_skill_normalizes_manifest():
|
||||
assert len(manifest.hash) == 64
|
||||
|
||||
|
||||
def test_name_falls_back_to_heading_without_frontmatter():
|
||||
manifest = _normalize({"SKILL.md": b"# Heading Name\n\nbody"}).manifest
|
||||
assert manifest.name == "Heading Name"
|
||||
assert manifest.description == ""
|
||||
def test_name_and_description_are_required_in_frontmatter():
|
||||
with pytest.raises(SkillPackageError) as exc_info:
|
||||
_normalize({"SKILL.md": b"# heading-name\n\nbody"})
|
||||
assert exc_info.value.code == "missing_skill_name"
|
||||
|
||||
|
||||
def test_shallowest_skill_md_preferred_during_normalization():
|
||||
@@ -155,7 +155,18 @@ def test_validate_and_normalize_strips_deeper_selected_skill_root():
|
||||
({"README.md": b"x"}, "skill.zip", "missing_skill_md"),
|
||||
({"SKILL.md": _SKILL_MD.encode()}, "skill.tar", "unsupported_extension"),
|
||||
({"SKILL.md": b""}, "skill.zip", "empty_skill_md"),
|
||||
({"SKILL.md": b"no name here"}, "skill.zip", "missing_skill_name"),
|
||||
({"SKILL.md": b"---\ndescription: valid\n---\n# no name here"}, "skill.zip", "missing_skill_name"),
|
||||
({"SKILL.md": b"---\nname: pdf-toolkit\n---\n# no description"}, "skill.zip", "missing_skill_description"),
|
||||
(
|
||||
{"SKILL.md": b"---\nname: PDF Toolkit\ndescription: valid\n---\n# invalid name"},
|
||||
"skill.zip",
|
||||
"invalid_skill_name",
|
||||
),
|
||||
(
|
||||
{"SKILL.md": f"---\nname: pdf-toolkit\ndescription: {'x' * 1025}\n---\n# long".encode()},
|
||||
"skill.zip",
|
||||
"invalid_skill_description",
|
||||
),
|
||||
({"SKILL.md": b"\xff\xfenot utf8"}, "skill.zip", "skill_md_not_utf8"),
|
||||
],
|
||||
)
|
||||
@@ -224,10 +235,10 @@ def test_bad_frontmatter_yaml_rejected():
|
||||
assert exc_info.value.code == "invalid_frontmatter"
|
||||
|
||||
|
||||
def test_unterminated_frontmatter_falls_back_to_heading():
|
||||
# leading '---' with no closing fence -> no frontmatter, use the heading
|
||||
manifest = _normalize({"SKILL.md": b"---\n# Heading Wins\nbody"}).manifest
|
||||
assert manifest.name == "Heading Wins"
|
||||
def test_unterminated_frontmatter_rejected():
|
||||
with pytest.raises(SkillPackageError) as exc_info:
|
||||
_normalize({"SKILL.md": b"---\n# heading-wins\nbody"})
|
||||
assert exc_info.value.code == "missing_skill_name"
|
||||
|
||||
|
||||
def test_validate_and_normalize_rejects_files_outside_selected_skill_root():
|
||||
|
||||
@@ -20,7 +20,7 @@ _AGENT_ID = "22222222-2222-2222-2222-222222222222"
|
||||
_USER_ID = "33333333-3333-3333-3333-333333333333"
|
||||
|
||||
_SKILL_MD = b"""---
|
||||
name: PDF Toolkit
|
||||
name: pdf-toolkit
|
||||
description: Work with PDFs.
|
||||
---
|
||||
|
||||
@@ -121,7 +121,7 @@ def test_standardize_creates_drive_owned_toolfiles_and_commits_archive_manifest(
|
||||
assert skill_row.is_skill is True
|
||||
assert skill_row.skill_metadata is not None
|
||||
skill_metadata = DriveSkillMetadata.model_validate_json(skill_row.skill_metadata)
|
||||
assert skill_metadata.name == "PDF Toolkit"
|
||||
assert skill_metadata.name == "pdf-toolkit"
|
||||
assert skill_metadata.manifest_files == ["SKILL.md", "scripts/run.py"]
|
||||
assert archive_row.file_kind == AgentDriveFileKind.TOOL_FILE
|
||||
assert archive_row.file_id == archive_tool_file.id
|
||||
@@ -132,7 +132,7 @@ def test_standardize_creates_drive_owned_toolfiles_and_commits_archive_manifest(
|
||||
# The returned upload response carries only the drive-derived fields the UI needs.
|
||||
skill = result["skill"]
|
||||
assert skill["path"] == "pdf-toolkit"
|
||||
assert skill["name"] == "PDF Toolkit"
|
||||
assert skill["name"] == "pdf-toolkit"
|
||||
assert skill["archive_key"] == "pdf-toolkit/.DIFY-SKILL-FULL.zip"
|
||||
assert skill["skill_md_key"] == "pdf-toolkit/SKILL.md"
|
||||
assert result["manifest"]["entry_path"] == "SKILL.md"
|
||||
|
||||
+213
-270
@@ -10,14 +10,19 @@ import io
|
||||
import json
|
||||
import logging
|
||||
import zipfile
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from unittest.mock import Mock, create_autospec, patch
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import Column, Integer, MetaData, String, Table
|
||||
from sqlalchemy import Column, Engine, Integer, MetaData, String, Table, delete, event, func, select
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from libs.archive_storage import ArchiveStorageNotConfiguredError
|
||||
from models.enums import CreatorUserRole
|
||||
from models.trigger import WorkflowTriggerLog
|
||||
from models.workflow import (
|
||||
WorkflowAppLog,
|
||||
@@ -28,6 +33,7 @@ from models.workflow import (
|
||||
WorkflowPauseReason,
|
||||
WorkflowRun,
|
||||
)
|
||||
from services.retention.workflow_run import restore_archived_workflow_run as restore_module
|
||||
from services.retention.workflow_run.restore_archived_workflow_run import (
|
||||
SCHEMA_MAPPERS,
|
||||
TABLE_MODELS,
|
||||
@@ -36,24 +42,49 @@ from services.retention.workflow_run.restore_archived_workflow_run import (
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Database:
|
||||
"""Explicit SQLite engine, caller session, and real service-owned session factory."""
|
||||
|
||||
engine: Engine
|
||||
session: Session
|
||||
session_maker: sessionmaker[Session]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def database(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> Iterator[Database]:
|
||||
WorkflowRun.metadata.create_all(
|
||||
sqlite_engine,
|
||||
tables=[WorkflowRun.__table__, WorkflowAppLog.__table__, WorkflowArchiveLog.__table__],
|
||||
)
|
||||
session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
with session_maker() as session:
|
||||
database = Database(engine=sqlite_engine, session=session, session_maker=session_maker)
|
||||
monkeypatch.setattr(restore_module, "db", database)
|
||||
# Production constructs PostgreSQL's equivalent statement; SQLite's
|
||||
# dialect keeps the conflict behavior executable in these tests.
|
||||
monkeypatch.setattr(restore_module, "pg_insert", sqlite_insert)
|
||||
yield database
|
||||
|
||||
|
||||
class WorkflowRunRestoreTestDataFactory:
|
||||
"""
|
||||
Factory for creating test data and mock objects.
|
||||
Factory for creating persisted-model-compatible test data.
|
||||
|
||||
Provides reusable methods to create consistent mock objects for testing
|
||||
workflow run restore operations.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def create_workflow_run_mock(
|
||||
def create_workflow_run(
|
||||
run_id: str = "run-123",
|
||||
tenant_id: str = "tenant-123",
|
||||
app_id: str = "app-123",
|
||||
created_at: datetime | None = None,
|
||||
**kwargs,
|
||||
) -> Mock:
|
||||
) -> WorkflowRun:
|
||||
"""
|
||||
Create a mock WorkflowRun object.
|
||||
Create a concrete WorkflowRun object.
|
||||
|
||||
Args:
|
||||
run_id: Unique identifier for the workflow run
|
||||
@@ -63,27 +94,44 @@ class WorkflowRunRestoreTestDataFactory:
|
||||
**kwargs: Additional attributes to set on the mock
|
||||
|
||||
Returns:
|
||||
Mock WorkflowRun object with specified attributes
|
||||
WorkflowRun object with specified attributes
|
||||
"""
|
||||
run = create_autospec(WorkflowRun, instance=True)
|
||||
run.id = run_id
|
||||
run.tenant_id = tenant_id
|
||||
run.app_id = app_id
|
||||
run.created_at = created_at or datetime(2024, 1, 1, 12, 0, 0)
|
||||
for key, value in kwargs.items():
|
||||
setattr(run, key, value)
|
||||
attrs = {
|
||||
"id": run_id,
|
||||
"tenant_id": tenant_id,
|
||||
"app_id": app_id,
|
||||
"workflow_id": "workflow-123",
|
||||
"type": "workflow",
|
||||
"triggered_from": "app-run",
|
||||
"version": "1",
|
||||
"graph": None,
|
||||
"inputs": None,
|
||||
"status": "succeeded",
|
||||
"outputs": "{}",
|
||||
"error": None,
|
||||
"elapsed_time": 0,
|
||||
"total_tokens": 0,
|
||||
"total_steps": 0,
|
||||
"created_by_role": CreatorUserRole.ACCOUNT,
|
||||
"created_by": "user-123",
|
||||
"created_at": created_at or datetime(2024, 1, 1, 12, 0, 0),
|
||||
"finished_at": None,
|
||||
"exceptions_count": 0,
|
||||
}
|
||||
attrs.update(kwargs)
|
||||
run = WorkflowRun(**attrs)
|
||||
return run
|
||||
|
||||
@staticmethod
|
||||
def create_workflow_archive_log_mock(
|
||||
def create_workflow_archive_log(
|
||||
run_id: str = "run-123",
|
||||
tenant_id: str = "tenant-123",
|
||||
app_id: str = "app-123",
|
||||
created_at: datetime | None = None,
|
||||
**kwargs,
|
||||
) -> Mock:
|
||||
) -> WorkflowArchiveLog:
|
||||
"""
|
||||
Create a mock WorkflowArchiveLog object.
|
||||
Create a concrete WorkflowArchiveLog object.
|
||||
|
||||
Args:
|
||||
run_id: Unique identifier for the workflow run
|
||||
@@ -93,16 +141,32 @@ class WorkflowRunRestoreTestDataFactory:
|
||||
**kwargs: Additional attributes to set on the mock
|
||||
|
||||
Returns:
|
||||
Mock WorkflowArchiveLog object with specified attributes
|
||||
WorkflowArchiveLog object with specified attributes
|
||||
"""
|
||||
archive_log = create_autospec(WorkflowArchiveLog, instance=True)
|
||||
archive_log.workflow_run_id = run_id
|
||||
archive_log.tenant_id = tenant_id
|
||||
archive_log.app_id = app_id
|
||||
archive_log.run_created_at = created_at or datetime(2024, 1, 1, 12, 0, 0)
|
||||
for key, value in kwargs.items():
|
||||
setattr(archive_log, key, value)
|
||||
return archive_log
|
||||
attrs = {
|
||||
"tenant_id": tenant_id,
|
||||
"app_id": app_id,
|
||||
"workflow_id": "workflow-123",
|
||||
"workflow_run_id": run_id,
|
||||
"created_by_role": CreatorUserRole.ACCOUNT,
|
||||
"created_by": "user-123",
|
||||
"log_id": None,
|
||||
"log_created_at": None,
|
||||
"log_created_from": None,
|
||||
"run_version": "1",
|
||||
"run_status": "succeeded",
|
||||
"run_triggered_from": "app-run",
|
||||
"run_error": None,
|
||||
"run_elapsed_time": 0,
|
||||
"run_total_tokens": 0,
|
||||
"run_total_steps": 0,
|
||||
"run_created_at": created_at or datetime(2024, 1, 1, 12, 0, 0),
|
||||
"run_finished_at": None,
|
||||
"run_exceptions_count": 0,
|
||||
"trigger_metadata": None,
|
||||
}
|
||||
attrs.update(kwargs)
|
||||
return WorkflowArchiveLog(**attrs)
|
||||
|
||||
@staticmethod
|
||||
def create_archive_zip_mock(
|
||||
@@ -137,7 +201,7 @@ class WorkflowRunRestoreTestDataFactory:
|
||||
"app_id": "app-123",
|
||||
"workflow_id": "workflow-123",
|
||||
"type": "workflow",
|
||||
"triggered_from": "app",
|
||||
"triggered_from": "app-run",
|
||||
"version": "1",
|
||||
"status": "succeeded",
|
||||
"created_by_role": "account",
|
||||
@@ -151,7 +215,7 @@ class WorkflowRunRestoreTestDataFactory:
|
||||
"app_id": "app-123",
|
||||
"workflow_id": "workflow-123",
|
||||
"workflow_run_id": "run-123",
|
||||
"created_from": "app",
|
||||
"created_from": "service-api",
|
||||
"created_by_role": "account",
|
||||
"created_by": "user-123",
|
||||
},
|
||||
@@ -161,7 +225,7 @@ class WorkflowRunRestoreTestDataFactory:
|
||||
"app_id": "app-123",
|
||||
"workflow_id": "workflow-123",
|
||||
"workflow_run_id": "run-123",
|
||||
"created_from": "app",
|
||||
"created_from": "service-api",
|
||||
"created_by_role": "account",
|
||||
"created_by": "user-123",
|
||||
},
|
||||
@@ -225,14 +289,10 @@ class TestGetWorkflowRunRepo:
|
||||
"""Tests for WorkflowRunRestore._get_workflow_run_repo method."""
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.DifyAPIRepositoryFactory")
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.sessionmaker")
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.db")
|
||||
def test_first_call_creates_repo(self, mock_db, mock_sessionmaker, mock_factory):
|
||||
def test_first_call_creates_repo(self, mock_factory, database: Database):
|
||||
"""First call should create and cache repository."""
|
||||
restore = WorkflowRunRestore()
|
||||
|
||||
mock_session = Mock()
|
||||
mock_sessionmaker.return_value = mock_session
|
||||
mock_repo = Mock()
|
||||
mock_factory.create_api_workflow_run_repository.return_value = mock_repo
|
||||
|
||||
@@ -240,8 +300,9 @@ class TestGetWorkflowRunRepo:
|
||||
|
||||
assert result is mock_repo
|
||||
assert restore.workflow_run_repo is mock_repo
|
||||
mock_sessionmaker.assert_called_once_with(bind=mock_db.engine, expire_on_commit=False)
|
||||
mock_factory.create_api_workflow_run_repository.assert_called_once_with(mock_session)
|
||||
session_maker = mock_factory.create_api_workflow_run_repository.call_args.args[0]
|
||||
assert isinstance(session_maker, sessionmaker)
|
||||
assert session_maker.kw["bind"] is database.engine
|
||||
|
||||
def test_cached_repo_returned(self):
|
||||
"""Subsequent calls should return cached repository."""
|
||||
@@ -492,47 +553,27 @@ class TestGetModelColumnInfo:
|
||||
class TestRestoreTableRecords:
|
||||
"""Tests for WorkflowRunRestore._restore_table_records method."""
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.TABLE_MODELS")
|
||||
def test_unknown_table_returns_zero(self, mock_table_models, caplog: pytest.LogCaptureFixture):
|
||||
def test_unknown_table_returns_zero(self, database: Database, caplog: pytest.LogCaptureFixture):
|
||||
"""Should return 0 for unknown table."""
|
||||
restore = WorkflowRunRestore()
|
||||
mock_table_models.get.return_value = None
|
||||
|
||||
mock_session = Mock()
|
||||
records = [{"id": "test"}]
|
||||
caplog.set_level(logging.WARNING, logger="services.retention.workflow_run.restore_archived_workflow_run")
|
||||
|
||||
result = restore._restore_table_records(mock_session, "unknown_table", records, schema_version="1.0")
|
||||
result = restore._restore_table_records(database.session, "unknown_table", records, schema_version="1.0")
|
||||
|
||||
assert result == 0
|
||||
assert "Unknown table: unknown_table" in caplog.messages
|
||||
|
||||
def test_empty_records_returns_zero(self):
|
||||
def test_empty_records_returns_zero(self, database: Database):
|
||||
"""Should return 0 for empty records list."""
|
||||
restore = WorkflowRunRestore()
|
||||
mock_session = Mock()
|
||||
|
||||
result = restore._restore_table_records(mock_session, "workflow_runs", [], schema_version="1.0")
|
||||
result = restore._restore_table_records(database.session, "workflow_runs", [], schema_version="1.0")
|
||||
assert result == 0
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.pg_insert")
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.cast")
|
||||
def test_successful_restore(self, mock_cast, mock_pg_insert):
|
||||
def test_successful_restore(self, database: Database):
|
||||
"""Should successfully restore records."""
|
||||
restore = WorkflowRunRestore()
|
||||
|
||||
# Mock session and execution
|
||||
mock_session = Mock()
|
||||
mock_result = Mock()
|
||||
mock_result.rowcount = 2
|
||||
mock_session.execute.return_value = mock_result
|
||||
mock_cast.return_value = mock_result
|
||||
|
||||
# Mock insert statement
|
||||
mock_stmt = Mock()
|
||||
mock_stmt.on_conflict_do_nothing.return_value = mock_stmt
|
||||
mock_pg_insert.return_value = mock_stmt
|
||||
|
||||
records = [
|
||||
{
|
||||
"id": "test1",
|
||||
@@ -540,7 +581,7 @@ class TestRestoreTableRecords:
|
||||
"app_id": "app-123",
|
||||
"workflow_id": "workflow-123",
|
||||
"type": "workflow",
|
||||
"triggered_from": "app",
|
||||
"triggered_from": "app-run",
|
||||
"version": "1",
|
||||
"status": "succeeded",
|
||||
"created_by_role": "account",
|
||||
@@ -552,7 +593,7 @@ class TestRestoreTableRecords:
|
||||
"app_id": "app-123",
|
||||
"workflow_id": "workflow-123",
|
||||
"type": "workflow",
|
||||
"triggered_from": "app",
|
||||
"triggered_from": "app-run",
|
||||
"version": "1",
|
||||
"status": "succeeded",
|
||||
"created_by_role": "account",
|
||||
@@ -560,38 +601,20 @@ class TestRestoreTableRecords:
|
||||
},
|
||||
]
|
||||
|
||||
result = restore._restore_table_records(mock_session, "workflow_runs", records, schema_version="1.0")
|
||||
result = restore._restore_table_records(database.session, "workflow_runs", records, schema_version="1.0")
|
||||
|
||||
assert result == 2
|
||||
mock_session.execute.assert_called_once()
|
||||
assert database.session.scalar(select(func.count(WorkflowRun.id))) == 2
|
||||
assert restore._restore_table_records(database.session, "workflow_runs", records, schema_version="1.0") == 0
|
||||
|
||||
def test_missing_required_columns_raises_error(self):
|
||||
def test_missing_required_columns_raises_error(self, database: Database):
|
||||
"""Should raise ValueError for missing required columns."""
|
||||
restore = WorkflowRunRestore()
|
||||
|
||||
mock_session = Mock()
|
||||
# Use a dedicated mock model to isolate required-column validation behavior.
|
||||
mock_model = Mock()
|
||||
records = [{"id": "test"}]
|
||||
|
||||
# Mock a required column
|
||||
required_column = Mock()
|
||||
required_column.key = "required_field"
|
||||
required_column.nullable = False
|
||||
required_column.default = None
|
||||
required_column.server_default = None
|
||||
required_column.autoincrement = False
|
||||
required_column.type = Mock()
|
||||
|
||||
# Mock the __table__ attribute properly
|
||||
mock_table = Mock()
|
||||
mock_table.columns = [required_column]
|
||||
mock_model.__table__ = mock_table
|
||||
|
||||
records = [{"name": "test"}] # Missing required 'required_field'
|
||||
|
||||
with patch.dict(TABLE_MODELS, {"test_table": mock_model}):
|
||||
with pytest.raises(ValueError, match="Missing required columns for test_table"):
|
||||
restore._restore_table_records(mock_session, "test_table", records, schema_version="1.0")
|
||||
with pytest.raises(ValueError, match="Missing required columns for workflow_runs"):
|
||||
restore._restore_table_records(database.session, "workflow_runs", records, schema_version="1.0")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -603,38 +626,38 @@ class TestRestoreFromRun:
|
||||
"""Tests for WorkflowRunRestore._restore_from_run method."""
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
|
||||
def test_archive_storage_not_configured(self, mock_get_storage):
|
||||
def test_archive_storage_not_configured(self, mock_get_storage, database: Database):
|
||||
"""Should handle ArchiveStorageNotConfiguredError."""
|
||||
restore = WorkflowRunRestore()
|
||||
mock_get_storage.side_effect = ArchiveStorageNotConfiguredError("Storage not configured")
|
||||
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run_mock()
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run()
|
||||
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
|
||||
result = restore._restore_from_run(run, session_maker=lambda: Mock())
|
||||
result = restore._restore_from_run(run, session_maker=database.session_maker)
|
||||
|
||||
assert result.success is False
|
||||
assert "Storage not configured" in result.error
|
||||
assert result.elapsed_time > 0
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
|
||||
def test_archive_bundle_not_found(self, mock_get_storage):
|
||||
def test_archive_bundle_not_found(self, mock_get_storage, database: Database):
|
||||
"""Should handle FileNotFoundError when archive bundle is missing."""
|
||||
restore = WorkflowRunRestore()
|
||||
mock_storage = Mock()
|
||||
mock_storage.get_object.side_effect = FileNotFoundError("Bundle not found")
|
||||
mock_get_storage.return_value = mock_storage
|
||||
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run_mock()
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run()
|
||||
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
|
||||
result = restore._restore_from_run(run, session_maker=lambda: Mock())
|
||||
result = restore._restore_from_run(run, session_maker=database.session_maker)
|
||||
|
||||
assert result.success is False
|
||||
assert "Archive bundle not found" in result.error
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
|
||||
def test_dry_run_mode(self, mock_get_storage):
|
||||
def test_dry_run_mode(self, mock_get_storage, database: Database):
|
||||
"""Should handle dry run mode correctly."""
|
||||
restore = WorkflowRunRestore(dry_run=True)
|
||||
|
||||
@@ -644,23 +667,16 @@ class TestRestoreFromRun:
|
||||
mock_storage.get_object.return_value = archive_data
|
||||
mock_get_storage.return_value = mock_storage
|
||||
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run_mock()
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run()
|
||||
|
||||
# Create a proper mock session with context manager support
|
||||
mock_session = Mock()
|
||||
mock_session.__enter__ = Mock(return_value=mock_session)
|
||||
mock_session.__exit__ = Mock(return_value=None)
|
||||
|
||||
result = restore._restore_from_run(run, session_maker=lambda: mock_session)
|
||||
result = restore._restore_from_run(run, session_maker=database.session_maker)
|
||||
|
||||
assert result.success is True
|
||||
assert result.restored_counts["workflow_runs"] == 1
|
||||
assert result.restored_counts["workflow_app_logs"] == 2
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.pg_insert")
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.cast")
|
||||
def test_successful_restore(self, mock_cast, mock_pg_insert, mock_get_storage):
|
||||
def test_successful_restore(self, mock_get_storage, database: Database):
|
||||
"""Should successfully restore from archive."""
|
||||
restore = WorkflowRunRestore()
|
||||
|
||||
@@ -670,53 +686,57 @@ class TestRestoreFromRun:
|
||||
mock_storage.get_object.return_value = archive_data
|
||||
mock_get_storage.return_value = mock_storage
|
||||
|
||||
# Mock session with context manager support
|
||||
mock_session = Mock()
|
||||
mock_session.__enter__ = Mock(return_value=mock_session)
|
||||
mock_session.__exit__ = Mock(return_value=None)
|
||||
|
||||
def session_maker():
|
||||
return mock_session
|
||||
|
||||
# Mock database execution to return integer counts
|
||||
mock_result_workflow_runs = Mock()
|
||||
mock_result_workflow_runs.rowcount = 1
|
||||
mock_result_app_logs = Mock()
|
||||
mock_result_app_logs.rowcount = 2
|
||||
|
||||
# Configure session.execute to return different results based on the table
|
||||
def mock_execute(stmt):
|
||||
if "workflow_runs" in str(stmt):
|
||||
return mock_result_workflow_runs
|
||||
else:
|
||||
return mock_result_app_logs
|
||||
|
||||
mock_session.execute.side_effect = mock_execute
|
||||
mock_cast.return_value = mock_result_workflow_runs
|
||||
|
||||
# Mock insert statement
|
||||
mock_stmt = Mock()
|
||||
mock_stmt.on_conflict_do_nothing.return_value = mock_stmt
|
||||
mock_pg_insert.return_value = mock_stmt
|
||||
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run_mock()
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log()
|
||||
database.session.add(archive_log)
|
||||
database.session.commit()
|
||||
|
||||
# Mock repository methods
|
||||
with patch.object(restore, "_get_workflow_run_repo") as mock_get_repo:
|
||||
mock_repo = Mock()
|
||||
mock_repo.delete_archive_log_by_run_id.side_effect = lambda session, run_id: session.execute(
|
||||
delete(WorkflowArchiveLog).where(WorkflowArchiveLog.workflow_run_id == run_id)
|
||||
)
|
||||
mock_get_repo.return_value = mock_repo
|
||||
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
|
||||
result = restore._restore_from_run(run, session_maker=session_maker)
|
||||
result = restore._restore_from_run(run, session_maker=database.session_maker)
|
||||
|
||||
assert result.success is True
|
||||
assert result.restored_counts["workflow_runs"] == 1
|
||||
assert result.restored_counts["workflow_app_logs"] >= 1 # Just check it's restored
|
||||
mock_session.commit.assert_called_once()
|
||||
mock_repo.delete_archive_log_by_run_id.assert_called_once_with(mock_session, run.id)
|
||||
assert result.restored_counts["workflow_app_logs"] == 2
|
||||
database.session.expire_all()
|
||||
assert database.session.scalar(select(func.count(WorkflowRun.id))) == 1
|
||||
assert database.session.scalar(select(func.count(WorkflowAppLog.id))) == 2
|
||||
assert database.session.scalar(select(func.count(WorkflowArchiveLog.id))) == 0
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
|
||||
def test_invalid_archive_bundle(self, mock_get_storage):
|
||||
def test_insert_failure_rolls_back_all_tables(self, mock_get_storage, database: Database):
|
||||
"""A later table failure must roll back earlier restored rows."""
|
||||
restore = WorkflowRunRestore()
|
||||
mock_storage = Mock()
|
||||
mock_storage.get_object.return_value = WorkflowRunRestoreTestDataFactory.create_archive_zip_mock()
|
||||
mock_get_storage.return_value = mock_storage
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run()
|
||||
|
||||
def fail_app_log_insert(_connection, _cursor, statement, _parameters, _context, _executemany):
|
||||
if statement.startswith("INSERT INTO workflow_app_logs"):
|
||||
raise RuntimeError("forced app-log insert failure")
|
||||
|
||||
event.listen(database.engine, "before_cursor_execute", fail_app_log_insert)
|
||||
try:
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click"):
|
||||
result = restore._restore_from_run(run, session_maker=database.session_maker)
|
||||
finally:
|
||||
event.remove(database.engine, "before_cursor_execute", fail_app_log_insert)
|
||||
|
||||
assert result.success is False
|
||||
assert result.error == "forced app-log insert failure"
|
||||
assert database.session.scalar(select(func.count(WorkflowRun.id))) == 0
|
||||
assert database.session.scalar(select(func.count(WorkflowAppLog.id))) == 0
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
|
||||
def test_invalid_archive_bundle(self, mock_get_storage, database: Database):
|
||||
"""Should handle invalid archive bundle."""
|
||||
restore = WorkflowRunRestore()
|
||||
|
||||
@@ -725,22 +745,17 @@ class TestRestoreFromRun:
|
||||
mock_storage.get_object.return_value = b"invalid zip data"
|
||||
mock_get_storage.return_value = mock_storage
|
||||
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run_mock()
|
||||
|
||||
# Create proper mock session
|
||||
mock_session = Mock()
|
||||
mock_session.__enter__ = Mock(return_value=mock_session)
|
||||
mock_session.__exit__ = Mock(return_value=None)
|
||||
run = WorkflowRunRestoreTestDataFactory.create_workflow_run()
|
||||
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
|
||||
result = restore._restore_from_run(run, session_maker=lambda: mock_session)
|
||||
result = restore._restore_from_run(run, session_maker=database.session_maker)
|
||||
|
||||
assert result.success is False
|
||||
# The error message comes from zipfile.BadZipFile which says "File is not a zip file"
|
||||
assert "File is not a zip file" in result.error
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
|
||||
def test_workflow_archive_log_input(self, mock_get_storage):
|
||||
def test_workflow_archive_log_input(self, mock_get_storage, database: Database):
|
||||
"""Should handle WorkflowArchiveLog input correctly."""
|
||||
restore = WorkflowRunRestore(dry_run=True)
|
||||
|
||||
@@ -750,14 +765,11 @@ class TestRestoreFromRun:
|
||||
mock_storage.get_object.return_value = archive_data
|
||||
mock_get_storage.return_value = mock_storage
|
||||
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log()
|
||||
database.session.add(archive_log)
|
||||
database.session.commit()
|
||||
|
||||
# Create proper mock session
|
||||
mock_session = Mock()
|
||||
mock_session.__enter__ = Mock(return_value=mock_session)
|
||||
mock_session.__exit__ = Mock(return_value=None)
|
||||
|
||||
result = restore._restore_from_run(archive_log, session_maker=lambda: mock_session)
|
||||
result = restore._restore_from_run(archive_log, session_maker=database.session_maker)
|
||||
|
||||
assert result.success is True
|
||||
assert result.run_id == archive_log.workflow_run_id
|
||||
@@ -772,39 +784,29 @@ class TestRestoreFromRun:
|
||||
class TestRestoreBatch:
|
||||
"""Tests for WorkflowRunRestore.restore_batch method."""
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.sessionmaker")
|
||||
def test_empty_tenant_ids_returns_empty(self, mock_sessionmaker):
|
||||
def test_empty_tenant_ids_returns_empty(self, database: Database):
|
||||
"""Should return empty list when tenant_ids is empty list."""
|
||||
restore = WorkflowRunRestore()
|
||||
|
||||
# Mock db.engine to avoid SQLAlchemy issues
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db:
|
||||
mock_db.engine = Mock()
|
||||
result = restore.restore_batch(
|
||||
tenant_ids=[],
|
||||
start_date=datetime(2024, 1, 1),
|
||||
end_date=datetime(2024, 1, 2),
|
||||
)
|
||||
result = restore.restore_batch(
|
||||
tenant_ids=[],
|
||||
start_date=datetime(2024, 1, 1),
|
||||
end_date=datetime(2024, 1, 2),
|
||||
)
|
||||
|
||||
assert result == []
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.ThreadPoolExecutor")
|
||||
def test_successful_batch_restore(self, mock_executor):
|
||||
def test_successful_batch_restore(self, mock_executor, database: Database):
|
||||
"""Should successfully restore batch of workflow runs."""
|
||||
restore = WorkflowRunRestore(workers=2)
|
||||
|
||||
# Mock session that supports context manager protocol
|
||||
mock_session = Mock()
|
||||
mock_session.__enter__ = Mock(return_value=mock_session)
|
||||
mock_session.__exit__ = Mock(return_value=None)
|
||||
|
||||
# Mock session factory that returns context manager sessions
|
||||
mock_session_factory = Mock(return_value=mock_session)
|
||||
|
||||
# Mock repository and archive logs
|
||||
mock_repo = Mock()
|
||||
archive_log1 = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock("run-1")
|
||||
archive_log2 = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock("run-2")
|
||||
archive_log1 = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log("run-1")
|
||||
archive_log2 = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log("run-2")
|
||||
database.session.add_all([archive_log1, archive_log2])
|
||||
database.session.commit()
|
||||
mock_repo.get_archived_logs_by_time_range.return_value = [archive_log1, archive_log2]
|
||||
|
||||
# Mock restore results
|
||||
@@ -821,38 +823,25 @@ class TestRestoreBatch:
|
||||
with patch.object(restore, "_get_workflow_run_repo", return_value=mock_repo):
|
||||
with patch.object(restore, "_restore_from_run", side_effect=[result1, result2]):
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
|
||||
# Mock sessionmaker and db.engine to avoid SQLAlchemy issues
|
||||
with patch(
|
||||
"services.retention.workflow_run.restore_archived_workflow_run.sessionmaker"
|
||||
) as mock_sessionmaker:
|
||||
mock_sessionmaker.return_value = mock_session_factory
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db:
|
||||
mock_db.engine = Mock()
|
||||
results = restore.restore_batch(
|
||||
tenant_ids=["tenant-1"],
|
||||
start_date=datetime(2024, 1, 1),
|
||||
end_date=datetime(2024, 1, 2),
|
||||
)
|
||||
results = restore.restore_batch(
|
||||
tenant_ids=["tenant-1"],
|
||||
start_date=datetime(2024, 1, 1),
|
||||
end_date=datetime(2024, 1, 2),
|
||||
)
|
||||
|
||||
assert len(results) == 2
|
||||
assert results[0].run_id == "run-1"
|
||||
assert results[1].run_id == "run-2"
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.ThreadPoolExecutor")
|
||||
def test_dry_run_batch_restore(self, mock_executor):
|
||||
def test_dry_run_batch_restore(self, mock_executor, database: Database):
|
||||
"""Should handle dry run mode for batch restore."""
|
||||
restore = WorkflowRunRestore(dry_run=True)
|
||||
|
||||
# Mock session that supports context manager protocol
|
||||
mock_session = Mock()
|
||||
mock_session.__enter__ = Mock(return_value=mock_session)
|
||||
mock_session.__exit__ = Mock(return_value=None)
|
||||
|
||||
# Mock session factory that returns context manager sessions
|
||||
mock_session_factory = Mock(return_value=mock_session)
|
||||
|
||||
mock_repo = Mock()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log()
|
||||
database.session.add(archive_log)
|
||||
database.session.commit()
|
||||
mock_repo.get_archived_logs_by_time_range.return_value = [archive_log]
|
||||
|
||||
result = RestoreResult(run_id="run-1", tenant_id="tenant-1", success=True, restored_counts={"workflow_runs": 1})
|
||||
@@ -867,18 +856,11 @@ class TestRestoreBatch:
|
||||
with patch.object(restore, "_get_workflow_run_repo", return_value=mock_repo):
|
||||
with patch.object(restore, "_restore_from_run", return_value=result):
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
|
||||
# Mock sessionmaker and db.engine to avoid SQLAlchemy issues
|
||||
with patch(
|
||||
"services.retention.workflow_run.restore_archived_workflow_run.sessionmaker"
|
||||
) as mock_sessionmaker:
|
||||
mock_sessionmaker.return_value = mock_session_factory
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db:
|
||||
mock_db.engine = Mock()
|
||||
results = restore.restore_batch(
|
||||
tenant_ids=["tenant-1"],
|
||||
start_date=datetime(2024, 1, 1),
|
||||
end_date=datetime(2024, 1, 2),
|
||||
)
|
||||
results = restore.restore_batch(
|
||||
tenant_ids=["tenant-1"],
|
||||
start_date=datetime(2024, 1, 1),
|
||||
end_date=datetime(2024, 1, 2),
|
||||
)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].success is True
|
||||
@@ -907,16 +889,14 @@ class TestRestoreByRunId:
|
||||
assert "not found" in result.error
|
||||
assert result.run_id == "nonexistent-run"
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.sessionmaker")
|
||||
def test_successful_restore_by_id(self, mock_sessionmaker):
|
||||
def test_successful_restore_by_id(self, database: Database):
|
||||
"""Should successfully restore by run ID."""
|
||||
restore = WorkflowRunRestore()
|
||||
|
||||
mock_session = Mock()
|
||||
mock_sessionmaker.return_value = mock_session
|
||||
|
||||
mock_repo = Mock()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log()
|
||||
database.session.add(archive_log)
|
||||
database.session.commit()
|
||||
mock_repo.get_archived_log_by_run_id.return_value = archive_log
|
||||
|
||||
result = RestoreResult(run_id="run-1", tenant_id="tenant-1", success=True, restored_counts={})
|
||||
@@ -924,24 +904,19 @@ class TestRestoreByRunId:
|
||||
with patch.object(restore, "_get_workflow_run_repo", return_value=mock_repo):
|
||||
with patch.object(restore, "_restore_from_run", return_value=result):
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
|
||||
# Mock db.engine to avoid SQLAlchemy issues
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db:
|
||||
mock_db.engine = Mock()
|
||||
actual_result = restore.restore_by_run_id("run-1")
|
||||
actual_result = restore.restore_by_run_id("run-1")
|
||||
|
||||
assert actual_result.success is True
|
||||
assert actual_result.run_id == "run-1"
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.sessionmaker")
|
||||
def test_dry_run_restore_by_id(self, mock_sessionmaker):
|
||||
def test_dry_run_restore_by_id(self, database: Database):
|
||||
"""Should handle dry run mode for restore by ID."""
|
||||
restore = WorkflowRunRestore(dry_run=True)
|
||||
|
||||
mock_session = Mock()
|
||||
mock_sessionmaker.return_value = mock_session
|
||||
|
||||
mock_repo = Mock()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log()
|
||||
database.session.add(archive_log)
|
||||
database.session.commit()
|
||||
mock_repo.get_archived_log_by_run_id.return_value = archive_log
|
||||
|
||||
result = RestoreResult(run_id="run-1", tenant_id="tenant-1", success=True, restored_counts={"workflow_runs": 1})
|
||||
@@ -949,10 +924,7 @@ class TestRestoreByRunId:
|
||||
with patch.object(restore, "_get_workflow_run_repo", return_value=mock_repo):
|
||||
with patch.object(restore, "_restore_from_run", return_value=result):
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
|
||||
# Mock db.engine to avoid SQLAlchemy issues
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db:
|
||||
mock_db.engine = Mock()
|
||||
actual_result = restore.restore_by_run_id("run-1")
|
||||
actual_result = restore.restore_by_run_id("run-1")
|
||||
|
||||
assert actual_result.success is True
|
||||
assert actual_result.run_id == "run-1"
|
||||
@@ -1038,8 +1010,7 @@ class TestIntegration:
|
||||
"""Integration tests combining multiple components."""
|
||||
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
|
||||
@patch("services.retention.workflow_run.restore_archived_workflow_run.ThreadPoolExecutor")
|
||||
def test_full_restore_flow(self, mock_executor, mock_get_storage):
|
||||
def test_full_restore_flow(self, mock_get_storage, database: Database):
|
||||
"""Test complete restore flow with all components."""
|
||||
restore = WorkflowRunRestore(workers=1)
|
||||
|
||||
@@ -1059,7 +1030,7 @@ class TestIntegration:
|
||||
"app_id": "app-123",
|
||||
"workflow_id": "workflow-123",
|
||||
"type": "workflow",
|
||||
"triggered_from": "app",
|
||||
"triggered_from": "app-run",
|
||||
"version": "1",
|
||||
"status": "succeeded",
|
||||
"created_by_role": "account",
|
||||
@@ -1072,48 +1043,20 @@ class TestIntegration:
|
||||
mock_storage.get_object.return_value = archive_data
|
||||
mock_get_storage.return_value = mock_storage
|
||||
|
||||
# Mock session that supports context manager protocol
|
||||
mock_session = Mock()
|
||||
mock_session.__enter__ = Mock(return_value=mock_session)
|
||||
mock_session.__exit__ = Mock(return_value=None)
|
||||
|
||||
# Mock session factory that returns context manager sessions
|
||||
mock_session_factory = Mock(return_value=mock_session)
|
||||
|
||||
mock_result = Mock()
|
||||
mock_result.rowcount = 1
|
||||
mock_session.execute.return_value = mock_result
|
||||
|
||||
# Mock repository
|
||||
mock_repo = Mock()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock()
|
||||
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log()
|
||||
database.session.add(archive_log)
|
||||
database.session.commit()
|
||||
mock_repo.get_archived_log_by_run_id.return_value = archive_log
|
||||
|
||||
# Mock ThreadPoolExecutor (not actually used in restore_by_run_id but needed for patch)
|
||||
mock_executor_instance = Mock()
|
||||
mock_executor_instance.__enter__ = Mock(return_value=mock_executor_instance)
|
||||
mock_executor_instance.__exit__ = Mock(return_value=None)
|
||||
mock_executor_instance.map = Mock(return_value=[])
|
||||
mock_executor.return_value = mock_executor_instance
|
||||
mock_repo.delete_archive_log_by_run_id.side_effect = lambda session, run_id: session.execute(
|
||||
delete(WorkflowArchiveLog).where(WorkflowArchiveLog.workflow_run_id == run_id)
|
||||
)
|
||||
|
||||
with patch.object(restore, "_get_workflow_run_repo", return_value=mock_repo):
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.pg_insert") as mock_insert:
|
||||
mock_stmt = Mock()
|
||||
mock_stmt.on_conflict_do_nothing.return_value = mock_stmt
|
||||
mock_insert.return_value = mock_stmt
|
||||
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.cast") as mock_cast:
|
||||
mock_cast.return_value = mock_result
|
||||
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
|
||||
# Mock sessionmaker and db.engine to avoid SQLAlchemy issues
|
||||
with patch(
|
||||
"services.retention.workflow_run.restore_archived_workflow_run.sessionmaker"
|
||||
) as mock_sessionmaker:
|
||||
mock_sessionmaker.return_value = mock_session_factory
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db:
|
||||
mock_db.engine = Mock()
|
||||
result = restore.restore_by_run_id("run-123")
|
||||
with patch("services.retention.workflow_run.restore_archived_workflow_run.click"):
|
||||
result = restore.restore_by_run_id("run-123")
|
||||
|
||||
assert result.success is True
|
||||
assert result.restored_counts.get("workflow_runs") == 1
|
||||
assert database.session.scalar(select(func.count(WorkflowRun.id))) == 1
|
||||
|
||||
@@ -63,6 +63,7 @@ def _target(
|
||||
) -> AgentConfigTarget:
|
||||
agent_soul = soul or _soul()
|
||||
return AgentConfigTarget(
|
||||
tenant_id=TENANT,
|
||||
agent_id=AGENT,
|
||||
version_id=version_id,
|
||||
kind=kind,
|
||||
@@ -508,7 +509,9 @@ def test_manifest_uses_items_shape_without_download_urls() -> None:
|
||||
),
|
||||
)
|
||||
|
||||
manifest = AgentConfigService._manifest_for_target(target)
|
||||
with patch(f"{MODULE}.SkillManagementService") as skill_management_service:
|
||||
skill_management_service.return_value.list_runtime_agent_skills.return_value = []
|
||||
manifest = AgentConfigService._manifest_for_target(target)
|
||||
|
||||
assert manifest == {
|
||||
"agent_id": AGENT,
|
||||
@@ -557,7 +560,9 @@ def test_manifest_preserves_missing_config_assets_and_pull_rejects_them() -> Non
|
||||
target = _target(kind=AgentConfigVersionKind.DRAFT, writable=False, soul=soul)
|
||||
service = AgentConfigService()
|
||||
|
||||
manifest = service._manifest_for_target(target)
|
||||
with patch(f"{MODULE}.SkillManagementService") as skill_management_service:
|
||||
skill_management_service.return_value.list_runtime_agent_skills.return_value = []
|
||||
manifest = service._manifest_for_target(target)
|
||||
|
||||
assert manifest["skills"]["items"][0]["is_missing"] is True # type: ignore[index]
|
||||
assert manifest["files"]["items"][0]["is_missing"] is True # type: ignore[index]
|
||||
@@ -606,6 +611,44 @@ def test_config_asset_refs_require_file_id_unless_marked_missing() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_manifest_appends_published_workspace_skills() -> None:
|
||||
target = _target(
|
||||
kind=AgentConfigVersionKind.DRAFT,
|
||||
writable=False,
|
||||
soul=_soul(
|
||||
config_skills=[
|
||||
AgentConfigSkillRefConfig(name="alpha", description="Alpha skill", file_id="tool-file-1")
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
with patch(f"{MODULE}.SkillManagementService") as skill_management_service:
|
||||
skill_management_service.return_value.list_runtime_agent_skills.return_value = [
|
||||
{
|
||||
"id": "workspace-skill-id",
|
||||
"name": "beta",
|
||||
"file_id": "tool-file-2",
|
||||
"description": "Beta workspace skill",
|
||||
"size": 123,
|
||||
"hash": "sha256:beta",
|
||||
"mime_type": "application/zip",
|
||||
},
|
||||
{
|
||||
"id": "duplicate",
|
||||
"name": "alpha",
|
||||
"file_id": "tool-file-ignored",
|
||||
"description": "Duplicate workspace skill",
|
||||
"size": 456,
|
||||
"hash": "sha256:ignored",
|
||||
"mime_type": "application/zip",
|
||||
},
|
||||
]
|
||||
manifest = AgentConfigService._manifest_for_target(target)
|
||||
|
||||
assert [item["name"] for item in manifest["skills"]["items"]] == ["alpha", "beta"]
|
||||
assert manifest["skills"]["items"][1]["file_id"] == "tool-file-2"
|
||||
|
||||
|
||||
def test_preview_skill_file_returns_text_preview() -> None:
|
||||
service = AgentConfigService()
|
||||
target = _target(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
||||
"""Unit tests for workflow app log views and trigger metadata helpers."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from models.enums import AppTriggerType, CreatorUserRole
|
||||
from models.workflow import WorkflowAppLog, WorkflowAppLogCreatedFrom
|
||||
from services.workflow_app_service import LogView, WorkflowAppService
|
||||
|
||||
|
||||
class TestLogView:
|
||||
def test_details_and_proxy_attributes(self) -> None:
|
||||
log = WorkflowAppLog(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
workflow_id="workflow-1",
|
||||
workflow_run_id="run-1",
|
||||
created_from=WorkflowAppLogCreatedFrom.WEB_APP,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by="account-1",
|
||||
)
|
||||
log.id = "log-1"
|
||||
|
||||
view = LogView(log=log, details={"trigger_metadata": {"type": "plugin"}})
|
||||
|
||||
assert view.details == {"trigger_metadata": {"type": "plugin"}}
|
||||
assert view.id == "log-1"
|
||||
|
||||
|
||||
class TestHandleTriggerMetadata:
|
||||
def test_returns_empty_dict_when_metadata_missing(self) -> None:
|
||||
assert WorkflowAppService().handle_trigger_metadata("tenant-1", None) == {}
|
||||
|
||||
def test_enriches_plugin_icons(self) -> None:
|
||||
metadata = {
|
||||
"type": AppTriggerType.TRIGGER_PLUGIN.value,
|
||||
"icon_filename": "light.png",
|
||||
"icon_dark_filename": "dark.png",
|
||||
}
|
||||
with patch(
|
||||
"services.workflow_app_service.PluginService.get_plugin_icon_url",
|
||||
side_effect=["https://cdn/light.png", "https://cdn/dark.png"],
|
||||
) as mock_icon:
|
||||
result = WorkflowAppService().handle_trigger_metadata("tenant-1", json.dumps(metadata))
|
||||
|
||||
assert result["icon"] == "https://cdn/light.png"
|
||||
assert result["icon_dark"] == "https://cdn/dark.png"
|
||||
assert mock_icon.call_count == 2
|
||||
|
||||
def test_non_plugin_metadata_without_icon_lookup(self) -> None:
|
||||
metadata = {"type": AppTriggerType.TRIGGER_WEBHOOK.value}
|
||||
with patch("services.workflow_app_service.PluginService.get_plugin_icon_url") as mock_icon:
|
||||
result = WorkflowAppService().handle_trigger_metadata("tenant-1", json.dumps(metadata))
|
||||
|
||||
assert result["type"] == AppTriggerType.TRIGGER_WEBHOOK.value
|
||||
mock_icon.assert_not_called()
|
||||
|
||||
|
||||
class TestSafeJsonLoads:
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
(None, None),
|
||||
("", None),
|
||||
('{"k":"v"}', {"k": "v"}),
|
||||
("not-json", None),
|
||||
({"raw": True}, {"raw": True}),
|
||||
],
|
||||
)
|
||||
def test_handles_various_inputs(self, value, expected) -> None:
|
||||
assert WorkflowAppService._safe_json_loads(value) == expected
|
||||
|
||||
|
||||
class TestSafeParseUuid:
|
||||
def test_returns_none_for_short_or_invalid_values(self) -> None:
|
||||
assert WorkflowAppService._safe_parse_uuid("short") is None
|
||||
assert WorkflowAppService._safe_parse_uuid("x" * 40) is None
|
||||
|
||||
def test_returns_uuid_for_valid_string(self) -> None:
|
||||
raw = str(uuid.uuid4())
|
||||
|
||||
result = WorkflowAppService._safe_parse_uuid(raw)
|
||||
|
||||
assert result is not None
|
||||
assert str(result) == raw
|
||||
@@ -157,9 +157,6 @@ ENABLE_WEBSITE_JINAREADER=true
|
||||
ENABLE_WEBSITE_FIRECRAWL=true
|
||||
ENABLE_WEBSITE_WATERCRAWL=true
|
||||
NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX=false
|
||||
# Enable preview features still in development (currently the /create and
|
||||
# /refine slash commands in the "Go to Anything" command palette).
|
||||
NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW=true
|
||||
NEXT_PUBLIC_ENABLE_AGENT_V2=true
|
||||
EXPERIMENTAL_ENABLE_VINEXT=false
|
||||
|
||||
|
||||
@@ -997,11 +997,15 @@ export const zSandboxListResponse = z.object({
|
||||
* Validated metadata extracted from a Skill package.
|
||||
*/
|
||||
export const zSkillManifest = z.object({
|
||||
description: z.string(),
|
||||
description: z.string().min(1).max(1024),
|
||||
entry_path: z.string(),
|
||||
files: z.array(z.string()),
|
||||
hash: z.string(),
|
||||
name: z.string(),
|
||||
name: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
||||
size: z.int(),
|
||||
})
|
||||
|
||||
|
||||
@@ -1373,11 +1373,15 @@ export const zAgentLogMetaResponse = z.object({
|
||||
* Validated metadata extracted from a Skill package.
|
||||
*/
|
||||
export const zSkillManifest = z.object({
|
||||
description: z.string(),
|
||||
description: z.string().min(1).max(1024),
|
||||
entry_path: z.string(),
|
||||
files: z.array(z.string()),
|
||||
hash: z.string(),
|
||||
name: z.string(),
|
||||
name: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
||||
size: z.int(),
|
||||
})
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ export type TagBindingRemovePayload = {
|
||||
type: TagType
|
||||
}
|
||||
|
||||
export type TagType = 'app' | 'knowledge' | 'snippet'
|
||||
export type TagType = 'app' | 'knowledge' | 'skill' | 'snippet'
|
||||
|
||||
export type PostTagBindingsData = {
|
||||
body: TagBindingPayload
|
||||
|
||||
@@ -14,7 +14,7 @@ export const zSimpleResultResponse = z.object({
|
||||
*
|
||||
* Tag type
|
||||
*/
|
||||
export const zTagType = z.enum(['app', 'knowledge', 'snippet'])
|
||||
export const zTagType = z.enum(['app', 'knowledge', 'skill', 'snippet'])
|
||||
|
||||
/**
|
||||
* TagBindingPayload
|
||||
|
||||
@@ -22,14 +22,14 @@ export type TagUpdateRequestPayload = {
|
||||
name: string
|
||||
}
|
||||
|
||||
export type TagType = 'app' | 'knowledge' | 'snippet'
|
||||
export type TagType = 'app' | 'knowledge' | 'skill' | 'snippet'
|
||||
|
||||
export type GetTagsData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
keyword?: string
|
||||
type?: '' | 'app' | 'knowledge' | 'snippet'
|
||||
type?: '' | 'app' | 'knowledge' | 'skill' | 'snippet'
|
||||
}
|
||||
url: '/tags'
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export const zTagUpdateRequestPayload = z.object({
|
||||
*
|
||||
* Tag type
|
||||
*/
|
||||
export const zTagType = z.enum(['app', 'knowledge', 'snippet'])
|
||||
export const zTagType = z.enum(['app', 'knowledge', 'skill', 'snippet'])
|
||||
|
||||
/**
|
||||
* TagBasePayload
|
||||
@@ -41,7 +41,7 @@ export const zTagBasePayload = z.object({
|
||||
|
||||
export const zGetTagsQuery = z.object({
|
||||
keyword: z.string().optional(),
|
||||
type: z.enum(['', 'app', 'knowledge', 'snippet']).optional().default(''),
|
||||
type: z.enum(['', 'app', 'knowledge', 'skill', 'snippet']).optional().default(''),
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,16 @@ export type AgentProviderListResponse = Array<{
|
||||
[key: string]: unknown
|
||||
}>
|
||||
|
||||
export type AgentSkillBindingsResponse = {
|
||||
agent_id: string
|
||||
data?: Array<AgentSkillBindingItemResponse>
|
||||
skill_ids?: Array<string>
|
||||
}
|
||||
|
||||
export type AgentSkillBindingsPayload = {
|
||||
skill_ids?: Array<string>
|
||||
}
|
||||
|
||||
export type SnippetPaginationResponse = {
|
||||
data: Array<SnippetListItemResponse>
|
||||
has_more: boolean
|
||||
@@ -635,6 +645,194 @@ export type WorkspaceAccessMatrix = {
|
||||
pagination?: Pagination | null
|
||||
}
|
||||
|
||||
export type SkillListResponse = {
|
||||
data?: Array<SkillResponse>
|
||||
has_more?: boolean
|
||||
limit?: number
|
||||
page?: number
|
||||
total?: number
|
||||
}
|
||||
|
||||
export type SkillCreatePayload = {
|
||||
description?: string
|
||||
display_name?: string | null
|
||||
icon?: string
|
||||
name?: string | null
|
||||
tags?: Array<string>
|
||||
}
|
||||
|
||||
export type SkillDetailResponse = {
|
||||
created_at: number
|
||||
created_by?: string | null
|
||||
created_by_name?: string | null
|
||||
description: string
|
||||
display_name: string
|
||||
files?: Array<SkillFileResponse>
|
||||
icon: string
|
||||
id: string
|
||||
latest_published_version_id?: string | null
|
||||
name: string
|
||||
name_manually_edited?: boolean
|
||||
reference_count?: number
|
||||
tags?: Array<string>
|
||||
updated_at: number
|
||||
updated_by?: string | null
|
||||
updated_by_name?: string | null
|
||||
visibility: string
|
||||
}
|
||||
|
||||
export type SkillFileUploadResponse = {
|
||||
hash: string
|
||||
id: string
|
||||
mime_type: string
|
||||
name: string
|
||||
size: number
|
||||
}
|
||||
|
||||
export type SkillTagListResponse = {
|
||||
data?: Array<SkillTagResponse>
|
||||
}
|
||||
|
||||
export type SkillDeletePayload = {
|
||||
confirmation_name?: string | null
|
||||
}
|
||||
|
||||
export type SkillDeleteResponse = {
|
||||
deleted: boolean
|
||||
id: string
|
||||
}
|
||||
|
||||
export type SkillMetadataPayload = {
|
||||
display_name?: string | null
|
||||
expected_updated_at?: number | null
|
||||
icon?: string | null
|
||||
tags?: Array<string> | null
|
||||
}
|
||||
|
||||
export type SkillResponse = {
|
||||
created_at: number
|
||||
created_by?: string | null
|
||||
created_by_name?: string | null
|
||||
description: string
|
||||
display_name: string
|
||||
icon: string
|
||||
id: string
|
||||
latest_published_version_id?: string | null
|
||||
name: string
|
||||
name_manually_edited?: boolean
|
||||
reference_count?: number
|
||||
tags?: Array<string>
|
||||
updated_at: number
|
||||
updated_by?: string | null
|
||||
updated_by_name?: string | null
|
||||
visibility: string
|
||||
}
|
||||
|
||||
export type SkillAssistModelPayload = {
|
||||
model: string
|
||||
model_settings?: { [key: string]: unknown } | null
|
||||
plugin_id?: string | null
|
||||
provider: string
|
||||
}
|
||||
|
||||
export type SkillAssistAttachmentPayload = {
|
||||
mime_type?: string | null
|
||||
name: string
|
||||
size?: number | null
|
||||
tool_file_id: string
|
||||
}
|
||||
|
||||
export type SkillAssistMessagePayload = {
|
||||
attachments?: Array<SkillAssistAttachmentPayload>
|
||||
message: string
|
||||
model?: SkillAssistModelPayload | null
|
||||
target_path?: string | null
|
||||
}
|
||||
|
||||
export type SkillDraftFileOperationPayload = {
|
||||
content?: string | null
|
||||
expected_updated_at?: number | null
|
||||
hash?: string | null
|
||||
mime_type?: string | null
|
||||
operation: SkillDraftFileOperation
|
||||
path: string
|
||||
size?: number | null
|
||||
target_path?: string | null
|
||||
tool_file_id?: string | null
|
||||
}
|
||||
|
||||
export type SkillDraftTreePayload = {
|
||||
expected_updated_at?: number | null
|
||||
files?: Array<SkillDraftTreeItemPayload>
|
||||
}
|
||||
|
||||
export type SkillFilePreviewResponse = {
|
||||
content: string
|
||||
hash: string
|
||||
mime_type: string
|
||||
path: string
|
||||
size: number
|
||||
}
|
||||
|
||||
export type SkillPublishPayload = {
|
||||
publish_note?: string
|
||||
version_name?: string | null
|
||||
}
|
||||
|
||||
export type SkillVersionResponse = {
|
||||
archive_size: number
|
||||
created_at: number
|
||||
hash_code: string
|
||||
id: string
|
||||
is_latest?: boolean
|
||||
publish_note: string
|
||||
published_by?: string | null
|
||||
published_by_name?: string | null
|
||||
skill_id: string
|
||||
version_name: string
|
||||
version_number: number
|
||||
}
|
||||
|
||||
export type SkillReferenceListResponse = {
|
||||
data?: Array<SkillReferenceResponse>
|
||||
}
|
||||
|
||||
export type SkillRestorePayload = {
|
||||
publish_note?: string
|
||||
version_id: string
|
||||
version_name?: string | null
|
||||
}
|
||||
|
||||
export type SkillVersionListResponse = {
|
||||
data?: Array<SkillVersionResponse>
|
||||
}
|
||||
|
||||
export type SkillVersionDeleteResponse = {
|
||||
deleted: boolean
|
||||
id: string
|
||||
latest_published_version_id?: string | null
|
||||
}
|
||||
|
||||
export type SkillVersionDetailResponse = {
|
||||
archive_size: number
|
||||
created_at: number
|
||||
files?: Array<SkillFileResponse>
|
||||
hash_code: string
|
||||
id: string
|
||||
is_latest?: boolean
|
||||
publish_note: string
|
||||
published_by?: string | null
|
||||
published_by_name?: string | null
|
||||
skill_id: string
|
||||
version_name: string
|
||||
version_number: number
|
||||
}
|
||||
|
||||
export type SkillVersionUpdatePayload = {
|
||||
publish_note?: string
|
||||
version_name?: string | null
|
||||
}
|
||||
|
||||
export type ToolLabelListResponse = Array<ToolLabel>
|
||||
|
||||
export type ApiToolProviderAddPayload = {
|
||||
@@ -1042,6 +1240,21 @@ export type WorkspaceCustomConfigResponse = {
|
||||
replace_webapp_logo?: string | null
|
||||
}
|
||||
|
||||
export type AgentSkillBindingItemResponse = {
|
||||
description: string
|
||||
display_name: string
|
||||
file_count: number
|
||||
icon: string
|
||||
id: string
|
||||
latest_published_at?: number | null
|
||||
latest_published_version_id?: string | null
|
||||
name: string
|
||||
priority: number
|
||||
status: string
|
||||
tags?: Array<string>
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
export type SnippetListItemResponse = {
|
||||
author_name: string | null
|
||||
created_at: number
|
||||
@@ -1502,6 +1715,60 @@ export type AccessPolicyRole = {
|
||||
role_tag?: string
|
||||
}
|
||||
|
||||
export type SkillFileResponse = {
|
||||
content?: string | null
|
||||
hash?: string | null
|
||||
id?: string | null
|
||||
kind: string
|
||||
mime_type?: string | null
|
||||
path: string
|
||||
size?: number | null
|
||||
storage?: string | null
|
||||
tool_file_id?: string | null
|
||||
}
|
||||
|
||||
export type SkillTagResponse = {
|
||||
count: number
|
||||
tag: string
|
||||
}
|
||||
|
||||
export type SkillDraftFileOperation =
|
||||
| 'delete'
|
||||
| 'mkdir'
|
||||
| 'rename'
|
||||
| 'upsert_text'
|
||||
| 'upsert_tool_file'
|
||||
|
||||
export type SkillDraftTreeItemPayload = {
|
||||
content?: string | null
|
||||
hash?: string | null
|
||||
kind?: SkillFileKind
|
||||
mime_type?: string | null
|
||||
path: string
|
||||
size?: number | null
|
||||
storage?: SkillFileStorage | null
|
||||
tool_file_id?: string | null
|
||||
}
|
||||
|
||||
export type SkillReferenceResponse = {
|
||||
agent_id: string
|
||||
agent_icon?: string | null
|
||||
agent_icon_background?: string | null
|
||||
agent_icon_type?: string | null
|
||||
app_id?: string | null
|
||||
display_name: string
|
||||
name: string
|
||||
node_id?: string | null
|
||||
node_name?: string | null
|
||||
type: string
|
||||
workflow_icon?: string | null
|
||||
workflow_icon_background?: string | null
|
||||
workflow_icon_type?: string | null
|
||||
workflow_id?: string | null
|
||||
workflow_name?: string | null
|
||||
workflow_version?: string | null
|
||||
}
|
||||
|
||||
export type ToolLabel = {
|
||||
icon: string
|
||||
label: I18nObject
|
||||
@@ -1978,6 +2245,10 @@ export type PermissionCatalogItem = {
|
||||
name: string
|
||||
}
|
||||
|
||||
export type SkillFileKind = 'directory' | 'file'
|
||||
|
||||
export type SkillFileStorage = 'text' | 'tool_file'
|
||||
|
||||
export type ToolParameter = {
|
||||
auto_generate?: PluginParameterAutoGenerate | null
|
||||
default?:
|
||||
@@ -2473,6 +2744,38 @@ export type GetWorkspacesCurrentAgentProvidersResponses = {
|
||||
export type GetWorkspacesCurrentAgentProvidersResponse =
|
||||
GetWorkspacesCurrentAgentProvidersResponses[keyof GetWorkspacesCurrentAgentProvidersResponses]
|
||||
|
||||
export type GetWorkspacesCurrentAgentsByAgentIdSkillsData = {
|
||||
body?: never
|
||||
path: {
|
||||
agent_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/current/agents/{agent_id}/skills'
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentAgentsByAgentIdSkillsResponses = {
|
||||
200: AgentSkillBindingsResponse
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentAgentsByAgentIdSkillsResponse =
|
||||
GetWorkspacesCurrentAgentsByAgentIdSkillsResponses[keyof GetWorkspacesCurrentAgentsByAgentIdSkillsResponses]
|
||||
|
||||
export type PutWorkspacesCurrentAgentsByAgentIdSkillsData = {
|
||||
body: AgentSkillBindingsPayload
|
||||
path: {
|
||||
agent_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/current/agents/{agent_id}/skills'
|
||||
}
|
||||
|
||||
export type PutWorkspacesCurrentAgentsByAgentIdSkillsResponses = {
|
||||
200: AgentSkillBindingsResponse
|
||||
}
|
||||
|
||||
export type PutWorkspacesCurrentAgentsByAgentIdSkillsResponse =
|
||||
PutWorkspacesCurrentAgentsByAgentIdSkillsResponses[keyof PutWorkspacesCurrentAgentsByAgentIdSkillsResponses]
|
||||
|
||||
export type GetWorkspacesCurrentCustomizedSnippetsData = {
|
||||
body?: never
|
||||
path?: never
|
||||
@@ -4625,6 +4928,369 @@ export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponses = {
|
||||
export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponse =
|
||||
GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponses[keyof GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponses]
|
||||
|
||||
export type GetWorkspacesCurrentSkillsData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
keyword?: string
|
||||
limit?: number
|
||||
page?: number
|
||||
tag?: Array<string>
|
||||
}
|
||||
url: '/workspaces/current/skills'
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentSkillsResponses = {
|
||||
200: SkillListResponse
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentSkillsResponse =
|
||||
GetWorkspacesCurrentSkillsResponses[keyof GetWorkspacesCurrentSkillsResponses]
|
||||
|
||||
export type PostWorkspacesCurrentSkillsData = {
|
||||
body: SkillCreatePayload
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/workspaces/current/skills'
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentSkillsResponses = {
|
||||
201: SkillDetailResponse
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentSkillsResponse =
|
||||
PostWorkspacesCurrentSkillsResponses[keyof PostWorkspacesCurrentSkillsResponses]
|
||||
|
||||
export type PostWorkspacesCurrentSkillsFilesUploadData = {
|
||||
body: {
|
||||
file: Blob | File
|
||||
}
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/workspaces/current/skills/files/upload'
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentSkillsFilesUploadResponses = {
|
||||
201: SkillFileUploadResponse
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentSkillsFilesUploadResponse =
|
||||
PostWorkspacesCurrentSkillsFilesUploadResponses[keyof PostWorkspacesCurrentSkillsFilesUploadResponses]
|
||||
|
||||
export type PostWorkspacesCurrentSkillsImportData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/workspaces/current/skills/import'
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentSkillsImportResponses = {
|
||||
201: SkillDetailResponse
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentSkillsImportResponse =
|
||||
PostWorkspacesCurrentSkillsImportResponses[keyof PostWorkspacesCurrentSkillsImportResponses]
|
||||
|
||||
export type GetWorkspacesCurrentSkillsTagsData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/workspaces/current/skills/tags'
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentSkillsTagsResponses = {
|
||||
200: SkillTagListResponse
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentSkillsTagsResponse =
|
||||
GetWorkspacesCurrentSkillsTagsResponses[keyof GetWorkspacesCurrentSkillsTagsResponses]
|
||||
|
||||
export type DeleteWorkspacesCurrentSkillsBySkillIdData = {
|
||||
body: SkillDeletePayload
|
||||
path: {
|
||||
skill_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/current/skills/{skill_id}'
|
||||
}
|
||||
|
||||
export type DeleteWorkspacesCurrentSkillsBySkillIdResponses = {
|
||||
200: SkillDeleteResponse
|
||||
}
|
||||
|
||||
export type DeleteWorkspacesCurrentSkillsBySkillIdResponse =
|
||||
DeleteWorkspacesCurrentSkillsBySkillIdResponses[keyof DeleteWorkspacesCurrentSkillsBySkillIdResponses]
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdData = {
|
||||
body?: never
|
||||
path: {
|
||||
skill_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/current/skills/{skill_id}'
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdResponses = {
|
||||
200: SkillDetailResponse
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdResponse =
|
||||
GetWorkspacesCurrentSkillsBySkillIdResponses[keyof GetWorkspacesCurrentSkillsBySkillIdResponses]
|
||||
|
||||
export type PatchWorkspacesCurrentSkillsBySkillIdData = {
|
||||
body: SkillMetadataPayload
|
||||
path: {
|
||||
skill_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/current/skills/{skill_id}'
|
||||
}
|
||||
|
||||
export type PatchWorkspacesCurrentSkillsBySkillIdResponses = {
|
||||
200: SkillResponse
|
||||
}
|
||||
|
||||
export type PatchWorkspacesCurrentSkillsBySkillIdResponse =
|
||||
PatchWorkspacesCurrentSkillsBySkillIdResponses[keyof PatchWorkspacesCurrentSkillsBySkillIdResponses]
|
||||
|
||||
export type PostWorkspacesCurrentSkillsBySkillIdAssistMessagesData = {
|
||||
body: SkillAssistMessagePayload
|
||||
path: {
|
||||
skill_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/current/skills/{skill_id}/assist/messages'
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentSkillsBySkillIdAssistMessagesResponses = {
|
||||
200: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentSkillsBySkillIdAssistMessagesResponse =
|
||||
PostWorkspacesCurrentSkillsBySkillIdAssistMessagesResponses[keyof PostWorkspacesCurrentSkillsBySkillIdAssistMessagesResponses]
|
||||
|
||||
export type PostWorkspacesCurrentSkillsBySkillIdDuplicateData = {
|
||||
body?: never
|
||||
path: {
|
||||
skill_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/current/skills/{skill_id}/duplicate'
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentSkillsBySkillIdDuplicateResponses = {
|
||||
201: SkillDetailResponse
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentSkillsBySkillIdDuplicateResponse =
|
||||
PostWorkspacesCurrentSkillsBySkillIdDuplicateResponses[keyof PostWorkspacesCurrentSkillsBySkillIdDuplicateResponses]
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdExportData = {
|
||||
body?: never
|
||||
path: {
|
||||
skill_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/current/skills/{skill_id}/export'
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdExportResponses = {
|
||||
200: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdExportResponse =
|
||||
GetWorkspacesCurrentSkillsBySkillIdExportResponses[keyof GetWorkspacesCurrentSkillsBySkillIdExportResponses]
|
||||
|
||||
export type PatchWorkspacesCurrentSkillsBySkillIdFilesData = {
|
||||
body: SkillDraftFileOperationPayload
|
||||
path: {
|
||||
skill_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/current/skills/{skill_id}/files'
|
||||
}
|
||||
|
||||
export type PatchWorkspacesCurrentSkillsBySkillIdFilesResponses = {
|
||||
200: SkillDetailResponse
|
||||
}
|
||||
|
||||
export type PatchWorkspacesCurrentSkillsBySkillIdFilesResponse =
|
||||
PatchWorkspacesCurrentSkillsBySkillIdFilesResponses[keyof PatchWorkspacesCurrentSkillsBySkillIdFilesResponses]
|
||||
|
||||
export type PutWorkspacesCurrentSkillsBySkillIdFilesData = {
|
||||
body: SkillDraftTreePayload
|
||||
path: {
|
||||
skill_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/current/skills/{skill_id}/files'
|
||||
}
|
||||
|
||||
export type PutWorkspacesCurrentSkillsBySkillIdFilesResponses = {
|
||||
200: SkillDetailResponse
|
||||
}
|
||||
|
||||
export type PutWorkspacesCurrentSkillsBySkillIdFilesResponse =
|
||||
PutWorkspacesCurrentSkillsBySkillIdFilesResponses[keyof PutWorkspacesCurrentSkillsBySkillIdFilesResponses]
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdFilesContentData = {
|
||||
body?: never
|
||||
path: {
|
||||
skill_id: string
|
||||
}
|
||||
query: {
|
||||
download?: string
|
||||
path: string
|
||||
version_id?: string
|
||||
}
|
||||
url: '/workspaces/current/skills/{skill_id}/files/content'
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdFilesContentResponses = {
|
||||
200: BinaryFileResponse
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdFilesContentResponse =
|
||||
GetWorkspacesCurrentSkillsBySkillIdFilesContentResponses[keyof GetWorkspacesCurrentSkillsBySkillIdFilesContentResponses]
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdFilesPreviewData = {
|
||||
body?: never
|
||||
path: {
|
||||
skill_id: string
|
||||
}
|
||||
query: {
|
||||
path: string
|
||||
version_id?: string
|
||||
}
|
||||
url: '/workspaces/current/skills/{skill_id}/files/preview'
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdFilesPreviewResponses = {
|
||||
200: SkillFilePreviewResponse
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdFilesPreviewResponse =
|
||||
GetWorkspacesCurrentSkillsBySkillIdFilesPreviewResponses[keyof GetWorkspacesCurrentSkillsBySkillIdFilesPreviewResponses]
|
||||
|
||||
export type PostWorkspacesCurrentSkillsBySkillIdPublishData = {
|
||||
body: SkillPublishPayload
|
||||
path: {
|
||||
skill_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/current/skills/{skill_id}/publish'
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentSkillsBySkillIdPublishResponses = {
|
||||
200: SkillVersionResponse
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentSkillsBySkillIdPublishResponse =
|
||||
PostWorkspacesCurrentSkillsBySkillIdPublishResponses[keyof PostWorkspacesCurrentSkillsBySkillIdPublishResponses]
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdReferencesData = {
|
||||
body?: never
|
||||
path: {
|
||||
skill_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/current/skills/{skill_id}/references'
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdReferencesResponses = {
|
||||
200: SkillReferenceListResponse
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdReferencesResponse =
|
||||
GetWorkspacesCurrentSkillsBySkillIdReferencesResponses[keyof GetWorkspacesCurrentSkillsBySkillIdReferencesResponses]
|
||||
|
||||
export type PostWorkspacesCurrentSkillsBySkillIdRestoreData = {
|
||||
body: SkillRestorePayload
|
||||
path: {
|
||||
skill_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/current/skills/{skill_id}/restore'
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentSkillsBySkillIdRestoreResponses = {
|
||||
200: SkillVersionResponse
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentSkillsBySkillIdRestoreResponse =
|
||||
PostWorkspacesCurrentSkillsBySkillIdRestoreResponses[keyof PostWorkspacesCurrentSkillsBySkillIdRestoreResponses]
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdVersionsData = {
|
||||
body?: never
|
||||
path: {
|
||||
skill_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/current/skills/{skill_id}/versions'
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdVersionsResponses = {
|
||||
200: SkillVersionListResponse
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdVersionsResponse =
|
||||
GetWorkspacesCurrentSkillsBySkillIdVersionsResponses[keyof GetWorkspacesCurrentSkillsBySkillIdVersionsResponses]
|
||||
|
||||
export type DeleteWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdData = {
|
||||
body?: never
|
||||
path: {
|
||||
skill_id: string
|
||||
version_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/current/skills/{skill_id}/versions/{version_id}'
|
||||
}
|
||||
|
||||
export type DeleteWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponses = {
|
||||
200: SkillVersionDeleteResponse
|
||||
}
|
||||
|
||||
export type DeleteWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponse =
|
||||
DeleteWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponses[keyof DeleteWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponses]
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdData = {
|
||||
body?: never
|
||||
path: {
|
||||
skill_id: string
|
||||
version_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/current/skills/{skill_id}/versions/{version_id}'
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponses = {
|
||||
200: SkillVersionDetailResponse
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponse =
|
||||
GetWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponses[keyof GetWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponses]
|
||||
|
||||
export type PatchWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdData = {
|
||||
body: SkillVersionUpdatePayload
|
||||
path: {
|
||||
skill_id: string
|
||||
version_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/current/skills/{skill_id}/versions/{version_id}'
|
||||
}
|
||||
|
||||
export type PatchWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponses = {
|
||||
200: SkillVersionResponse
|
||||
}
|
||||
|
||||
export type PatchWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponse =
|
||||
PatchWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponses[keyof PatchWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponses]
|
||||
|
||||
export type GetWorkspacesCurrentToolLabelsData = {
|
||||
body?: never
|
||||
path?: never
|
||||
|
||||
@@ -12,6 +12,13 @@ export const zAgentProviderResponse = z.record(z.string(), z.unknown())
|
||||
*/
|
||||
export const zAgentProviderListResponse = z.array(z.record(z.string(), z.unknown()))
|
||||
|
||||
/**
|
||||
* AgentSkillBindingsPayload
|
||||
*/
|
||||
export const zAgentSkillBindingsPayload = z.object({
|
||||
skill_ids: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SnippetImportPayload
|
||||
*
|
||||
@@ -436,6 +443,155 @@ export const zReplaceBindingsRequest = z.object({
|
||||
role_ids: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillCreatePayload
|
||||
*/
|
||||
export const zSkillCreatePayload = z.object({
|
||||
description: z.string().optional().default(''),
|
||||
display_name: z.string().nullish(),
|
||||
icon: z.string().optional().default('📄'),
|
||||
name: z.string().nullish(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillFileUploadResponse
|
||||
*/
|
||||
export const zSkillFileUploadResponse = z.object({
|
||||
hash: z.string(),
|
||||
id: z.string(),
|
||||
mime_type: z.string(),
|
||||
name: z.string(),
|
||||
size: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillDeletePayload
|
||||
*/
|
||||
export const zSkillDeletePayload = z.object({
|
||||
confirmation_name: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillDeleteResponse
|
||||
*/
|
||||
export const zSkillDeleteResponse = z.object({
|
||||
deleted: z.boolean(),
|
||||
id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillMetadataPayload
|
||||
*/
|
||||
export const zSkillMetadataPayload = z.object({
|
||||
display_name: z.string().nullish(),
|
||||
expected_updated_at: z.int().nullish(),
|
||||
icon: z.string().nullish(),
|
||||
tags: z.array(z.string()).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillResponse
|
||||
*/
|
||||
export const zSkillResponse = z.object({
|
||||
created_at: z.int(),
|
||||
created_by: z.string().nullish(),
|
||||
created_by_name: z.string().nullish(),
|
||||
description: z.string(),
|
||||
display_name: z.string(),
|
||||
icon: z.string(),
|
||||
id: z.string(),
|
||||
latest_published_version_id: z.string().nullish(),
|
||||
name: z.string(),
|
||||
name_manually_edited: z.boolean().optional().default(false),
|
||||
reference_count: z.int().optional().default(0),
|
||||
tags: z.array(z.string()).optional(),
|
||||
updated_at: z.int(),
|
||||
updated_by: z.string().nullish(),
|
||||
updated_by_name: z.string().nullish(),
|
||||
visibility: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillListResponse
|
||||
*/
|
||||
export const zSkillListResponse = z.object({
|
||||
data: z.array(zSkillResponse).optional(),
|
||||
has_more: z.boolean().optional().default(false),
|
||||
limit: z.int().optional().default(20),
|
||||
page: z.int().optional().default(1),
|
||||
total: z.int().optional().default(0),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillFilePreviewResponse
|
||||
*/
|
||||
export const zSkillFilePreviewResponse = z.object({
|
||||
content: z.string(),
|
||||
hash: z.string(),
|
||||
mime_type: z.string(),
|
||||
path: z.string(),
|
||||
size: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillPublishPayload
|
||||
*/
|
||||
export const zSkillPublishPayload = z.object({
|
||||
publish_note: z.string().max(1024).optional().default(''),
|
||||
version_name: z.string().max(128).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillVersionResponse
|
||||
*/
|
||||
export const zSkillVersionResponse = z.object({
|
||||
archive_size: z.int(),
|
||||
created_at: z.int(),
|
||||
hash_code: z.string(),
|
||||
id: z.string(),
|
||||
is_latest: z.boolean().optional().default(false),
|
||||
publish_note: z.string(),
|
||||
published_by: z.string().nullish(),
|
||||
published_by_name: z.string().nullish(),
|
||||
skill_id: z.string(),
|
||||
version_name: z.string(),
|
||||
version_number: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillRestorePayload
|
||||
*/
|
||||
export const zSkillRestorePayload = z.object({
|
||||
publish_note: z.string().max(1024).optional().default(''),
|
||||
version_id: z.string(),
|
||||
version_name: z.string().max(128).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillVersionListResponse
|
||||
*/
|
||||
export const zSkillVersionListResponse = z.object({
|
||||
data: z.array(zSkillVersionResponse).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillVersionDeleteResponse
|
||||
*/
|
||||
export const zSkillVersionDeleteResponse = z.object({
|
||||
deleted: z.boolean(),
|
||||
id: z.string(),
|
||||
latest_published_version_id: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillVersionUpdatePayload
|
||||
*/
|
||||
export const zSkillVersionUpdatePayload = z.object({
|
||||
publish_note: z.string().max(1024).optional().default(''),
|
||||
version_name: z.string().max(128).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* ApiToolProviderDeletePayload
|
||||
*/
|
||||
@@ -656,6 +812,33 @@ export const zSwitchWorkspaceResponse = z.object({
|
||||
result: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSkillBindingItemResponse
|
||||
*/
|
||||
export const zAgentSkillBindingItemResponse = z.object({
|
||||
description: z.string(),
|
||||
display_name: z.string(),
|
||||
file_count: z.int(),
|
||||
icon: z.string(),
|
||||
id: z.string(),
|
||||
latest_published_at: z.int().nullish(),
|
||||
latest_published_version_id: z.string().nullish(),
|
||||
name: z.string(),
|
||||
priority: z.int(),
|
||||
status: z.string(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
updated_at: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSkillBindingsResponse
|
||||
*/
|
||||
export const zAgentSkillBindingsResponse = z.object({
|
||||
agent_id: z.string(),
|
||||
data: z.array(zAgentSkillBindingItemResponse).optional(),
|
||||
skill_ids: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* IconInfo
|
||||
*
|
||||
@@ -1244,6 +1427,164 @@ export const zWorkspaceAccessMatrix = z.object({
|
||||
pagination: zPagination.nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillFileResponse
|
||||
*/
|
||||
export const zSkillFileResponse = z.object({
|
||||
content: z.string().nullish(),
|
||||
hash: z.string().nullish(),
|
||||
id: z.string().nullish(),
|
||||
kind: z.string(),
|
||||
mime_type: z.string().nullish(),
|
||||
path: z.string(),
|
||||
size: z.int().nullish(),
|
||||
storage: z.string().nullish(),
|
||||
tool_file_id: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillDetailResponse
|
||||
*/
|
||||
export const zSkillDetailResponse = z.object({
|
||||
created_at: z.int(),
|
||||
created_by: z.string().nullish(),
|
||||
created_by_name: z.string().nullish(),
|
||||
description: z.string(),
|
||||
display_name: z.string(),
|
||||
files: z.array(zSkillFileResponse).optional(),
|
||||
icon: z.string(),
|
||||
id: z.string(),
|
||||
latest_published_version_id: z.string().nullish(),
|
||||
name: z.string(),
|
||||
name_manually_edited: z.boolean().optional().default(false),
|
||||
reference_count: z.int().optional().default(0),
|
||||
tags: z.array(z.string()).optional(),
|
||||
updated_at: z.int(),
|
||||
updated_by: z.string().nullish(),
|
||||
updated_by_name: z.string().nullish(),
|
||||
visibility: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillVersionDetailResponse
|
||||
*/
|
||||
export const zSkillVersionDetailResponse = z.object({
|
||||
archive_size: z.int(),
|
||||
created_at: z.int(),
|
||||
files: z.array(zSkillFileResponse).optional(),
|
||||
hash_code: z.string(),
|
||||
id: z.string(),
|
||||
is_latest: z.boolean().optional().default(false),
|
||||
publish_note: z.string(),
|
||||
published_by: z.string().nullish(),
|
||||
published_by_name: z.string().nullish(),
|
||||
skill_id: z.string(),
|
||||
version_name: z.string(),
|
||||
version_number: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillTagResponse
|
||||
*/
|
||||
export const zSkillTagResponse = z.object({
|
||||
count: z.int(),
|
||||
tag: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillTagListResponse
|
||||
*/
|
||||
export const zSkillTagListResponse = z.object({
|
||||
data: z.array(zSkillTagResponse).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillAssistAttachmentPayload
|
||||
*/
|
||||
export const zSkillAssistAttachmentPayload = z.object({
|
||||
mime_type: z.string().min(1).max(255).nullish(),
|
||||
name: z.string().min(1).max(255),
|
||||
size: z.int().gte(0).nullish(),
|
||||
tool_file_id: z.string().min(1),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillAssistModelPayload
|
||||
*/
|
||||
export const zSkillAssistModelPayload = z.object({
|
||||
model: z.string().min(1).max(255),
|
||||
model_settings: z.record(z.string(), z.unknown()).nullish(),
|
||||
plugin_id: z.string().min(1).max(255).nullish(),
|
||||
provider: z.string().min(1).max(255),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillAssistMessagePayload
|
||||
*
|
||||
* One user message and optional uploaded context for the read-only Skill Authoring assistant.
|
||||
*/
|
||||
export const zSkillAssistMessagePayload = z.object({
|
||||
attachments: z.array(zSkillAssistAttachmentPayload).max(10).optional(),
|
||||
message: z.string().min(1).max(8000),
|
||||
model: zSkillAssistModelPayload.nullish(),
|
||||
target_path: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillDraftFileOperation
|
||||
*/
|
||||
export const zSkillDraftFileOperation = z.enum([
|
||||
'delete',
|
||||
'mkdir',
|
||||
'rename',
|
||||
'upsert_text',
|
||||
'upsert_tool_file',
|
||||
])
|
||||
|
||||
/**
|
||||
* SkillDraftFileOperationPayload
|
||||
*/
|
||||
export const zSkillDraftFileOperationPayload = z.object({
|
||||
content: z.string().nullish(),
|
||||
expected_updated_at: z.int().nullish(),
|
||||
hash: z.string().nullish(),
|
||||
mime_type: z.string().nullish(),
|
||||
operation: zSkillDraftFileOperation,
|
||||
path: z.string(),
|
||||
size: z.int().gte(0).nullish(),
|
||||
target_path: z.string().nullish(),
|
||||
tool_file_id: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillReferenceResponse
|
||||
*/
|
||||
export const zSkillReferenceResponse = z.object({
|
||||
agent_id: z.string(),
|
||||
agent_icon: z.string().nullish(),
|
||||
agent_icon_background: z.string().nullish(),
|
||||
agent_icon_type: z.string().nullish(),
|
||||
app_id: z.string().nullish(),
|
||||
display_name: z.string(),
|
||||
name: z.string(),
|
||||
node_id: z.string().nullish(),
|
||||
node_name: z.string().nullish(),
|
||||
type: z.string(),
|
||||
workflow_icon: z.string().nullish(),
|
||||
workflow_icon_background: z.string().nullish(),
|
||||
workflow_icon_type: z.string().nullish(),
|
||||
workflow_id: z.string().nullish(),
|
||||
workflow_name: z.string().nullish(),
|
||||
workflow_version: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillReferenceListResponse
|
||||
*/
|
||||
export const zSkillReferenceListResponse = z.object({
|
||||
data: z.array(zSkillReferenceResponse).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* ToolEmojiIcon
|
||||
*/
|
||||
@@ -2096,6 +2437,42 @@ export const zPermissionCatalogResponse = z.object({
|
||||
groups: z.array(zPermissionCatalogGroup).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillFileKind
|
||||
*
|
||||
* Draft file entry kind.
|
||||
*/
|
||||
export const zSkillFileKind = z.enum(['directory', 'file'])
|
||||
|
||||
/**
|
||||
* SkillFileStorage
|
||||
*
|
||||
* How a draft file's content is stored.
|
||||
*/
|
||||
export const zSkillFileStorage = z.enum(['text', 'tool_file'])
|
||||
|
||||
/**
|
||||
* SkillDraftTreeItemPayload
|
||||
*/
|
||||
export const zSkillDraftTreeItemPayload = z.object({
|
||||
content: z.string().nullish(),
|
||||
hash: z.string().nullish(),
|
||||
kind: zSkillFileKind.optional().default('file'),
|
||||
mime_type: z.string().nullish(),
|
||||
path: z.string(),
|
||||
size: z.int().gte(0).nullish(),
|
||||
storage: zSkillFileStorage.nullish(),
|
||||
tool_file_id: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SkillDraftTreePayload
|
||||
*/
|
||||
export const zSkillDraftTreePayload = z.object({
|
||||
expected_updated_at: z.int().nullish(),
|
||||
files: z.array(zSkillDraftTreeItemPayload).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Option
|
||||
*/
|
||||
@@ -3421,6 +3798,26 @@ export const zGetWorkspacesCurrentAgentProviderByProviderNameResponse = zAgentPr
|
||||
*/
|
||||
export const zGetWorkspacesCurrentAgentProvidersResponse = zAgentProviderListResponse
|
||||
|
||||
export const zGetWorkspacesCurrentAgentsByAgentIdSkillsPath = z.object({
|
||||
agent_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Agent Skill bindings
|
||||
*/
|
||||
export const zGetWorkspacesCurrentAgentsByAgentIdSkillsResponse = zAgentSkillBindingsResponse
|
||||
|
||||
export const zPutWorkspacesCurrentAgentsByAgentIdSkillsBody = zAgentSkillBindingsPayload
|
||||
|
||||
export const zPutWorkspacesCurrentAgentsByAgentIdSkillsPath = z.object({
|
||||
agent_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Agent Skill bindings replaced
|
||||
*/
|
||||
export const zPutWorkspacesCurrentAgentsByAgentIdSkillsResponse = zAgentSkillBindingsResponse
|
||||
|
||||
export const zGetWorkspacesCurrentCustomizedSnippetsQuery = z.object({
|
||||
creators: z.array(z.string()).optional(),
|
||||
is_published: z.boolean().optional(),
|
||||
@@ -4694,6 +5091,234 @@ export const zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdR
|
||||
*/
|
||||
export const zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponse = zWorkspaceAccessMatrix
|
||||
|
||||
export const zGetWorkspacesCurrentSkillsQuery = z.object({
|
||||
keyword: z.string().optional(),
|
||||
limit: z.int().gte(1).lte(100).optional().default(20),
|
||||
page: z.int().gte(1).lte(99999).optional().default(1),
|
||||
tag: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Workspace skills
|
||||
*/
|
||||
export const zGetWorkspacesCurrentSkillsResponse = zSkillListResponse
|
||||
|
||||
export const zPostWorkspacesCurrentSkillsBody = zSkillCreatePayload
|
||||
|
||||
/**
|
||||
* Skill created
|
||||
*/
|
||||
export const zPostWorkspacesCurrentSkillsResponse = zSkillDetailResponse
|
||||
|
||||
export const zPostWorkspacesCurrentSkillsFilesUploadBody = z.object({
|
||||
file: z.custom<Blob | File>((value) => value instanceof Blob || value instanceof File),
|
||||
})
|
||||
|
||||
/**
|
||||
* Skill draft file uploaded
|
||||
*/
|
||||
export const zPostWorkspacesCurrentSkillsFilesUploadResponse = zSkillFileUploadResponse
|
||||
|
||||
/**
|
||||
* Skill imported
|
||||
*/
|
||||
export const zPostWorkspacesCurrentSkillsImportResponse = zSkillDetailResponse
|
||||
|
||||
/**
|
||||
* Workspace Skill tags
|
||||
*/
|
||||
export const zGetWorkspacesCurrentSkillsTagsResponse = zSkillTagListResponse
|
||||
|
||||
export const zDeleteWorkspacesCurrentSkillsBySkillIdBody = zSkillDeletePayload
|
||||
|
||||
export const zDeleteWorkspacesCurrentSkillsBySkillIdPath = z.object({
|
||||
skill_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Skill deleted
|
||||
*/
|
||||
export const zDeleteWorkspacesCurrentSkillsBySkillIdResponse = zSkillDeleteResponse
|
||||
|
||||
export const zGetWorkspacesCurrentSkillsBySkillIdPath = z.object({
|
||||
skill_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Skill detail
|
||||
*/
|
||||
export const zGetWorkspacesCurrentSkillsBySkillIdResponse = zSkillDetailResponse
|
||||
|
||||
export const zPatchWorkspacesCurrentSkillsBySkillIdBody = zSkillMetadataPayload
|
||||
|
||||
export const zPatchWorkspacesCurrentSkillsBySkillIdPath = z.object({
|
||||
skill_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Skill updated
|
||||
*/
|
||||
export const zPatchWorkspacesCurrentSkillsBySkillIdResponse = zSkillResponse
|
||||
|
||||
export const zPostWorkspacesCurrentSkillsBySkillIdAssistMessagesBody = zSkillAssistMessagePayload
|
||||
|
||||
export const zPostWorkspacesCurrentSkillsBySkillIdAssistMessagesPath = z.object({
|
||||
skill_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Skill Authoring assistant event stream
|
||||
*/
|
||||
export const zPostWorkspacesCurrentSkillsBySkillIdAssistMessagesResponse = z.record(
|
||||
z.string(),
|
||||
z.unknown(),
|
||||
)
|
||||
|
||||
export const zPostWorkspacesCurrentSkillsBySkillIdDuplicatePath = z.object({
|
||||
skill_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Skill duplicated
|
||||
*/
|
||||
export const zPostWorkspacesCurrentSkillsBySkillIdDuplicateResponse = zSkillDetailResponse
|
||||
|
||||
export const zGetWorkspacesCurrentSkillsBySkillIdExportPath = z.object({
|
||||
skill_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Published Skill zip archive
|
||||
*/
|
||||
export const zGetWorkspacesCurrentSkillsBySkillIdExportResponse = z.record(z.string(), z.unknown())
|
||||
|
||||
export const zPatchWorkspacesCurrentSkillsBySkillIdFilesBody = zSkillDraftFileOperationPayload
|
||||
|
||||
export const zPatchWorkspacesCurrentSkillsBySkillIdFilesPath = z.object({
|
||||
skill_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Draft file operation applied
|
||||
*/
|
||||
export const zPatchWorkspacesCurrentSkillsBySkillIdFilesResponse = zSkillDetailResponse
|
||||
|
||||
export const zPutWorkspacesCurrentSkillsBySkillIdFilesBody = zSkillDraftTreePayload
|
||||
|
||||
export const zPutWorkspacesCurrentSkillsBySkillIdFilesPath = z.object({
|
||||
skill_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Draft files replaced
|
||||
*/
|
||||
export const zPutWorkspacesCurrentSkillsBySkillIdFilesResponse = zSkillDetailResponse
|
||||
|
||||
export const zGetWorkspacesCurrentSkillsBySkillIdFilesContentPath = z.object({
|
||||
skill_id: z.string(),
|
||||
})
|
||||
|
||||
export const zGetWorkspacesCurrentSkillsBySkillIdFilesContentQuery = z.object({
|
||||
download: z.string().optional(),
|
||||
path: z.string(),
|
||||
version_id: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Skill file content
|
||||
*/
|
||||
export const zGetWorkspacesCurrentSkillsBySkillIdFilesContentResponse = zBinaryFileResponse
|
||||
|
||||
export const zGetWorkspacesCurrentSkillsBySkillIdFilesPreviewPath = z.object({
|
||||
skill_id: z.string(),
|
||||
})
|
||||
|
||||
export const zGetWorkspacesCurrentSkillsBySkillIdFilesPreviewQuery = z.object({
|
||||
path: z.string(),
|
||||
version_id: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Skill file text preview
|
||||
*/
|
||||
export const zGetWorkspacesCurrentSkillsBySkillIdFilesPreviewResponse = zSkillFilePreviewResponse
|
||||
|
||||
export const zPostWorkspacesCurrentSkillsBySkillIdPublishBody = zSkillPublishPayload
|
||||
|
||||
export const zPostWorkspacesCurrentSkillsBySkillIdPublishPath = z.object({
|
||||
skill_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Skill published
|
||||
*/
|
||||
export const zPostWorkspacesCurrentSkillsBySkillIdPublishResponse = zSkillVersionResponse
|
||||
|
||||
export const zGetWorkspacesCurrentSkillsBySkillIdReferencesPath = z.object({
|
||||
skill_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Skill references
|
||||
*/
|
||||
export const zGetWorkspacesCurrentSkillsBySkillIdReferencesResponse = zSkillReferenceListResponse
|
||||
|
||||
export const zPostWorkspacesCurrentSkillsBySkillIdRestoreBody = zSkillRestorePayload
|
||||
|
||||
export const zPostWorkspacesCurrentSkillsBySkillIdRestorePath = z.object({
|
||||
skill_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Skill version restored
|
||||
*/
|
||||
export const zPostWorkspacesCurrentSkillsBySkillIdRestoreResponse = zSkillVersionResponse
|
||||
|
||||
export const zGetWorkspacesCurrentSkillsBySkillIdVersionsPath = z.object({
|
||||
skill_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Skill versions
|
||||
*/
|
||||
export const zGetWorkspacesCurrentSkillsBySkillIdVersionsResponse = zSkillVersionListResponse
|
||||
|
||||
export const zDeleteWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdPath = z.object({
|
||||
skill_id: z.string(),
|
||||
version_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Skill version deleted
|
||||
*/
|
||||
export const zDeleteWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponse =
|
||||
zSkillVersionDeleteResponse
|
||||
|
||||
export const zGetWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdPath = z.object({
|
||||
skill_id: z.string(),
|
||||
version_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Skill version detail
|
||||
*/
|
||||
export const zGetWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponse =
|
||||
zSkillVersionDetailResponse
|
||||
|
||||
export const zPatchWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdBody =
|
||||
zSkillVersionUpdatePayload
|
||||
|
||||
export const zPatchWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdPath = z.object({
|
||||
skill_id: z.string(),
|
||||
version_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Skill version updated
|
||||
*/
|
||||
export const zPatchWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponse =
|
||||
zSkillVersionResponse
|
||||
|
||||
/**
|
||||
* Tool labels retrieved successfully
|
||||
*/
|
||||
|
||||
@@ -18,6 +18,7 @@ The current Flask-RESTX generator still emits these response entries under `appl
|
||||
| service | GET | `/files/{file_id}/preview` | Original file MIME type, optionally attachment | `BinaryFileResponse` |
|
||||
| console | GET | `/workspaces/current/plugin/icon` | Plugin asset MIME type | `BinaryFileResponse` |
|
||||
| console | GET | `/workspaces/current/plugin/asset` | `application/octet-stream` | `BinaryFileResponse` |
|
||||
| console | GET | `/workspaces/current/skills/{skill_id}/files/content` | Skill file MIME type, optionally attachment | `BinaryFileResponse` |
|
||||
| console | GET | `/workspaces/current/tool-provider/builtin/{provider}/icon` | Tool icon MIME type | `BinaryFileResponse` |
|
||||
| console | GET | `/workspaces/current/trigger-provider/{provider}/icon` | Trigger icon response | `BinaryFileResponse` |
|
||||
| console | GET | `/workspaces/{tenant_id}/model-providers/{provider}/{icon_type}/{lang}` | Model provider icon MIME type | `BinaryFileResponse` |
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import SkillDetailPage from '@/features/skills/detail-page'
|
||||
|
||||
export default function Page() {
|
||||
return <SkillDetailPage />
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import SkillsPage from '@/features/skills/page'
|
||||
|
||||
export default function Page() {
|
||||
return <SkillsPage />
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
|
||||
import { defaultPlan } from '@/app/components/billing/config'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
@@ -67,11 +68,16 @@ describe('ArchivedLogsNotice', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should show notice for paid workspace managers', () => {
|
||||
it('should show an accessible notice for paid workspace managers', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderNotice()
|
||||
|
||||
expect(screen.getByText('appLog.archives.notice.description')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'appLog.archives.notice.action' }))
|
||||
const notice = screen.getByRole('status')
|
||||
expect(notice).toHaveAttribute('aria-live', 'polite')
|
||||
expect(notice).toHaveAttribute('aria-atomic', 'true')
|
||||
expect(within(notice).getByText('appLog.archives.notice.description')).toBeInTheDocument()
|
||||
|
||||
await user.click(within(notice).getByRole('button', { name: 'appLog.archives.notice.action' }))
|
||||
expect(setShowAccountSettingModal).toHaveBeenCalledWith({
|
||||
payload: ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES,
|
||||
})
|
||||
|
||||
@@ -1,9 +1,37 @@
|
||||
import type { QueryParam } from '../index'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import Filter, { TIME_PERIOD_MAPPING } from '../filter'
|
||||
|
||||
let mockAnnotationsCountLoading = false
|
||||
let mockAnnotationsCountData: { count: number } | null = { count: 10 }
|
||||
const mockRuntime = vi.hoisted(() => ({
|
||||
deploymentEdition: 'CLOUD',
|
||||
enableBilling: true,
|
||||
isFetchedPlan: true,
|
||||
isFetchedPlanInfo: true,
|
||||
planType: 'professional',
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
|
||||
return {
|
||||
...actual,
|
||||
useSuspenseQuery: () => ({ data: mockRuntime.deploymentEdition }),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/provider-context', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/provider-context')>()
|
||||
return {
|
||||
...actual,
|
||||
useProviderContext: () => ({
|
||||
enableBilling: mockRuntime.enableBilling,
|
||||
isFetchedPlan: mockRuntime.isFetchedPlan,
|
||||
isFetchedPlanInfo: mockRuntime.isFetchedPlanInfo,
|
||||
plan: { type: mockRuntime.planType },
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/service/use-log', () => ({
|
||||
useAnnotationsCount: () => ({
|
||||
@@ -12,28 +40,43 @@ vi.mock('@/service/use-log', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/chip', () => ({
|
||||
default: ({
|
||||
items,
|
||||
value,
|
||||
onSelect,
|
||||
onClear,
|
||||
}: {
|
||||
items: Array<{ value: string; name: string }>
|
||||
value?: string
|
||||
onSelect: (item: { value: string; name: string }) => void
|
||||
onClear: () => void
|
||||
}) => {
|
||||
const currentItem = items.find((item) => item.value === value) ?? items[0]
|
||||
return (
|
||||
<div>
|
||||
<div>{currentItem?.name}</div>
|
||||
<button onClick={() => onSelect(items.at(-1)!)}>{`select-${items.at(-1)?.value}`}</button>
|
||||
<button onClick={onClear}>clear-chip</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
vi.mock('@/app/components/base/chip', async () => {
|
||||
const { useState } = await import('react')
|
||||
|
||||
return {
|
||||
default: function MockChip({
|
||||
items,
|
||||
value,
|
||||
onSelect,
|
||||
onClear,
|
||||
}: {
|
||||
items: Array<{ value: string; name: string }>
|
||||
value?: string
|
||||
onSelect: (item: { value: string; name: string }) => void
|
||||
onClear: () => void
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const currentItem = items.find((item) => item.value === value) ?? items[0]
|
||||
return (
|
||||
<div>
|
||||
<div>{currentItem?.name}</div>
|
||||
<button aria-label={`open-options-${items[0]?.value}`} onClick={() => setIsOpen(true)}>
|
||||
open-chip
|
||||
</button>
|
||||
{isOpen && (
|
||||
<ul aria-label={`options-${items[0]?.value}`}>
|
||||
{items.map((item) => (
|
||||
<li key={item.value}>{item.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<button onClick={() => onSelect(items.at(-1)!)}>{`select-${items.at(-1)?.value}`}</button>
|
||||
<button onClick={onClear}>clear-chip</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/base/sort', () => ({
|
||||
default: ({ onSelect }: { onSelect: (value: string) => void }) => (
|
||||
@@ -59,6 +102,11 @@ describe('Filter', () => {
|
||||
vi.clearAllMocks()
|
||||
mockAnnotationsCountLoading = false
|
||||
mockAnnotationsCountData = { count: 10 }
|
||||
mockRuntime.deploymentEdition = 'CLOUD'
|
||||
mockRuntime.enableBilling = true
|
||||
mockRuntime.isFetchedPlan = true
|
||||
mockRuntime.isFetchedPlanInfo = true
|
||||
mockRuntime.planType = 'professional'
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
@@ -124,6 +172,77 @@ describe('Filter', () => {
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should only show supported periods for Cloud sandbox workspaces', () => {
|
||||
mockRuntime.deploymentEdition = 'CLOUD'
|
||||
mockRuntime.planType = 'sandbox'
|
||||
|
||||
render(<Filter {...defaultProps} queryParams={{ ...defaultQueryParams, period: '2' }} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'open-options-1' }))
|
||||
|
||||
const periodOptions = within(screen.getByRole('list', { name: 'options-1' }))
|
||||
expect(periodOptions.getAllByRole('listitem').map((item) => item.textContent)).toEqual([
|
||||
expect.stringMatching(/(?:^|\.)filter\.period\.today(?=$|:)/),
|
||||
expect.stringMatching(/(?:^|\.)filter\.period\.last7days(?=$|:)/),
|
||||
expect.stringMatching(/(?:^|\.)filter\.period\.last30days(?=$|:)/),
|
||||
])
|
||||
})
|
||||
|
||||
it('should only show supported periods while the Cloud plan is pending', () => {
|
||||
mockRuntime.isFetchedPlan = false
|
||||
mockRuntime.isFetchedPlanInfo = false
|
||||
|
||||
render(<Filter {...defaultProps} queryParams={{ ...defaultQueryParams, period: '2' }} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'open-options-1' }))
|
||||
|
||||
const periodOptions = within(screen.getByRole('list', { name: 'options-1' }))
|
||||
expect(periodOptions.getAllByRole('listitem').map((item) => item.textContent)).toEqual([
|
||||
expect.stringMatching(/(?:^|\.)filter\.period\.today(?=$|:)/),
|
||||
expect.stringMatching(/(?:^|\.)filter\.period\.last7days(?=$|:)/),
|
||||
expect.stringMatching(/(?:^|\.)filter\.period\.last30days(?=$|:)/),
|
||||
])
|
||||
})
|
||||
|
||||
it('should keep all periods when Cloud billing is known to be disabled', () => {
|
||||
mockRuntime.enableBilling = false
|
||||
mockRuntime.isFetchedPlan = false
|
||||
mockRuntime.isFetchedPlanInfo = true
|
||||
|
||||
render(<Filter {...defaultProps} queryParams={{ ...defaultQueryParams, period: '2' }} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'open-options-1' }))
|
||||
|
||||
const periodOptions = within(screen.getByRole('list', { name: 'options-1' }))
|
||||
expect(periodOptions.getAllByRole('listitem')).toHaveLength(9)
|
||||
})
|
||||
|
||||
it('should keep all periods for sandbox workspaces outside Cloud', () => {
|
||||
mockRuntime.deploymentEdition = 'COMMUNITY'
|
||||
mockRuntime.planType = 'sandbox'
|
||||
|
||||
render(<Filter {...defaultProps} queryParams={{ ...defaultQueryParams, period: '2' }} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'open-options-1' }))
|
||||
|
||||
const periodOptions = within(screen.getByRole('list', { name: 'options-1' }))
|
||||
expect(periodOptions.getAllByRole('listitem')).toHaveLength(9)
|
||||
})
|
||||
|
||||
it('should reset the Cloud sandbox period to today when cleared', () => {
|
||||
mockRuntime.deploymentEdition = 'CLOUD'
|
||||
mockRuntime.planType = 'sandbox'
|
||||
|
||||
render(<Filter {...defaultProps} queryParams={{ ...defaultQueryParams, period: '2' }} />)
|
||||
|
||||
fireEvent.click(screen.getAllByText('clear-chip')[0]!)
|
||||
|
||||
expect(mockSetQueryParams).toHaveBeenCalledWith({
|
||||
...defaultQueryParams,
|
||||
period: '1',
|
||||
})
|
||||
})
|
||||
|
||||
it('should update keyword when typing in search input', () => {
|
||||
render(<Filter {...defaultProps} />)
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
/* oxlint-disable typescript/no-explicit-any */
|
||||
import type { CloudSandboxPlanState } from '../cloud-sandbox-retention'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import dayjs from 'dayjs'
|
||||
import { APP_PAGE_LIMIT } from '@/config'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import Logs from '../index'
|
||||
@@ -11,11 +14,27 @@ vi.mock('@/context/i18n', () => ({
|
||||
const mockReplace = vi.fn()
|
||||
const mockUseChatConversations = vi.fn()
|
||||
const mockUseCompletionConversations = vi.fn()
|
||||
const mockPlanState = vi.hoisted(() => ({
|
||||
value: 'unrestricted' as CloudSandboxPlanState,
|
||||
}))
|
||||
const mockDebouncedPeriod = vi.hoisted(() => ({
|
||||
value: null as string | null,
|
||||
}))
|
||||
|
||||
let mockSearchParams = new URLSearchParams()
|
||||
vi.mock('ahooks', async () => {
|
||||
return {
|
||||
useDebounce: <T,>(value: T) => value,
|
||||
useDebounce: <T,>(value: T) => {
|
||||
if (
|
||||
mockDebouncedPeriod.value === null ||
|
||||
typeof value !== 'object' ||
|
||||
value === null ||
|
||||
!('period' in value)
|
||||
)
|
||||
return value
|
||||
|
||||
return { ...value, period: mockDebouncedPeriod.value }
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -33,28 +52,19 @@ vi.mock('@/next/navigation', () => ({
|
||||
vi.mock('@/service/use-log', () => ({
|
||||
useChatConversations: (...args: unknown[]) => mockUseChatConversations(...args),
|
||||
useCompletionConversations: (...args: unknown[]) => mockUseCompletionConversations(...args),
|
||||
useAnnotationsCount: () => ({
|
||||
data: { count: 0 },
|
||||
isLoading: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../filter', () => ({
|
||||
TIME_PERIOD_MAPPING: {
|
||||
2: { value: 7 },
|
||||
9: { value: 0 },
|
||||
},
|
||||
default: ({ setQueryParams }: { setQueryParams: (next: Record<string, string>) => void }) => (
|
||||
<button
|
||||
onClick={() =>
|
||||
setQueryParams({
|
||||
period: '9',
|
||||
annotation_status: 'all',
|
||||
sort_by: '-created_at',
|
||||
keyword: 'hello',
|
||||
})
|
||||
}
|
||||
>
|
||||
filter-controls
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
vi.mock('../cloud-sandbox-retention', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../cloud-sandbox-retention')>()
|
||||
return {
|
||||
...actual,
|
||||
useCloudSandboxPlanStatus: () => mockPlanState.value,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../list', () => ({
|
||||
default: ({ logs }: { logs: { total?: number } }) => (
|
||||
@@ -69,6 +79,10 @@ vi.mock('../empty-element', () => ({
|
||||
default: () => <div>empty-logs</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../retention-upgrade-notice', () => ({
|
||||
RetentionUpgradeNotice: () => <div>retention-upgrade-notice</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/loading', () => ({
|
||||
default: () => <div>loading-logs</div>,
|
||||
}))
|
||||
@@ -85,6 +99,8 @@ describe('Logs', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSearchParams = new URLSearchParams()
|
||||
mockPlanState.value = 'unrestricted'
|
||||
mockDebouncedPeriod.value = null
|
||||
mockUseChatConversations.mockReturnValue({
|
||||
data: undefined,
|
||||
refetch: vi.fn(),
|
||||
@@ -117,6 +133,7 @@ describe('Logs', () => {
|
||||
expect(
|
||||
screen.getByRole('link', { name: /(?:^|\.)operation\.learnMore(?=$|:)/ }),
|
||||
).toHaveAttribute('href', 'https://docs.example.com/use-dify/monitor/logs')
|
||||
expect(screen.getByText('retention-upgrade-notice')).toBeInTheDocument()
|
||||
expect(screen.getByText('loading-logs')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -166,4 +183,101 @@ describe('Logs', () => {
|
||||
|
||||
expect(mockReplace).toHaveBeenCalledWith('/apps/app-1/logs?page=2', { scroll: false })
|
||||
})
|
||||
|
||||
it('should query the last 30 days when a Sandbox user selects the longest period', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockPlanState.value = 'sandbox'
|
||||
mockUseChatConversations.mockReturnValue({
|
||||
data: { total: 0 },
|
||||
refetch: vi.fn(),
|
||||
})
|
||||
|
||||
render(
|
||||
<Logs
|
||||
appDetail={
|
||||
{
|
||||
id: 'app-sandbox-last-30-days',
|
||||
mode: AppModeEnum.CHAT,
|
||||
} as any
|
||||
}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('combobox', { name: /appLog\.filter\.period\.last7days/ }))
|
||||
await user.click(await screen.findByText(/appLog\.filter\.period\.last30days/))
|
||||
|
||||
expect(
|
||||
screen.getByRole('combobox', { name: /appLog\.filter\.period\.last30days/ }),
|
||||
).toBeInTheDocument()
|
||||
expect(mockUseChatConversations.mock.calls.at(-1)?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
start: dayjs().subtract(30, 'day').startOf('day').format('YYYY-MM-DD HH:mm'),
|
||||
end: dayjs().endOf('day').format('YYYY-MM-DD HH:mm'),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should use a valid period for the real Chip and request when a cached period settles to Sandbox', async () => {
|
||||
const user = userEvent.setup()
|
||||
const appDetail = {
|
||||
id: 'app-period-transition',
|
||||
mode: AppModeEnum.CHAT,
|
||||
} as any
|
||||
mockUseChatConversations.mockReturnValue({
|
||||
data: { total: 0 },
|
||||
refetch: vi.fn(),
|
||||
})
|
||||
|
||||
const unrestrictedRender = render(<Logs appDetail={appDetail} />)
|
||||
|
||||
await user.click(screen.getByRole('combobox', { name: /appLog\.filter\.period\.last7days/ }))
|
||||
await user.click(await screen.findByText(/appLog\.filter\.period\.allTime/))
|
||||
expect(mockUseChatConversations.mock.calls.at(-1)?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
params: expect.not.objectContaining({
|
||||
start: expect.anything(),
|
||||
end: expect.anything(),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
unrestrictedRender.unmount()
|
||||
|
||||
mockPlanState.value = 'pending'
|
||||
mockDebouncedPeriod.value = '9'
|
||||
const pendingRender = render(<Logs appDetail={appDetail} />)
|
||||
|
||||
expect(
|
||||
screen.getByRole('combobox', { name: /appLog\.filter\.period\.today/ }),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: /common\.operation\.clear appLog\.filter\.period\.today/,
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
expect(mockUseChatConversations.mock.calls.at(-1)?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
start: dayjs().startOf('day').format('YYYY-MM-DD HH:mm'),
|
||||
end: expect.any(String),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
mockPlanState.value = 'sandbox'
|
||||
pendingRender.rerender(<Logs appDetail={appDetail} />)
|
||||
|
||||
expect(
|
||||
screen.getByRole('combobox', { name: /appLog\.filter\.period\.today/ }),
|
||||
).toBeInTheDocument()
|
||||
expect(mockUseChatConversations.mock.calls.at(-1)?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
start: dayjs().startOf('day').format('YYYY-MM-DD HH:mm'),
|
||||
end: expect.any(String),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen'
|
||||
import { screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
|
||||
import { defaultPlan } from '@/app/components/billing/config'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { createConsoleQueryWrapper } from '@/test/console/query-data'
|
||||
import { render } from '@/test/console/render'
|
||||
import { RetentionUpgradeNotice } from '../retention-upgrade-notice'
|
||||
|
||||
vi.mock('@/context/provider-context', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/provider-context')>()
|
||||
return {
|
||||
...actual,
|
||||
useProviderContext: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/modal-context', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/modal-context')>()
|
||||
return {
|
||||
...actual,
|
||||
useModalContext: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
const mockUseProviderContext = vi.mocked(useProviderContext)
|
||||
const mockUseModalContext = vi.mocked(useModalContext)
|
||||
|
||||
describe('RetentionUpgradeNotice', () => {
|
||||
const setShowPricingModal = vi.fn()
|
||||
|
||||
function mockProvider({
|
||||
enableBilling = true,
|
||||
isFetchedPlan = true,
|
||||
isFetchedPlanInfo = true,
|
||||
planType = Plan.sandbox,
|
||||
}: {
|
||||
enableBilling?: boolean
|
||||
isFetchedPlan?: boolean
|
||||
isFetchedPlanInfo?: boolean
|
||||
planType?: Plan
|
||||
} = {}) {
|
||||
mockUseProviderContext.mockReturnValue(
|
||||
createMockProviderContextValue({
|
||||
enableBilling,
|
||||
isFetchedPlan,
|
||||
isFetchedPlanInfo,
|
||||
plan: {
|
||||
...defaultPlan,
|
||||
type: planType,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function renderNotice(deploymentEdition: DeploymentEdition = 'CLOUD') {
|
||||
const { wrapper } = createConsoleQueryWrapper({
|
||||
systemFeatures: { deployment_edition: deploymentEdition },
|
||||
})
|
||||
return render(<RetentionUpgradeNotice />, { wrapper })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockProvider()
|
||||
mockUseModalContext.mockReturnValue({
|
||||
setShowPricingModal,
|
||||
} as unknown as ReturnType<typeof useModalContext>)
|
||||
})
|
||||
|
||||
it('should show accessible upgrade guidance for Cloud sandbox workspaces', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderNotice()
|
||||
|
||||
const notice = screen.getByRole('status')
|
||||
expect(notice).toHaveAttribute('aria-live', 'polite')
|
||||
expect(notice).toHaveAttribute('aria-atomic', 'true')
|
||||
expect(within(notice).getByText('appLog.retention.upgradeTip.description')).toBeInTheDocument()
|
||||
|
||||
await user.click(
|
||||
within(notice).getByRole('button', { name: 'billing.upgradeBtn.encourageShort' }),
|
||||
)
|
||||
expect(setShowPricingModal).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'paid Cloud workspaces',
|
||||
provider: { planType: Plan.professional },
|
||||
deploymentEdition: 'CLOUD',
|
||||
},
|
||||
{
|
||||
name: 'self-hosted sandbox workspaces',
|
||||
provider: { planType: Plan.sandbox },
|
||||
deploymentEdition: 'COMMUNITY',
|
||||
},
|
||||
{
|
||||
name: 'workspaces without billing',
|
||||
provider: { enableBilling: false },
|
||||
deploymentEdition: 'CLOUD',
|
||||
},
|
||||
{
|
||||
name: 'workspaces before plan loading completes',
|
||||
provider: { isFetchedPlan: false, isFetchedPlanInfo: false },
|
||||
deploymentEdition: 'CLOUD',
|
||||
},
|
||||
] as const)('should not show guidance for $name', ({ provider, deploymentEdition }) => {
|
||||
mockProvider(provider)
|
||||
|
||||
renderNotice(deploymentEdition)
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -31,16 +32,27 @@ export function ArchivedLogsNotice() {
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className="mb-3 flex items-start gap-2 rounded-lg border border-util-colors-warning-warning-200 bg-util-colors-warning-warning-50 px-3 py-2">
|
||||
<span
|
||||
aria-hidden
|
||||
className="mt-0.5 i-ri-information-line size-4 shrink-0 text-util-colors-warning-warning-600"
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="relative mb-3 shrink-0 overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute -inset-px bg-linear-to-r from-components-badge-status-light-normal-halo to-background-gradient-mask-transparent opacity-40"
|
||||
/>
|
||||
<div className="min-w-0 flex-1 system-xs-regular text-util-colors-warning-warning-700">
|
||||
{t(($) => $['archives.notice.description'], { ns: 'appLog' })}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 system-xs-semibold text-util-colors-warning-warning-700 underline underline-offset-2 hover:text-text-primary"
|
||||
<div className="relative flex items-center gap-3 px-3 py-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="i-ri-information-2-fill size-5 shrink-0 text-text-accent"
|
||||
/>
|
||||
<p className="min-w-0 flex-1 system-sm-semibold wrap-break-word text-text-primary">
|
||||
{t(($) => $['archives.notice.description'], { ns: 'appLog' })}
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="shrink-0"
|
||||
onClick={() =>
|
||||
setShowAccountSettingModal({
|
||||
payload: ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES,
|
||||
@@ -48,7 +60,7 @@ export function ArchivedLogsNotice() {
|
||||
}
|
||||
>
|
||||
{t(($) => $['archives.notice.action'], { ns: 'appLog' })}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
|
||||
export const CLOUD_SANDBOX_TIME_PERIOD_KEYS = new Set(['1', '2', '3'])
|
||||
export const CLOUD_SANDBOX_CLEARED_TIME_PERIOD = '1'
|
||||
|
||||
const CLOUD_SANDBOX_LONGEST_TIME_PERIOD = '3'
|
||||
const CLOUD_SANDBOX_LONGEST_TIME_PERIOD_OPTION = {
|
||||
value: 30,
|
||||
name: 'last30days',
|
||||
} as const
|
||||
|
||||
export type CloudSandboxPlanState = 'pending' | 'sandbox' | 'unrestricted'
|
||||
|
||||
export function isLogTimePeriodRestricted(planState: CloudSandboxPlanState) {
|
||||
return planState !== 'unrestricted'
|
||||
}
|
||||
|
||||
export function resolveLogTimePeriod(period: string, planState: CloudSandboxPlanState) {
|
||||
if (!isLogTimePeriodRestricted(planState) || CLOUD_SANDBOX_TIME_PERIOD_KEYS.has(period))
|
||||
return period
|
||||
|
||||
return CLOUD_SANDBOX_CLEARED_TIME_PERIOD
|
||||
}
|
||||
|
||||
export function resolveLogTimePeriodOption<T extends { value: number; name: string }>(
|
||||
period: string,
|
||||
option: T,
|
||||
planState: CloudSandboxPlanState,
|
||||
) {
|
||||
if (isLogTimePeriodRestricted(planState) && period === CLOUD_SANDBOX_LONGEST_TIME_PERIOD)
|
||||
return CLOUD_SANDBOX_LONGEST_TIME_PERIOD_OPTION
|
||||
|
||||
return option
|
||||
}
|
||||
|
||||
export function useCloudSandboxPlanStatus(): CloudSandboxPlanState {
|
||||
const { data: deploymentEdition } = useSuspenseQuery({
|
||||
...systemFeaturesQueryOptions(),
|
||||
select: ({ deployment_edition }) => deployment_edition,
|
||||
})
|
||||
const { enableBilling, isFetchedPlan, isFetchedPlanInfo, plan } = useProviderContext()
|
||||
|
||||
if (deploymentEdition !== 'CLOUD') return 'unrestricted'
|
||||
if (!isFetchedPlanInfo) return 'pending'
|
||||
if (!enableBilling) return 'unrestricted'
|
||||
if (!isFetchedPlan) return 'pending'
|
||||
|
||||
return plan.type === Plan.sandbox ? 'sandbox' : 'unrestricted'
|
||||
}
|
||||
@@ -11,6 +11,13 @@ import Chip from '@/app/components/base/chip'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Sort from '@/app/components/base/sort'
|
||||
import { useAnnotationsCount } from '@/service/use-log'
|
||||
import {
|
||||
CLOUD_SANDBOX_CLEARED_TIME_PERIOD,
|
||||
CLOUD_SANDBOX_TIME_PERIOD_KEYS,
|
||||
isLogTimePeriodRestricted,
|
||||
resolveLogTimePeriodOption,
|
||||
useCloudSandboxPlanStatus,
|
||||
} from './cloud-sandbox-retention'
|
||||
|
||||
dayjs.extend(quarterOfYear)
|
||||
|
||||
@@ -45,6 +52,12 @@ const Filter: FC<IFilterProps> = ({
|
||||
}: IFilterProps) => {
|
||||
const { data, isLoading } = useAnnotationsCount(appId)
|
||||
const { t } = useTranslation()
|
||||
const planState = useCloudSandboxPlanStatus()
|
||||
const isTimePeriodRestricted = isLogTimePeriodRestricted(planState)
|
||||
const timePeriodEntries = Object.entries(TIME_PERIOD_MAPPING)
|
||||
.filter(([key]) => !isTimePeriodRestricted || CLOUD_SANDBOX_TIME_PERIOD_KEYS.has(key))
|
||||
.map(([key, option]) => [key, resolveLogTimePeriodOption(key, option, planState)] as const)
|
||||
|
||||
if (isLoading || !data) return null
|
||||
return (
|
||||
<div className="mb-2 flex flex-row flex-wrap items-center gap-2">
|
||||
@@ -56,8 +69,13 @@ const Filter: FC<IFilterProps> = ({
|
||||
onSelect={(item) => {
|
||||
setQueryParams({ ...queryParams, period: item.value })
|
||||
}}
|
||||
onClear={() => setQueryParams({ ...queryParams, period: '9' })}
|
||||
items={Object.entries(TIME_PERIOD_MAPPING).map(([k, v]) => ({
|
||||
onClear={() =>
|
||||
setQueryParams({
|
||||
...queryParams,
|
||||
period: isTimePeriodRestricted ? CLOUD_SANDBOX_CLEARED_TIME_PERIOD : '9',
|
||||
})
|
||||
}
|
||||
items={timePeriodEntries.map(([k, v]) => ({
|
||||
value: k,
|
||||
name: t(($) => $[`filter.period.${v.name}`], { ns: 'appLog' }),
|
||||
}))}
|
||||
|
||||
@@ -15,9 +15,15 @@ import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { useChatConversations, useCompletionConversations } from '@/service/use-log'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import PageTitle from '../log-annotation/page-title'
|
||||
import {
|
||||
resolveLogTimePeriod,
|
||||
resolveLogTimePeriodOption,
|
||||
useCloudSandboxPlanStatus,
|
||||
} from './cloud-sandbox-retention'
|
||||
import EmptyElement from './empty-element'
|
||||
import Filter, { TIME_PERIOD_MAPPING } from './filter'
|
||||
import List from './list'
|
||||
import { RetentionUpgradeNotice } from './retention-upgrade-notice'
|
||||
|
||||
type ILogsProps = {
|
||||
appDetail: App
|
||||
@@ -57,6 +63,7 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||
return pageParam - 1
|
||||
}, [searchParams])
|
||||
const cachedState = logsStateCache.get(appDetail.id)
|
||||
const cloudSandboxPlanState = useCloudSandboxPlanStatus()
|
||||
const [queryParams, setQueryParams] = useState<QueryParam>(
|
||||
cachedState?.queryParams ?? defaultQueryParams,
|
||||
)
|
||||
@@ -64,7 +71,15 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||
() => cachedState?.currPage ?? getPageFromParams(),
|
||||
)
|
||||
const [limit, setLimit] = React.useState<number>(cachedState?.limit ?? APP_PAGE_LIMIT)
|
||||
const effectivePeriod = resolveLogTimePeriod(queryParams.period, cloudSandboxPlanState)
|
||||
const effectiveQueryParams = { ...queryParams, period: effectivePeriod }
|
||||
const debouncedQueryParams = useDebounce(queryParams, { wait: 500 })
|
||||
const requestQueryParams = { ...debouncedQueryParams, period: effectivePeriod }
|
||||
const requestTimePeriod = resolveLogTimePeriodOption(
|
||||
requestQueryParams.period,
|
||||
TIME_PERIOD_MAPPING[requestQueryParams.period]!,
|
||||
cloudSandboxPlanState,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const pageFromParams = getPageFromParams()
|
||||
@@ -85,17 +100,17 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||
const query = {
|
||||
page: currPage + 1,
|
||||
limit,
|
||||
...(debouncedQueryParams.period !== '9'
|
||||
...(requestQueryParams.period !== '9'
|
||||
? {
|
||||
start: dayjs()
|
||||
.subtract(TIME_PERIOD_MAPPING[debouncedQueryParams.period]!.value, 'day')
|
||||
.subtract(requestTimePeriod.value, 'day')
|
||||
.startOf('day')
|
||||
.format('YYYY-MM-DD HH:mm'),
|
||||
end: dayjs().endOf('day').format('YYYY-MM-DD HH:mm'),
|
||||
}
|
||||
: {}),
|
||||
...(isChatMode ? { sort_by: debouncedQueryParams.sort_by } : {}),
|
||||
...omit(debouncedQueryParams, ['period']),
|
||||
...(isChatMode ? { sort_by: requestQueryParams.sort_by } : {}),
|
||||
...omit(requestQueryParams, ['period']),
|
||||
}
|
||||
|
||||
// When the details are obtained, proceed to the next request
|
||||
@@ -143,9 +158,10 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||
<Filter
|
||||
isChatMode={isChatMode}
|
||||
appId={appDetail.id}
|
||||
queryParams={queryParams}
|
||||
queryParams={effectiveQueryParams}
|
||||
setQueryParams={handleQueryParamsChange}
|
||||
/>
|
||||
<RetentionUpgradeNotice />
|
||||
{total === undefined ? (
|
||||
<Loading type="app" />
|
||||
) : total > 0 ? (
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
|
||||
import { useCloudSandboxPlanStatus } from './cloud-sandbox-retention'
|
||||
|
||||
export function RetentionUpgradeNotice() {
|
||||
const { t } = useTranslation()
|
||||
const planState = useCloudSandboxPlanStatus()
|
||||
|
||||
if (planState !== 'sandbox') return null
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="relative mb-3 shrink-0 overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute -inset-px bg-linear-to-r from-components-badge-status-light-normal-halo to-background-gradient-mask-transparent opacity-40"
|
||||
/>
|
||||
<div className="relative flex items-center gap-3 px-3 py-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-components-button-primary-bg"
|
||||
>
|
||||
<span className="i-ri-file-list-3-fill size-4 text-components-button-primary-text" />
|
||||
</span>
|
||||
<p className="min-w-0 flex-1 system-sm-medium wrap-break-word text-text-primary">
|
||||
{t(($) => $['retention.upgradeTip.description'], { ns: 'appLog' })}
|
||||
</p>
|
||||
<UpgradeBtn
|
||||
isShort
|
||||
size="custom"
|
||||
className="h-8! shrink-0 rounded-lg! px-2"
|
||||
loc="logs-retention"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -17,6 +17,35 @@ import Filter, { TIME_PERIOD_MAPPING } from '../filter'
|
||||
// Mocks
|
||||
// ============================================================================
|
||||
|
||||
const mockRuntime = vi.hoisted(() => ({
|
||||
deploymentEdition: 'CLOUD',
|
||||
enableBilling: true,
|
||||
isFetchedPlan: true,
|
||||
isFetchedPlanInfo: true,
|
||||
planType: 'professional',
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
|
||||
return {
|
||||
...actual,
|
||||
useSuspenseQuery: () => ({ data: mockRuntime.deploymentEdition }),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/provider-context', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/provider-context')>()
|
||||
return {
|
||||
...actual,
|
||||
useProviderContext: () => ({
|
||||
enableBilling: mockRuntime.enableBilling,
|
||||
isFetchedPlan: mockRuntime.isFetchedPlan,
|
||||
isFetchedPlanInfo: mockRuntime.isFetchedPlanInfo,
|
||||
plan: { type: mockRuntime.planType },
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const mockTrackEvent = vi.fn()
|
||||
vi.mock('@/app/components/base/amplitude/utils', () => ({
|
||||
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
||||
@@ -41,6 +70,11 @@ describe('Filter', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockRuntime.deploymentEdition = 'CLOUD'
|
||||
mockRuntime.enableBilling = true
|
||||
mockRuntime.isFetchedPlan = true
|
||||
mockRuntime.isFetchedPlanInfo = true
|
||||
mockRuntime.planType = 'professional'
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -176,6 +210,69 @@ describe('Filter', () => {
|
||||
// Time Period Filter Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Time Period Filter', () => {
|
||||
it('should only show supported periods for Cloud sandbox workspaces', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockRuntime.deploymentEdition = 'CLOUD'
|
||||
mockRuntime.planType = 'sandbox'
|
||||
|
||||
render(
|
||||
<Filter queryParams={createDefaultQueryParams()} setQueryParams={defaultSetQueryParams} />,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('combobox', { name: 'appLog.filter.period.last7days' }))
|
||||
|
||||
const listbox = await screen.findByRole('listbox')
|
||||
expect(
|
||||
within(listbox)
|
||||
.getAllByRole('option')
|
||||
.map((option) => option.textContent),
|
||||
).toEqual([
|
||||
'appLog.filter.period.today',
|
||||
'appLog.filter.period.last7days',
|
||||
'appLog.filter.period.last30days',
|
||||
])
|
||||
})
|
||||
|
||||
it('should keep all periods for sandbox workspaces outside Cloud', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockRuntime.deploymentEdition = 'COMMUNITY'
|
||||
mockRuntime.planType = 'sandbox'
|
||||
|
||||
render(
|
||||
<Filter queryParams={createDefaultQueryParams()} setQueryParams={defaultSetQueryParams} />,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('combobox', { name: 'appLog.filter.period.last7days' }))
|
||||
|
||||
const listbox = await screen.findByRole('listbox')
|
||||
expect(within(listbox).getAllByRole('option')).toHaveLength(9)
|
||||
})
|
||||
|
||||
it('should reset the Cloud sandbox period to today when cleared', async () => {
|
||||
const user = userEvent.setup()
|
||||
const setQueryParams = vi.fn()
|
||||
mockRuntime.deploymentEdition = 'CLOUD'
|
||||
mockRuntime.planType = 'sandbox'
|
||||
|
||||
render(
|
||||
<Filter
|
||||
queryParams={createDefaultQueryParams({ period: '3' })}
|
||||
setQueryParams={setQueryParams}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: /common\.operation\.clear appLog\.filter\.period\.last30days/,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(setQueryParams).toHaveBeenCalledWith({
|
||||
status: 'all',
|
||||
period: '1',
|
||||
})
|
||||
})
|
||||
|
||||
it('should display current period value', () => {
|
||||
render(
|
||||
<Filter
|
||||
|
||||
@@ -15,11 +15,13 @@ import type { UseQueryResult } from '@tanstack/react-query'
|
||||
* - trigger-by-display.spec.tsx
|
||||
*/
|
||||
import type { MockedFunction } from 'vitest'
|
||||
import type { CloudSandboxPlanState } from '../../log/cloud-sandbox-retention'
|
||||
import type { ILogsProps } from '../index'
|
||||
import type { WorkflowAppLogDetail, WorkflowLogsResponse, WorkflowRunDetail } from '@/models/log'
|
||||
import type { App, AppIconType, AppModeEnum } from '@/types/app'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import dayjs from 'dayjs'
|
||||
import { APP_PAGE_LIMIT } from '@/config'
|
||||
import { WorkflowRunTriggeredFrom } from '@/models/log'
|
||||
import * as useLogModule from '@/service/use-log'
|
||||
@@ -31,10 +33,35 @@ import Logs from '../index'
|
||||
// Mocks
|
||||
// ============================================================================
|
||||
|
||||
const mockPlanState = vi.hoisted(() => ({
|
||||
value: 'unrestricted' as CloudSandboxPlanState,
|
||||
}))
|
||||
const mockDebouncedPeriod = vi.hoisted(() => ({
|
||||
value: null as string | null,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-log')
|
||||
|
||||
vi.mock('../../log/cloud-sandbox-retention', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../log/cloud-sandbox-retention')>()
|
||||
return {
|
||||
...actual,
|
||||
useCloudSandboxPlanStatus: () => mockPlanState.value,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useDebounce: <T,>(value: T) => value,
|
||||
useDebounce: <T,>(value: T) => {
|
||||
if (
|
||||
mockDebouncedPeriod.value === null ||
|
||||
typeof value !== 'object' ||
|
||||
value === null ||
|
||||
!('period' in value)
|
||||
)
|
||||
return value
|
||||
|
||||
return { ...value, period: mockDebouncedPeriod.value }
|
||||
},
|
||||
useDebounceFn: (fn: (value: string) => void) => ({ run: fn }),
|
||||
useBoolean: (initial: boolean) => {
|
||||
const setters = {
|
||||
@@ -58,6 +85,10 @@ vi.mock('@/next/link', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../log/retention-upgrade-notice', () => ({
|
||||
RetentionUpgradeNotice: () => <div>retention-upgrade-notice</div>,
|
||||
}))
|
||||
|
||||
// Mock the Run component to avoid complex dependencies
|
||||
vi.mock('@/app/components/workflow/run', () => ({
|
||||
default: ({ runDetailUrl, tracingListUrl }: { runDetailUrl: string; tracingListUrl: string }) => (
|
||||
@@ -237,6 +268,8 @@ describe('Logs Container', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPlanState.value = 'unrestricted'
|
||||
mockDebouncedPeriod.value = null
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -272,6 +305,7 @@ describe('Logs Container', () => {
|
||||
|
||||
// Assert
|
||||
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
|
||||
expect(screen.getByText('retention-upgrade-notice')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -444,6 +478,76 @@ describe('Logs Container', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should query the last 30 days when a Sandbox user selects the longest period', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockPlanState.value = 'sandbox'
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
data: createMockLogsResponse([], 0),
|
||||
}),
|
||||
)
|
||||
|
||||
renderWithQueryClient(<Logs {...defaultProps} />)
|
||||
|
||||
await user.click(screen.getByText('appLog.filter.period.last7days'))
|
||||
await user.click(await screen.findByText('appLog.filter.period.last30days'))
|
||||
|
||||
expect(
|
||||
screen.getByRole('combobox', { name: 'appLog.filter.period.last30days' }),
|
||||
).toBeInTheDocument()
|
||||
const params = getMockCallParams()?.params
|
||||
expect(
|
||||
dayjs(String(params?.created_at__before)).diff(String(params?.created_at__after), 'day'),
|
||||
).toBe(30)
|
||||
})
|
||||
|
||||
it('should use a valid period for the real Chip and request when plan state settles to Sandbox', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockedUseWorkflowLogs.mockReturnValue(
|
||||
createMockQueryResult<WorkflowLogsResponse>({
|
||||
data: createMockLogsResponse([], 0),
|
||||
}),
|
||||
)
|
||||
const rendered = renderWithQueryClient(<Logs {...defaultProps} />)
|
||||
|
||||
await user.click(screen.getByText('appLog.filter.period.last7days'))
|
||||
await user.click(await screen.findByText('appLog.filter.period.allTime'))
|
||||
expect(getMockCallParams()?.params).not.toHaveProperty('created_at__after')
|
||||
expect(getMockCallParams()?.params).not.toHaveProperty('created_at__before')
|
||||
|
||||
mockPlanState.value = 'pending'
|
||||
mockDebouncedPeriod.value = '9'
|
||||
rendered.rerender(<Logs {...defaultProps} />)
|
||||
|
||||
expect(
|
||||
screen.getByRole('combobox', { name: 'appLog.filter.period.today' }),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: /common\.operation\.clear appLog\.filter\.period\.today/,
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
expect(getMockCallParams()?.params).toEqual(
|
||||
expect.objectContaining({
|
||||
created_at__after: expect.any(String),
|
||||
created_at__before: expect.any(String),
|
||||
}),
|
||||
)
|
||||
|
||||
mockPlanState.value = 'sandbox'
|
||||
rendered.rerender(<Logs {...defaultProps} />)
|
||||
|
||||
expect(
|
||||
screen.getByRole('combobox', { name: 'appLog.filter.period.today' }),
|
||||
).toBeInTheDocument()
|
||||
expect(getMockCallParams()?.params).toEqual(
|
||||
expect.objectContaining({
|
||||
created_at__after: expect.any(String),
|
||||
created_at__before: expect.any(String),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should update query when typing keyword', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
|
||||
@@ -10,6 +10,13 @@ import { useTranslation } from 'react-i18next'
|
||||
import { trackEvent } from '@/app/components/base/amplitude/utils'
|
||||
import Chip from '@/app/components/base/chip'
|
||||
import Input from '@/app/components/base/input'
|
||||
import {
|
||||
CLOUD_SANDBOX_CLEARED_TIME_PERIOD,
|
||||
CLOUD_SANDBOX_TIME_PERIOD_KEYS,
|
||||
isLogTimePeriodRestricted,
|
||||
resolveLogTimePeriodOption,
|
||||
useCloudSandboxPlanStatus,
|
||||
} from '../log/cloud-sandbox-retention'
|
||||
|
||||
dayjs.extend(quarterOfYear)
|
||||
|
||||
@@ -36,6 +43,12 @@ type IFilterProps = {
|
||||
|
||||
const Filter: FC<IFilterProps> = ({ queryParams, setQueryParams }: IFilterProps) => {
|
||||
const { t } = useTranslation()
|
||||
const planState = useCloudSandboxPlanStatus()
|
||||
const isTimePeriodRestricted = isLogTimePeriodRestricted(planState)
|
||||
const timePeriodEntries = Object.entries(TIME_PERIOD_MAPPING)
|
||||
.filter(([key]) => !isTimePeriodRestricted || CLOUD_SANDBOX_TIME_PERIOD_KEYS.has(key))
|
||||
.map(([key, option]) => [key, resolveLogTimePeriodOption(key, option, planState)] as const)
|
||||
|
||||
return (
|
||||
<div className="mb-2 flex flex-row flex-wrap gap-2">
|
||||
<Chip
|
||||
@@ -63,8 +76,13 @@ const Filter: FC<IFilterProps> = ({ queryParams, setQueryParams }: IFilterProps)
|
||||
onSelect={(item) => {
|
||||
setQueryParams({ ...queryParams, period: item.value })
|
||||
}}
|
||||
onClear={() => setQueryParams({ ...queryParams, period: '9' })}
|
||||
items={Object.entries(TIME_PERIOD_MAPPING).map(([k, v]) => ({
|
||||
onClear={() =>
|
||||
setQueryParams({
|
||||
...queryParams,
|
||||
period: isTimePeriodRestricted ? CLOUD_SANDBOX_CLEARED_TIME_PERIOD : '9',
|
||||
})
|
||||
}
|
||||
items={timePeriodEntries.map(([k, v]) => ({
|
||||
value: k,
|
||||
name: t(($) => $[`filter.period.${v.name}`], { ns: 'appLog' }),
|
||||
}))}
|
||||
|
||||
@@ -19,6 +19,12 @@ import { useWorkflowLogs } from '@/service/use-log'
|
||||
import PageTitle from '../log-annotation/page-title'
|
||||
import { ArchivedLogsNotice } from '../log/archived-logs-notice'
|
||||
import { shouldShowArchivedLogsNotice } from '../log/archived-logs-notice-utils'
|
||||
import {
|
||||
resolveLogTimePeriod,
|
||||
resolveLogTimePeriodOption,
|
||||
useCloudSandboxPlanStatus,
|
||||
} from '../log/cloud-sandbox-retention'
|
||||
import { RetentionUpgradeNotice } from '../log/retention-upgrade-notice'
|
||||
import Filter, { TIME_PERIOD_MAPPING } from './filter'
|
||||
import List from './list'
|
||||
|
||||
@@ -43,26 +49,35 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||
})
|
||||
const [queryParams, setQueryParams] = useState<QueryParam>({ status: 'all', period: '2' })
|
||||
const [currPage, setCurrPage] = React.useState<number>(0)
|
||||
const cloudSandboxPlanState = useCloudSandboxPlanStatus()
|
||||
const effectivePeriod = resolveLogTimePeriod(queryParams.period, cloudSandboxPlanState)
|
||||
const effectiveQueryParams = { ...queryParams, period: effectivePeriod }
|
||||
const debouncedQueryParams = useDebounce(queryParams, { wait: 500 })
|
||||
const requestQueryParams = { ...debouncedQueryParams, period: effectivePeriod }
|
||||
const requestTimePeriod = resolveLogTimePeriodOption(
|
||||
requestQueryParams.period,
|
||||
TIME_PERIOD_MAPPING[requestQueryParams.period]!,
|
||||
cloudSandboxPlanState,
|
||||
)
|
||||
const [limit, setLimit] = React.useState<number>(APP_PAGE_LIMIT)
|
||||
|
||||
const query = {
|
||||
page: currPage + 1,
|
||||
detail: true,
|
||||
limit,
|
||||
...(debouncedQueryParams.status !== 'all' ? { status: debouncedQueryParams.status } : {}),
|
||||
...(debouncedQueryParams.keyword ? { keyword: debouncedQueryParams.keyword } : {}),
|
||||
...(debouncedQueryParams.period !== '9'
|
||||
...(requestQueryParams.status !== 'all' ? { status: requestQueryParams.status } : {}),
|
||||
...(requestQueryParams.keyword ? { keyword: requestQueryParams.keyword } : {}),
|
||||
...(requestQueryParams.period !== '9'
|
||||
? {
|
||||
created_at__after: dayjs()
|
||||
.subtract(TIME_PERIOD_MAPPING[debouncedQueryParams.period]!.value, 'day')
|
||||
.subtract(requestTimePeriod.value, 'day')
|
||||
.startOf('day')
|
||||
.tz(timezone)
|
||||
.format('YYYY-MM-DDTHH:mm:ssZ'),
|
||||
created_at__before: dayjs().endOf('day').tz(timezone).format('YYYY-MM-DDTHH:mm:ssZ'),
|
||||
}
|
||||
: {}),
|
||||
...omit(debouncedQueryParams, ['period', 'status']),
|
||||
...omit(requestQueryParams, ['period', 'status']),
|
||||
}
|
||||
|
||||
const { data: workflowLogs, refetch: mutate } = useWorkflowLogs({
|
||||
@@ -72,7 +87,7 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||
const total = workflowLogs?.total
|
||||
const totalPages = total ? Math.max(Math.ceil(total / limit), 1) : 1
|
||||
const showArchivedLogsNotice = shouldShowArchivedLogsNotice(
|
||||
queryParams.period,
|
||||
effectiveQueryParams.period,
|
||||
TIME_PERIOD_MAPPING,
|
||||
)
|
||||
|
||||
@@ -83,7 +98,8 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||
description={t(($) => $.workflowSubtitle, { ns: 'appLog' })}
|
||||
/>
|
||||
<div className="flex max-h-[calc(100%-16px)] flex-1 flex-col py-4">
|
||||
<Filter queryParams={queryParams} setQueryParams={setQueryParams} />
|
||||
<Filter queryParams={effectiveQueryParams} setQueryParams={setQueryParams} />
|
||||
<RetentionUpgradeNotice />
|
||||
{showArchivedLogsNotice && <ArchivedLogsNotice />}
|
||||
{/* workflow log */}
|
||||
{total === undefined ? (
|
||||
|
||||
+12
-6
@@ -91,9 +91,12 @@ describe('AgentRosterResponseContent', () => {
|
||||
await user.click(processToggle)
|
||||
|
||||
expect(processToggle).toHaveAttribute('aria-expanded', 'true')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('history answer')).toBeInTheDocument()
|
||||
})
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByText('history answer')).toBeInTheDocument()
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
)
|
||||
|
||||
expect(screen.queryByText('internal thought should not render')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -122,9 +125,12 @@ describe('AgentRosterResponseContent', () => {
|
||||
render(<AgentRosterResponseContent item={item} />)
|
||||
await user.click(screen.getByRole('button', { name: 'Thinking' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('const answer = 42').tagName).toBe('CODE')
|
||||
})
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByText('const answer = 42').tagName).toBe('CODE')
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
)
|
||||
})
|
||||
|
||||
it('should keep one collapsible thinking timeline while response parts interleave', async () => {
|
||||
|
||||
+33
-376
@@ -1,416 +1,73 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import ModelParameterModal from '../index'
|
||||
|
||||
let parameterRules: Array<Record<string, unknown>> | undefined = [
|
||||
{
|
||||
name: 'temperature',
|
||||
label: { en_US: 'Temperature' },
|
||||
type: 'float',
|
||||
default: 0.7,
|
||||
min: 0,
|
||||
max: 1,
|
||||
help: { en_US: 'Control randomness' },
|
||||
},
|
||||
]
|
||||
let isRulesLoading = false
|
||||
let isRulesPending = false
|
||||
let currentProvider: Record<string, unknown> | undefined = {
|
||||
provider: 'openai',
|
||||
label: { en_US: 'OpenAI' },
|
||||
}
|
||||
let currentModel: Record<string, unknown> | undefined = {
|
||||
model: 'gpt-3.5-turbo',
|
||||
status: 'active',
|
||||
model_properties: { mode: 'chat' },
|
||||
}
|
||||
let activeTextGenerationModelList: Array<Record<string, unknown>> = [
|
||||
{
|
||||
provider: 'openai',
|
||||
models: [
|
||||
{
|
||||
model: 'gpt-3.5-turbo',
|
||||
model_properties: { mode: 'chat' },
|
||||
features: ['vision'],
|
||||
},
|
||||
{
|
||||
model: 'gpt-4.1',
|
||||
model_properties: { mode: 'chat' },
|
||||
features: ['vision', 'tool-call'],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
const mocks = vi.hoisted(() => ({
|
||||
openIntegrationsSetting: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
isAPIKeySet: true,
|
||||
}),
|
||||
vi.mock('@/app/components/header/account-setting/use-integrations-setting', () => ({
|
||||
useIntegrationsSetting: () => mocks.openIntegrationsSetting,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useModelParameterRules: () => ({
|
||||
data: {
|
||||
data: parameterRules,
|
||||
data: [],
|
||||
},
|
||||
isLoading: isRulesLoading,
|
||||
isPending: isRulesPending,
|
||||
isLoading: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks', () => ({
|
||||
useTextGenerationCurrentProviderAndModelAndModelList: () => ({
|
||||
currentProvider,
|
||||
currentModel,
|
||||
activeTextGenerationModelList,
|
||||
activeTextGenerationModelList: [],
|
||||
currentModel: undefined,
|
||||
currentProvider: undefined,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../parameter-item', () => ({
|
||||
default: ({
|
||||
parameterRule,
|
||||
onChange,
|
||||
onSwitch,
|
||||
nodesOutputVars,
|
||||
availableNodes,
|
||||
}: {
|
||||
parameterRule: { name: string; label: { en_US: string } }
|
||||
onChange: (v: number) => void
|
||||
onSwitch: (checked: boolean, val: unknown) => void
|
||||
nodesOutputVars?: unknown[]
|
||||
availableNodes?: unknown[]
|
||||
}) => (
|
||||
<div
|
||||
data-testid={`param-${parameterRule.name}`}
|
||||
data-has-nodes-output-vars={!!nodesOutputVars}
|
||||
data-has-available-nodes={!!availableNodes}
|
||||
>
|
||||
{parameterRule.label.en_US}
|
||||
<button onClick={() => onChange(0.9)}>Change</button>
|
||||
<button onClick={() => onSwitch(false, undefined)}>Remove</button>
|
||||
<button onClick={() => onSwitch(true, 'assigned')}>Add</button>
|
||||
</div>
|
||||
vi.mock('../../model-selector', () => ({
|
||||
default: ({ onConfigureEmptyState }: { onConfigureEmptyState?: () => void }) => (
|
||||
<button type="button" onClick={onConfigureEmptyState}>
|
||||
configure-empty-model
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../model-selector', () => ({
|
||||
default: ({
|
||||
onHide,
|
||||
onSelect,
|
||||
}: {
|
||||
onHide: () => void
|
||||
onSelect: (value: { provider: string; model: string }) => void
|
||||
}) => (
|
||||
<div data-testid="model-selector">
|
||||
<button onClick={() => onSelect({ provider: 'openai', model: 'gpt-4.1' })}>
|
||||
Select GPT-4.1
|
||||
</button>
|
||||
<button onClick={onHide}>hide</button>
|
||||
</div>
|
||||
),
|
||||
vi.mock('@/app/components/base/loading', () => ({
|
||||
default: () => <div>loading</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../parameter-item', () => ({
|
||||
default: () => <div>parameter-item</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../presets-parameter', () => ({
|
||||
default: ({
|
||||
onSelect,
|
||||
supportedParameterNames,
|
||||
}: {
|
||||
onSelect: (id: number) => void
|
||||
supportedParameterNames?: string[]
|
||||
}) => {
|
||||
if (supportedParameterNames && !supportedParameterNames.includes('temperature')) return null
|
||||
|
||||
return <button onClick={() => onSelect(1)}>Preset 1</button>
|
||||
},
|
||||
default: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../presets-parameter-utils', () => ({
|
||||
getSupportedPresetConfig: (_toneId: number, supportedParameterNames?: string[]) => {
|
||||
if (supportedParameterNames && !supportedParameterNames.includes('temperature')) return {}
|
||||
|
||||
return { temperature: 0.8 }
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../trigger', () => ({
|
||||
default: () => <button type="button">Open Settings</button>,
|
||||
}))
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/config')>()
|
||||
return {
|
||||
...actual,
|
||||
PROVIDER_WITH_PRESET_TONE: ['openai'],
|
||||
}
|
||||
})
|
||||
|
||||
describe('ModelParameterModal', () => {
|
||||
const openSettings = () =>
|
||||
fireEvent.click(screen.getByRole('button', { name: /modelProvider\.modelSettings/i }))
|
||||
const defaultProps = {
|
||||
isAdvancedMode: false,
|
||||
modelId: 'gpt-3.5-turbo',
|
||||
provider: 'openai',
|
||||
setModel: vi.fn(),
|
||||
completionParams: { temperature: 0.7 },
|
||||
onCompletionParamsChange: vi.fn(),
|
||||
hideDebugWithMultipleModel: false,
|
||||
debugWithMultipleModel: false,
|
||||
onDebugWithMultipleModelChange: vi.fn(),
|
||||
readonly: false,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
isRulesLoading = false
|
||||
isRulesPending = false
|
||||
parameterRules = [
|
||||
{
|
||||
name: 'temperature',
|
||||
label: { en_US: 'Temperature' },
|
||||
type: 'float',
|
||||
default: 0.7,
|
||||
min: 0,
|
||||
max: 1,
|
||||
help: { en_US: 'Control randomness' },
|
||||
},
|
||||
]
|
||||
currentProvider = { provider: 'openai', label: { en_US: 'OpenAI' } }
|
||||
currentModel = {
|
||||
model: 'gpt-3.5-turbo',
|
||||
status: 'active',
|
||||
model_properties: { mode: 'chat' },
|
||||
}
|
||||
activeTextGenerationModelList = [
|
||||
{
|
||||
provider: 'openai',
|
||||
models: [
|
||||
{
|
||||
model: 'gpt-3.5-turbo',
|
||||
model_properties: { mode: 'chat' },
|
||||
features: ['vision'],
|
||||
},
|
||||
{
|
||||
model: 'gpt-4.1',
|
||||
model_properties: { mode: 'chat' },
|
||||
features: ['vision', 'tool-call'],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
it('should render trigger and open modal content when trigger is clicked', () => {
|
||||
render(<ModelParameterModal {...defaultProps} />)
|
||||
|
||||
openSettings()
|
||||
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('param-temperature')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep model selection and model settings as separate actions', () => {
|
||||
render(<ModelParameterModal {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('param-temperature')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Select GPT-4.1'))
|
||||
|
||||
expect(defaultProps.setModel).toHaveBeenCalledWith({
|
||||
modelId: 'gpt-4.1',
|
||||
provider: 'openai',
|
||||
mode: 'chat',
|
||||
features: ['vision', 'tool-call'],
|
||||
})
|
||||
expect(screen.queryByTestId('param-temperature')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /modelProvider\.modelSettings/i }))
|
||||
|
||||
expect(screen.getByTestId('param-temperature')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should disable model settings when no model is selected', () => {
|
||||
render(<ModelParameterModal {...defaultProps} provider="" modelId="" />)
|
||||
|
||||
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /modelProvider\.modelSettings/i })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should call onCompletionParamsChange when parameter changes and switch actions happen', () => {
|
||||
render(<ModelParameterModal {...defaultProps} />)
|
||||
openSettings()
|
||||
|
||||
fireEvent.click(screen.getByText('Change'))
|
||||
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalledWith({
|
||||
...defaultProps.completionParams,
|
||||
temperature: 0.9,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('Remove'))
|
||||
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalledWith({})
|
||||
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalledWith({
|
||||
...defaultProps.completionParams,
|
||||
temperature: 'assigned',
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onCompletionParamsChange when preset is selected', () => {
|
||||
render(<ModelParameterModal {...defaultProps} />)
|
||||
openSettings()
|
||||
fireEvent.click(screen.getByText('Preset 1'))
|
||||
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalledWith({
|
||||
...defaultProps.completionParams,
|
||||
temperature: 0.8,
|
||||
})
|
||||
})
|
||||
|
||||
it('should not render preset control when visible parameters do not support preset keys', () => {
|
||||
parameterRules = [
|
||||
{
|
||||
name: 'max_tokens',
|
||||
label: { en_US: 'Max Tokens' },
|
||||
type: 'int',
|
||||
default: 256,
|
||||
min: 1,
|
||||
max: 4096,
|
||||
},
|
||||
]
|
||||
|
||||
render(<ModelParameterModal {...defaultProps} />)
|
||||
openSettings()
|
||||
|
||||
expect(screen.queryByText('Preset 1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call setModel when model selector picks another model', () => {
|
||||
render(<ModelParameterModal {...defaultProps} />)
|
||||
openSettings()
|
||||
fireEvent.click(screen.getByText('Select GPT-4.1'))
|
||||
|
||||
expect(defaultProps.setModel).toHaveBeenCalledWith({
|
||||
modelId: 'gpt-4.1',
|
||||
provider: 'openai',
|
||||
mode: 'chat',
|
||||
features: ['vision', 'tool-call'],
|
||||
})
|
||||
})
|
||||
|
||||
it('should toggle debug mode when debug footer is clicked', () => {
|
||||
render(<ModelParameterModal {...defaultProps} />)
|
||||
openSettings()
|
||||
fireEvent.click(screen.getByText(/debugAsMultipleModel/i))
|
||||
expect(defaultProps.onDebugWithMultipleModelChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render loading state when parameter rules are loading', () => {
|
||||
isRulesLoading = true
|
||||
isRulesPending = true
|
||||
render(<ModelParameterModal {...defaultProps} />)
|
||||
openSettings()
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render parameter loading when model is not configured and parameter rules query is pending but disabled', () => {
|
||||
isRulesPending = true
|
||||
parameterRules = []
|
||||
|
||||
render(<ModelParameterModal {...defaultProps} provider="" modelId="" />)
|
||||
openSettings()
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not open content when readonly is true', () => {
|
||||
render(<ModelParameterModal {...defaultProps} readonly />)
|
||||
expect(screen.getByRole('button', { name: /modelProvider\.modelSettings/i })).toBeDisabled()
|
||||
expect(screen.queryByTestId('param-temperature')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render no parameter items when rules are undefined', () => {
|
||||
parameterRules = undefined
|
||||
render(<ModelParameterModal {...defaultProps} />)
|
||||
openSettings()
|
||||
expect(screen.queryByTestId('param-temperature')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass nodesOutputVars and availableNodes to ParameterItem', () => {
|
||||
const mockNodesOutputVars = [{ nodeId: 'n1', title: 'Node', vars: [] }]
|
||||
const mockAvailableNodes = [{ id: 'n1', data: { title: 'Node', type: 'llm' } }]
|
||||
|
||||
it('opens provider settings from the model selector empty state', () => {
|
||||
render(
|
||||
<ModelParameterModal
|
||||
{...defaultProps}
|
||||
isInWorkflow
|
||||
nodesOutputVars={mockNodesOutputVars as never}
|
||||
availableNodes={mockAvailableNodes as never}
|
||||
/>,
|
||||
)
|
||||
|
||||
openSettings()
|
||||
|
||||
const paramEl = screen.getByTestId('param-temperature')
|
||||
expect(paramEl).toHaveAttribute('data-has-nodes-output-vars', 'true')
|
||||
expect(paramEl).toHaveAttribute('data-has-available-nodes', 'true')
|
||||
})
|
||||
|
||||
it('should support custom triggers, workflow mode, and missing default model values', async () => {
|
||||
render(
|
||||
<ModelParameterModal
|
||||
{...defaultProps}
|
||||
provider=""
|
||||
isAdvancedMode
|
||||
modelId=""
|
||||
isInWorkflow
|
||||
renderTrigger={({ open }) => <span>{open ? 'Custom Open' : 'Custom Closed'}</span>}
|
||||
provider=""
|
||||
completionParams={{}}
|
||||
setModel={vi.fn()}
|
||||
onCompletionParamsChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Custom Closed'))
|
||||
fireEvent.click(screen.getByText('configure-empty-model'))
|
||||
|
||||
expect(screen.getByText('Custom Open')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('hide'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument()
|
||||
expect(mocks.openIntegrationsSetting).toHaveBeenCalledWith({
|
||||
payload: 'provider',
|
||||
})
|
||||
})
|
||||
|
||||
it('should append the stop parameter in advanced mode and show the single-model debug label', () => {
|
||||
render(<ModelParameterModal {...defaultProps} isAdvancedMode debugWithMultipleModel />)
|
||||
|
||||
openSettings()
|
||||
|
||||
expect(screen.getByTestId('param-stop')).toBeInTheDocument()
|
||||
expect(screen.getByText(/debugAsSingleModel/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the empty loading fallback when rules resolve to an empty list', () => {
|
||||
parameterRules = []
|
||||
isRulesLoading = true
|
||||
isRulesPending = true
|
||||
|
||||
render(<ModelParameterModal {...defaultProps} />)
|
||||
openSettings()
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('param-temperature')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should support custom trigger placement outside workflow mode', () => {
|
||||
render(
|
||||
<ModelParameterModal
|
||||
{...defaultProps}
|
||||
renderTrigger={({ open }) => <span>{open ? 'Popup Open' : 'Popup Closed'}</span>}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Popup Closed'))
|
||||
|
||||
expect(screen.getByText('Popup Open')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
+42
-12
@@ -1,5 +1,5 @@
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import type { DefaultModel, FormValue, ModelParameterRule } from '../declarations'
|
||||
import type { DefaultModel, FormValue, Model, ModelParameterRule } from '../declarations'
|
||||
import type { ParameterValue } from './parameter-item'
|
||||
import type { TriggerProps } from './types'
|
||||
import type { Node, NodeOutPutVar } from '@/app/components/workflow/types'
|
||||
@@ -7,8 +7,9 @@ import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arrows'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting'
|
||||
import { PROVIDER_WITH_PRESET_TONE, STOP_PARAMETER_RULE } from '@/config'
|
||||
import { useModelParameterRules } from '@/service/use-common'
|
||||
import { useTextGenerationCurrentProviderAndModelAndModelList } from '../hooks'
|
||||
@@ -34,8 +35,10 @@ export type ModelParameterModalProps = {
|
||||
debugWithMultipleModel?: boolean
|
||||
onDebugWithMultipleModelChange?: () => void
|
||||
renderTrigger?: (v: TriggerProps) => ReactNode
|
||||
triggerContainerClassName?: string
|
||||
readonly?: boolean
|
||||
isInWorkflow?: boolean
|
||||
modelList?: Model[]
|
||||
scope?: string
|
||||
nodesOutputVars?: NodeOutPutVar[]
|
||||
availableNodes?: Node[]
|
||||
@@ -53,17 +56,25 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
debugWithMultipleModel,
|
||||
onDebugWithMultipleModelChange,
|
||||
renderTrigger,
|
||||
triggerContainerClassName,
|
||||
readonly,
|
||||
isInWorkflow,
|
||||
modelList,
|
||||
nodesOutputVars,
|
||||
availableNodes,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const openIntegrationsSetting = useIntegrationsSetting()
|
||||
const { data: parameterRulesData, isLoading } = useModelParameterRules(provider, modelId)
|
||||
const isRulesLoading = !!provider && !!modelId && isLoading
|
||||
const { currentProvider, currentModel, activeTextGenerationModelList } =
|
||||
useTextGenerationCurrentProviderAndModelAndModelList({ provider, model: modelId })
|
||||
const availableTextGenerationModelList = modelList ?? activeTextGenerationModelList
|
||||
const selectedProvider =
|
||||
modelList?.find((modelItem) => modelItem.provider === provider) ?? currentProvider
|
||||
const selectedModel =
|
||||
selectedProvider?.models?.find((modelItem) => modelItem.model === modelId) ?? currentModel
|
||||
|
||||
const parameterRules: ModelParameterRule[] = useMemo(() => {
|
||||
return parameterRulesData?.data || []
|
||||
@@ -71,6 +82,7 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
const supportedPresetParameterNames = useMemo(() => {
|
||||
return parameterRules.map((parameterRule) => parameterRule.name)
|
||||
}, [parameterRules])
|
||||
const hasSelectedModel = !!provider && !!modelId
|
||||
|
||||
const handleParamChange = (key: string, value: ParameterValue) => {
|
||||
onCompletionParamsChange({
|
||||
@@ -80,10 +92,10 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
}
|
||||
|
||||
const handleChangeModel = ({ provider, model }: DefaultModel) => {
|
||||
const targetProvider = activeTextGenerationModelList.find(
|
||||
const targetProvider = availableTextGenerationModelList.find(
|
||||
(modelItem) => modelItem.provider === provider,
|
||||
)
|
||||
const targetModelItem = targetProvider?.models.find((modelItem) => modelItem.model === model)
|
||||
const targetModelItem = targetProvider?.models?.find((modelItem) => modelItem.model === model)
|
||||
setModel({
|
||||
modelId: model,
|
||||
provider,
|
||||
@@ -91,6 +103,15 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
features: targetModelItem?.features || [],
|
||||
})
|
||||
}
|
||||
const handleOpenModelSettings = () => {
|
||||
if (readonly || !hasSelectedModel) return
|
||||
setOpen(true)
|
||||
}
|
||||
const handleConfigureEmptyState = () => {
|
||||
if (readonly) return
|
||||
|
||||
openIntegrationsSetting({ payload: ACCOUNT_SETTING_TAB.PROVIDER })
|
||||
}
|
||||
|
||||
const handleSwitch = (key: string, value: boolean, assignValue: ParameterValue) => {
|
||||
if (!value) {
|
||||
@@ -114,8 +135,6 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
})
|
||||
}
|
||||
|
||||
const hasSelectedModel = !!provider && !!modelId
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
@@ -133,8 +152,8 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
>
|
||||
{renderTrigger({
|
||||
open,
|
||||
currentProvider,
|
||||
currentModel,
|
||||
currentProvider: selectedProvider,
|
||||
currentModel: selectedModel,
|
||||
providerName: provider,
|
||||
modelId,
|
||||
})}
|
||||
@@ -142,11 +161,16 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-8 min-w-[296px] items-center gap-px overflow-hidden rounded-lg">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-8 min-w-[296px] items-center gap-px overflow-hidden rounded-lg',
|
||||
triggerContainerClassName,
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<ModelSelector
|
||||
defaultModel={provider || modelId ? { provider, model: modelId } : undefined}
|
||||
modelList={activeTextGenerationModelList}
|
||||
modelList={availableTextGenerationModelList}
|
||||
readonly={readonly}
|
||||
triggerClassName={cn(
|
||||
'h-8! w-full rounded-r-none!',
|
||||
@@ -154,6 +178,8 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
'border border-workflow-block-parma-bg bg-workflow-block-parma-bg hover:bg-workflow-block-parma-bg',
|
||||
)}
|
||||
onSelect={handleChangeModel}
|
||||
onConfigureEmptyState={handleConfigureEmptyState}
|
||||
onOpenProviderSettings={handleOpenModelSettings}
|
||||
/>
|
||||
</div>
|
||||
<PopoverTrigger
|
||||
@@ -187,8 +213,9 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
<div className="px-4 pt-2 pb-4">
|
||||
<ModelSelector
|
||||
defaultModel={hasSelectedModel ? { provider, model: modelId } : undefined}
|
||||
modelList={activeTextGenerationModelList}
|
||||
modelList={availableTextGenerationModelList}
|
||||
onSelect={handleChangeModel}
|
||||
onOpenProviderSettings={handleOpenModelSettings}
|
||||
onHide={() => setOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
@@ -249,7 +276,10 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
{debugWithMultipleModel
|
||||
? t(($) => $.debugAsSingleModel, { ns: 'appDebug' })
|
||||
: t(($) => $.debugAsMultipleModel, { ns: 'appDebug' })}
|
||||
<ArrowNarrowLeft className="size-3 rotate-180" />
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-custom-vender-line-arrows-arrow-narrow-left size-3 rotate-180"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
|
||||
@@ -33,6 +33,7 @@ type ModelSelectorProps = {
|
||||
hideProviderSettingsFooter?: boolean
|
||||
onConfigureEmptyState?: () => void
|
||||
onOpenMarketplace?: () => void
|
||||
onOpenProviderSettings?: () => void
|
||||
providerSettingsSource?: 'agent'
|
||||
showModelMeta?: boolean
|
||||
modelPredicate?: ModelSelectorModelPredicate
|
||||
@@ -52,6 +53,7 @@ function ModelSelector({
|
||||
hideProviderSettingsFooter,
|
||||
onConfigureEmptyState,
|
||||
onOpenMarketplace,
|
||||
onOpenProviderSettings,
|
||||
providerSettingsSource,
|
||||
showModelMeta,
|
||||
modelPredicate,
|
||||
@@ -180,6 +182,7 @@ function ModelSelector({
|
||||
modelSuggestionPredicate={modelSuggestionPredicate}
|
||||
onConfigureEmptyState={onConfigureEmptyState ? handleConfigureEmptyState : undefined}
|
||||
onOpenMarketplace={onOpenMarketplace}
|
||||
onOpenProviderSettings={onOpenProviderSettings}
|
||||
onInputValueChange={setInputValue}
|
||||
onHide={handleHide}
|
||||
/>
|
||||
|
||||
+25
-18
@@ -9,7 +9,6 @@ import { StatusDot } from '@langgenius/dify-ui/status-dot'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CreditsCoin } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { useCredentialPermissions } from '@/hooks/use-credential-permissions'
|
||||
@@ -57,7 +56,8 @@ function PopupItem({
|
||||
const updateModelProviders = useUpdateModelProviders()
|
||||
const currentProvider = modelProviders.find((provider) => provider.provider === model.provider)
|
||||
const { canUseCredential, canCreateCredential, canManageCredential } = useCredentialPermissions()
|
||||
const canOpenCredentialDropdown = canUseCredential || canCreateCredential || canManageCredential
|
||||
const canOpenCredentialDropdown =
|
||||
!!currentProvider && (canUseCredential || canCreateCredential || canManageCredential)
|
||||
const handleOpenModelModal = () => {
|
||||
if (!canCreateCredential) return
|
||||
|
||||
@@ -77,7 +77,8 @@ function PopupItem({
|
||||
})
|
||||
}
|
||||
|
||||
const state = useCredentialPanelState(currentProvider)
|
||||
// oxlint-disable-next-line eslint-react/use-state -- This domain hook returns credential panel state, not a React useState tuple.
|
||||
const credentialPanelState = useCredentialPanelState(currentProvider)
|
||||
const { isChangingPriority, handleChangePriority } = useChangeProviderPriority(currentProvider)
|
||||
const groupItems = useMemo(
|
||||
() =>
|
||||
@@ -90,10 +91,11 @@ function PopupItem({
|
||||
[model.models, model.provider],
|
||||
)
|
||||
|
||||
const isUsingCredits = state.priority === 'credits'
|
||||
const hasCredits = !state.isCreditsExhausted
|
||||
const isApiKeyActive = state.variant === 'api-active' || state.variant === 'api-fallback'
|
||||
const { credentialName } = state
|
||||
const isUsingCredits = credentialPanelState.priority === 'credits'
|
||||
const hasCredits = !credentialPanelState.isCreditsExhausted
|
||||
const isApiKeyActive =
|
||||
credentialPanelState.variant === 'api-active' || credentialPanelState.variant === 'api-fallback'
|
||||
const { credentialName } = credentialPanelState
|
||||
|
||||
const handleCloseDropdown = useCallback(() => {
|
||||
setDropdownOpen(false)
|
||||
@@ -129,7 +131,10 @@ function PopupItem({
|
||||
{isUsingCredits ? (
|
||||
hasCredits ? (
|
||||
<>
|
||||
<CreditsCoin className="size-3" />
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-custom-vender-line-financeandecommerce-credits-coin size-3"
|
||||
/>
|
||||
<span className="ml-1 truncate">
|
||||
{t(($) => $['modelProvider.selector.aiCredits'], { ns: 'common' })}
|
||||
</span>
|
||||
@@ -161,15 +166,17 @@ function PopupItem({
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent placement="bottom-end">
|
||||
<DropdownContent
|
||||
provider={currentProvider}
|
||||
state={state}
|
||||
isChangingPriority={isChangingPriority}
|
||||
onChangePriority={handleChangePriority}
|
||||
onClose={handleCloseDropdown}
|
||||
/>
|
||||
</PopoverContent>
|
||||
{currentProvider && (
|
||||
<PopoverContent placement="bottom-end">
|
||||
<DropdownContent
|
||||
provider={currentProvider}
|
||||
state={credentialPanelState}
|
||||
isChangingPriority={isChangingPriority}
|
||||
onChangePriority={handleChangePriority}
|
||||
onClose={handleCloseDropdown}
|
||||
/>
|
||||
</PopoverContent>
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
{!collapsed &&
|
||||
@@ -215,7 +222,7 @@ function PopupItem({
|
||||
</ModelName>
|
||||
</div>
|
||||
{defaultModel?.model === modelItem.model &&
|
||||
defaultModel.provider === currentProvider.provider && (
|
||||
defaultModel.provider === model.provider && (
|
||||
<ComboboxItemIndicator className="shrink-0 text-text-accent">
|
||||
<span
|
||||
className="i-custom-vender-line-general-check size-4"
|
||||
|
||||
+8
-1
@@ -69,6 +69,7 @@ export type PopupProps = {
|
||||
onConfigureEmptyState?: () => void
|
||||
onInputValueChange: (value: string) => void
|
||||
onOpenMarketplace?: () => void
|
||||
onOpenProviderSettings?: () => void
|
||||
onHide: () => void
|
||||
}
|
||||
function Popup({
|
||||
@@ -83,6 +84,7 @@ function Popup({
|
||||
onConfigureEmptyState,
|
||||
onInputValueChange,
|
||||
onOpenMarketplace,
|
||||
onOpenProviderSettings,
|
||||
onHide,
|
||||
}: PopupProps) {
|
||||
const { t } = useTranslation()
|
||||
@@ -255,11 +257,16 @@ function Popup({
|
||||
|
||||
const handleOpenSettings = useCallback(() => {
|
||||
onHide()
|
||||
if (onOpenProviderSettings) {
|
||||
onOpenProviderSettings()
|
||||
return
|
||||
}
|
||||
|
||||
openIntegrationsSetting({
|
||||
payload: ACCOUNT_SETTING_TAB.PROVIDER,
|
||||
source: providerSettingsSource,
|
||||
})
|
||||
}, [onHide, openIntegrationsSetting, providerSettingsSource])
|
||||
}, [onHide, onOpenProviderSettings, openIntegrationsSetting, providerSettingsSource])
|
||||
const handleClosePreviewCard = useCallback(() => {
|
||||
previewCardHandle.close()
|
||||
}, [previewCardHandle])
|
||||
|
||||
@@ -564,6 +564,10 @@ describe('MainNav', () => {
|
||||
expect(screen.getByRole('link', { name: /common.menus.apps/ })).toHaveAttribute('href', '/apps')
|
||||
expect(screen.getByRole('link', { name: /Agents/ })).toHaveAttribute('href', '/agents')
|
||||
expect(screen.getByRole('link', { name: /Agents common.menus.status/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /common.mainNav.skills/ })).toHaveAttribute(
|
||||
'href',
|
||||
'/skills',
|
||||
)
|
||||
expect(screen.getByRole('link', { name: /common.menus.datasets/ })).toHaveAttribute(
|
||||
'href',
|
||||
'/datasets',
|
||||
@@ -584,6 +588,10 @@ describe('MainNav', () => {
|
||||
renderMainNav()
|
||||
|
||||
expect(screen.queryByRole('link', { name: /Agents/ })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /common.mainNav.skills/ })).toHaveAttribute(
|
||||
'href',
|
||||
'/skills',
|
||||
)
|
||||
})
|
||||
|
||||
it('hides the roster entry when the user lacks agent.manage', () => {
|
||||
@@ -743,6 +751,7 @@ describe('MainNav', () => {
|
||||
expect(screen.getByRole('link', { name: /common.mainNav.home/ })).toHaveAttribute('href', '/')
|
||||
expect(screen.getByRole('link', { name: /common.menus.apps/ })).toHaveAttribute('href', '/apps')
|
||||
expect(screen.queryByRole('link', { name: /Agents/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: /common.mainNav.skills/ })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /common.menus.datasets/ })).toHaveAttribute(
|
||||
'href',
|
||||
'/datasets',
|
||||
|
||||
@@ -32,6 +32,8 @@ export type DetailSidebarVisibilityOptions = Pick<
|
||||
const VISIBLE_TO_ALL: MainNavRouteVisibility = () => true
|
||||
const CAN_MANAGE_AGENTS: MainNavRouteVisibility = (options) => options.canManageAgents
|
||||
const CAN_USE_APP_DEPLOY: MainNavRouteVisibility = (options) => options.canUseAppDeploy
|
||||
const NOT_DATASET_OPERATOR: MainNavRouteVisibility = (options) =>
|
||||
!options.isCurrentWorkspaceDatasetOperator
|
||||
|
||||
function isPathUnderRoute(pathname: string, route: string) {
|
||||
return pathname === route || pathname.startsWith(`${route}/`)
|
||||
@@ -69,6 +71,15 @@ export const MAIN_NAV_ROUTES = [
|
||||
visibility: CAN_MANAGE_AGENTS,
|
||||
feature: 'agentV2',
|
||||
},
|
||||
{
|
||||
key: 'skills',
|
||||
href: '/skills',
|
||||
labelKey: 'mainNav.skills',
|
||||
active: (path: string) => isPathUnderRoute(path, '/skills'),
|
||||
icon: 'i-ri-box-3-line',
|
||||
activeIcon: 'i-ri-box-3-fill',
|
||||
visibility: NOT_DATASET_OPERATOR,
|
||||
},
|
||||
{
|
||||
key: 'datasets',
|
||||
href: '/datasets',
|
||||
|
||||
+4
-2
@@ -119,8 +119,10 @@ describe('ConversationVariableModal', () => {
|
||||
})
|
||||
|
||||
expect(screen.getAllByText('session_state')).toHaveLength(2)
|
||||
expect(screen.getByText((content) => content.includes('formatted-100'))).toBeInTheDocument()
|
||||
expect(screen.getByTestId('conversation-code-editor')).toHaveTextContent('{"latest":1}')
|
||||
expect(
|
||||
await screen.findByText((content) => content.includes('formatted-100')),
|
||||
).toBeInTheDocument()
|
||||
expect(await screen.findByTestId('conversation-code-editor')).toHaveTextContent('{"latest":1}')
|
||||
|
||||
await user.click(screen.getByText('summary'))
|
||||
expect(screen.getByText('latest text')).toBeInTheDocument()
|
||||
|
||||
@@ -16,6 +16,10 @@ vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({ push: mockPush, replace: mockReplace }),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/common', () => ({
|
||||
fetchSetupStatus: vi.fn(),
|
||||
fetchInitValidateStatus: vi.fn(),
|
||||
|
||||
@@ -27,6 +27,23 @@ export const viewport: Viewport = {
|
||||
viewportFit: 'cover',
|
||||
}
|
||||
|
||||
const resizeObserverErrorFilterScript = `
|
||||
(() => {
|
||||
const ignoredMessages = new Set([
|
||||
'ResizeObserver loop completed with undelivered notifications.',
|
||||
'ResizeObserver loop limit exceeded',
|
||||
]);
|
||||
const ignore = (event) => {
|
||||
const message = event?.message || event?.reason?.message;
|
||||
if (!ignoredMessages.has(message)) return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
};
|
||||
window.addEventListener('error', ignore, true);
|
||||
window.addEventListener('unhandledrejection', ignore, true);
|
||||
})();
|
||||
`
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const datasetMap = getDatasetMap()
|
||||
const queryClient = getQueryClientServer()
|
||||
@@ -43,6 +60,11 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
<html lang={locale ?? 'en'} className="h-full" suppressHydrationWarning>
|
||||
<head>
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<script
|
||||
nonce={nonce}
|
||||
// oxlint-disable-next-line eslint-react/dom-no-dangerously-set-innerhtml -- Static early listener must run before the dev error overlay registers.
|
||||
dangerouslySetInnerHTML={{ __html: resizeObserverErrorFilterScript }}
|
||||
/>
|
||||
<meta name="theme-color" content="#1C64F2" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
|
||||
@@ -8,15 +8,15 @@ import type {
|
||||
} from './add-actions-context'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { AgentOrchestrateAddActionsContext } from './add-actions-context'
|
||||
import { useAgentOrchestrateReadOnly } from './read-only-context'
|
||||
import { useAgentOrchestrateViewingVersion } from './read-only-context'
|
||||
|
||||
export function AgentOrchestrateAddActionsProvider({ children }: { children: ReactNode }) {
|
||||
const readOnly = useAgentOrchestrateReadOnly()
|
||||
const isViewingVersion = useAgentOrchestrateViewingVersion()
|
||||
const [actions, setActions] = useState<AgentOrchestrateAddActions>({})
|
||||
|
||||
const registerAction = useCallback(
|
||||
(key: AgentOrchestrateAddActionKey, action: AgentOrchestrateAddAction) => {
|
||||
if (readOnly) return () => undefined
|
||||
if (isViewingVersion) return () => undefined
|
||||
|
||||
setActions((currentActions) => {
|
||||
if (currentActions[key] === action) return currentActions
|
||||
@@ -37,15 +37,15 @@ export function AgentOrchestrateAddActionsProvider({ children }: { children: Rea
|
||||
})
|
||||
}
|
||||
},
|
||||
[readOnly],
|
||||
[isViewingVersion],
|
||||
)
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
actions: readOnly ? {} : actions,
|
||||
actions: isViewingVersion ? {} : actions,
|
||||
registerAction,
|
||||
}),
|
||||
[actions, readOnly, registerAction],
|
||||
[actions, isViewingVersion, registerAction],
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
+3
-3
@@ -4,7 +4,7 @@ import type { ButtonProps } from '@langgenius/dify-ui/button'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAgentOrchestrateReadOnly } from '../read-only-context'
|
||||
import { useAgentOrchestrateViewingVersion } from '../read-only-context'
|
||||
|
||||
type ConfigureSectionAddButtonProps = Omit<
|
||||
ButtonProps,
|
||||
@@ -19,9 +19,9 @@ export function ConfigureSectionAddButton({
|
||||
...props
|
||||
}: ConfigureSectionAddButtonProps) {
|
||||
const { t } = useTranslation('common')
|
||||
const readOnly = useAgentOrchestrateReadOnly()
|
||||
const isViewingVersion = useAgentOrchestrateViewingVersion()
|
||||
|
||||
if (readOnly) return null
|
||||
if (isViewingVersion) return null
|
||||
|
||||
return (
|
||||
<Button
|
||||
|
||||
+22
-7
@@ -13,7 +13,10 @@ import { agentComposerDraftAtom } from '@/features/agent-v2/agent-composer/store
|
||||
import { QueryClientTestProvider } from '@/test/console/query-provider'
|
||||
import { createSystemFeaturesFixture } from '@/test/console/system-features'
|
||||
import { AgentConfigApiContextProvider } from '../../config-context'
|
||||
import { AgentOrchestrateReadOnlyContext } from '../../read-only-context'
|
||||
import {
|
||||
AgentOrchestrateReadOnlyContext,
|
||||
AgentOrchestrateViewingVersionContext,
|
||||
} from '../../read-only-context'
|
||||
import { AgentFiles } from '../index'
|
||||
|
||||
type ConfigFileQueryOptionsInput = {
|
||||
@@ -171,10 +174,12 @@ function renderAgentFiles({
|
||||
initialDraft = createInitialDraft(),
|
||||
apiContext = { agentId: 'agent-1', draftType: 'draft' } satisfies AgentConfigApiContext,
|
||||
readOnly = false,
|
||||
viewingVersion = false,
|
||||
}: {
|
||||
initialDraft?: AgentSoulConfigFormState
|
||||
apiContext?: AgentConfigApiContext
|
||||
readOnly?: boolean
|
||||
viewingVersion?: boolean
|
||||
} = {}) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -191,10 +196,12 @@ function renderAgentFiles({
|
||||
<QueryClientTestProvider queryClient={queryClient}>
|
||||
<AgentConfigApiContextProvider value={apiContext}>
|
||||
<AgentComposerProvider initialDraft={initialDraft}>
|
||||
<AgentOrchestrateReadOnlyContext value={readOnly}>
|
||||
<AgentFiles />
|
||||
<ConfigSnapshotProbe />
|
||||
</AgentOrchestrateReadOnlyContext>
|
||||
<AgentOrchestrateViewingVersionContext value={viewingVersion}>
|
||||
<AgentOrchestrateReadOnlyContext value={readOnly}>
|
||||
<AgentFiles />
|
||||
<ConfigSnapshotProbe />
|
||||
</AgentOrchestrateReadOnlyContext>
|
||||
</AgentOrchestrateViewingVersionContext>
|
||||
</AgentComposerProvider>
|
||||
</AgentConfigApiContextProvider>
|
||||
</QueryClientTestProvider>,
|
||||
@@ -642,8 +649,8 @@ describe('AgentFiles', () => {
|
||||
expect(snapshot.config_note).toBe('')
|
||||
})
|
||||
|
||||
it('should keep flat config files visible without drive-prefix filtering and disable add in read-only mode', () => {
|
||||
renderAgentFiles({ readOnly: true })
|
||||
it('should keep flat config files visible without drive-prefix filtering and disable add when viewing a version', () => {
|
||||
renderAgentFiles({ readOnly: true, viewingVersion: true })
|
||||
|
||||
expect(screen.getByText('diagram.png')).toBeInTheDocument()
|
||||
expect(screen.getByText('brief.md')).toBeInTheDocument()
|
||||
@@ -651,4 +658,12 @@ describe('AgentFiles', () => {
|
||||
screen.queryByRole('button', { name: /agentV2\.agentDetail\.configure\.files\.add/i }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep add action available for build drafts', () => {
|
||||
renderAgentFiles({ readOnly: true })
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.files\.add/i }),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,7 +23,10 @@ import { AgentKnowledgeRetrieval } from './knowledge'
|
||||
import { AgentModelField } from './model-config/field'
|
||||
import { AgentPromptEditor } from './prompt-editor'
|
||||
import { AgentConfigurePublishBar } from './publish-bar'
|
||||
import { AgentOrchestrateReadOnlyContext } from './read-only-context'
|
||||
import {
|
||||
AgentOrchestrateReadOnlyContext,
|
||||
AgentOrchestrateViewingVersionContext,
|
||||
} from './read-only-context'
|
||||
import { AgentSkills } from './skills'
|
||||
import { AgentTools } from './tools'
|
||||
|
||||
@@ -132,41 +135,43 @@ export function AgentOrchestratePanel({
|
||||
/>
|
||||
)}
|
||||
|
||||
<AgentOrchestrateReadOnlyContext value={readOnly}>
|
||||
<div aria-readonly={readOnly} className="flex min-h-0 flex-1 flex-col">
|
||||
<ScrollArea
|
||||
className="min-h-0 flex-1 overflow-hidden"
|
||||
label={showHeader ? undefined : orchestrateLabel}
|
||||
slotClassNames={{
|
||||
viewport: 'overscroll-contain',
|
||||
content: cn('min-h-full px-4 py-3', hasBottomAction && 'pb-20'),
|
||||
scrollbar: hasBottomAction ? 'z-20' : undefined,
|
||||
}}
|
||||
>
|
||||
<AgentConfigApiContextProvider value={configApiContext}>
|
||||
<AgentOrchestrateAddActionsProvider>
|
||||
<AgentBuildDraftChangedKeysProvider
|
||||
changedKeys={
|
||||
isBuildDraftActive ? buildDraftChangedKeys : EMPTY_BUILD_DRAFT_CHANGED_KEYS
|
||||
}
|
||||
>
|
||||
<AgentModelField
|
||||
currentModel={currentModel}
|
||||
textGenerationModelList={textGenerationModelList}
|
||||
onSelect={onSelectModel}
|
||||
/>
|
||||
<AgentPromptEditor />
|
||||
<AgentSkills />
|
||||
<AgentFiles />
|
||||
<AgentTools />
|
||||
<AgentKnowledgeRetrieval />
|
||||
<AgentAdvancedSettings />
|
||||
</AgentBuildDraftChangedKeysProvider>
|
||||
</AgentOrchestrateAddActionsProvider>
|
||||
</AgentConfigApiContextProvider>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</AgentOrchestrateReadOnlyContext>
|
||||
<AgentOrchestrateViewingVersionContext value={!!selectedVersionSnapshot}>
|
||||
<AgentOrchestrateReadOnlyContext value={readOnly}>
|
||||
<div aria-readonly={readOnly} className="flex min-h-0 flex-1 flex-col">
|
||||
<ScrollArea
|
||||
className="min-h-0 flex-1 overflow-hidden"
|
||||
label={showHeader ? undefined : orchestrateLabel}
|
||||
slotClassNames={{
|
||||
viewport: 'overscroll-contain',
|
||||
content: cn('min-h-full px-4 py-3', hasBottomAction && 'pb-20'),
|
||||
scrollbar: hasBottomAction ? 'z-20' : undefined,
|
||||
}}
|
||||
>
|
||||
<AgentConfigApiContextProvider value={configApiContext}>
|
||||
<AgentOrchestrateAddActionsProvider>
|
||||
<AgentBuildDraftChangedKeysProvider
|
||||
changedKeys={
|
||||
isBuildDraftActive ? buildDraftChangedKeys : EMPTY_BUILD_DRAFT_CHANGED_KEYS
|
||||
}
|
||||
>
|
||||
<AgentModelField
|
||||
currentModel={currentModel}
|
||||
textGenerationModelList={textGenerationModelList}
|
||||
onSelect={onSelectModel}
|
||||
/>
|
||||
<AgentPromptEditor />
|
||||
<AgentSkills />
|
||||
<AgentFiles />
|
||||
<AgentTools />
|
||||
<AgentKnowledgeRetrieval />
|
||||
<AgentAdvancedSettings />
|
||||
</AgentBuildDraftChangedKeysProvider>
|
||||
</AgentOrchestrateAddActionsProvider>
|
||||
</AgentConfigApiContextProvider>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</AgentOrchestrateReadOnlyContext>
|
||||
</AgentOrchestrateViewingVersionContext>
|
||||
|
||||
{orchestrateBottomAction ? (
|
||||
<AgentOrchestrateBottomActions shrinkOnOpen={!bottomAction}>
|
||||
|
||||
+23
-6
@@ -10,7 +10,10 @@ import { AgentComposerProvider } from '@/features/agent-v2/agent-composer/provid
|
||||
import { agentComposerDraftAtom } from '@/features/agent-v2/agent-composer/store'
|
||||
import { RerankingModeEnum } from '@/models/datasets'
|
||||
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
import { AgentOrchestrateReadOnlyContext } from '../../read-only-context'
|
||||
import {
|
||||
AgentOrchestrateReadOnlyContext,
|
||||
AgentOrchestrateViewingVersionContext,
|
||||
} from '../../read-only-context'
|
||||
import { AgentKnowledgeRetrieval } from '../index'
|
||||
|
||||
vi.mock('@/context/workspace-state', async () => {
|
||||
@@ -107,17 +110,21 @@ function ConfigSnapshotPreview() {
|
||||
function renderKnowledgeRetrieval({
|
||||
initialDraft = agentKnowledgeDraft,
|
||||
readOnly = false,
|
||||
viewingVersion = false,
|
||||
showConfigSnapshot = false,
|
||||
}: {
|
||||
initialDraft?: AgentSoulConfigFormState
|
||||
readOnly?: boolean
|
||||
viewingVersion?: boolean
|
||||
showConfigSnapshot?: boolean
|
||||
} = {}) {
|
||||
return render(
|
||||
<AgentComposerProvider initialDraft={initialDraft}>
|
||||
<AgentOrchestrateReadOnlyContext value={readOnly}>
|
||||
<AgentKnowledgeRetrieval />
|
||||
</AgentOrchestrateReadOnlyContext>
|
||||
<AgentOrchestrateViewingVersionContext value={viewingVersion}>
|
||||
<AgentOrchestrateReadOnlyContext value={readOnly}>
|
||||
<AgentKnowledgeRetrieval />
|
||||
</AgentOrchestrateReadOnlyContext>
|
||||
</AgentOrchestrateViewingVersionContext>
|
||||
{showConfigSnapshot && <ConfigSnapshotPreview />}
|
||||
</AgentComposerProvider>,
|
||||
)
|
||||
@@ -154,8 +161,8 @@ describe('AgentKnowledgeRetrieval', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide add, edit, and remove actions when readonly', () => {
|
||||
renderKnowledgeRetrieval({ readOnly: true })
|
||||
it('should hide add, edit, and remove actions when viewing a version', () => {
|
||||
renderKnowledgeRetrieval({ readOnly: true, viewingVersion: true })
|
||||
|
||||
expect(
|
||||
screen.getByText('agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne'),
|
||||
@@ -176,6 +183,16 @@ describe('AgentKnowledgeRetrieval', () => {
|
||||
}),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep add action available for build drafts', () => {
|
||||
renderKnowledgeRetrieval({ readOnly: true })
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.add',
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
|
||||
+5
@@ -1,7 +1,12 @@
|
||||
import { createContext, use } from 'react'
|
||||
|
||||
export const AgentOrchestrateReadOnlyContext = createContext(false)
|
||||
export const AgentOrchestrateViewingVersionContext = createContext(false)
|
||||
|
||||
export function useAgentOrchestrateReadOnly() {
|
||||
return use(AgentOrchestrateReadOnlyContext)
|
||||
}
|
||||
|
||||
export function useAgentOrchestrateViewingVersion() {
|
||||
return use(AgentOrchestrateViewingVersionContext)
|
||||
}
|
||||
|
||||
+564
-20
@@ -1,3 +1,4 @@
|
||||
import type { SkillResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { AgentConfigApiContext } from '../../config-context'
|
||||
import type { AgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
@@ -11,7 +12,10 @@ import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-compo
|
||||
import { AgentComposerProvider } from '@/features/agent-v2/agent-composer/provider'
|
||||
import { agentComposerDraftAtom } from '@/features/agent-v2/agent-composer/store'
|
||||
import { AgentConfigApiContextProvider } from '../../config-context'
|
||||
import { AgentOrchestrateReadOnlyContext } from '../../read-only-context'
|
||||
import {
|
||||
AgentOrchestrateReadOnlyContext,
|
||||
AgentOrchestrateViewingVersionContext,
|
||||
} from '../../read-only-context'
|
||||
import { AgentSkills } from '../index'
|
||||
|
||||
type ConfigSkillInspectQueryOptionsInput = {
|
||||
@@ -39,10 +43,16 @@ type ConfigSkillDownloadQueryOptionsInput = {
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
agentSkillBindingsKey: vi.fn((_options: unknown): unknown[] => ['workspace-agent-skills']),
|
||||
agentSkillBindingsQueryOptions: vi.fn((_options: unknown) => ({})),
|
||||
deleteSkillMutationFn: vi.fn(async (_input: unknown) => ({
|
||||
removed_names: ['Tender Analyzer'],
|
||||
result: 'success',
|
||||
})),
|
||||
replaceAgentSkillBindingsMutationFn: vi.fn(async (input: { body: { skill_ids?: string[] } }) => ({
|
||||
agent_id: 'agent-1',
|
||||
skill_ids: input.body.skill_ids ?? [],
|
||||
})),
|
||||
uploadSkillMutationFn: vi.fn(async (_input: unknown) => ({
|
||||
config_version: { id: 'draft-1', kind: 'draft', writable: true },
|
||||
skill: {
|
||||
@@ -59,6 +69,8 @@ const mocks = vi.hoisted(() => ({
|
||||
inspectQueryOptions: vi.fn((_options: ConfigSkillInspectQueryOptionsInput) => ({})),
|
||||
previewQueryOptions: vi.fn((_options: ConfigSkillFileQueryOptionsInput) => ({})),
|
||||
downloadQueryOptions: vi.fn((_options: ConfigSkillFileQueryOptionsInput) => ({})),
|
||||
workspaceSkillsQueryOptions: vi.fn((_options: unknown) => ({})),
|
||||
workspaceSkillsInfiniteOptions: vi.fn((_options: unknown) => ({})),
|
||||
downloadBlob: vi.fn(),
|
||||
downloadUrl: vi.fn(),
|
||||
fetch: vi.fn(),
|
||||
@@ -165,9 +177,43 @@ vi.mock('@/service/client', () => ({
|
||||
},
|
||||
},
|
||||
},
|
||||
workspaces: {
|
||||
current: {
|
||||
agents: {
|
||||
byAgentId: {
|
||||
skills: {
|
||||
get: {
|
||||
key: mocks.agentSkillBindingsKey,
|
||||
queryOptions: mocks.agentSkillBindingsQueryOptions,
|
||||
},
|
||||
put: {
|
||||
mutationOptions: () => ({ mutationFn: mocks.replaceAgentSkillBindingsMutationFn }),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
skills: {
|
||||
get: {
|
||||
queryOptions: mocks.workspaceSkillsQueryOptions,
|
||||
infiniteOptions: mocks.workspaceSkillsInfiniteOptions,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
async function openUploadSkillDialog(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.skills\.add/i }),
|
||||
)
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: /agentV2\.agentDetail\.configure\.skills\.addMenu\.upload\.label/i,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function ConfigSnapshotProbe() {
|
||||
const draft = useAtomValue(agentComposerDraftAtom)
|
||||
const configSnapshot = formStateToAgentSoulConfig({ formState: draft })
|
||||
@@ -175,6 +221,23 @@ function ConfigSnapshotProbe() {
|
||||
return <pre aria-label="config snapshot">{JSON.stringify(configSnapshot)}</pre>
|
||||
}
|
||||
|
||||
function createWorkspaceSkill(overrides: Partial<SkillResponse> = {}): SkillResponse {
|
||||
return {
|
||||
id: 'workspace-skill-1',
|
||||
name: 'refund-approval',
|
||||
display_name: 'Refund approval',
|
||||
description: 'Handle refund requests.',
|
||||
icon: '💳',
|
||||
latest_published_version_id: 'version-1',
|
||||
reference_count: 0,
|
||||
tags: [],
|
||||
visibility: 'workspace',
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function renderAgentSkills({
|
||||
initialDraft = {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
@@ -189,10 +252,12 @@ function renderAgentSkills({
|
||||
},
|
||||
apiContext = { agentId: 'agent-1', draftType: 'draft' } satisfies AgentConfigApiContext,
|
||||
readOnly = false,
|
||||
viewingVersion = false,
|
||||
}: {
|
||||
initialDraft?: AgentSoulConfigFormState
|
||||
apiContext?: AgentConfigApiContext
|
||||
readOnly?: boolean
|
||||
viewingVersion?: boolean
|
||||
} = {}) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -205,10 +270,12 @@ function renderAgentSkills({
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AgentConfigApiContextProvider value={apiContext}>
|
||||
<AgentComposerProvider initialDraft={initialDraft}>
|
||||
<AgentOrchestrateReadOnlyContext value={readOnly}>
|
||||
<AgentSkills />
|
||||
<ConfigSnapshotProbe />
|
||||
</AgentOrchestrateReadOnlyContext>
|
||||
<AgentOrchestrateViewingVersionContext value={viewingVersion}>
|
||||
<AgentOrchestrateReadOnlyContext value={readOnly}>
|
||||
<AgentSkills />
|
||||
<ConfigSnapshotProbe />
|
||||
</AgentOrchestrateReadOnlyContext>
|
||||
</AgentOrchestrateViewingVersionContext>
|
||||
</AgentComposerProvider>
|
||||
</AgentConfigApiContextProvider>
|
||||
</QueryClientProvider>,
|
||||
@@ -225,6 +292,22 @@ describe('AgentSkills', () => {
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
}),
|
||||
)
|
||||
mocks.agentSkillBindingsKey.mockImplementation((options) => {
|
||||
const { input } = options as { input: { params: { agent_id: string } } }
|
||||
return ['workspace-agent-skills', input]
|
||||
})
|
||||
mocks.agentSkillBindingsQueryOptions.mockImplementation((options) => {
|
||||
const { input } = options as { input: { params: { agent_id: string } } }
|
||||
|
||||
return {
|
||||
queryKey: ['workspace-agent-skills', input],
|
||||
queryFn: async () => ({
|
||||
agent_id: input.params.agent_id,
|
||||
skill_ids: [],
|
||||
data: [],
|
||||
}),
|
||||
}
|
||||
})
|
||||
mocks.inspectQueryOptions.mockImplementation(({ input }) => ({
|
||||
queryKey: ['inspect-skill', input],
|
||||
queryFn: async () => ({
|
||||
@@ -293,6 +376,38 @@ describe('AgentSkills', () => {
|
||||
url: `https://example.com/${input.params.name}.skill`,
|
||||
}),
|
||||
}))
|
||||
mocks.workspaceSkillsQueryOptions.mockImplementation((options) => {
|
||||
const { input } = options as { input: { query?: { keyword?: string } } }
|
||||
|
||||
return {
|
||||
queryKey: ['workspace-skills', input],
|
||||
queryFn: async () => ({
|
||||
data: [],
|
||||
}),
|
||||
}
|
||||
})
|
||||
mocks.workspaceSkillsInfiniteOptions.mockImplementation((options) => {
|
||||
const { input, getNextPageParam, initialPageParam } = options as {
|
||||
input: (pageParam: number) => {
|
||||
query?: { keyword?: string; limit?: number; page?: 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: [],
|
||||
has_more: false,
|
||||
limit: input(pageParam).query?.limit ?? 20,
|
||||
page: pageParam,
|
||||
total: 0,
|
||||
}),
|
||||
getNextPageParam,
|
||||
initialPageParam,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -372,9 +487,7 @@ describe('AgentSkills', () => {
|
||||
const user = userEvent.setup()
|
||||
renderAgentSkills({ initialDraft: defaultAgentSoulConfigFormState })
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.skills\.add/i }),
|
||||
)
|
||||
await openUploadSkillDialog(user)
|
||||
|
||||
const input = await waitFor(() => {
|
||||
const element = document.querySelector('input[type="file"]')
|
||||
@@ -418,13 +531,375 @@ describe('AgentSkills', () => {
|
||||
expect(toast.success).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should hide skill package guidance before an upload fails', async () => {
|
||||
it('should bind workspace skills without adding them to inline config skills', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.workspaceSkillsInfiniteOptions.mockImplementation((options) => {
|
||||
const { input, getNextPageParam, initialPageParam } = options as {
|
||||
input: (pageParam: number) => {
|
||||
query?: { keyword?: string; limit?: number; page?: 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: [
|
||||
{
|
||||
id: 'workspace-skill-1',
|
||||
name: 'refund-approval',
|
||||
display_name: 'Refund approval',
|
||||
description: 'Handle refund requests.',
|
||||
icon: '💳',
|
||||
latest_published_version_id: 'version-1',
|
||||
reference_count: 0,
|
||||
tags: [],
|
||||
visibility: 'workspace',
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
limit: 20,
|
||||
page: pageParam,
|
||||
total: 1,
|
||||
}),
|
||||
getNextPageParam,
|
||||
initialPageParam,
|
||||
}
|
||||
})
|
||||
renderAgentSkills({ initialDraft: defaultAgentSoulConfigFormState })
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.skills\.add/i }),
|
||||
)
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: /agentV2\.agentDetail\.configure\.skills\.addMenu\.workspace\.label/i,
|
||||
}),
|
||||
)
|
||||
await user.click(await screen.findByRole('button', { name: /Refund approval/ }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.replaceAgentSkillBindingsMutationFn.mock.calls[0]?.[0]).toEqual({
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
body: {
|
||||
skill_ids: ['workspace-skill-1'],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const snapshot = JSON.parse(screen.getByLabelText('config snapshot').textContent ?? '{}')
|
||||
expect(snapshot.config_skills).toEqual([])
|
||||
})
|
||||
|
||||
it('should allow workflow agent nodes to bind workspace skills', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.workspaceSkillsInfiniteOptions.mockImplementation((options) => {
|
||||
const { input, getNextPageParam, initialPageParam } = options as {
|
||||
input: (pageParam: number) => {
|
||||
query?: { keyword?: string; limit?: number; page?: 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: [
|
||||
{
|
||||
id: 'workspace-skill-1',
|
||||
name: 'refund-approval',
|
||||
display_name: 'Refund approval',
|
||||
description: 'Handle refund requests.',
|
||||
icon: '💳',
|
||||
latest_published_version_id: 'version-1',
|
||||
reference_count: 0,
|
||||
tags: [],
|
||||
visibility: 'workspace',
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
limit: 20,
|
||||
page: pageParam,
|
||||
total: 1,
|
||||
}),
|
||||
getNextPageParam,
|
||||
initialPageParam,
|
||||
}
|
||||
})
|
||||
renderAgentSkills({
|
||||
initialDraft: defaultAgentSoulConfigFormState,
|
||||
apiContext: {
|
||||
agentId: 'workflow-agent-1',
|
||||
draftType: 'draft',
|
||||
workflow: {
|
||||
appId: 'workflow-app-1',
|
||||
nodeId: 'agent-node-1',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.skills\.add/i }),
|
||||
)
|
||||
const workspaceMenuItem = screen.getByRole('button', {
|
||||
name: /agentV2\.agentDetail\.configure\.skills\.addMenu\.workspace\.label/i,
|
||||
})
|
||||
expect(workspaceMenuItem).not.toBeDisabled()
|
||||
|
||||
await user.click(workspaceMenuItem)
|
||||
await user.click(await screen.findByRole('button', { name: /Refund approval/ }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.replaceAgentSkillBindingsMutationFn.mock.calls[0]?.[0]).toEqual({
|
||||
params: {
|
||||
agent_id: 'workflow-agent-1',
|
||||
},
|
||||
body: {
|
||||
skill_ids: ['workspace-skill-1'],
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should mark already bound workspace skills as added and prevent duplicate binding', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.agentSkillBindingsQueryOptions.mockImplementation((options) => {
|
||||
const { input } = options as { input: { params: { agent_id: string } } }
|
||||
|
||||
return {
|
||||
queryKey: ['workspace-agent-skills', input],
|
||||
queryFn: async () => ({
|
||||
agent_id: input.params.agent_id,
|
||||
skill_ids: ['workspace-skill-1'],
|
||||
data: [
|
||||
{
|
||||
...createWorkspaceSkill(),
|
||||
priority: 0,
|
||||
status: 'published',
|
||||
file_count: 1,
|
||||
latest_published_at: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}
|
||||
})
|
||||
mocks.workspaceSkillsInfiniteOptions.mockImplementation((options) => {
|
||||
const { input, getNextPageParam, initialPageParam } = options as {
|
||||
input: (pageParam: number) => {
|
||||
query?: { keyword?: string; limit?: number; page?: 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(),
|
||||
createWorkspaceSkill({
|
||||
id: 'draft-skill',
|
||||
name: 'draft-skill',
|
||||
display_name: 'Draft skill',
|
||||
latest_published_version_id: null,
|
||||
}),
|
||||
],
|
||||
has_more: false,
|
||||
limit: 20,
|
||||
page: pageParam,
|
||||
total: 2,
|
||||
}),
|
||||
getNextPageParam,
|
||||
initialPageParam,
|
||||
}
|
||||
})
|
||||
renderAgentSkills({ initialDraft: defaultAgentSoulConfigFormState })
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.skills\.add/i }),
|
||||
)
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: /agentV2\.agentDetail\.configure\.skills\.addMenu\.workspace\.label/i,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(
|
||||
await screen.findByText('agentV2.agentDetail.configure.skills.workspaceSelector.added'),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('agentV2.agentDetail.configure.skills.workspaceSelector.draft'),
|
||||
).toBeInTheDocument()
|
||||
|
||||
const addedSkillButton = screen
|
||||
.getByText('agentV2.agentDetail.configure.skills.workspaceSelector.added')
|
||||
.closest('button')
|
||||
const draftSkillButton = screen
|
||||
.getByText('agentV2.agentDetail.configure.skills.workspaceSelector.draft')
|
||||
.closest('button')
|
||||
expect(addedSkillButton).toBeDisabled()
|
||||
expect(draftSkillButton).toBeDisabled()
|
||||
|
||||
expect(mocks.replaceAgentSkillBindingsMutationFn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should fetch the next workspace skill page when scrolling the selector', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.workspaceSkillsInfiniteOptions.mockImplementation((options) => {
|
||||
const { input, getNextPageParam, initialPageParam } = options as {
|
||||
input: (pageParam: number) => {
|
||||
query?: { keyword?: string; limit?: number; page?: 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:
|
||||
pageParam === 1
|
||||
? [createWorkspaceSkill()]
|
||||
: [
|
||||
createWorkspaceSkill({
|
||||
id: 'workspace-skill-2',
|
||||
name: 'sales-follow-up',
|
||||
display_name: 'Sales follow-up',
|
||||
}),
|
||||
],
|
||||
has_more: pageParam === 1,
|
||||
limit: 20,
|
||||
page: pageParam,
|
||||
total: 2,
|
||||
}),
|
||||
getNextPageParam,
|
||||
initialPageParam,
|
||||
}
|
||||
})
|
||||
renderAgentSkills({ initialDraft: defaultAgentSoulConfigFormState })
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.skills\.add/i }),
|
||||
)
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: /agentV2\.agentDetail\.configure\.skills\.addMenu\.workspace\.label/i,
|
||||
}),
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Refund approval').length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
const scrollContainer = document.querySelector('.overflow-y-auto')
|
||||
expect(scrollContainer).not.toBeNull()
|
||||
Object.defineProperties(scrollContainer!, {
|
||||
clientHeight: { configurable: true, value: 100 },
|
||||
scrollHeight: { configurable: true, value: 160 },
|
||||
scrollTop: { configurable: true, value: 80 },
|
||||
})
|
||||
fireEvent.scroll(scrollContainer!)
|
||||
|
||||
expect(await screen.findByText('Sales follow-up')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should remove workspace skill bindings from the configured agent', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.agentSkillBindingsQueryOptions.mockImplementation((options) => {
|
||||
const { input } = options as { input: { params: { agent_id: string } } }
|
||||
|
||||
return {
|
||||
queryKey: ['workspace-agent-skills', input],
|
||||
queryFn: async () => ({
|
||||
agent_id: input.params.agent_id,
|
||||
skill_ids: ['workspace-skill-1'],
|
||||
data: [
|
||||
{
|
||||
...createWorkspaceSkill(),
|
||||
priority: 0,
|
||||
status: 'published',
|
||||
file_count: 1,
|
||||
latest_published_at: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}
|
||||
})
|
||||
renderAgentSkills({ initialDraft: defaultAgentSoulConfigFormState })
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'agentV2.agentDetail.configure.skills.moreActions:{"name":"Refund approval"}',
|
||||
}),
|
||||
)
|
||||
await user.click(await screen.findByText('agentV2.agentDetail.configure.skills.removeAction'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.replaceAgentSkillBindingsMutationFn.mock.calls[0]?.[0]).toEqual({
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
body: {
|
||||
skill_ids: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
expect(toast.success).toHaveBeenCalledWith(
|
||||
'agentV2.agentDetail.configure.skills.workspaceSelector.removeSuccess',
|
||||
)
|
||||
})
|
||||
|
||||
it('should open workspace skill details in a new tab from the row menu', async () => {
|
||||
const user = userEvent.setup()
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
mocks.agentSkillBindingsQueryOptions.mockImplementation((options) => {
|
||||
const { input } = options as { input: { params: { agent_id: string } } }
|
||||
|
||||
return {
|
||||
queryKey: ['workspace-agent-skills', input],
|
||||
queryFn: async () => ({
|
||||
agent_id: input.params.agent_id,
|
||||
skill_ids: ['workspace-skill-1'],
|
||||
data: [
|
||||
{
|
||||
...createWorkspaceSkill(),
|
||||
priority: 0,
|
||||
status: 'published',
|
||||
file_count: 1,
|
||||
latest_published_at: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}
|
||||
})
|
||||
renderAgentSkills({ initialDraft: defaultAgentSoulConfigFormState })
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'agentV2.agentDetail.configure.skills.moreActions:{"name":"Refund approval"}',
|
||||
}),
|
||||
)
|
||||
await user.click(await screen.findByText('agentV2.agentDetail.configure.skills.openInLibrary'))
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'/skills/workspace-skill-1',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
})
|
||||
|
||||
it('should hide skill package guidance before an upload fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderAgentSkills({ initialDraft: defaultAgentSoulConfigFormState })
|
||||
|
||||
await openUploadSkillDialog(user)
|
||||
|
||||
expect(
|
||||
screen.queryByText('agentV2.agentDetail.configure.skills.upload.warning.specification'),
|
||||
@@ -438,9 +913,7 @@ describe('AgentSkills', () => {
|
||||
.mockImplementationOnce(() => new Promise<never>(() => undefined))
|
||||
renderAgentSkills({ initialDraft: defaultAgentSoulConfigFormState })
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.skills\.add/i }),
|
||||
)
|
||||
await openUploadSkillDialog(user)
|
||||
const input = await waitFor(() => {
|
||||
const element = document.querySelector('input[type="file"]')
|
||||
expect(element).not.toBeNull()
|
||||
@@ -477,9 +950,7 @@ describe('AgentSkills', () => {
|
||||
mocks.uploadSkillMutationFn.mockRejectedValueOnce(new Error('Backend upload error'))
|
||||
renderAgentSkills({ initialDraft: defaultAgentSoulConfigFormState })
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.skills\.add/i }),
|
||||
)
|
||||
await openUploadSkillDialog(user)
|
||||
|
||||
const input = await waitFor(() => {
|
||||
const element = document.querySelector('input[type="file"]')
|
||||
@@ -515,9 +986,7 @@ describe('AgentSkills', () => {
|
||||
},
|
||||
})
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.skills\.add/i }),
|
||||
)
|
||||
await openUploadSkillDialog(user)
|
||||
const input = await waitFor(() => {
|
||||
const element = document.querySelector('input[type="file"]')
|
||||
expect(element).not.toBeNull()
|
||||
@@ -869,12 +1338,87 @@ describe('AgentSkills', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should disable add and remove actions when the section is read only', () => {
|
||||
const { container } = renderAgentSkills({ readOnly: true })
|
||||
it('should disable add and remove actions when viewing a version', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.agentSkillBindingsQueryOptions.mockImplementation((options) => {
|
||||
const { input } = options as { input: { params: { agent_id: string } } }
|
||||
|
||||
return {
|
||||
queryKey: ['workspace-agent-skills', input],
|
||||
queryFn: async () => ({
|
||||
agent_id: input.params.agent_id,
|
||||
skill_ids: ['workspace-skill-1'],
|
||||
data: [
|
||||
{
|
||||
...createWorkspaceSkill(),
|
||||
priority: 0,
|
||||
status: 'published',
|
||||
file_count: 1,
|
||||
latest_published_at: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}
|
||||
})
|
||||
const { container } = renderAgentSkills({
|
||||
apiContext: {
|
||||
agentId: 'agent-1',
|
||||
draftType: 'draft',
|
||||
versionId: 'version-1',
|
||||
},
|
||||
readOnly: true,
|
||||
viewingVersion: true,
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /agentV2\.agentDetail\.configure\.skills\.add/i }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(container.querySelector('[data-agent-skill-remove-button]')).toBeNull()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'agentV2.agentDetail.configure.skills.moreActions:{"name":"Refund approval"}',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.getByText('agentV2.agentDetail.configure.skills.openInLibrary'),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByText('agentV2.agentDetail.configure.skills.removeAction'),
|
||||
).not.toBeInTheDocument()
|
||||
expect(mocks.replaceAgentSkillBindingsMutationFn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should keep the add menu available for build draft skills', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderAgentSkills({
|
||||
apiContext: {
|
||||
agentId: 'agent-1',
|
||||
draftType: 'debug_build',
|
||||
},
|
||||
initialDraft: {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
skills: [],
|
||||
},
|
||||
readOnly: true,
|
||||
})
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: /agentV2\.agentDetail\.configure\.skills\.add/i,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', {
|
||||
name: /agentV2\.agentDetail\.configure\.skills\.addMenu\.workspace\.label/i,
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: /agentV2\.agentDetail\.configure\.skills\.addMenu\.upload\.label/i,
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
+543
-16
@@ -1,30 +1,391 @@
|
||||
'use client'
|
||||
|
||||
import type {
|
||||
AgentSkillBindingItemResponse,
|
||||
SkillResponse,
|
||||
} from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { UIEvent } from 'react'
|
||||
import type { AgentOrchestrateAddActionOptions } from '../add-actions-context'
|
||||
import type { AgentSkill } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import {
|
||||
keepPreviousData,
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SearchInput } from '@/app/components/base/search-input'
|
||||
import { SkeletonRectangle } from '@/app/components/base/skeleton'
|
||||
import {
|
||||
agentComposerSkillsAtom,
|
||||
removeAgentSkillAtom,
|
||||
upsertAgentSkillAtom,
|
||||
} from '@/features/agent-v2/agent-composer/store-modules/skills'
|
||||
import Link from '@/next/link'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { useRegisterAgentOrchestrateAddAction } from '../add-actions-context'
|
||||
import { ConfigureSectionAddButton } from '../common/add-button'
|
||||
import { ConfigureSectionEmpty } from '../common/empty'
|
||||
import { ConfigureSection } from '../common/section'
|
||||
import { AgentConfigureTipContent } from '../common/tip-content'
|
||||
import { useAgentConfigApiContext } from '../config-context'
|
||||
import {
|
||||
useAgentOrchestrateReadOnly,
|
||||
useAgentOrchestrateViewingVersion,
|
||||
} from '../read-only-context'
|
||||
import { AgentSkillItem } from './item'
|
||||
import { AgentSkillUploadDialog } from './upload-dialog'
|
||||
|
||||
const WORKSPACE_SKILLS_PAGE_SIZE = 20
|
||||
|
||||
function AgentSkillAddMenuItem({
|
||||
badge,
|
||||
description,
|
||||
disabled,
|
||||
iconClassName,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
badge?: string
|
||||
description: string
|
||||
disabled?: boolean
|
||||
iconClassName: string
|
||||
label: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className="flex w-full min-w-0 items-start gap-3 rounded-lg px-2 py-2 text-left outline-hidden hover:not-disabled:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn('mt-0.5 size-4 shrink-0 text-text-tertiary', iconClassName)}
|
||||
/>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate system-sm-medium text-text-secondary">{label}</span>
|
||||
{badge && (
|
||||
<span className="shrink-0 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1.5 py-0.5 system-2xs-medium-uppercase text-text-tertiary">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="line-clamp-2 system-xs-regular text-text-tertiary">{description}</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceSkillIcon({ icon }: { icon?: string }) {
|
||||
return (
|
||||
<span className="flex size-5 shrink-0 items-center justify-center rounded-md border-[0.5px] border-divider-subtle bg-background-default-dodge">
|
||||
{icon ? (
|
||||
<span className="text-[12px] leading-none">{icon}</span>
|
||||
) : (
|
||||
<span aria-hidden className="i-ri-box-3-line size-3.5 text-text-tertiary" />
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceSkillRow({
|
||||
disabled,
|
||||
isAdded,
|
||||
isPending,
|
||||
onSelect,
|
||||
onPreview,
|
||||
selected,
|
||||
skill,
|
||||
}: {
|
||||
disabled: boolean
|
||||
isAdded: boolean
|
||||
isPending: boolean
|
||||
onSelect: (skill: SkillResponse) => void
|
||||
onPreview: (skill: SkillResponse) => void
|
||||
selected: boolean
|
||||
skill: SkillResponse
|
||||
}) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || isAdded || isPending}
|
||||
onClick={() => onSelect(skill)}
|
||||
onFocus={() => onPreview(skill)}
|
||||
onMouseEnter={() => onPreview(skill)}
|
||||
className={cn(
|
||||
'flex h-12 w-full min-w-0 items-center gap-2 rounded-lg px-2 text-left outline-hidden hover:not-disabled:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-default disabled:opacity-60',
|
||||
selected && 'bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<WorkspaceSkillIcon icon={skill.icon} />
|
||||
<span className="flex w-0 min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="truncate system-sm-medium text-text-secondary">{skill.display_name}</span>
|
||||
<span className="truncate system-xs-regular text-text-tertiary">{skill.name}</span>
|
||||
</span>
|
||||
{isAdded && (
|
||||
<span className="shrink-0 system-xs-medium text-text-tertiary">
|
||||
{t(($) => $['agentDetail.configure.skills.workspaceSelector.added'])}
|
||||
</span>
|
||||
)}
|
||||
{!isAdded && disabled && (
|
||||
<span className="shrink-0 system-xs-medium text-text-tertiary">
|
||||
{t(($) => $['agentDetail.configure.skills.workspaceSelector.draft'])}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceSkillPreview({ skill }: { skill?: SkillResponse }) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
|
||||
if (!skill) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center px-6 text-center system-xs-regular text-text-tertiary">
|
||||
{t(($) => $['agentDetail.configure.skills.workspaceSelector.empty'])}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-4 p-4">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<WorkspaceSkillIcon icon={skill.icon} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate system-md-semibold text-text-primary">{skill.display_name}</div>
|
||||
<div className="mt-0.5 truncate system-xs-regular text-text-tertiary">{skill.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
{!!skill.tags?.length && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{skill.tags.slice(0, 5).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-[5px] border border-divider-subtle bg-components-badge-bg-dimm px-1.5 py-0.5 system-2xs-medium-uppercase text-text-tertiary"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="line-clamp-6 system-sm-regular text-text-secondary">{skill.description}</p>
|
||||
{(skill.updated_by_name || skill.created_by_name) && (
|
||||
<div className="mt-auto system-xs-regular text-text-tertiary">
|
||||
{skill.updated_by_name || skill.created_by_name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceSkillSelector({
|
||||
boundSkillIds,
|
||||
isBindingPending,
|
||||
onSelect,
|
||||
}: {
|
||||
boundSkillIds: string[]
|
||||
isBindingPending: boolean
|
||||
onSelect: (skill: SkillResponse) => void
|
||||
}) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [previewSkillId, setPreviewSkillId] = useState<string | undefined>(undefined)
|
||||
const debouncedKeyword = useDebounce(keyword.trim(), { wait: 300 })
|
||||
const skillsQuery = useInfiniteQuery({
|
||||
...consoleQuery.workspaces.current.skills.get.infiniteOptions({
|
||||
input: (pageParam) => ({
|
||||
query: {
|
||||
limit: WORKSPACE_SKILLS_PAGE_SIZE,
|
||||
page: Number(pageParam),
|
||||
...(debouncedKeyword ? { keyword: debouncedKeyword } : {}),
|
||||
},
|
||||
}),
|
||||
getNextPageParam: (lastPage) => (lastPage.has_more ? (lastPage.page ?? 1) + 1 : undefined),
|
||||
initialPageParam: 1,
|
||||
placeholderData: keepPreviousData,
|
||||
}),
|
||||
})
|
||||
const boundSkillIdSet = useMemo(() => new Set(boundSkillIds), [boundSkillIds])
|
||||
const skills = skillsQuery.data?.pages.flatMap((page) => page.data ?? []) ?? []
|
||||
const previewSkill = skills.find((skill) => skill.id === previewSkillId) ?? skills[0]
|
||||
const hasNextPage = skillsQuery.hasNextPage ?? false
|
||||
const isFetchingNextPage = skillsQuery.isFetchingNextPage
|
||||
const fetchNextPage = skillsQuery.fetchNextPage
|
||||
|
||||
const handleListScroll = useCallback(
|
||||
(event: UIEvent<HTMLDivElement>) => {
|
||||
const target = event.currentTarget
|
||||
const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight
|
||||
if (scrollBottom < 80 && hasNextPage && !isFetchingNextPage) void fetchNextPage()
|
||||
},
|
||||
[fetchNextPage, hasNextPage, isFetchingNextPage],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex h-[520px] w-[560px] overflow-hidden rounded-xl border border-divider-regular bg-components-panel-bg shadow-lg">
|
||||
<div className="flex min-w-0 flex-1 flex-col border-r border-divider-subtle">
|
||||
<div className="border-b border-divider-subtle p-3">
|
||||
<div className="relative">
|
||||
<SearchInput
|
||||
value={keyword}
|
||||
onValueChange={setKeyword}
|
||||
placeholder={t(($) => $['agentDetail.configure.skills.workspaceSelector.search'])}
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute top-1/2 right-8 i-ri-price-tag-3-line size-4 -translate-y-1/2 text-text-tertiary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-1.5" onScroll={handleListScroll}>
|
||||
{skillsQuery.isPending && (
|
||||
<div className="space-y-2 p-1">
|
||||
<SkeletonRectangle className="h-10 rounded-lg" />
|
||||
<SkeletonRectangle className="h-10 rounded-lg" />
|
||||
<SkeletonRectangle className="h-10 rounded-lg" />
|
||||
</div>
|
||||
)}
|
||||
{!skillsQuery.isPending && skills.length === 0 && (
|
||||
<div className="flex h-full items-center justify-center px-4 text-center system-xs-regular text-text-tertiary">
|
||||
{t(($) => $['agentDetail.configure.skills.workspaceSelector.empty'])}
|
||||
</div>
|
||||
)}
|
||||
{!skillsQuery.isPending &&
|
||||
skills.map((skill) => (
|
||||
<WorkspaceSkillRow
|
||||
key={skill.id}
|
||||
disabled={!skill.latest_published_version_id}
|
||||
isAdded={boundSkillIdSet.has(skill.id)}
|
||||
isPending={isBindingPending}
|
||||
selected={previewSkill?.id === skill.id}
|
||||
skill={skill}
|
||||
onPreview={(skill) => setPreviewSkillId(skill.id)}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
{skillsQuery.isFetchingNextPage && (
|
||||
<div className="space-y-2 p-1">
|
||||
<SkeletonRectangle className="h-10 rounded-lg" />
|
||||
<SkeletonRectangle className="h-10 rounded-lg" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
href="/skills"
|
||||
className="flex h-10 items-center justify-between border-t border-divider-subtle px-3 system-sm-medium text-text-secondary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
>
|
||||
<span>{t(($) => $['agentDetail.configure.skills.workspaceSelector.manage'])}</span>
|
||||
<span aria-hidden className="i-ri-arrow-right-up-line size-4 text-text-tertiary" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="w-[240px] shrink-0 bg-background-default">
|
||||
<WorkspaceSkillPreview skill={previewSkill} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceAgentSkillItem({
|
||||
canRemove,
|
||||
skill,
|
||||
onRemove,
|
||||
}: {
|
||||
canRemove: boolean
|
||||
skill: AgentSkillBindingItemResponse
|
||||
onRemove: (skillId: string) => void
|
||||
}) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const readOnly = useAgentOrchestrateReadOnly()
|
||||
const displayName = skill.display_name || skill.name
|
||||
const handleOpenInLibrary = useCallback(() => {
|
||||
window.open(`/skills/${skill.id}`, '_blank', 'noopener,noreferrer')
|
||||
}, [skill.id])
|
||||
|
||||
return (
|
||||
<div className="group relative h-8 overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs shadow-shadow-shadow-3 hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm">
|
||||
<Link
|
||||
href={`/skills/${skill.id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex h-full w-full min-w-0 cursor-pointer items-center gap-1 rounded-lg py-1 pr-8 pl-2 text-left outline-hidden select-none focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid"
|
||||
>
|
||||
<WorkspaceSkillIcon icon={skill.icon} />
|
||||
<span className="flex w-0 min-w-0 flex-1 items-center gap-1">
|
||||
<span className="min-w-0 truncate system-sm-medium text-text-secondary">
|
||||
{displayName}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-ri-arrow-right-up-line size-3.5 shrink-0 text-text-quaternary opacity-0 group-focus-within:opacity-100 group-hover:opacity-100"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 system-xs-regular text-text-tertiary',
|
||||
!readOnly && 'group-focus-within:opacity-0 group-hover:opacity-0',
|
||||
)}
|
||||
>
|
||||
{skill.name}
|
||||
</span>
|
||||
</Link>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t(($) => $['agentDetail.configure.skills.moreActions'], {
|
||||
name: displayName,
|
||||
})}
|
||||
className="absolute top-1/2 right-1 z-10 flex size-6 -translate-y-1/2 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden data-popup-open:bg-state-base-hover data-popup-open:text-text-secondary"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-48">
|
||||
<DropdownMenuItem className="gap-2" onClick={handleOpenInLibrary}>
|
||||
<span aria-hidden className="i-ri-arrow-right-up-line size-4 shrink-0" />
|
||||
<span>{t(($) => $['agentDetail.configure.skills.openInLibrary'])}</span>
|
||||
</DropdownMenuItem>
|
||||
{canRemove && (
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
className="gap-2"
|
||||
onClick={() => onRemove(skill.id)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
|
||||
<span>{t(($) => $['agentDetail.configure.skills.removeAction'])}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AgentSkills() {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const skillsTip = t(($) => $['agentDetail.configure.skills.tip'])
|
||||
const skillsListId = 'agent-configure-skills-list'
|
||||
const queryClient = useQueryClient()
|
||||
const isViewingVersion = useAgentOrchestrateViewingVersion()
|
||||
const [addMenuOpen, setAddMenuOpen] = useState(false)
|
||||
const [addMenuView, setAddMenuView] = useState<'menu' | 'workspace-selector'>('menu')
|
||||
const [isUploadOpen, setIsUploadOpen] = useState(false)
|
||||
const promptAddCallbackRef = useRef<AgentOrchestrateAddActionOptions['onAdded']>(undefined)
|
||||
const apiContext = useAgentConfigApiContext()
|
||||
@@ -37,6 +398,68 @@ export function AgentSkills() {
|
||||
const { mutate: deleteAppSkill } = useMutation(
|
||||
consoleQuery.apps.byAppId.agent.config.skills.byName.delete.mutationOptions(),
|
||||
)
|
||||
const agentSkillBindingsQueryOptions =
|
||||
consoleQuery.workspaces.current.agents.byAgentId.skills.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
agent_id: apiContext.agentId,
|
||||
},
|
||||
},
|
||||
})
|
||||
const agentSkillBindingsQuery = useQuery({
|
||||
...agentSkillBindingsQueryOptions,
|
||||
})
|
||||
const { isPending: isReplacingAgentSkillBindings, mutate: replaceAgentSkillBindings } =
|
||||
useMutation(consoleQuery.workspaces.current.agents.byAgentId.skills.put.mutationOptions())
|
||||
const workspaceSkills = agentSkillBindingsQuery.data?.data ?? []
|
||||
const boundSkillIds =
|
||||
agentSkillBindingsQuery.data?.skill_ids ?? workspaceSkills.map((skill) => skill.id)
|
||||
const hasSkills = skills.length > 0 || workspaceSkills.length > 0
|
||||
const invalidateAgentSkillBindings = useCallback(() => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: consoleQuery.workspaces.current.agents.byAgentId.skills.get.key({
|
||||
type: 'query',
|
||||
input: {
|
||||
params: {
|
||||
agent_id: apiContext.agentId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
}, [apiContext.agentId, queryClient])
|
||||
|
||||
const replaceWorkspaceSkillBindings = useCallback(
|
||||
(skillIds: string[], onSuccess?: () => void) => {
|
||||
if (isViewingVersion) return
|
||||
|
||||
replaceAgentSkillBindings(
|
||||
{
|
||||
params: {
|
||||
agent_id: apiContext.agentId,
|
||||
},
|
||||
body: {
|
||||
skill_ids: skillIds,
|
||||
},
|
||||
},
|
||||
{
|
||||
onError: () => {
|
||||
toast.error(t(($) => $['agentDetail.configure.skills.workspaceSelector.saveFailed']))
|
||||
},
|
||||
onSuccess: () => {
|
||||
invalidateAgentSkillBindings()
|
||||
onSuccess?.()
|
||||
},
|
||||
},
|
||||
)
|
||||
},
|
||||
[
|
||||
apiContext.agentId,
|
||||
invalidateAgentSkillBindings,
|
||||
isViewingVersion,
|
||||
replaceAgentSkillBindings,
|
||||
t,
|
||||
],
|
||||
)
|
||||
|
||||
const handleOpenUpload = useCallback((options?: AgentOrchestrateAddActionOptions) => {
|
||||
promptAddCallbackRef.current = options?.onAdded
|
||||
@@ -44,6 +467,20 @@ export function AgentSkills() {
|
||||
}, [])
|
||||
useRegisterAgentOrchestrateAddAction('skills', handleOpenUpload)
|
||||
|
||||
const handleAddMenuOpenChange = useCallback((open: boolean) => {
|
||||
setAddMenuOpen(open)
|
||||
if (!open) setAddMenuView('menu')
|
||||
}, [])
|
||||
|
||||
const handleOpenWorkspaceSelector = useCallback(() => {
|
||||
setAddMenuView('workspace-selector')
|
||||
}, [])
|
||||
|
||||
const handleOpenUploadFromMenu = useCallback(() => {
|
||||
setAddMenuOpen(false)
|
||||
handleOpenUpload()
|
||||
}, [handleOpenUpload])
|
||||
|
||||
const handleUploaded = useCallback(
|
||||
(skill: AgentSkill) => {
|
||||
upsertAgentSkill(skill)
|
||||
@@ -53,11 +490,36 @@ export function AgentSkills() {
|
||||
[upsertAgentSkill],
|
||||
)
|
||||
|
||||
const handleSelectWorkspaceSkill = useCallback(
|
||||
(skill: SkillResponse) => {
|
||||
if (!skill.latest_published_version_id || boundSkillIds.includes(skill.id)) return
|
||||
|
||||
replaceWorkspaceSkillBindings([...boundSkillIds, skill.id], () => {
|
||||
toast.success(t(($) => $['agentDetail.configure.skills.workspaceSelector.addSuccess']))
|
||||
setAddMenuOpen(false)
|
||||
setAddMenuView('menu')
|
||||
})
|
||||
},
|
||||
[boundSkillIds, replaceWorkspaceSkillBindings, t],
|
||||
)
|
||||
|
||||
const handleUploadOpenChange = useCallback((open: boolean) => {
|
||||
if (!open) promptAddCallbackRef.current = undefined
|
||||
setIsUploadOpen(open)
|
||||
}, [])
|
||||
|
||||
const handleRemoveWorkspaceSkill = useCallback(
|
||||
(skillId: string) => {
|
||||
replaceWorkspaceSkillBindings(
|
||||
boundSkillIds.filter((item) => item !== skillId),
|
||||
() => {
|
||||
toast.success(t(($) => $['agentDetail.configure.skills.workspaceSelector.removeSuccess']))
|
||||
},
|
||||
)
|
||||
},
|
||||
[boundSkillIds, replaceWorkspaceSkillBindings, t],
|
||||
)
|
||||
|
||||
const handleRemoveSkill = useCallback(
|
||||
(skillId: string) => {
|
||||
const skill = skills.find((item) => item.id === skillId)
|
||||
@@ -113,26 +575,91 @@ export function AgentSkills() {
|
||||
rootClassName="border-b border-divider-subtle pt-4"
|
||||
panelContentClassName="flex flex-col gap-1 pb-4"
|
||||
actions={
|
||||
<ConfigureSectionAddButton
|
||||
ariaLabel={t(($) => $['agentDetail.configure.skills.add'])}
|
||||
onClick={() => handleOpenUpload()}
|
||||
/>
|
||||
!isViewingVersion && (
|
||||
<Popover open={addMenuOpen} onOpenChange={handleAddMenuOpenChange}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label={t(($) => $['agentDetail.configure.skills.add'])}
|
||||
variant="ghost"
|
||||
size="small"
|
||||
className="shrink-0 gap-1 px-2"
|
||||
>
|
||||
<span aria-hidden className="i-ri-add-line size-3.5" />
|
||||
<span>{tCommon(($) => $['operation.add'])}</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
popupClassName={
|
||||
addMenuView === 'menu'
|
||||
? 'w-[320px] bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]'
|
||||
: 'w-[560px] overflow-hidden border-none bg-transparent p-0 shadow-none'
|
||||
}
|
||||
>
|
||||
{addMenuView === 'menu' ? (
|
||||
<>
|
||||
<AgentSkillAddMenuItem
|
||||
iconClassName="i-custom-public-agent-building-blocks"
|
||||
label={t(($) => $['agentDetail.configure.skills.addMenu.workspace.label'])}
|
||||
description={t(
|
||||
($) => $['agentDetail.configure.skills.addMenu.workspace.description'],
|
||||
)}
|
||||
onClick={handleOpenWorkspaceSelector}
|
||||
/>
|
||||
<AgentSkillAddMenuItem
|
||||
badge={t(($) => $['agentDetail.configure.skills.addMenu.upload.badge'])}
|
||||
iconClassName="i-ri-upload-cloud-2-line"
|
||||
label={t(($) => $['agentDetail.configure.skills.addMenu.upload.label'])}
|
||||
description={t(
|
||||
($) => $['agentDetail.configure.skills.addMenu.upload.description'],
|
||||
)}
|
||||
onClick={handleOpenUploadFromMenu}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<WorkspaceSkillSelector
|
||||
boundSkillIds={boundSkillIds}
|
||||
isBindingPending={isReplacingAgentSkillBindings}
|
||||
onSelect={handleSelectWorkspaceSkill}
|
||||
/>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
>
|
||||
{skills.length === 0 ? (
|
||||
{!hasSkills ? (
|
||||
<ConfigureSectionEmpty
|
||||
title={t(($) => $['agentDetail.configure.skills.empty.title'])}
|
||||
description={t(($) => $['agentDetail.configure.skills.empty.description'])}
|
||||
/>
|
||||
) : (
|
||||
skills.map((skill) => (
|
||||
<AgentSkillItem
|
||||
key={skill.id}
|
||||
apiContext={apiContext}
|
||||
skill={skill}
|
||||
onRemove={handleRemoveSkill}
|
||||
/>
|
||||
))
|
||||
<>
|
||||
{workspaceSkills.length > 0 && (
|
||||
<div className="px-1 pt-1 pb-0.5 system-xs-medium-uppercase text-text-tertiary">
|
||||
{t(($) => $['agentDetail.configure.skills.fromSkillLibrary'])}
|
||||
</div>
|
||||
)}
|
||||
{workspaceSkills.map((skill) => (
|
||||
<WorkspaceAgentSkillItem
|
||||
key={skill.id}
|
||||
canRemove={!isViewingVersion}
|
||||
skill={skill}
|
||||
onRemove={handleRemoveWorkspaceSkill}
|
||||
/>
|
||||
))}
|
||||
{skills.map((skill) => (
|
||||
<AgentSkillItem
|
||||
key={skill.id}
|
||||
apiContext={apiContext}
|
||||
skill={skill}
|
||||
onRemove={handleRemoveSkill}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</ConfigureSection>
|
||||
<AgentSkillUploadDialog
|
||||
|
||||
+28
-7
@@ -15,7 +15,10 @@ import {
|
||||
agentComposerSavedDraftAtom,
|
||||
isAgentComposerDirtyAtom,
|
||||
} from '@/features/agent-v2/agent-composer/store'
|
||||
import { AgentOrchestrateReadOnlyContext } from '../../read-only-context'
|
||||
import {
|
||||
AgentOrchestrateReadOnlyContext,
|
||||
AgentOrchestrateViewingVersionContext,
|
||||
} from '../../read-only-context'
|
||||
import { AgentTools } from '../index'
|
||||
|
||||
const toolProviderState = vi.hoisted(() => ({
|
||||
@@ -340,7 +343,13 @@ function renderAgentToolsWithStore(initialDraft: AgentSoulConfigFormState = agen
|
||||
}
|
||||
}
|
||||
|
||||
function renderReadonlyAgentTools(initialDraft: AgentSoulConfigFormState = agentToolsDraft) {
|
||||
function renderReadonlyAgentTools({
|
||||
initialDraft = agentToolsDraft,
|
||||
viewingVersion = false,
|
||||
}: {
|
||||
initialDraft?: AgentSoulConfigFormState
|
||||
viewingVersion?: boolean
|
||||
} = {}) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
@@ -352,9 +361,11 @@ function renderReadonlyAgentTools(initialDraft: AgentSoulConfigFormState = agent
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AgentComposerProvider initialDraft={initialDraft}>
|
||||
<AgentOrchestrateReadOnlyContext value>
|
||||
<AgentTools />
|
||||
</AgentOrchestrateReadOnlyContext>
|
||||
<AgentOrchestrateViewingVersionContext value={viewingVersion}>
|
||||
<AgentOrchestrateReadOnlyContext value>
|
||||
<AgentTools />
|
||||
</AgentOrchestrateReadOnlyContext>
|
||||
</AgentOrchestrateViewingVersionContext>
|
||||
</AgentComposerProvider>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
@@ -441,9 +452,9 @@ describe('AgentTools', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide add, edit, and remove actions when readonly', async () => {
|
||||
it('should hide add, edit, and remove actions when viewing a version', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderReadonlyAgentTools()
|
||||
renderReadonlyAgentTools({ viewingVersion: true })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', {
|
||||
@@ -484,6 +495,16 @@ describe('AgentTools', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep add action available for build drafts', () => {
|
||||
renderReadonlyAgentTools()
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'agentV2.agentDetail.configure.tools.add',
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide CLI tool rows while CLI tools are disabled', () => {
|
||||
renderAgentTools()
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ import { ConfigureSectionAddButton } from '../common/add-button'
|
||||
import { ConfigureSectionEmpty } from '../common/empty'
|
||||
import { ConfigureSection } from '../common/section'
|
||||
import { AgentConfigureTipContent } from '../common/tip-content'
|
||||
import { useAgentOrchestrateReadOnly } from '../read-only-context'
|
||||
import { useAgentOrchestrateViewingVersion } from '../read-only-context'
|
||||
import { CliToolDialog } from './cli-tool/dialog'
|
||||
import { AgentCliToolItem } from './cli-tool/item'
|
||||
import {
|
||||
@@ -400,7 +400,7 @@ function AddToolMenu({
|
||||
|
||||
export function AgentTools() {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const readOnly = useAgentOrchestrateReadOnly()
|
||||
const isViewingVersion = useAgentOrchestrateViewingVersion()
|
||||
const setProviderToolCredential = useSetAtom(setProviderToolCredentialAtom)
|
||||
const providerById = useAgentToolProviderMap()
|
||||
const tools = useAtomValue(agentComposerToolsAtom)
|
||||
@@ -533,7 +533,7 @@ export function AgentTools() {
|
||||
rootClassName="border-b border-divider-subtle pt-4"
|
||||
panelContentClassName="flex flex-col gap-1 pb-4"
|
||||
actions={
|
||||
!readOnly ? (
|
||||
!isViewingVersion ? (
|
||||
<AddToolMenu
|
||||
onAddCliTool={openCliToolDialog}
|
||||
onAddTools={addTools}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { AgentAppPartial, AgentIconType } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -224,7 +225,12 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pointer-events-none absolute top-2 right-2 z-20 flex items-center overflow-hidden rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 opacity-0 shadow-lg backdrop-blur-xs transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 has-data-popup-open:pointer-events-auto has-data-popup-open:opacity-100">
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute right-2 z-20 flex items-center overflow-hidden rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 opacity-0 shadow-lg backdrop-blur-xs transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 has-data-popup-open:pointer-events-auto has-data-popup-open:opacity-100',
|
||||
isDraft ? 'top-7' : 'top-2',
|
||||
)}
|
||||
>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t(($) => $['roster.moreActions'], { name: agent.name })}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Skills
|
||||
|
||||
Workspace Skill management UI. This module owns the Skills list, filters, and list-level actions.
|
||||
|
||||
## Internal Modules
|
||||
|
||||
None.
|
||||
|
||||
## External Modules
|
||||
|
||||
- app/components/base/search-input
|
||||
- app/components/base/skeleton
|
||||
- app/components/base/tooltip
|
||||
- hooks/use-document-title
|
||||
- hooks/use-timestamp
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,488 @@
|
||||
import type {
|
||||
SkillResponse,
|
||||
SkillTagResponse,
|
||||
} from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { ReactNode } from 'react'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import SkillsPage from '../page'
|
||||
|
||||
type SkillsInfiniteOptions = {
|
||||
getNextPageParam: (lastPage: { has_more: boolean; page: number }) => number | undefined
|
||||
initialPageParam: number
|
||||
input: (pageParam: unknown) => {
|
||||
query: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createSkillMutationFn: vi.fn(),
|
||||
deleteSkillMutationFn: vi.fn(),
|
||||
downloadBlob: vi.fn(),
|
||||
duplicateSkillMutationFn: vi.fn(),
|
||||
exportSkillArchiveBlob: vi.fn(),
|
||||
importSkillMutationFn: vi.fn(),
|
||||
push: vi.fn(),
|
||||
queryState: {
|
||||
keyword: '',
|
||||
tag: [] as string[],
|
||||
},
|
||||
skills: [] as SkillResponse[],
|
||||
skillPages: [] as SkillResponse[][],
|
||||
skillsKey: vi.fn((_options: unknown): unknown[] => ['skills']),
|
||||
skillsQueryOptions: vi.fn((_options: SkillsInfiniteOptions) => ({})),
|
||||
tags: [] as SkillTagResponse[],
|
||||
tagsKey: vi.fn((_options: unknown): unknown[] => ['skill-tags']),
|
||||
tagsQueryOptions: vi.fn((_options: unknown) => ({})),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useDebounce: (value: unknown) => value,
|
||||
}))
|
||||
|
||||
vi.mock('nuqs', async () => {
|
||||
const React = await import('react')
|
||||
const listeners = new Map<'keyword' | 'tag', Set<() => void>>()
|
||||
const createParser = () => ({
|
||||
withDefault: () => ({
|
||||
withOptions: () => ({}),
|
||||
}),
|
||||
})
|
||||
|
||||
return {
|
||||
debounce: () => undefined,
|
||||
parseAsArrayOf: () => ({
|
||||
withDefault: () => ({}),
|
||||
}),
|
||||
parseAsString: createParser(),
|
||||
useQueryState: (name: 'keyword' | 'tag') => {
|
||||
const [value, setValue] = React.useState(mocks.queryState[name])
|
||||
React.useEffect(() => {
|
||||
const nameListeners = listeners.get(name) ?? new Set<() => void>()
|
||||
listeners.set(name, nameListeners)
|
||||
const listener = () => setValue(mocks.queryState[name])
|
||||
nameListeners.add(listener)
|
||||
|
||||
return () => {
|
||||
nameListeners.delete(listener)
|
||||
}
|
||||
}, [name])
|
||||
const setQueryValue = (nextValue: string | string[]) => {
|
||||
mocks.queryState[name] = nextValue as never
|
||||
setValue(nextValue as never)
|
||||
listeners.get(name)?.forEach((listener) => listener())
|
||||
return Promise.resolve(new URLSearchParams())
|
||||
}
|
||||
|
||||
return [value, setQueryValue] as const
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-timestamp', () => ({
|
||||
default: () => ({
|
||||
formatTime: () => '2026-07-22 10:00',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/link', () => ({
|
||||
default: ({ children, href, ...props }: { children: ReactNode; href: string }) => (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mocks.push,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadBlob: mocks.downloadBlob,
|
||||
}))
|
||||
|
||||
vi.mock('../client', () => ({
|
||||
fetchSkillArchiveBlob: mocks.exportSkillArchiveBlob,
|
||||
uploadSkillFile: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
workspaces: {
|
||||
current: {
|
||||
skills: {
|
||||
get: {
|
||||
key: mocks.skillsKey,
|
||||
infiniteOptions: mocks.skillsQueryOptions,
|
||||
},
|
||||
post: {
|
||||
mutationOptions: () => ({ mutationFn: mocks.createSkillMutationFn }),
|
||||
},
|
||||
import: {
|
||||
post: {
|
||||
mutationOptions: () => ({ mutationFn: mocks.importSkillMutationFn }),
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
get: {
|
||||
key: mocks.tagsKey,
|
||||
queryOptions: mocks.tagsQueryOptions,
|
||||
},
|
||||
},
|
||||
bySkillId: {
|
||||
delete: {
|
||||
mutationOptions: () => ({ mutationFn: mocks.deleteSkillMutationFn }),
|
||||
},
|
||||
duplicate: {
|
||||
post: {
|
||||
mutationOptions: () => ({ mutationFn: mocks.duplicateSkillMutationFn }),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
function createSkill(overrides: Partial<SkillResponse> = {}): SkillResponse {
|
||||
return {
|
||||
id: 'skill-1',
|
||||
name: 'refund-approval',
|
||||
display_name: 'Refund approval',
|
||||
icon: '💳',
|
||||
description: 'Handle refund requests.',
|
||||
tags: ['support'],
|
||||
visibility: 'workspace',
|
||||
latest_published_version_id: 'version-1',
|
||||
reference_count: 2,
|
||||
created_at: 1784631405,
|
||||
updated_at: 1784638487,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function renderSkillsPage() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
mutations: { retry: false },
|
||||
queries: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SkillsPage />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('SkillsPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.queryState.keyword = ''
|
||||
mocks.queryState.tag = []
|
||||
mocks.skills = [createSkill()]
|
||||
mocks.skillPages = [mocks.skills]
|
||||
mocks.tags = [
|
||||
{ count: 2, tag: 'support' },
|
||||
{ count: 1, tag: 'sales' },
|
||||
]
|
||||
mocks.skillsKey.mockImplementation((options) => ['skills', options])
|
||||
mocks.tagsKey.mockImplementation((options) => ['skill-tags', options])
|
||||
mocks.skillsQueryOptions.mockImplementation((options) => ({
|
||||
queryKey: ['skills', options],
|
||||
queryFn: async ({ pageParam }: { pageParam: unknown }) => {
|
||||
const page = Number(pageParam)
|
||||
return {
|
||||
data: mocks.skillPages[page - 1] ?? [],
|
||||
has_more: page < mocks.skillPages.length,
|
||||
page,
|
||||
total: mocks.skillPages.flat().length,
|
||||
}
|
||||
},
|
||||
getNextPageParam: options.getNextPageParam,
|
||||
initialPageParam: options.initialPageParam,
|
||||
}))
|
||||
mocks.tagsQueryOptions.mockImplementation((options) => ({
|
||||
queryKey: ['skill-tags', options],
|
||||
queryFn: async () => ({
|
||||
data: mocks.tags,
|
||||
}),
|
||||
}))
|
||||
mocks.createSkillMutationFn.mockResolvedValue(createSkill({ id: 'created-skill' }))
|
||||
mocks.importSkillMutationFn.mockResolvedValue(createSkill({ id: 'imported-skill' }))
|
||||
mocks.duplicateSkillMutationFn.mockResolvedValue(createSkill({ id: 'duplicated-skill' }))
|
||||
mocks.exportSkillArchiveBlob.mockResolvedValue(new Blob(['skill archive']))
|
||||
mocks.deleteSkillMutationFn.mockResolvedValue({
|
||||
deleted: true,
|
||||
id: 'skill-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('renders skills with tags, reference count, and detail links', async () => {
|
||||
renderSkillsPage()
|
||||
|
||||
const skillLink = await screen.findByRole('link', { name: /Refund approval/ })
|
||||
expect(skillLink).toHaveAttribute('href', '/skills/skill-1')
|
||||
expect(screen.getByText('refund-approval')).toBeInTheDocument()
|
||||
expect(screen.getByText('Handle refund requests.')).toBeInTheDocument()
|
||||
expect(screen.getByText('support')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('agentV2.skillManagement.referenceCount:{"count":2}'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('passes keyword and selected tags to the list query', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillsPage()
|
||||
|
||||
await user.type(
|
||||
await screen.findByRole('searchbox', {
|
||||
name: 'agentV2.skillManagement.searchLabel',
|
||||
}),
|
||||
'refund',
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
const queryOptions = mocks.skillsQueryOptions.mock.lastCall?.[0]
|
||||
expect(queryOptions?.input(1)).toEqual({
|
||||
query: {
|
||||
keyword: 'refund',
|
||||
limit: 20,
|
||||
page: 1,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'agentV2.skillManagement.tags' }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('support').length).toBeGreaterThan(1)
|
||||
})
|
||||
await user.click(screen.getAllByText('support').at(-1)!)
|
||||
|
||||
await waitFor(() => {
|
||||
const queryOptions = mocks.skillsQueryOptions.mock.lastCall?.[0]
|
||||
expect(queryOptions?.input(1)).toEqual({
|
||||
query: {
|
||||
keyword: 'refund',
|
||||
limit: 20,
|
||||
page: 1,
|
||||
tag: ['support'],
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('loads the next skill page when the list scrolls near the bottom', async () => {
|
||||
const firstPageSkills = Array.from({ length: 20 }, (_, index) =>
|
||||
createSkill({
|
||||
id: `skill-${index + 1}`,
|
||||
name: `skill-${index + 1}`,
|
||||
display_name: `Skill ${index + 1}`,
|
||||
}),
|
||||
)
|
||||
const nextPageSkill = createSkill({
|
||||
id: 'skill-21',
|
||||
name: 'skill-21',
|
||||
display_name: 'Skill 21',
|
||||
})
|
||||
mocks.skills = firstPageSkills
|
||||
mocks.skillPages = [firstPageSkills, [nextPageSkill]]
|
||||
|
||||
renderSkillsPage()
|
||||
|
||||
const skillList = await screen.findByRole('region', {
|
||||
name: 'agentV2.skillManagement.listLabel',
|
||||
})
|
||||
await screen.findByRole('heading', { name: 'Skill 1' })
|
||||
expect(within(skillList).getAllByRole('article')).toHaveLength(20)
|
||||
|
||||
const scrollViewport = skillList.parentElement?.parentElement
|
||||
expect(scrollViewport).not.toBeNull()
|
||||
Object.defineProperties(scrollViewport!, {
|
||||
clientHeight: { configurable: true, value: 600 },
|
||||
scrollHeight: { configurable: true, value: 1200 },
|
||||
scrollTop: { configurable: true, value: 560 },
|
||||
})
|
||||
fireEvent.scroll(scrollViewport!)
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Skill 21' })).toBeInTheDocument()
|
||||
expect(within(skillList).getAllByRole('article')).toHaveLength(21)
|
||||
expect(mocks.skillsQueryOptions.mock.lastCall?.[0].input(2)).toEqual({
|
||||
query: {
|
||||
limit: 20,
|
||||
page: 2,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('creates a placeholder skill and navigates to its detail page', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillsPage()
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'agentV2.skillManagement.create' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.createSkillMutationFn).toHaveBeenCalledWith(
|
||||
{
|
||||
body: {},
|
||||
},
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
expect(toast.success).toHaveBeenCalledWith('agentV2.skillManagement.createSuccess')
|
||||
expect(mocks.push).toHaveBeenCalledWith('/skills/created-skill')
|
||||
})
|
||||
|
||||
it('imports a package file and navigates to the imported skill', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = renderSkillsPage()
|
||||
|
||||
const fileInput = container.querySelector<HTMLInputElement>('input[type="file"]')
|
||||
expect(fileInput).not.toBeNull()
|
||||
const file = new File(['skill'], 'refund.skill', { type: 'application/zip' })
|
||||
|
||||
await user.upload(fileInput!, file)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.importSkillMutationFn).toHaveBeenCalledWith(
|
||||
{
|
||||
body: {
|
||||
file,
|
||||
},
|
||||
},
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
expect(toast.success).toHaveBeenCalledWith('agentV2.skillManagement.importSuccess')
|
||||
expect(mocks.push).toHaveBeenCalledWith('/skills/imported-skill')
|
||||
})
|
||||
|
||||
it('duplicates a skill from the card action menu', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillsPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'agentV2.skillManagement.moreActions:{"name":"Refund approval"}',
|
||||
}),
|
||||
)
|
||||
await user.click(await screen.findByText('common.operation.duplicate'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.duplicateSkillMutationFn).toHaveBeenCalledWith(
|
||||
{
|
||||
params: {
|
||||
skill_id: 'skill-1',
|
||||
},
|
||||
},
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
expect(toast.success).toHaveBeenCalledWith('agentV2.skillManagement.duplicateSuccess')
|
||||
})
|
||||
|
||||
it('exports a published skill from the card action menu', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillsPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'agentV2.skillManagement.moreActions:{"name":"Refund approval"}',
|
||||
}),
|
||||
)
|
||||
await user.click(await screen.findByText('common.operation.export'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.exportSkillArchiveBlob).toHaveBeenCalledWith('skill-1')
|
||||
})
|
||||
expect(mocks.downloadBlob).toHaveBeenCalledWith({
|
||||
data: expect.any(Blob),
|
||||
fileName: 'refund-approval.zip',
|
||||
})
|
||||
})
|
||||
|
||||
it('does not show export for an unpublished skill', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skills = [createSkill({ latest_published_version_id: null })]
|
||||
mocks.skillPages = [mocks.skills]
|
||||
renderSkillsPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'agentV2.skillManagement.moreActions:{"name":"Refund approval"}',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(screen.queryByText('common.operation.export')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('confirms deletion with the skill name and refreshes list data', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillsPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'agentV2.skillManagement.moreActions:{"name":"Refund approval"}',
|
||||
}),
|
||||
)
|
||||
await user.click(await screen.findByText('common.operation.delete'))
|
||||
const dialog = await screen.findByRole('alertdialog')
|
||||
|
||||
expect(
|
||||
within(dialog).getByText(
|
||||
'agentV2.skillManagement.deleteDialog.referencedDescription:{"count":2}',
|
||||
),
|
||||
).toBeInTheDocument()
|
||||
|
||||
await user.click(within(dialog).getByRole('button', { name: 'common.operation.delete' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.deleteSkillMutationFn).toHaveBeenCalledWith(
|
||||
{
|
||||
body: {
|
||||
confirmation_name: 'refund-approval',
|
||||
},
|
||||
params: {
|
||||
skill_id: 'skill-1',
|
||||
},
|
||||
},
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
expect(toast.success).toHaveBeenCalledWith('agentV2.skillManagement.deleteSuccess')
|
||||
})
|
||||
|
||||
it('shows the empty-search state without create or import actions', async () => {
|
||||
mocks.queryState.keyword = 'missing'
|
||||
mocks.skills = []
|
||||
mocks.skillPages = [[]]
|
||||
|
||||
renderSkillsPage()
|
||||
|
||||
expect(await screen.findByText('agentV2.skillManagement.emptySearch')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByText('agentV2.skillManagement.emptyAction.createTitle'),
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByText('agentV2.skillManagement.emptyAction.importTitle'),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,195 @@
|
||||
import type {
|
||||
SkillAssistAttachmentPayload,
|
||||
SkillFileUploadResponse,
|
||||
} from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type {
|
||||
DefaultModel,
|
||||
FormValue,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
// oxlint-disable-next-line no-restricted-imports
|
||||
import type { IOnCompleted, IOnData, IOnError } from '@/service/base'
|
||||
// oxlint-disable-next-line no-restricted-imports
|
||||
import { get, post, ssePost, upload } from '@/service/base'
|
||||
|
||||
function parseSkillUploadErrorMessage(message: string) {
|
||||
const trimmedMessage = message.trim()
|
||||
if (!trimmedMessage.startsWith('{')) return trimmedMessage
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(trimmedMessage)
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const parsedMessage = (parsed as Record<string, unknown>).message
|
||||
if (typeof parsedMessage === 'string' && parsedMessage.trim()) return parsedMessage.trim()
|
||||
}
|
||||
} catch {
|
||||
return trimmedMessage
|
||||
}
|
||||
|
||||
return trimmedMessage
|
||||
}
|
||||
|
||||
function readSkillUploadErrorMessage(
|
||||
error: unknown,
|
||||
visited = new Set<unknown>(),
|
||||
): string | undefined {
|
||||
if (!error || visited.has(error)) return undefined
|
||||
if (typeof error === 'string') return parseSkillUploadErrorMessage(error)
|
||||
if (typeof error !== 'object') return undefined
|
||||
|
||||
visited.add(error)
|
||||
const record = error as Record<string, unknown>
|
||||
|
||||
for (const key of ['data', 'body', 'error', 'cause', 'response']) {
|
||||
const nestedMessage = readSkillUploadErrorMessage(record[key], visited)
|
||||
if (nestedMessage) return nestedMessage
|
||||
}
|
||||
|
||||
const message = record.message
|
||||
if (typeof message === 'string' && message.trim()) return parseSkillUploadErrorMessage(message)
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function getSkillUploadResponseErrorMessage(response: Response) {
|
||||
try {
|
||||
const data: unknown = await response.clone().json()
|
||||
return readSkillUploadErrorMessage(data)
|
||||
} catch {
|
||||
try {
|
||||
const text = await response.clone().text()
|
||||
if (text.trim()) return parseSkillUploadErrorMessage(text)
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadSkillFile(
|
||||
file: File,
|
||||
options?: {
|
||||
onProgress?: (progress: number) => void
|
||||
},
|
||||
) {
|
||||
const body = new FormData()
|
||||
body.append('file', file)
|
||||
|
||||
try {
|
||||
if (options?.onProgress) {
|
||||
const onProgress = (event: ProgressEvent) => {
|
||||
if (!event.lengthComputable) return
|
||||
|
||||
options.onProgress?.(Math.floor((event.loaded / event.total) * 100))
|
||||
}
|
||||
|
||||
const response = await upload(
|
||||
{
|
||||
xhr: new XMLHttpRequest(),
|
||||
data: body,
|
||||
onprogress: onProgress,
|
||||
},
|
||||
false,
|
||||
'/workspaces/current/skills/files/upload',
|
||||
)
|
||||
|
||||
return response as SkillFileUploadResponse
|
||||
}
|
||||
|
||||
return await post<SkillFileUploadResponse>(
|
||||
'/workspaces/current/skills/files/upload',
|
||||
{ body },
|
||||
{
|
||||
bodyStringify: false,
|
||||
deleteContentType: true,
|
||||
silent: true,
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Response
|
||||
? await getSkillUploadResponseErrorMessage(error)
|
||||
: readSkillUploadErrorMessage(error)
|
||||
|
||||
if (message) {
|
||||
const normalizedError = new Error(message)
|
||||
normalizedError.cause = error
|
||||
throw normalizedError
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchSkillFileBlob({
|
||||
download = false,
|
||||
path,
|
||||
skillId,
|
||||
versionId,
|
||||
}: {
|
||||
download?: boolean
|
||||
path: string
|
||||
skillId: string
|
||||
versionId: string | null
|
||||
}) {
|
||||
const params = new URLSearchParams({ path })
|
||||
if (versionId) params.set('version_id', versionId)
|
||||
if (download) params.set('download', '1')
|
||||
|
||||
const response = await get<Response>(
|
||||
`/workspaces/current/skills/${encodeURIComponent(skillId)}/files/content?${params.toString()}`,
|
||||
{},
|
||||
{ needAllResponseContent: true },
|
||||
)
|
||||
return response.blob()
|
||||
}
|
||||
|
||||
export async function fetchSkillArchiveBlob(skillId: string) {
|
||||
const response = await get<Response>(
|
||||
`/workspaces/current/skills/${encodeURIComponent(skillId)}/export`,
|
||||
{},
|
||||
{ needAllResponseContent: true },
|
||||
)
|
||||
return response.blob()
|
||||
}
|
||||
|
||||
export function sendSkillAssistMessage({
|
||||
attachments,
|
||||
getAbortController,
|
||||
message,
|
||||
model,
|
||||
onCompleted,
|
||||
onData,
|
||||
onError,
|
||||
onUnhandledEvent,
|
||||
skillId,
|
||||
targetPath,
|
||||
}: {
|
||||
attachments?: SkillAssistAttachmentPayload[]
|
||||
getAbortController?: (abortController: AbortController) => void
|
||||
message: string
|
||||
model?: DefaultModel & {
|
||||
model_settings?: FormValue
|
||||
}
|
||||
onCompleted?: IOnCompleted
|
||||
onData?: IOnData
|
||||
onError?: IOnError
|
||||
onUnhandledEvent?: (event: Record<string, unknown>) => void
|
||||
skillId: string
|
||||
targetPath?: string
|
||||
}) {
|
||||
return ssePost(
|
||||
`/workspaces/current/skills/${encodeURIComponent(skillId)}/assist/messages`,
|
||||
{
|
||||
body: {
|
||||
attachments,
|
||||
message,
|
||||
model,
|
||||
target_path: targetPath,
|
||||
},
|
||||
},
|
||||
{
|
||||
getAbortController,
|
||||
onCompleted,
|
||||
onData,
|
||||
onError,
|
||||
onUnhandledEvent,
|
||||
},
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,759 @@
|
||||
'use client'
|
||||
|
||||
import type { SkillResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { UIEvent } from 'react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import {
|
||||
ScrollAreaContent,
|
||||
ScrollAreaRoot,
|
||||
ScrollAreaScrollbar,
|
||||
ScrollAreaThumb,
|
||||
ScrollAreaViewport,
|
||||
} from '@langgenius/dify-ui/scroll-area'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SearchInput } from '@/app/components/base/search-input'
|
||||
import { SkeletonRectangle } from '@/app/components/base/skeleton'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import useTimestamp from '@/hooks/use-timestamp'
|
||||
import Link from '@/next/link'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { downloadBlob } from '@/utils/download'
|
||||
import { fetchSkillArchiveBlob } from './client'
|
||||
import { skillKeywordQueryParser, skillQueryParamNames, skillTagQueryParser } from './query-params'
|
||||
|
||||
const placeholderCardIds = Array.from(
|
||||
{ length: 16 },
|
||||
(_, index) => `skill-placeholder-card-${index}`,
|
||||
)
|
||||
const skeletonRows = ['primary', 'secondary', 'tertiary'] as const
|
||||
const SKILLS_PAGE_SIZE = 20
|
||||
|
||||
function skillsListQueryKey() {
|
||||
return consoleQuery.workspaces.current.skills.get.key({ type: 'query' })
|
||||
}
|
||||
|
||||
function SkillIcon({ icon }: { icon?: string }) {
|
||||
return (
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] border-[0.5px] border-divider-regular bg-background-default-dodge">
|
||||
{icon ? (
|
||||
<span className="system-lg-medium text-text-secondary">{icon}</span>
|
||||
) : (
|
||||
<span aria-hidden className="i-ri-box-3-line size-5 text-text-tertiary" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SkillTagBadge({ tag }: { tag: string }) {
|
||||
return (
|
||||
<span className="flex min-w-4 shrink-0 items-center justify-center rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1.5 py-0.5 system-2xs-medium-uppercase text-text-tertiary">
|
||||
<span className="max-w-28 truncate">{tag}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function SkillCardSkeleton() {
|
||||
return (
|
||||
<>
|
||||
{skeletonRows.map((row) => (
|
||||
<div
|
||||
key={row}
|
||||
className="relative h-42 rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg shadow-xs shadow-shadow-shadow-3"
|
||||
>
|
||||
<div className="flex items-center gap-3 px-4 pt-4 pb-2">
|
||||
<SkeletonRectangle className="my-0 size-10 shrink-0 rounded-[10px] opacity-20" />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<SkeletonRectangle className="my-0 h-3 w-36 max-w-full rounded-md opacity-20" />
|
||||
<SkeletonRectangle className="my-0 h-2 w-24 max-w-full rounded-md opacity-12" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-1">
|
||||
<SkeletonRectangle className="my-0 h-2 w-full rounded-md opacity-12" />
|
||||
<SkeletonRectangle className="my-0 mt-2 h-2 w-3/4 rounded-md opacity-10" />
|
||||
</div>
|
||||
<div className="flex gap-1 px-4 pt-2">
|
||||
<SkeletonRectangle className="my-0 h-5 w-14 rounded-md opacity-12" />
|
||||
<SkeletonRectangle className="my-0 h-5 w-20 rounded-md opacity-10" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function SkillPlaceholderState({
|
||||
creating,
|
||||
importing,
|
||||
isEmptySearch,
|
||||
onCreate,
|
||||
onImport,
|
||||
title,
|
||||
}: {
|
||||
creating?: boolean
|
||||
importing?: boolean
|
||||
isEmptySearch?: boolean
|
||||
onCreate?: () => void
|
||||
onImport?: () => void
|
||||
title: string
|
||||
}) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="skill-placeholder-title"
|
||||
className="relative col-span-full min-h-[calc(100vh-142px)] overflow-hidden"
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-0 grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] grid-rows-4 gap-3">
|
||||
{placeholderCardIds.map((id) => (
|
||||
<div key={id} className="rounded-xl bg-background-default-lighter opacity-75" />
|
||||
))}
|
||||
</div>
|
||||
<div className="pointer-events-none absolute inset-0 bg-linear-to-b from-background-body/0 to-background-body" />
|
||||
<div className="absolute inset-0 flex items-center justify-center overflow-hidden p-2">
|
||||
<div className="flex w-[420px] max-w-full flex-col items-center gap-3">
|
||||
<div className="flex size-14 items-center justify-center rounded-[10px]">
|
||||
<div className="flex size-full min-w-px items-center justify-center overflow-hidden rounded-xl border border-dashed border-divider-regular bg-components-card-bg p-1 backdrop-blur-md">
|
||||
<span aria-hidden className="i-ri-box-3-line size-6 text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
<h2
|
||||
id="skill-placeholder-title"
|
||||
className="system-sm-regular whitespace-nowrap text-text-tertiary"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{!isEmptySearch && (
|
||||
<div className="mt-2 flex w-full flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={creating || importing}
|
||||
className="flex h-11 w-full cursor-pointer items-center gap-3 rounded-xl bg-components-card-bg px-4 text-left shadow-xs outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={onCreate}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'size-4 shrink-0 text-text-tertiary',
|
||||
creating ? 'i-ri-loader-4-line animate-spin' : 'i-ri-sparkling-2-line',
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate system-sm-medium text-text-secondary">
|
||||
{t(($) => $['skillManagement.emptyAction.createTitle'])}
|
||||
</span>
|
||||
<span className="block truncate system-xs-regular text-text-tertiary">
|
||||
{t(($) => $['skillManagement.emptyAction.createDescription'])}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={creating || importing}
|
||||
className="flex h-11 w-full cursor-pointer items-center gap-3 rounded-xl bg-components-card-bg px-4 text-left shadow-xs outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={onImport}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'size-4 shrink-0 text-text-tertiary',
|
||||
importing ? 'i-ri-loader-4-line animate-spin' : 'i-ri-upload-line',
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate system-sm-medium text-text-secondary">
|
||||
{t(($) => $['skillManagement.emptyAction.importTitle'])}
|
||||
</span>
|
||||
<span className="block truncate system-xs-regular text-text-tertiary">
|
||||
{t(($) => $['skillManagement.emptyAction.importDescription'])}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function DeleteSkillDialog({
|
||||
open,
|
||||
skill,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean
|
||||
skill: SkillResponse
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const queryClient = useQueryClient()
|
||||
const deleteMutation = useMutation(
|
||||
consoleQuery.workspaces.current.skills.bySkillId.delete.mutationOptions(),
|
||||
)
|
||||
const referenceCount = skill.reference_count ?? 0
|
||||
const description =
|
||||
referenceCount > 0
|
||||
? t(($) => $['skillManagement.deleteDialog.referencedDescription'], {
|
||||
count: referenceCount,
|
||||
})
|
||||
: t(($) => $['skillManagement.deleteDialog.description'])
|
||||
|
||||
const handleDelete = () => {
|
||||
if (deleteMutation.isPending) return
|
||||
|
||||
deleteMutation.mutate(
|
||||
{
|
||||
params: {
|
||||
skill_id: skill.id,
|
||||
},
|
||||
body: {
|
||||
confirmation_name: skill.name,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t(($) => $['skillManagement.deleteSuccess']))
|
||||
void queryClient.invalidateQueries({ queryKey: skillsListQueryKey() })
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: consoleQuery.workspaces.current.skills.tags.get.key({ type: 'query' }),
|
||||
})
|
||||
onOpenChange(false)
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t(($) => $['skillManagement.deleteFailed']))
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent className="p-6">
|
||||
<AlertDialogTitle className="truncate title-2xl-semi-bold text-text-primary">
|
||||
{t(($) => $['skillManagement.deleteDialog.title'], { name: skill.display_name })}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="mt-2 system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
|
||||
{description}
|
||||
</AlertDialogDescription>
|
||||
<AlertDialogActions className="p-0 pt-6">
|
||||
<AlertDialogCancelButton disabled={deleteMutation.isPending}>
|
||||
{tCommon(($) => $['operation.cancel'])}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton
|
||||
tone="destructive"
|
||||
loading={deleteMutation.isPending}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{tCommon(($) => $['operation.delete'])}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
||||
function SkillCard({ skill }: { skill: SkillResponse }) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const { formatTime } = useTimestamp()
|
||||
const queryClient = useQueryClient()
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false)
|
||||
const duplicateMutation = useMutation(
|
||||
consoleQuery.workspaces.current.skills.bySkillId.duplicate.post.mutationOptions(),
|
||||
)
|
||||
const exportMutation = useMutation({
|
||||
mutationFn: () => fetchSkillArchiveBlob(skill.id),
|
||||
onSuccess: (blob) => {
|
||||
downloadBlob({ data: blob, fileName: `${skill.name}.zip` })
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(tCommon(($) => $['operation.downloadFailed']))
|
||||
},
|
||||
})
|
||||
const tags = skill.tags ?? []
|
||||
const isDraft = !skill.latest_published_version_id
|
||||
const updatedAt = formatTime(
|
||||
skill.updated_at,
|
||||
t(($) => $['skillManagement.dateTimeFormat']),
|
||||
)
|
||||
|
||||
const handleDuplicate = () => {
|
||||
if (duplicateMutation.isPending) return
|
||||
|
||||
duplicateMutation.mutate(
|
||||
{
|
||||
params: {
|
||||
skill_id: skill.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t(($) => $['skillManagement.duplicateSuccess']))
|
||||
void queryClient.invalidateQueries({ queryKey: skillsListQueryKey() })
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: consoleQuery.workspaces.current.skills.tags.get.key({ type: 'query' }),
|
||||
})
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t(($) => $['skillManagement.duplicateFailed']))
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const handleExport = () => {
|
||||
if (exportMutation.isPending) return
|
||||
|
||||
exportMutation.mutate()
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="group relative col-span-1 h-42 min-w-0 overflow-hidden rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs shadow-shadow-shadow-3 transition-shadow duration-200 ease-in-out hover:shadow-lg">
|
||||
<div className="flex h-full min-w-0 flex-col">
|
||||
<Link
|
||||
href={`/skills/${skill.id}`}
|
||||
className="block min-w-0 shrink-0 cursor-pointer outline-hidden"
|
||||
>
|
||||
<div className="flex items-center gap-3 px-4 pt-4 pb-2">
|
||||
<SkillIcon icon={skill.icon} />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 py-px">
|
||||
<h2 className="truncate system-md-semibold text-text-secondary">
|
||||
{skill.display_name}
|
||||
</h2>
|
||||
<p className="truncate system-xs-regular text-text-tertiary">{skill.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-1 system-xs-regular text-text-tertiary">
|
||||
<div className="line-clamp-2 min-h-8">{skill.description}</div>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="relative h-6 shrink-0 px-3">
|
||||
{tags.length > 0 && (
|
||||
<div className="flex min-w-0 gap-1 overflow-hidden p-1">
|
||||
{tags.slice(0, 4).map((tag) => (
|
||||
<SkillTagBadge key={tag} tag={tag} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="pointer-events-none absolute top-0 right-0 bottom-0 w-14 bg-linear-to-r from-components-card-bg-transparent to-components-card-bg" />
|
||||
</div>
|
||||
<div className="flex min-w-0 shrink-0 items-center px-4 pt-2 pb-3 system-xs-regular text-text-tertiary">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1">
|
||||
<span className="shrink-0">
|
||||
{t(($) => $['skillManagement.referenceCount'], {
|
||||
count: skill.reference_count ?? 0,
|
||||
})}
|
||||
</span>
|
||||
<span aria-hidden className="shrink-0 text-text-quaternary">
|
||||
·
|
||||
</span>
|
||||
<span className="min-w-0 truncate">
|
||||
{isDraft
|
||||
? t(($) => $['skillManagement.editedAt'], { time: updatedAt })
|
||||
: t(($) => $['skillManagement.publishedAt'], { time: updatedAt })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isDraft && (
|
||||
<div className="absolute top-[-0.5px] right-0 flex h-5 items-start overflow-hidden">
|
||||
<div className="h-5 w-3 bg-background-section-burn [clip-path:polygon(0_0,100%_0,100%_100%)]" />
|
||||
<div className="flex h-5 items-center bg-background-section-burn pr-2 pl-0.5 system-2xs-medium-uppercase text-text-tertiary">
|
||||
{t(($) => $['skillManagement.draft'])}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute right-2 z-20 flex items-center overflow-hidden rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 opacity-0 shadow-lg backdrop-blur-xs transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 has-data-popup-open:pointer-events-auto has-data-popup-open:opacity-100',
|
||||
isDraft ? 'top-7' : 'top-2',
|
||||
)}
|
||||
>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t(($) => $['skillManagement.moreActions'], { name: skill.display_name })}
|
||||
className="flex size-8 cursor-pointer items-center justify-center rounded-lg p-1.5 hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden data-popup-open:bg-state-base-hover"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{t(($) => $['skillManagement.moreActions'], { name: skill.display_name })}
|
||||
</span>
|
||||
<span aria-hidden className="i-ri-more-fill size-4.5 text-text-tertiary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-40">
|
||||
<DropdownMenuItem className="gap-2" onClick={handleDuplicate}>
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-ri-file-copy-line size-4 shrink-0 text-text-tertiary"
|
||||
/>
|
||||
<span>{tCommon(($) => $['operation.duplicate'])}</span>
|
||||
</DropdownMenuItem>
|
||||
{skill.latest_published_version_id && (
|
||||
<DropdownMenuItem className="gap-2" onClick={handleExport}>
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-ri-download-2-line size-4 shrink-0 text-text-tertiary"
|
||||
/>
|
||||
<span>{tCommon(($) => $['operation.export'])}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
className="gap-2"
|
||||
onClick={() => setIsDeleteOpen(true)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
|
||||
<span>{tCommon(($) => $['operation.delete'])}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<DeleteSkillDialog skill={skill} open={isDeleteOpen} onOpenChange={setIsDeleteOpen} />
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function SkillTagFilter({ tags }: { tags: string[] }) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const [selectedTags, setSelectedTags] = useQueryState(
|
||||
skillQueryParamNames.tag,
|
||||
skillTagQueryParser,
|
||||
)
|
||||
const selectedTagSet = new Set(selectedTags)
|
||||
|
||||
const toggleTag = (tag: string) => {
|
||||
const nextTags = selectedTagSet.has(tag)
|
||||
? selectedTags.filter((item) => item !== tag)
|
||||
: [...selectedTags, tag]
|
||||
|
||||
void setSelectedTags(nextTags)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
'flex h-8 shrink-0 cursor-pointer items-center gap-1 rounded-lg bg-components-input-bg-normal px-2 py-1 system-sm-regular text-text-tertiary hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
|
||||
selectedTags.length > 0 && 'text-text-secondary',
|
||||
)}
|
||||
>
|
||||
<span>{t(($) => $['skillManagement.tags'])}</span>
|
||||
{selectedTags.length > 0 && (
|
||||
<span className="flex min-w-4 shrink-0 items-center justify-center rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase text-text-tertiary tabular-nums">
|
||||
{selectedTags.length}
|
||||
</span>
|
||||
)}
|
||||
<span aria-hidden className="i-ri-arrow-down-s-line size-4 text-text-tertiary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent placement="bottom-start" sideOffset={4} popupClassName="w-52">
|
||||
{tags.length === 0 ? (
|
||||
<DropdownMenuItem disabled>{t(($) => $['skillManagement.noTags'])}</DropdownMenuItem>
|
||||
) : (
|
||||
tags.map((tag) => (
|
||||
<DropdownMenuItem key={tag} className="gap-2" onClick={() => toggleTag(tag)}>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'i-ri-check-line size-4 shrink-0',
|
||||
selectedTagSet.has(tag) ? 'text-text-accent' : 'text-transparent',
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">{tag}</span>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
{selectedTags.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="gap-2" onClick={() => setSelectedTags([])}>
|
||||
<span aria-hidden className="i-ri-close-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span>{t(($) => $['skillManagement.clearTags'])}</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function SkillsToolbar({
|
||||
creating,
|
||||
importing,
|
||||
onCreate,
|
||||
onImport,
|
||||
tags,
|
||||
}: {
|
||||
creating: boolean
|
||||
importing: boolean
|
||||
onCreate: () => void
|
||||
onImport: () => void
|
||||
tags: string[]
|
||||
}) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const [keyword, setKeyword] = useQueryState(skillQueryParamNames.keyword, skillKeywordQueryParser)
|
||||
const isMutating = creating || importing
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<SkillTagFilter tags={tags} />
|
||||
<SearchInput
|
||||
aria-label={t(($) => $['skillManagement.searchLabel'])}
|
||||
className="h-8 w-50 min-w-0 shrink"
|
||||
placeholder={t(($) => $['skillManagement.searchPlaceholder'])}
|
||||
value={keyword}
|
||||
onValueChange={(value) => {
|
||||
void setKeyword(value)
|
||||
}}
|
||||
/>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
className="h-8 gap-1 px-3"
|
||||
disabled={isMutating}
|
||||
loading={importing}
|
||||
onClick={onImport}
|
||||
>
|
||||
<span aria-hidden className="i-ri-upload-line size-4" />
|
||||
<span className="px-0.5 system-sm-medium">{t(($) => $['skillManagement.import'])}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="h-8 gap-0.5 px-3"
|
||||
disabled={isMutating}
|
||||
loading={creating}
|
||||
onClick={onCreate}
|
||||
>
|
||||
<span aria-hidden className="i-ri-add-line size-4" />
|
||||
<span className="px-0.5 system-sm-medium">{t(($) => $['skillManagement.create'])}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SkillGrid({
|
||||
creating,
|
||||
importing,
|
||||
isEmptySearch,
|
||||
isError,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
isPending,
|
||||
onCreate,
|
||||
onImport,
|
||||
skills,
|
||||
}: {
|
||||
creating: boolean
|
||||
importing: boolean
|
||||
isEmptySearch: boolean
|
||||
isError: boolean
|
||||
isFetching: boolean
|
||||
isFetchingNextPage: boolean
|
||||
isPending: boolean
|
||||
onCreate: () => void
|
||||
onImport: () => void
|
||||
skills: SkillResponse[]
|
||||
}) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={t(($) => $['skillManagement.listLabel'])}
|
||||
className="grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] gap-2.5"
|
||||
aria-busy={isFetching || undefined}
|
||||
>
|
||||
{isPending && <SkillCardSkeleton />}
|
||||
{!isPending && isError && (
|
||||
<SkillPlaceholderState title={t(($) => $['skillManagement.loadingError'])} />
|
||||
)}
|
||||
{!isPending && !isError && skills.length === 0 && (
|
||||
<SkillPlaceholderState
|
||||
creating={creating}
|
||||
importing={importing}
|
||||
isEmptySearch={isEmptySearch}
|
||||
onCreate={onCreate}
|
||||
onImport={onImport}
|
||||
title={
|
||||
isEmptySearch
|
||||
? t(($) => $['skillManagement.emptySearch'])
|
||||
: t(($) => $['skillManagement.empty'])
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!isPending && !isError && skills.map((skill) => <SkillCard key={skill.id} skill={skill} />)}
|
||||
{!isPending && !isError && isFetchingNextPage && <SkillCardSkeleton />}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SkillsPage() {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
const importInputRef = useRef<HTMLInputElement>(null)
|
||||
const [keyword] = useQueryState(skillQueryParamNames.keyword, skillKeywordQueryParser)
|
||||
const [selectedTags] = useQueryState(skillQueryParamNames.tag, skillTagQueryParser)
|
||||
const debouncedKeyword = useDebounce(keyword.trim(), { wait: 300 })
|
||||
const createMutation = useMutation(consoleQuery.workspaces.current.skills.post.mutationOptions())
|
||||
const importMutation = useMutation(
|
||||
consoleQuery.workspaces.current.skills.import.post.mutationOptions(),
|
||||
)
|
||||
const skillsQuery = useInfiniteQuery({
|
||||
...consoleQuery.workspaces.current.skills.get.infiniteOptions({
|
||||
input: (pageParam) => ({
|
||||
query: {
|
||||
limit: SKILLS_PAGE_SIZE,
|
||||
page: Number(pageParam),
|
||||
...(debouncedKeyword ? { keyword: debouncedKeyword } : {}),
|
||||
...(selectedTags.length > 0 ? { tag: selectedTags } : {}),
|
||||
},
|
||||
}),
|
||||
getNextPageParam: (lastPage) => (lastPage.has_more ? (lastPage.page ?? 1) + 1 : undefined),
|
||||
initialPageParam: 1,
|
||||
}),
|
||||
})
|
||||
const tagsQuery = useQuery(consoleQuery.workspaces.current.skills.tags.get.queryOptions())
|
||||
const skills = skillsQuery.data?.pages.flatMap((page) => page.data ?? []) ?? []
|
||||
const tags = (tagsQuery.data?.data ?? []).map((tag) => tag.tag)
|
||||
|
||||
useDocumentTitle(t(($) => $['skillManagement.title']))
|
||||
|
||||
const invalidateSkills = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: skillsListQueryKey() })
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: consoleQuery.workspaces.current.skills.tags.get.key({ type: 'query' }),
|
||||
})
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
if (createMutation.isPending) return
|
||||
|
||||
createMutation.mutate(
|
||||
{
|
||||
body: {},
|
||||
},
|
||||
{
|
||||
onSuccess: (skill) => {
|
||||
toast.success(t(($) => $['skillManagement.createSuccess']))
|
||||
invalidateSkills()
|
||||
router.push(`/skills/${skill.id}`)
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t(($) => $['skillManagement.createFailed']))
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const handleFileChange = (file: File | undefined) => {
|
||||
if (!file || importMutation.isPending) return
|
||||
|
||||
importMutation.mutate(
|
||||
{
|
||||
body: {
|
||||
file,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: (skill) => {
|
||||
toast.success(t(($) => $['skillManagement.importSuccess']))
|
||||
invalidateSkills()
|
||||
router.push(`/skills/${skill.id}`)
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t(($) => $['skillManagement.importFailed']))
|
||||
},
|
||||
onSettled: () => {
|
||||
if (importInputRef.current) importInputRef.current.value = ''
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const handleListScroll = (event: UIEvent<HTMLDivElement>) => {
|
||||
const target = event.currentTarget
|
||||
const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight
|
||||
if (scrollBottom < 80 && skillsQuery.hasNextPage && !skillsQuery.isFetchingNextPage)
|
||||
void skillsQuery.fetchNextPage()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-0 min-w-0 grow flex-col overflow-hidden bg-background-body">
|
||||
<div className="shrink-0 bg-background-body px-8 pt-4 pb-2">
|
||||
<div className="flex h-6 min-w-0 items-center justify-between gap-4">
|
||||
<h1 className="min-w-0 flex-1 truncate text-[18px]/[21.6px] font-semibold text-text-primary">
|
||||
{t(($) => $['skillManagement.title'])}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="mt-3.5">
|
||||
<input
|
||||
ref={importInputRef}
|
||||
type="file"
|
||||
accept=".zip,.skill,application/zip"
|
||||
className="hidden"
|
||||
onChange={(event) => handleFileChange(event.currentTarget.files?.[0])}
|
||||
/>
|
||||
<SkillsToolbar
|
||||
creating={createMutation.isPending}
|
||||
importing={importMutation.isPending}
|
||||
onCreate={handleCreate}
|
||||
onImport={() => importInputRef.current?.click()}
|
||||
tags={tags}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<ScrollAreaRoot className="relative h-full min-h-0 min-w-0 overflow-hidden">
|
||||
<ScrollAreaViewport
|
||||
tabIndex={-1}
|
||||
className="overscroll-contain"
|
||||
onScroll={handleListScroll}
|
||||
>
|
||||
<ScrollAreaContent className="min-h-full px-8 pt-2 pb-8">
|
||||
<SkillGrid
|
||||
creating={createMutation.isPending}
|
||||
importing={importMutation.isPending}
|
||||
skills={skills}
|
||||
isEmptySearch={!!debouncedKeyword || selectedTags.length > 0}
|
||||
isError={skillsQuery.isError}
|
||||
isFetching={skillsQuery.isFetching}
|
||||
isFetchingNextPage={skillsQuery.isFetchingNextPage}
|
||||
isPending={skillsQuery.isPending}
|
||||
onCreate={handleCreate}
|
||||
onImport={() => importInputRef.current?.click()}
|
||||
/>
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { debounce, parseAsArrayOf, parseAsString } from 'nuqs'
|
||||
|
||||
export const skillQueryParamNames = {
|
||||
keyword: 'keyword',
|
||||
tag: 'tag',
|
||||
} as const
|
||||
|
||||
export const skillKeywordQueryParser = parseAsString.withDefault('').withOptions({
|
||||
limitUrlUpdates: debounce(300),
|
||||
})
|
||||
|
||||
export const skillTagQueryParser = parseAsArrayOf(parseAsString, ';').withDefault([])
|
||||
@@ -203,15 +203,22 @@
|
||||
"agentDetail.configure.rightPanel.previewTipBody": "يشغل Preview الوكيل المكتمل كما سيراه المستخدمون، مع ردود واضحة وميزات الدردشة.",
|
||||
"agentDetail.configure.rightPanel.previewTipTitle": "عاين وكيلك",
|
||||
"agentDetail.configure.skills.add": "إضافة مهارة",
|
||||
"agentDetail.configure.skills.addMenu.upload.badge": "EMBEDDED",
|
||||
"agentDetail.configure.skills.addMenu.upload.label": "Upload package",
|
||||
"agentDetail.configure.skills.addMenu.workspace.label": "From skill library",
|
||||
"agentDetail.configure.skills.detail.contentRegion": "محتوى تفاصيل المهارة",
|
||||
"agentDetail.configure.skills.detail.fileCount": "{{count}} ملفات",
|
||||
"agentDetail.configure.skills.detail.files": "الملفات",
|
||||
"agentDetail.configure.skills.empty.description": "تمنح المهارات الوكيل خبرة قابلة لإعادة الاستخدام يمكنه استدعاؤها أثناء العمل",
|
||||
"agentDetail.configure.skills.empty.title": "لا توجد مهارات بعد",
|
||||
"agentDetail.configure.skills.fromSkillLibrary": "From skill library",
|
||||
"agentDetail.configure.skills.itemType": "مهارة",
|
||||
"agentDetail.configure.skills.label": "المهارات",
|
||||
"agentDetail.configure.skills.missing": "المهارة غير موجودة",
|
||||
"agentDetail.configure.skills.moreActions": "More actions for {{name}}",
|
||||
"agentDetail.configure.skills.openInLibrary": "Open in Skill library",
|
||||
"agentDetail.configure.skills.remove": "إزالة {{name}}",
|
||||
"agentDetail.configure.skills.removeAction": "Remove",
|
||||
"agentDetail.configure.skills.richTip": "اجمع التعليمات والملفات والبرامج النصية لمهمة متكررة في Skill. أشر إليها باستخدام / في Prompt. <docLink>معرفة المزيد</docLink>\n\nفي وضع Build، يمكن للوكيل إعدادها لك.",
|
||||
"agentDetail.configure.skills.tip": "اجمع التعليمات والملفات والبرامج النصية لمهمة متكررة في Skill. أشر إليها باستخدام / في Prompt. معرفة المزيد\n\nفي وضع Build، يمكن للوكيل إعدادها لك.",
|
||||
"agentDetail.configure.skills.toggle": "تبديل المهارات",
|
||||
@@ -421,5 +428,117 @@
|
||||
"roster.sort.optionsLabel": "خيارات الفرز",
|
||||
"roster.sort.recentlyCreated": "الأحدث إنشاءً",
|
||||
"roster.updateSuccess": "تم تحديث الوكيل.",
|
||||
"roster.usageStatus.draft": "مسودة"
|
||||
"roster.usageStatus.draft": "مسودة",
|
||||
"skillManagement.clearTags": "Clear tags",
|
||||
"skillManagement.create": "Create",
|
||||
"skillManagement.createFailed": "Failed to create skill.",
|
||||
"skillManagement.createSuccess": "Skill created.",
|
||||
"skillManagement.dateTimeFormat": "MMM D, YYYY HH:mm",
|
||||
"skillManagement.deleteDialog.description": "This skill will be removed from the workspace. Agents that reference it may lose access to this capability.",
|
||||
"skillManagement.deleteDialog.title": "Delete {{name}}?",
|
||||
"skillManagement.deleteFailed": "Failed to delete skill.",
|
||||
"skillManagement.deleteSuccess": "Skill deleted.",
|
||||
"skillManagement.detail.addMetadata": "Add metadata",
|
||||
"skillManagement.detail.addMetadataDescription": "Add a frontmatter field to this Markdown file.",
|
||||
"skillManagement.detail.addTag": "Add tag",
|
||||
"skillManagement.detail.addTagDescription": "Create or bind a tag to this Skill. New tags are saved with the Skill metadata.",
|
||||
"skillManagement.detail.addTagSuccess": "Tag added.",
|
||||
"skillManagement.detail.back": "Back to Skills",
|
||||
"skillManagement.detail.cancelAddMetadata": "Cancel adding metadata",
|
||||
"skillManagement.detail.closeFileTab": "Close {{name}}",
|
||||
"skillManagement.detail.collapseSidebar": "طي الشريط الجانبي",
|
||||
"skillManagement.detail.createFile": "New file",
|
||||
"skillManagement.detail.createFileDescription": "Enter a file path relative to the skill root.",
|
||||
"skillManagement.detail.createFileMenu": "New file...",
|
||||
"skillManagement.detail.createFileSuccess": "File created.",
|
||||
"skillManagement.detail.createFolder": "New folder",
|
||||
"skillManagement.detail.createFolderDescription": "Enter a folder path relative to the skill root.",
|
||||
"skillManagement.detail.createFolderMenu": "New folder...",
|
||||
"skillManagement.detail.createFolderSuccess": "Folder created.",
|
||||
"skillManagement.detail.createdBy": "Created by {{name}}",
|
||||
"skillManagement.detail.currentDraft": "Current draft",
|
||||
"skillManagement.detail.deleteFileConfirm": "Delete this file?",
|
||||
"skillManagement.detail.deleteFileSuccess": "File deleted.",
|
||||
"skillManagement.detail.deleteVersionConfirm": "Delete this version?",
|
||||
"skillManagement.detail.deleteVersionFailed": "Failed to delete version.",
|
||||
"skillManagement.detail.deleteVersionSuccess": "Version deleted.",
|
||||
"skillManagement.detail.downloadFile": "Download file",
|
||||
"skillManagement.detail.draft": "Draft",
|
||||
"skillManagement.detail.expandSidebar": "توسيع الشريط الجانبي",
|
||||
"skillManagement.detail.fileCount": "{{count}} FILES",
|
||||
"skillManagement.detail.fileMeta": "{{type}} · {{size}} bytes",
|
||||
"skillManagement.detail.fileMissing": "File not found.",
|
||||
"skillManagement.detail.fileOperationFailed": "File operation failed.",
|
||||
"skillManagement.detail.files": "Files",
|
||||
"skillManagement.detail.latest": "Latest",
|
||||
"skillManagement.detail.loadFailed": "Failed to load skill.",
|
||||
"skillManagement.detail.markdownLiveMode": "Live preview",
|
||||
"skillManagement.detail.markdownSourceMode": "Source editor",
|
||||
"skillManagement.detail.metadataKey": "name",
|
||||
"skillManagement.detail.metadataValue": "value",
|
||||
"skillManagement.detail.moveFileSuccess": "File moved.",
|
||||
"skillManagement.detail.moveFilesSuccess": "Files moved.",
|
||||
"skillManagement.detail.noFileSelected": "Select a file to preview.",
|
||||
"skillManagement.detail.noFiles": "No files.",
|
||||
"skillManagement.detail.noSearchResults": "لا توجد ملفات مطابقة.",
|
||||
"skillManagement.detail.noVersions": "No published versions yet.",
|
||||
"skillManagement.detail.previewUnsupported": "Preview is not supported for this file.",
|
||||
"skillManagement.detail.publish": "Publish",
|
||||
"skillManagement.detail.publishFailed": "Failed to publish skill.",
|
||||
"skillManagement.detail.publishSuccess": "Skill published.",
|
||||
"skillManagement.detail.published": "Published",
|
||||
"skillManagement.detail.readonly": "Read only",
|
||||
"skillManagement.detail.referenceFiles.confirm": "Enter Confirm",
|
||||
"skillManagement.detail.referenceFiles.empty": "No files available.",
|
||||
"skillManagement.detail.referenceFiles.livePlaceholder": "Write instructions for the agent, type / to reference files",
|
||||
"skillManagement.detail.referenceFiles.navigate": "↑↓ Navigate",
|
||||
"skillManagement.detail.referenceFiles.title": "Reference files",
|
||||
"skillManagement.detail.referencedBy": "Referenced by {{count}} Apps",
|
||||
"skillManagement.detail.removeMetadata": "Delete {{name}} metadata",
|
||||
"skillManagement.detail.removeTag": "Remove {{tag}}",
|
||||
"skillManagement.detail.removeTagSuccess": "Tag removed.",
|
||||
"skillManagement.detail.renameFile": "Rename file",
|
||||
"skillManagement.detail.renameFileDescription": "Enter the new path relative to the skill root.",
|
||||
"skillManagement.detail.renameFileSuccess": "File renamed.",
|
||||
"skillManagement.detail.renameVersion": "Rename",
|
||||
"skillManagement.detail.renameVersionFailed": "Failed to rename version.",
|
||||
"skillManagement.detail.renameVersionPrompt": "Version name",
|
||||
"skillManagement.detail.renameVersionSuccess": "Version renamed.",
|
||||
"skillManagement.detail.restoreVersion": "Restore",
|
||||
"skillManagement.detail.restoreVersionFailed": "Failed to restore version.",
|
||||
"skillManagement.detail.restoreVersionSuccess": "Version restored to draft.",
|
||||
"skillManagement.detail.save": "Save",
|
||||
"skillManagement.detail.saveFailed": "Failed to save file.",
|
||||
"skillManagement.detail.saveSuccess": "File saved.",
|
||||
"skillManagement.detail.saved": "Saved",
|
||||
"skillManagement.detail.savedAt": "Saved {{time}}",
|
||||
"skillManagement.detail.saving": "Saving...",
|
||||
"skillManagement.detail.searchFiles": "البحث عن الملفات",
|
||||
"skillManagement.detail.unsavedChanges": "Unsaved changes",
|
||||
"skillManagement.detail.updateTagsFailed": "Failed to update tags.",
|
||||
"skillManagement.detail.uploadFile": "Upload file",
|
||||
"skillManagement.detail.uploadFileFailed": "File upload failed.",
|
||||
"skillManagement.detail.uploadFileSuccess": "File uploaded.",
|
||||
"skillManagement.detail.uploadFilesMenu": "Upload files...",
|
||||
"skillManagement.detail.versionMeta": "{{hash}} · {{time}}",
|
||||
"skillManagement.detail.versions": "Versions",
|
||||
"skillManagement.draft": "Draft",
|
||||
"skillManagement.duplicateFailed": "Failed to duplicate skill.",
|
||||
"skillManagement.duplicateSuccess": "Skill duplicated.",
|
||||
"skillManagement.editedAt": "Edited {{time}}",
|
||||
"skillManagement.empty": "No skills yet",
|
||||
"skillManagement.emptySearch": "No skills found",
|
||||
"skillManagement.import": "Import",
|
||||
"skillManagement.importFailed": "Failed to import skill.",
|
||||
"skillManagement.importSuccess": "Skill imported.",
|
||||
"skillManagement.listLabel": "Workspace skills",
|
||||
"skillManagement.loadingError": "Failed to load skills",
|
||||
"skillManagement.moreActions": "More actions for {{name}}",
|
||||
"skillManagement.noTags": "No tags",
|
||||
"skillManagement.publishedAt": "Published {{time}}",
|
||||
"skillManagement.referenceCount": "{{count}} Refs",
|
||||
"skillManagement.searchLabel": "Search skills",
|
||||
"skillManagement.searchPlaceholder": "Search",
|
||||
"skillManagement.tags": "Tags",
|
||||
"skillManagement.title": "Skills"
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"archives.empty.title": "لا توجد سجلات مؤرشفة",
|
||||
"archives.error.description": "حدّث الصفحة أو حاول مرة أخرى لاحقًا.",
|
||||
"archives.error.title": "تعذّر تحميل السجلات المؤرشفة",
|
||||
"archives.notice.action": "عرض السجلات المؤرشفة",
|
||||
"archives.notice.action": "فتح السجلات المؤرشفة",
|
||||
"archives.notice.description": "قد تكون بعض السجلات ضمن هذا النطاق الزمني قد أُرشفت.",
|
||||
"archives.summary.latest": "أحدث أرشيف",
|
||||
"archives.summary.months": "الأشهر المؤرشفة",
|
||||
@@ -66,6 +66,7 @@
|
||||
"filter.period.yearToDate": "السنة حتى الآن",
|
||||
"filter.sortBy": "رتب حسب:",
|
||||
"monitoring.description": "يسجل الرصد حالة تشغيل التطبيق، بما في ذلك الأداء ونشاط المستخدمين والتكاليف.",
|
||||
"retention.upgradeTip.description": "قم بالترقية للاحتفاظ بجميع السجلات التي يتم إنشاؤها بعد الترقية دون حد زمني؛ لا يمكن استعادة السجلات التي انتهت مدة الاحتفاظ بها قبل الترقية.",
|
||||
"runDetail.fileListDetail": "تفاصيل",
|
||||
"runDetail.fileListLabel": "تفاصيل الملف",
|
||||
"runDetail.testWithParams": "اختبار مع المعلمات",
|
||||
|
||||
@@ -201,6 +201,7 @@
|
||||
"mainNav.home": "الرئيسية",
|
||||
"mainNav.integrations": "التكاملات",
|
||||
"mainNav.marketplace": "سوق الإضافات",
|
||||
"mainNav.skills": "Skills",
|
||||
"mainNav.webApps.noResults": "لم يتم العثور على تطبيقات ويب",
|
||||
"mainNav.webApps.openApp": "فتح تطبيق الويب {{name}}",
|
||||
"mainNav.webApps.searchPlaceholder": "البحث في تطبيقات الويب",
|
||||
@@ -462,6 +463,7 @@
|
||||
"operation.downloading": "جارٍ التنزيل...",
|
||||
"operation.duplicate": "تكرار",
|
||||
"operation.edit": "تعديل",
|
||||
"operation.export": "تصدير",
|
||||
"operation.exporting": "جارٍ التصدير",
|
||||
"operation.fill": "ملء تلقائي",
|
||||
"operation.format": "تنسيق",
|
||||
@@ -503,6 +505,7 @@
|
||||
"operation.sure": "أنا متأكد",
|
||||
"operation.toggleFullscreen": "تبديل ملء الشاشة",
|
||||
"operation.toggleMute": "تبديل كتم الصوت",
|
||||
"operation.upload": "Upload",
|
||||
"operation.view": "عرض",
|
||||
"operation.viewDetails": "عرض التفاصيل",
|
||||
"operation.viewMore": "عرض المزيد",
|
||||
|
||||
@@ -203,15 +203,22 @@
|
||||
"agentDetail.configure.rightPanel.previewTipBody": "Preview führt den fertigen Agenten so aus, wie deine Benutzer ihn sehen, mit klaren Antworten und Chat-Funktionen.",
|
||||
"agentDetail.configure.rightPanel.previewTipTitle": "Agenten vorschauen",
|
||||
"agentDetail.configure.skills.add": "Skill hinzufügen",
|
||||
"agentDetail.configure.skills.addMenu.upload.badge": "EMBEDDED",
|
||||
"agentDetail.configure.skills.addMenu.upload.label": "Upload package",
|
||||
"agentDetail.configure.skills.addMenu.workspace.label": "From skill library",
|
||||
"agentDetail.configure.skills.detail.contentRegion": "Skill-Detailinhalt",
|
||||
"agentDetail.configure.skills.detail.fileCount": "{{count}} DATEIEN",
|
||||
"agentDetail.configure.skills.detail.files": "Dateien",
|
||||
"agentDetail.configure.skills.empty.description": "Skills geben dem Agenten wiederverwendbare Fachkenntnisse, die er bei der Arbeit nutzen kann",
|
||||
"agentDetail.configure.skills.empty.title": "Noch keine Skills",
|
||||
"agentDetail.configure.skills.fromSkillLibrary": "From skill library",
|
||||
"agentDetail.configure.skills.itemType": "Skill",
|
||||
"agentDetail.configure.skills.label": "Skills",
|
||||
"agentDetail.configure.skills.missing": "Skill nicht gefunden",
|
||||
"agentDetail.configure.skills.moreActions": "More actions for {{name}}",
|
||||
"agentDetail.configure.skills.openInLibrary": "Open in Skill library",
|
||||
"agentDetail.configure.skills.remove": "{{name}} entfernen",
|
||||
"agentDetail.configure.skills.removeAction": "Remove",
|
||||
"agentDetail.configure.skills.richTip": "Bündeln Sie Anweisungen, Dateien und Skripte für eine wiederkehrende Aufgabe in einer Skill. Verweisen Sie im Prompt mit / darauf. <docLink>Mehr erfahren</docLink>\n\nIm Build-Modus kann der Agent diese Einrichtung für Sie übernehmen.",
|
||||
"agentDetail.configure.skills.tip": "Bündeln Sie Anweisungen, Dateien und Skripte für eine wiederkehrende Aufgabe in einer Skill. Verweisen Sie im Prompt mit / darauf. Mehr erfahren\n\nIm Build-Modus kann der Agent diese Einrichtung für Sie übernehmen.",
|
||||
"agentDetail.configure.skills.toggle": "Skills umschalten",
|
||||
@@ -421,5 +428,117 @@
|
||||
"roster.sort.optionsLabel": "Sortieroptionen",
|
||||
"roster.sort.recentlyCreated": "Zuletzt erstellt",
|
||||
"roster.updateSuccess": "Agent aktualisiert.",
|
||||
"roster.usageStatus.draft": "Entwurf"
|
||||
"roster.usageStatus.draft": "Entwurf",
|
||||
"skillManagement.clearTags": "Clear tags",
|
||||
"skillManagement.create": "Create",
|
||||
"skillManagement.createFailed": "Failed to create skill.",
|
||||
"skillManagement.createSuccess": "Skill created.",
|
||||
"skillManagement.dateTimeFormat": "MMM D, YYYY HH:mm",
|
||||
"skillManagement.deleteDialog.description": "This skill will be removed from the workspace. Agents that reference it may lose access to this capability.",
|
||||
"skillManagement.deleteDialog.title": "Delete {{name}}?",
|
||||
"skillManagement.deleteFailed": "Failed to delete skill.",
|
||||
"skillManagement.deleteSuccess": "Skill deleted.",
|
||||
"skillManagement.detail.addMetadata": "Add metadata",
|
||||
"skillManagement.detail.addMetadataDescription": "Add a frontmatter field to this Markdown file.",
|
||||
"skillManagement.detail.addTag": "Add tag",
|
||||
"skillManagement.detail.addTagDescription": "Create or bind a tag to this Skill. New tags are saved with the Skill metadata.",
|
||||
"skillManagement.detail.addTagSuccess": "Tag added.",
|
||||
"skillManagement.detail.back": "Back to Skills",
|
||||
"skillManagement.detail.cancelAddMetadata": "Cancel adding metadata",
|
||||
"skillManagement.detail.closeFileTab": "Close {{name}}",
|
||||
"skillManagement.detail.collapseSidebar": "Seitenleiste einklappen",
|
||||
"skillManagement.detail.createFile": "New file",
|
||||
"skillManagement.detail.createFileDescription": "Enter a file path relative to the skill root.",
|
||||
"skillManagement.detail.createFileMenu": "New file...",
|
||||
"skillManagement.detail.createFileSuccess": "File created.",
|
||||
"skillManagement.detail.createFolder": "New folder",
|
||||
"skillManagement.detail.createFolderDescription": "Enter a folder path relative to the skill root.",
|
||||
"skillManagement.detail.createFolderMenu": "New folder...",
|
||||
"skillManagement.detail.createFolderSuccess": "Folder created.",
|
||||
"skillManagement.detail.createdBy": "Created by {{name}}",
|
||||
"skillManagement.detail.currentDraft": "Current draft",
|
||||
"skillManagement.detail.deleteFileConfirm": "Delete this file?",
|
||||
"skillManagement.detail.deleteFileSuccess": "File deleted.",
|
||||
"skillManagement.detail.deleteVersionConfirm": "Delete this version?",
|
||||
"skillManagement.detail.deleteVersionFailed": "Failed to delete version.",
|
||||
"skillManagement.detail.deleteVersionSuccess": "Version deleted.",
|
||||
"skillManagement.detail.downloadFile": "Download file",
|
||||
"skillManagement.detail.draft": "Draft",
|
||||
"skillManagement.detail.expandSidebar": "Seitenleiste ausklappen",
|
||||
"skillManagement.detail.fileCount": "{{count}} FILES",
|
||||
"skillManagement.detail.fileMeta": "{{type}} · {{size}} bytes",
|
||||
"skillManagement.detail.fileMissing": "File not found.",
|
||||
"skillManagement.detail.fileOperationFailed": "File operation failed.",
|
||||
"skillManagement.detail.files": "Files",
|
||||
"skillManagement.detail.latest": "Latest",
|
||||
"skillManagement.detail.loadFailed": "Failed to load skill.",
|
||||
"skillManagement.detail.markdownLiveMode": "Live preview",
|
||||
"skillManagement.detail.markdownSourceMode": "Source editor",
|
||||
"skillManagement.detail.metadataKey": "name",
|
||||
"skillManagement.detail.metadataValue": "value",
|
||||
"skillManagement.detail.moveFileSuccess": "File moved.",
|
||||
"skillManagement.detail.moveFilesSuccess": "Files moved.",
|
||||
"skillManagement.detail.noFileSelected": "Select a file to preview.",
|
||||
"skillManagement.detail.noFiles": "No files.",
|
||||
"skillManagement.detail.noSearchResults": "Keine passenden Dateien.",
|
||||
"skillManagement.detail.noVersions": "No published versions yet.",
|
||||
"skillManagement.detail.previewUnsupported": "Preview is not supported for this file.",
|
||||
"skillManagement.detail.publish": "Publish",
|
||||
"skillManagement.detail.publishFailed": "Failed to publish skill.",
|
||||
"skillManagement.detail.publishSuccess": "Skill published.",
|
||||
"skillManagement.detail.published": "Published",
|
||||
"skillManagement.detail.readonly": "Read only",
|
||||
"skillManagement.detail.referenceFiles.confirm": "Enter Confirm",
|
||||
"skillManagement.detail.referenceFiles.empty": "No files available.",
|
||||
"skillManagement.detail.referenceFiles.livePlaceholder": "Write instructions for the agent, type / to reference files",
|
||||
"skillManagement.detail.referenceFiles.navigate": "↑↓ Navigate",
|
||||
"skillManagement.detail.referenceFiles.title": "Reference files",
|
||||
"skillManagement.detail.referencedBy": "Referenced by {{count}} Apps",
|
||||
"skillManagement.detail.removeMetadata": "Delete {{name}} metadata",
|
||||
"skillManagement.detail.removeTag": "Remove {{tag}}",
|
||||
"skillManagement.detail.removeTagSuccess": "Tag removed.",
|
||||
"skillManagement.detail.renameFile": "Rename file",
|
||||
"skillManagement.detail.renameFileDescription": "Enter the new path relative to the skill root.",
|
||||
"skillManagement.detail.renameFileSuccess": "File renamed.",
|
||||
"skillManagement.detail.renameVersion": "Rename",
|
||||
"skillManagement.detail.renameVersionFailed": "Failed to rename version.",
|
||||
"skillManagement.detail.renameVersionPrompt": "Version name",
|
||||
"skillManagement.detail.renameVersionSuccess": "Version renamed.",
|
||||
"skillManagement.detail.restoreVersion": "Restore",
|
||||
"skillManagement.detail.restoreVersionFailed": "Failed to restore version.",
|
||||
"skillManagement.detail.restoreVersionSuccess": "Version restored to draft.",
|
||||
"skillManagement.detail.save": "Save",
|
||||
"skillManagement.detail.saveFailed": "Failed to save file.",
|
||||
"skillManagement.detail.saveSuccess": "File saved.",
|
||||
"skillManagement.detail.saved": "Saved",
|
||||
"skillManagement.detail.savedAt": "Saved {{time}}",
|
||||
"skillManagement.detail.saving": "Saving...",
|
||||
"skillManagement.detail.searchFiles": "Dateien suchen",
|
||||
"skillManagement.detail.unsavedChanges": "Unsaved changes",
|
||||
"skillManagement.detail.updateTagsFailed": "Failed to update tags.",
|
||||
"skillManagement.detail.uploadFile": "Upload file",
|
||||
"skillManagement.detail.uploadFileFailed": "File upload failed.",
|
||||
"skillManagement.detail.uploadFileSuccess": "File uploaded.",
|
||||
"skillManagement.detail.uploadFilesMenu": "Upload files...",
|
||||
"skillManagement.detail.versionMeta": "{{hash}} · {{time}}",
|
||||
"skillManagement.detail.versions": "Versions",
|
||||
"skillManagement.draft": "Draft",
|
||||
"skillManagement.duplicateFailed": "Failed to duplicate skill.",
|
||||
"skillManagement.duplicateSuccess": "Skill duplicated.",
|
||||
"skillManagement.editedAt": "Edited {{time}}",
|
||||
"skillManagement.empty": "No skills yet",
|
||||
"skillManagement.emptySearch": "No skills found",
|
||||
"skillManagement.import": "Import",
|
||||
"skillManagement.importFailed": "Failed to import skill.",
|
||||
"skillManagement.importSuccess": "Skill imported.",
|
||||
"skillManagement.listLabel": "Workspace skills",
|
||||
"skillManagement.loadingError": "Failed to load skills",
|
||||
"skillManagement.moreActions": "More actions for {{name}}",
|
||||
"skillManagement.noTags": "No tags",
|
||||
"skillManagement.publishedAt": "Published {{time}}",
|
||||
"skillManagement.referenceCount": "{{count}} Refs",
|
||||
"skillManagement.searchLabel": "Search skills",
|
||||
"skillManagement.searchPlaceholder": "Search",
|
||||
"skillManagement.tags": "Tags",
|
||||
"skillManagement.title": "Skills"
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"archives.empty.title": "Keine archivierten Protokolle",
|
||||
"archives.error.description": "Aktualisieren Sie die Seite oder versuchen Sie es später erneut.",
|
||||
"archives.error.title": "Archivierte Protokolle konnten nicht geladen werden",
|
||||
"archives.notice.action": "Archivierte Protokolle anzeigen",
|
||||
"archives.notice.action": "Archivierte Protokolle öffnen",
|
||||
"archives.notice.description": "Einige Protokolle in diesem Zeitraum wurden möglicherweise archiviert.",
|
||||
"archives.summary.latest": "Neueste Archivierung",
|
||||
"archives.summary.months": "Archivierte Monate",
|
||||
@@ -66,6 +66,7 @@
|
||||
"filter.period.yearToDate": "Jahr bis heute",
|
||||
"filter.sortBy": "Sortieren nach:",
|
||||
"monitoring.description": "Das Monitoring zeichnet den Betriebsstatus der Anwendung auf, einschließlich Leistung, Nutzeraktivität und Kosten.",
|
||||
"retention.upgradeTip.description": "Führen Sie ein Upgrade Ihres Plans durch, um alle danach erstellten Protokolle unbegrenzt aufzubewahren. Protokolle, deren Aufbewahrungsfrist vor dem Upgrade abgelaufen ist, können nicht wiederhergestellt werden.",
|
||||
"runDetail.fileListDetail": "Detail",
|
||||
"runDetail.fileListLabel": "Details zur Datei",
|
||||
"runDetail.testWithParams": "Test mit Parametern",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"mainNav.home": "Startseite",
|
||||
"mainNav.integrations": "Integrationen",
|
||||
"mainNav.marketplace": "Marketplace",
|
||||
"mainNav.skills": "Skills",
|
||||
"mainNav.webApps.noResults": "Keine Web-Apps gefunden",
|
||||
"mainNav.webApps.openApp": "Web-App {{name}} öffnen",
|
||||
"mainNav.webApps.searchPlaceholder": "Web-Apps suchen",
|
||||
@@ -463,6 +464,7 @@
|
||||
"operation.downloading": "Wird heruntergeladen...",
|
||||
"operation.duplicate": "Duplikat",
|
||||
"operation.edit": "Bearbeiten",
|
||||
"operation.export": "Exportieren",
|
||||
"operation.exporting": "Exportiere",
|
||||
"operation.fill": "Automatisch ausfüllen",
|
||||
"operation.format": "Format",
|
||||
@@ -504,6 +506,7 @@
|
||||
"operation.sure": "Ich bin sicher",
|
||||
"operation.toggleFullscreen": "Vollbild umschalten",
|
||||
"operation.toggleMute": "Stummschaltung umschalten",
|
||||
"operation.upload": "Upload",
|
||||
"operation.view": "Ansehen",
|
||||
"operation.viewDetails": "Details anzeigen",
|
||||
"operation.viewMore": "MEHR SEHEN",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user