mirror of
https://github.com/langgenius/dify.git
synced 2026-09-08 11:04:27 +08:00
fix: fix some ui issue
This commit is contained in:
parent
3ac6e1b115
commit
f0fb69f4c7
@ -108,13 +108,28 @@ draft file operations:
|
||||
- delete: delete a draft file or directory. Never delete SKILL.md.
|
||||
|
||||
Allowed write targets are SKILL.md and files/directories under scripts/,
|
||||
references/, and assets/. When revising SKILL.md, preserve valid frontmatter and
|
||||
include a lowercase kebab-case name, a non-empty description, and
|
||||
metadata.display-name when appropriate. Do not claim that you published a Skill
|
||||
or changed anything outside the draft files. Only SKILL.md should contain Skill
|
||||
frontmatter fields such as name, description, or metadata.display-name. Ordinary
|
||||
Markdown files under references/ should contain only their own document content
|
||||
unless the user explicitly asks for YAML frontmatter in that file.
|
||||
references/, and assets/. When creating or revising SKILL.md, preserve valid
|
||||
frontmatter and include a meaningful lowercase kebab-case name, a non-empty
|
||||
description, and metadata.display-name. If the current draft is untitled, never
|
||||
keep placeholder values such as name: untitled-skill-*, metadata.display-name:
|
||||
Untitled skill, or the default placeholder description in the completed
|
||||
SKILL.md. The frontmatter name, metadata.display-name, and first H1 heading must
|
||||
describe the same Skill. Derive the kebab-case name from the actual Skill title,
|
||||
for example:
|
||||
---
|
||||
name: customer-issue-tiered-handling
|
||||
description: Classify and route customer support issues by severity and handling path.
|
||||
metadata:
|
||||
display-name: Customer Issue Tiered Handling
|
||||
---
|
||||
|
||||
# Customer Issue Tiered Handling
|
||||
|
||||
Do not claim that you published a Skill or changed anything outside the draft
|
||||
files. Only SKILL.md should contain Skill frontmatter fields such as name,
|
||||
description, or metadata.display-name. Ordinary Markdown files under references/
|
||||
should contain only their own document content unless the user explicitly asks
|
||||
for YAML frontmatter in that file.
|
||||
|
||||
Respond with JSON only:
|
||||
{
|
||||
@ -761,6 +776,19 @@ class SkillManagementService:
|
||||
"status": exc.status_code,
|
||||
}
|
||||
)
|
||||
except IntegrityError as exc:
|
||||
logger.warning("skill_assistant_action_conflict skill_id=%s error=%s", skill_id, exc)
|
||||
error_message, details = self._skill_name_conflict_from_integrity_error(exc)
|
||||
payload: dict[str, Any] = {
|
||||
"event": "error",
|
||||
"id": message_id,
|
||||
"code": "skill_name_conflict",
|
||||
"message": error_message,
|
||||
"status": 422,
|
||||
}
|
||||
if details:
|
||||
payload["details"] = details
|
||||
yield self._assistant_sse(payload)
|
||||
except Exception:
|
||||
logger.exception("skill_assistant_action_failed skill_id=%s", skill_id)
|
||||
yield self._assistant_sse(
|
||||
@ -886,6 +914,15 @@ class SkillManagementService:
|
||||
def _assistant_sse(payload: dict[str, Any]) -> str:
|
||||
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||
|
||||
@staticmethod
|
||||
def _skill_name_conflict_from_integrity_error(exc: IntegrityError) -> tuple[str, dict[str, str]]:
|
||||
text = str(getattr(exc, "orig", exc))
|
||||
match = re.search(r"Key \(tenant_id, name\)=\([^,]+,\s*([^)]+)\) already exists", text)
|
||||
if match:
|
||||
name = match.group(1)
|
||||
return f'Skill name "{name}" already exists. Please choose a different name.', {"name": name}
|
||||
return "Skill name already exists. Please choose a different name.", {}
|
||||
|
||||
@classmethod
|
||||
def _sanitize_assistant_operation_content(cls, operation: SkillAssistDraftOperationPayload) -> str | None:
|
||||
content = operation.content
|
||||
@ -2381,10 +2418,7 @@ class SkillManagementService:
|
||||
display_name: str,
|
||||
current_skill_id: str,
|
||||
) -> str:
|
||||
base = re.sub(r"[^a-z0-9]+", "-", display_name.strip().lower()).strip("-")
|
||||
if not base:
|
||||
base = _UNTITLED_SKILL_NAME_PREFIX
|
||||
base = validate_skill_name(base[:64].strip("-") or _UNTITLED_SKILL_NAME_PREFIX)
|
||||
base = SkillManagementService._name_from_display_name(display_name)
|
||||
names = set(
|
||||
session.scalars(select(Skill.name).where(Skill.tenant_id == tenant_id, Skill.id != current_skill_id))
|
||||
)
|
||||
@ -2398,6 +2432,38 @@ class SkillManagementService:
|
||||
return candidate
|
||||
suffix += 1
|
||||
|
||||
@staticmethod
|
||||
def _name_from_display_name(display_name: str) -> str:
|
||||
base = re.sub(r"[^a-z0-9]+", "-", display_name.strip().lower()).strip("-")
|
||||
if not base:
|
||||
base = _UNTITLED_SKILL_NAME_PREFIX
|
||||
return validate_skill_name(base[:64].strip("-") or _UNTITLED_SKILL_NAME_PREFIX)
|
||||
|
||||
@staticmethod
|
||||
def _ensure_skill_name_available(
|
||||
session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
current_skill_id: str,
|
||||
name: str,
|
||||
) -> None:
|
||||
with session.no_autoflush:
|
||||
existing_id = session.scalar(
|
||||
select(Skill.id)
|
||||
.where(
|
||||
Skill.tenant_id == tenant_id,
|
||||
Skill.id != current_skill_id,
|
||||
Skill.name == name,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if existing_id is not None:
|
||||
raise SkillManagementServiceError(
|
||||
"skill_name_conflict",
|
||||
f'Skill name "{name}" already exists. Please choose a different name.',
|
||||
details={"name": name},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_frontmatter(content: str) -> dict[str, Any]:
|
||||
match = _FRONTMATTER_RE.match(content)
|
||||
@ -2424,7 +2490,6 @@ class SkillManagementService:
|
||||
)
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
@staticmethod
|
||||
def _frontmatter_field_line(content: str, field: str) -> int:
|
||||
match = _FRONTMATTER_RE.match(content)
|
||||
@ -2507,7 +2572,7 @@ class SkillManagementService:
|
||||
skill.name_manually_edited = True
|
||||
skill.name = name
|
||||
skill.description = self._require_frontmatter_description(frontmatter, content=content)
|
||||
display_name = self._display_name_override_from_frontmatter(frontmatter)
|
||||
display_name = self._display_name_from_draft_skill_md(frontmatter=frontmatter, content=content)
|
||||
if display_name is not None:
|
||||
skill.display_name = display_name
|
||||
|
||||
@ -2806,6 +2871,9 @@ class SkillManagementService:
|
||||
if skill_md is None or skill_md.kind != SkillFileKind.FILE or skill_md.storage != SkillFileStorage.TEXT:
|
||||
raise SkillManagementServiceError("missing_skill_md", "skill must contain text SKILL.md")
|
||||
skill_md_content = skill_md.content or ""
|
||||
if not strict_frontmatter and sync_frontmatter_name:
|
||||
skill_md_content = self._normalize_untitled_draft_skill_md_name(skill=skill, content=skill_md_content)
|
||||
entries_by_path[_SKILL_MD] = skill_md.model_copy(update={"content": skill_md_content})
|
||||
if strict_frontmatter:
|
||||
frontmatter = self._parse_frontmatter(skill_md_content)
|
||||
frontmatter_name = self._require_frontmatter_name(frontmatter, content=skill_md_content)
|
||||
@ -2894,22 +2962,139 @@ class SkillManagementService:
|
||||
except SkillManagementServiceError:
|
||||
return
|
||||
name = frontmatter.get("name")
|
||||
display_name = self._display_name_override_from_frontmatter(frontmatter)
|
||||
if isinstance(name, str) and name.strip():
|
||||
try:
|
||||
validated_name = validate_skill_name(name)
|
||||
except ValueError:
|
||||
validated_name = None
|
||||
if validated_name is not None:
|
||||
if validated_name != skill.name:
|
||||
session = object_session(skill)
|
||||
auto_generated_name = False
|
||||
if (
|
||||
self._should_auto_sync_name(skill)
|
||||
and display_name is not None
|
||||
and display_name != _UNTITLED_DISPLAY_NAME
|
||||
and session is not None
|
||||
):
|
||||
generated_name = self._name_from_display_name(display_name)
|
||||
validated_name = generated_name
|
||||
auto_generated_name = True
|
||||
if validated_name != skill.name and session is not None:
|
||||
self._ensure_skill_name_available(
|
||||
session,
|
||||
tenant_id=skill.tenant_id,
|
||||
current_skill_id=skill.id,
|
||||
name=validated_name,
|
||||
)
|
||||
if validated_name != skill.name and not auto_generated_name:
|
||||
skill.name_manually_edited = True
|
||||
skill.name = validated_name
|
||||
description = frontmatter.get("description")
|
||||
if isinstance(description, str) and description.strip():
|
||||
skill.description = description.strip()[:1024]
|
||||
display_name = self._display_name_override_from_frontmatter(frontmatter)
|
||||
if display_name is not None:
|
||||
skill.display_name = display_name
|
||||
|
||||
def _normalize_untitled_draft_skill_md_name(self, *, skill: Skill, content: str) -> str:
|
||||
"""Replace placeholder builder names with the generated display-name slug.
|
||||
|
||||
Skill Builder starts from an untitled draft. Some models preserve the
|
||||
placeholder ``name: untitled-skill-*`` while correctly generating a
|
||||
meaningful ``metadata.display-name``. Normalize the file before it is
|
||||
saved so the editor, detail payload, and future export all show the same
|
||||
generated kebab-case name.
|
||||
"""
|
||||
if not self._should_auto_sync_name(skill):
|
||||
return content
|
||||
|
||||
try:
|
||||
frontmatter = self._parse_frontmatter(content)
|
||||
except SkillManagementServiceError:
|
||||
return content
|
||||
|
||||
name = frontmatter.get("name")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
return content
|
||||
try:
|
||||
validated_name = validate_skill_name(name)
|
||||
except ValueError:
|
||||
return content
|
||||
display_name = self._display_name_from_draft_skill_md(frontmatter=frontmatter, content=content)
|
||||
if display_name is None:
|
||||
return content
|
||||
|
||||
session = object_session(skill)
|
||||
if session is None:
|
||||
return content
|
||||
generated_name = self._name_from_display_name(display_name)
|
||||
next_content = content
|
||||
if display_name != self._display_name_override_from_frontmatter(frontmatter):
|
||||
next_content = self._replace_or_insert_frontmatter_display_name(next_content, display_name)
|
||||
if generated_name == validated_name:
|
||||
return next_content
|
||||
self._ensure_skill_name_available(
|
||||
session,
|
||||
tenant_id=skill.tenant_id,
|
||||
current_skill_id=skill.id,
|
||||
name=generated_name,
|
||||
)
|
||||
|
||||
return re.sub(r"(?m)^name:\s*.*$", f"name: {generated_name}", next_content, count=1)
|
||||
|
||||
def _display_name_from_draft_skill_md(self, *, frontmatter: dict[str, Any], content: str) -> str | None:
|
||||
display_name = self._display_name_override_from_frontmatter(frontmatter)
|
||||
if display_name is not None and display_name != _UNTITLED_DISPLAY_NAME:
|
||||
return display_name
|
||||
if display_name is None:
|
||||
return None
|
||||
|
||||
heading = self._first_markdown_heading(content)
|
||||
if heading is None or heading == _UNTITLED_DISPLAY_NAME:
|
||||
return None
|
||||
return heading[:128]
|
||||
|
||||
@staticmethod
|
||||
def _first_markdown_heading(content: str) -> str | None:
|
||||
body = _FRONTMATTER_RE.sub("", content, count=1)
|
||||
match = re.search(r"(?m)^#\s+(.+?)\s*$", body)
|
||||
if match is None:
|
||||
return None
|
||||
heading = match.group(1).strip()
|
||||
return heading or None
|
||||
|
||||
@staticmethod
|
||||
def _replace_or_insert_frontmatter_display_name(content: str, display_name: str) -> str:
|
||||
match = _FRONTMATTER_RE.match(content)
|
||||
if match is None:
|
||||
return content
|
||||
|
||||
frontmatter = match.group(1)
|
||||
escaped_display_name = yaml.safe_dump(
|
||||
display_name,
|
||||
allow_unicode=True,
|
||||
default_flow_style=True,
|
||||
sort_keys=False,
|
||||
).splitlines()[0]
|
||||
if re.search(r"(?m)^\s*(display-name|display_name)\s*:", frontmatter):
|
||||
next_frontmatter = re.sub(
|
||||
r"(?m)^(\s*)(display-name|display_name)\s*:.*$",
|
||||
lambda match: f"{match.group(1)}display-name: {escaped_display_name}",
|
||||
frontmatter,
|
||||
count=1,
|
||||
)
|
||||
elif re.search(r"(?m)^metadata\s*:\s*$", frontmatter):
|
||||
next_frontmatter = re.sub(
|
||||
r"(?m)^metadata\s*:\s*$",
|
||||
f"metadata:\n display-name: {escaped_display_name}",
|
||||
frontmatter,
|
||||
count=1,
|
||||
)
|
||||
else:
|
||||
next_frontmatter = f"{frontmatter}\nmetadata:\n display-name: {escaped_display_name}"
|
||||
|
||||
return f"---\n{next_frontmatter}\n---\n{content[match.end():]}"
|
||||
|
||||
def _sync_skill_md_text(self, skill: Skill, content: str) -> str:
|
||||
body = _FRONTMATTER_RE.sub("", content, count=1)
|
||||
metadata = self._parse_frontmatter(content)
|
||||
|
||||
@ -13,6 +13,7 @@ from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import Table, delete, func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from core.db.session_factory import session_factory
|
||||
from core.tools.tool_file_manager import ToolFileManager
|
||||
@ -491,6 +492,69 @@ def test_create_assistant_action_stream_strips_skill_frontmatter_from_reference_
|
||||
assert reference["content"] == "# Refund Policy\n"
|
||||
|
||||
|
||||
def test_create_assistant_action_stream_reports_skill_name_database_conflict() -> None:
|
||||
service = SkillManagementService(tool_file_manager=_FakeToolFileManager())
|
||||
created = service.create_skill(
|
||||
tenant_id=TENANT,
|
||||
user_id=USER,
|
||||
payload=SkillCreatePayload(name="untitled-skill-1", description="Draft skill."),
|
||||
)
|
||||
model_output = json.dumps(
|
||||
{
|
||||
"reply": "已创建用于客户问题分级处理的 skill 草案",
|
||||
"operations": [
|
||||
{
|
||||
"operation": "upsert_text",
|
||||
"path": "SKILL.md",
|
||||
"mime_type": "text/markdown",
|
||||
"content": (
|
||||
"---\n"
|
||||
"name: customer-issue-triage\n"
|
||||
"description: Customer issue triage.\n"
|
||||
"metadata:\n"
|
||||
" display-name: Customer Issue Triage\n"
|
||||
"---\n"
|
||||
"# Customer Issue Triage\n"
|
||||
),
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
model = SimpleNamespace(
|
||||
invoke_llm=lambda **_kwargs: SimpleNamespace(
|
||||
message=SimpleNamespace(get_text_content=lambda: model_output),
|
||||
)
|
||||
)
|
||||
manager = SimpleNamespace(get_default_model_instance=lambda **_kwargs: model)
|
||||
integrity_error = IntegrityError(
|
||||
"UPDATE skills",
|
||||
{},
|
||||
Exception(
|
||||
'duplicate key value violates unique constraint "skill_tenant_name_unique"\n'
|
||||
"DETAIL: Key (tenant_id, name)=(tenant, customer-issue-triage) already exists."
|
||||
),
|
||||
)
|
||||
|
||||
with (
|
||||
patch("services.skill_management_service.ModelManager.for_tenant", return_value=manager),
|
||||
patch.object(service, "apply_draft_file_operation", side_effect=integrity_error),
|
||||
):
|
||||
response = list(
|
||||
service.create_assistant_action_stream(
|
||||
tenant_id=TENANT,
|
||||
user_id=USER,
|
||||
skill_id=created["id"],
|
||||
message="创建客户问题分级处理 skill",
|
||||
)
|
||||
)
|
||||
|
||||
events = [json.loads(chunk.removeprefix("data: ").strip()) for chunk in response]
|
||||
assert [event["event"] for event in events] == ["message", "error"]
|
||||
assert events[1]["code"] == "skill_name_conflict"
|
||||
assert events[1]["message"] == 'Skill name "customer-issue-triage" already exists. Please choose a different name.'
|
||||
assert events[1]["details"] == {"name": "customer-issue-triage"}
|
||||
|
||||
|
||||
def test_sync_assistant_model_config_updates_debugger_draft() -> None:
|
||||
openai_model = AgentSoulModelConfig(
|
||||
plugin_id="langgenius/openai",
|
||||
@ -1564,6 +1628,187 @@ def test_apply_draft_file_operation_syncs_frontmatter_display_name_to_db() -> No
|
||||
assert updated["description"] == "Handle refund approvals."
|
||||
|
||||
|
||||
def test_apply_draft_file_operation_generates_name_for_builder_created_skill() -> None:
|
||||
service = SkillManagementService(tool_file_manager=_FakeToolFileManager())
|
||||
created = service.create_skill(tenant_id=TENANT, user_id=USER, payload=SkillCreatePayload())
|
||||
|
||||
updated = service.apply_draft_file_operation(
|
||||
tenant_id=TENANT,
|
||||
user_id=USER,
|
||||
skill_id=created["id"],
|
||||
payload=SkillDraftFileOperationPayload(
|
||||
operation="upsert_text",
|
||||
path="SKILL.md",
|
||||
content=(
|
||||
"---\n"
|
||||
f"name: {created['name']}\n"
|
||||
"description: Classify and route customer issues.\n"
|
||||
"metadata:\n"
|
||||
" display-name: Customer Issue Tiered Handling\n"
|
||||
"---\n"
|
||||
"# Customer Issue Tiered Handling\n"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
skill_md = next(item for item in updated["files"] if item["path"] == "SKILL.md")
|
||||
assert updated["name"] == "customer-issue-tiered-handling"
|
||||
assert updated["display_name"] == "Customer Issue Tiered Handling"
|
||||
assert updated["name_manually_edited"] is False
|
||||
assert "name: customer-issue-tiered-handling" in skill_md["content"]
|
||||
assert f"name: {created['name']}" not in skill_md["content"]
|
||||
|
||||
|
||||
def test_apply_draft_file_operation_prefers_builder_display_name_for_generated_name() -> None:
|
||||
service = SkillManagementService(tool_file_manager=_FakeToolFileManager())
|
||||
created = service.create_skill(tenant_id=TENANT, user_id=USER, payload=SkillCreatePayload())
|
||||
|
||||
updated = service.apply_draft_file_operation(
|
||||
tenant_id=TENANT,
|
||||
user_id=USER,
|
||||
skill_id=created["id"],
|
||||
payload=SkillDraftFileOperationPayload(
|
||||
operation="upsert_text",
|
||||
path="SKILL.md",
|
||||
content=(
|
||||
"---\n"
|
||||
"name: customer-issue-triage\n"
|
||||
"description: Classify and route customer issues.\n"
|
||||
"metadata:\n"
|
||||
" display-name: Customer Issue Tiered Handling\n"
|
||||
"---\n"
|
||||
"# Customer Issue Tiered Handling\n"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
skill_md = next(item for item in updated["files"] if item["path"] == "SKILL.md")
|
||||
assert updated["name"] == "customer-issue-tiered-handling"
|
||||
assert updated["display_name"] == "Customer Issue Tiered Handling"
|
||||
assert updated["name_manually_edited"] is False
|
||||
assert "name: customer-issue-tiered-handling" in skill_md["content"]
|
||||
assert "name: customer-issue-triage" not in skill_md["content"]
|
||||
|
||||
|
||||
def test_apply_draft_file_operation_uses_builder_heading_when_display_name_is_placeholder() -> None:
|
||||
service = SkillManagementService(tool_file_manager=_FakeToolFileManager())
|
||||
created = service.create_skill(tenant_id=TENANT, user_id=USER, payload=SkillCreatePayload())
|
||||
|
||||
updated = service.apply_draft_file_operation(
|
||||
tenant_id=TENANT,
|
||||
user_id=USER,
|
||||
skill_id=created["id"],
|
||||
payload=SkillDraftFileOperationPayload(
|
||||
operation="upsert_text",
|
||||
path="SKILL.md",
|
||||
content=(
|
||||
"---\n"
|
||||
f"name: {created['name']}\n"
|
||||
"description: Classify and route customer issues.\n"
|
||||
"metadata:\n"
|
||||
" display-name: Untitled skill\n"
|
||||
"---\n"
|
||||
"# Customer Issue Tiered Handling\n"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
skill_md = next(item for item in updated["files"] if item["path"] == "SKILL.md")
|
||||
assert updated["name"] == "customer-issue-tiered-handling"
|
||||
assert updated["display_name"] == "Customer Issue Tiered Handling"
|
||||
assert updated["name_manually_edited"] is False
|
||||
assert "name: customer-issue-tiered-handling" in skill_md["content"]
|
||||
assert "display-name: Customer Issue Tiered Handling" in skill_md["content"]
|
||||
assert f"name: {created['name']}" not in skill_md["content"]
|
||||
|
||||
|
||||
def test_apply_draft_file_operation_keeps_auto_generated_name_in_sync_with_builder_display_name() -> None:
|
||||
service = SkillManagementService(tool_file_manager=_FakeToolFileManager())
|
||||
created = service.create_skill(tenant_id=TENANT, user_id=USER, payload=SkillCreatePayload())
|
||||
first_update = service.apply_draft_file_operation(
|
||||
tenant_id=TENANT,
|
||||
user_id=USER,
|
||||
skill_id=created["id"],
|
||||
payload=SkillDraftFileOperationPayload(
|
||||
operation="upsert_text",
|
||||
path="SKILL.md",
|
||||
content=(
|
||||
"---\n"
|
||||
"name: customer-issue-triage\n"
|
||||
"description: Classify customer issues.\n"
|
||||
"metadata:\n"
|
||||
" display-name: Customer Issue Triage\n"
|
||||
"---\n"
|
||||
"# Customer Issue Triage\n"
|
||||
),
|
||||
),
|
||||
)
|
||||
assert first_update["name"] == "customer-issue-triage"
|
||||
assert first_update["name_manually_edited"] is False
|
||||
|
||||
updated = service.apply_draft_file_operation(
|
||||
tenant_id=TENANT,
|
||||
user_id=USER,
|
||||
skill_id=created["id"],
|
||||
payload=SkillDraftFileOperationPayload(
|
||||
operation="upsert_text",
|
||||
path="SKILL.md",
|
||||
content=(
|
||||
"---\n"
|
||||
"name: customer-issue-triage\n"
|
||||
"description: Classify and route customer issues.\n"
|
||||
"metadata:\n"
|
||||
" display-name: Customer Issue Tiered Handling\n"
|
||||
"---\n"
|
||||
"# Customer Issue Tiered Handling\n"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
skill_md = next(item for item in updated["files"] if item["path"] == "SKILL.md")
|
||||
assert updated["name"] == "customer-issue-tiered-handling"
|
||||
assert updated["display_name"] == "Customer Issue Tiered Handling"
|
||||
assert updated["name_manually_edited"] is False
|
||||
assert "name: customer-issue-tiered-handling" in skill_md["content"]
|
||||
assert "name: customer-issue-triage" not in skill_md["content"]
|
||||
|
||||
|
||||
def test_apply_draft_file_operation_reports_builder_generated_name_conflict() -> None:
|
||||
service = SkillManagementService(tool_file_manager=_FakeToolFileManager())
|
||||
service.create_skill(
|
||||
tenant_id=TENANT,
|
||||
user_id=USER,
|
||||
payload=SkillCreatePayload(name="customer-issue-tiered-handling"),
|
||||
)
|
||||
created = service.create_skill(tenant_id=TENANT, user_id=USER, payload=SkillCreatePayload())
|
||||
|
||||
with pytest.raises(SkillManagementServiceError) as exc_info:
|
||||
service.apply_draft_file_operation(
|
||||
tenant_id=TENANT,
|
||||
user_id=USER,
|
||||
skill_id=created["id"],
|
||||
payload=SkillDraftFileOperationPayload(
|
||||
operation="upsert_text",
|
||||
path="SKILL.md",
|
||||
content=(
|
||||
"---\n"
|
||||
"name: customer-issue-triage\n"
|
||||
"description: Classify and route customer issues.\n"
|
||||
"metadata:\n"
|
||||
" display-name: Customer Issue Tiered Handling\n"
|
||||
"---\n"
|
||||
"# Customer Issue Tiered Handling\n"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assert exc_info.value.code == "skill_name_conflict"
|
||||
assert exc_info.value.details == {"name": "customer-issue-tiered-handling"}
|
||||
assert exc_info.value.message == (
|
||||
'Skill name "customer-issue-tiered-handling" already exists. Please choose a different name.'
|
||||
)
|
||||
|
||||
|
||||
def test_publish_syncs_frontmatter_display_name_from_existing_draft() -> None:
|
||||
service = SkillManagementService(tool_file_manager=_FakeToolFileManager())
|
||||
created = service.create_skill(tenant_id=TENANT, user_id=USER, payload=SkillCreatePayload())
|
||||
|
||||
@ -0,0 +1,81 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import { AgentOrchestrateAddActionsProvider } from '../add-actions'
|
||||
import {
|
||||
useAgentOrchestrateAddActions,
|
||||
useRegisterAgentOrchestrateAddAction,
|
||||
} from '../add-actions-context'
|
||||
import { AgentOrchestrateViewingVersionContext } from '../read-only-context'
|
||||
|
||||
function RegisteredActionProbe({ onRegister }: { onRegister: () => void }) {
|
||||
useRegisterAgentOrchestrateAddAction('skills', onRegister)
|
||||
return <ActionsProbe />
|
||||
}
|
||||
|
||||
function ActionsProbe() {
|
||||
const actions = useAgentOrchestrateAddActions()
|
||||
|
||||
return <div>{actions.skills ? 'registered' : 'empty'}</div>
|
||||
}
|
||||
|
||||
function ToggleRegisteredActionProbe({ onRegister }: { onRegister: () => void }) {
|
||||
const [visible, setVisible] = useState(true)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setVisible(false)}>
|
||||
remove action
|
||||
</button>
|
||||
{visible && <RegisteredActionProbe onRegister={onRegister} />}
|
||||
{!visible && <ActionsProbe />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
describe('AgentOrchestrateAddActionsProvider', () => {
|
||||
it('registers add actions for editable drafts', () => {
|
||||
const action = vi.fn()
|
||||
|
||||
render(
|
||||
<AgentOrchestrateAddActionsProvider>
|
||||
<RegisteredActionProbe onRegister={action} />
|
||||
</AgentOrchestrateAddActionsProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('registered')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not expose add actions while viewing a version', () => {
|
||||
const action = vi.fn()
|
||||
|
||||
render(
|
||||
<AgentOrchestrateViewingVersionContext value>
|
||||
<AgentOrchestrateAddActionsProvider>
|
||||
<RegisteredActionProbe onRegister={action} />
|
||||
</AgentOrchestrateAddActionsProvider>
|
||||
</AgentOrchestrateViewingVersionContext>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('empty')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('unregisters add actions when the owning section unmounts', async () => {
|
||||
const user = userEvent.setup()
|
||||
const action = vi.fn()
|
||||
|
||||
render(
|
||||
<AgentOrchestrateAddActionsProvider>
|
||||
<ToggleRegisteredActionProbe onRegister={action} />
|
||||
</AgentOrchestrateAddActionsProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('registered')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'remove action' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('empty')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -9,6 +9,7 @@ import { detectPlatform } from '@tanstack/react-hotkeys'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import SkillDetailPage from '../detail-page'
|
||||
|
||||
@ -54,6 +55,10 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('copy-to-clipboard', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/markdown', () => ({
|
||||
Markdown: ({ content }: { content: string }) => <div>{content}</div>,
|
||||
}))
|
||||
@ -2290,6 +2295,46 @@ describe('SkillDetailPage', () => {
|
||||
).toBeEnabled()
|
||||
})
|
||||
|
||||
it('replaces optimistic Skill Builder replies when the assistant stream returns an error', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createDefaultSkillDraftDetail()
|
||||
mocks.sendSkillAssistMessage.mockImplementation(({ onData, onError }) => {
|
||||
onData?.('已创建用于客户问题分级处理的 skill 草案', true, {
|
||||
messageId: 'assistant-message',
|
||||
})
|
||||
onError?.(
|
||||
'the Skill Authoring assistant could not apply its response',
|
||||
'skill_assistant_failed',
|
||||
)
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.builder.exampleIssueTriage',
|
||||
}),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
'the Skill Authoring assistant could not apply its response',
|
||||
)
|
||||
})
|
||||
expect(toast.error).toHaveBeenCalledTimes(1)
|
||||
const errorMessage = screen.getByText(
|
||||
'the Skill Authoring assistant could not apply its response',
|
||||
)
|
||||
expect(errorMessage).toBeInTheDocument()
|
||||
expect(errorMessage.closest('.rounded-xl')).toHaveClass(
|
||||
'border-state-destructive-border',
|
||||
'bg-state-destructive-hover',
|
||||
'text-text-destructive',
|
||||
)
|
||||
expect(screen.queryByText('已创建用于客户问题分级处理的 skill 草案')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a publish confirmation for referenced skills before publishing updates', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createSkillDetail({ reference_count: 1 })
|
||||
@ -3870,7 +3915,7 @@ describe('SkillDetailPage', () => {
|
||||
...primaryModifier,
|
||||
})
|
||||
|
||||
expect(toast.success).toHaveBeenCalledWith('skill.skillManagement.detail.copyFileSuccess')
|
||||
expect(toast.success).toHaveBeenCalledWith('skill.skillManagement.detail.copyContentSuccess')
|
||||
})
|
||||
|
||||
it('cuts the context-menu file with the displayed keyboard shortcut', async () => {
|
||||
@ -3919,7 +3964,7 @@ describe('SkillDetailPage', () => {
|
||||
await user.click(getFileTreeButton('SKILL.md'))
|
||||
fireEvent.copy(getFileTreeButton('SKILL.md'))
|
||||
await user.click(screen.getByRole('button', { name: 'scripts' }))
|
||||
fireEvent.paste(document)
|
||||
fireEvent.paste(screen.getByTestId('skill-detail-sidebar'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledWith(
|
||||
@ -3934,6 +3979,58 @@ describe('SkillDetailPage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('lets editable fields handle native copy even when page selection contains an empty draft marker', async () => {
|
||||
const user = userEvent.setup()
|
||||
const getSelectionSpy = vi.spyOn(window, 'getSelection').mockReturnValue({
|
||||
toString: () => '<!-- dify-skill-empty-draft -->',
|
||||
} as Selection)
|
||||
try {
|
||||
renderSkillDetailPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getFileTreeButton('SKILL.md')).toBeInTheDocument()
|
||||
})
|
||||
await user.click(getFileTreeButton('SKILL.md'))
|
||||
const builderInput = screen.getByPlaceholderText(
|
||||
'skill.skillManagement.detail.builder.modifyPlaceholder',
|
||||
)
|
||||
fireEvent.copy(builderInput)
|
||||
|
||||
expect(copy).not.toHaveBeenCalledWith('<!-- dify-skill-empty-draft -->')
|
||||
expect(toast.success).not.toHaveBeenCalledWith(
|
||||
'skill.skillManagement.detail.copyContentSuccess',
|
||||
)
|
||||
expect(toast.success).not.toHaveBeenCalledWith('skill.skillManagement.detail.copyFileSuccess')
|
||||
} finally {
|
||||
getSelectionSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not let file-tree copy hotkeys override copying from the builder panel', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillDetailPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getFileTreeButton('SKILL.md')).toBeInTheDocument()
|
||||
})
|
||||
await user.click(getFileTreeButton('SKILL.md'))
|
||||
|
||||
const builderControl = screen.getByRole('button', {
|
||||
name: 'skill.skillManagement.detail.builder.close',
|
||||
})
|
||||
fireEvent.keyDown(builderControl, {
|
||||
code: 'KeyC',
|
||||
key: 'c',
|
||||
...primaryModifier,
|
||||
})
|
||||
|
||||
expect(copy).not.toHaveBeenCalled()
|
||||
expect(toast.success).not.toHaveBeenCalledWith(
|
||||
'skill.skillManagement.detail.copyContentSuccess',
|
||||
)
|
||||
expect(toast.success).not.toHaveBeenCalledWith('skill.skillManagement.detail.copyFileSuccess')
|
||||
})
|
||||
|
||||
it('opens only the copied file after pasting it beside the source file', async () => {
|
||||
const user = userEvent.setup()
|
||||
const sourceFile = createSkillDetail().files![0]!
|
||||
@ -3962,7 +4059,7 @@ describe('SkillDetailPage', () => {
|
||||
})
|
||||
await user.click(getFileTreeButton('SKILL.md'))
|
||||
fireEvent.copy(getFileTreeButton('SKILL.md'))
|
||||
fireEvent.paste(document)
|
||||
fireEvent.paste(screen.getByTestId('skill-detail-sidebar'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledWith(
|
||||
@ -4036,7 +4133,7 @@ describe('SkillDetailPage', () => {
|
||||
})
|
||||
await user.click(getFileTreeButton('SKILL.md'))
|
||||
fireEvent.copy(getFileTreeButton('SKILL.md'))
|
||||
fireEvent.paste(document)
|
||||
fireEvent.paste(screen.getByTestId('skill-detail-sidebar'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledTimes(2)
|
||||
@ -4116,7 +4213,7 @@ describe('SkillDetailPage', () => {
|
||||
await user.click(getFileTreeButton('alpha.md'))
|
||||
fireEvent.click(getFileTreeButton('beta.md'), primaryModifier)
|
||||
fireEvent.copy(getFileTreeButton('beta.md'))
|
||||
fireEvent.paste(document)
|
||||
fireEvent.paste(screen.getByTestId('skill-detail-sidebar'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledTimes(3)
|
||||
|
||||
@ -175,6 +175,7 @@ export function sendSkillAssistMessage({
|
||||
skillId: string
|
||||
targetPath?: string
|
||||
}) {
|
||||
let streamErrorHandled = false
|
||||
return ssePost(
|
||||
`/workspaces/current/skills/${encodeURIComponent(skillId)}/assist/messages`,
|
||||
{
|
||||
@ -186,10 +187,24 @@ export function sendSkillAssistMessage({
|
||||
},
|
||||
},
|
||||
{
|
||||
silent: true,
|
||||
getAbortController,
|
||||
onCompleted,
|
||||
onData,
|
||||
onError,
|
||||
onCompleted: (hasError, errorMessage) => {
|
||||
onCompleted?.(streamErrorHandled && hasError ? false : hasError, errorMessage)
|
||||
},
|
||||
onData: (chunk, isFirstMessage, moreInfo) => {
|
||||
if (moreInfo.errorMessage) {
|
||||
streamErrorHandled = true
|
||||
onError?.(moreInfo.errorMessage, moreInfo.errorCode)
|
||||
return
|
||||
}
|
||||
|
||||
onData?.(chunk, isFirstMessage, moreInfo)
|
||||
},
|
||||
onError: (errorMessage, errorCode) => {
|
||||
streamErrorHandled = true
|
||||
onError?.(errorMessage, errorCode)
|
||||
},
|
||||
onUnhandledEvent,
|
||||
},
|
||||
)
|
||||
|
||||
@ -0,0 +1,59 @@
|
||||
import type { DragEvent } from 'react'
|
||||
import { setSkillFileDragPreview } from '../file-tree-drag-preview'
|
||||
|
||||
function createDragEvent(setDragImage: (element: Element, x: number, y: number) => void) {
|
||||
return {
|
||||
dataTransfer: {
|
||||
setDragImage,
|
||||
},
|
||||
} as unknown as DragEvent<HTMLElement>
|
||||
}
|
||||
|
||||
describe('setSkillFileDragPreview', () => {
|
||||
it('renders a named preview for a single dragged file', () => {
|
||||
const setDragImage = vi.fn()
|
||||
|
||||
setSkillFileDragPreview(createDragEvent(setDragImage), {
|
||||
count: 1,
|
||||
iconClassName: 'i-ri-markdown-line',
|
||||
name: 'SKILL.md',
|
||||
})
|
||||
|
||||
const preview = setDragImage.mock.calls[0]?.[0] as HTMLElement
|
||||
expect(preview).toHaveTextContent('SKILL.md')
|
||||
expect(preview.querySelector('[aria-hidden="true"]')).toHaveClass('i-ri-markdown-line')
|
||||
expect(setDragImage).toHaveBeenCalledWith(preview, 10, 12)
|
||||
})
|
||||
|
||||
it('renders an item count preview for multiple dragged files', () => {
|
||||
const setDragImage = vi.fn()
|
||||
|
||||
setSkillFileDragPreview(createDragEvent(setDragImage), {
|
||||
count: 3,
|
||||
iconClassName: 'i-ri-markdown-line',
|
||||
name: 'SKILL.md',
|
||||
})
|
||||
|
||||
const preview = setDragImage.mock.calls[0]?.[0] as HTMLElement
|
||||
expect(preview).toHaveTextContent('3 items')
|
||||
expect(preview).not.toHaveTextContent('SKILL.md')
|
||||
expect(setDragImage).toHaveBeenCalledWith(preview, 10, 12)
|
||||
})
|
||||
|
||||
it('skips preview creation when dataTransfer cannot set a drag image', () => {
|
||||
const initialChildCount = document.body.childElementCount
|
||||
|
||||
setSkillFileDragPreview(
|
||||
{
|
||||
dataTransfer: {},
|
||||
} as unknown as DragEvent<HTMLElement>,
|
||||
{
|
||||
count: 1,
|
||||
iconClassName: 'i-ri-markdown-line',
|
||||
name: 'SKILL.md',
|
||||
},
|
||||
)
|
||||
|
||||
expect(document.body.childElementCount).toBe(initialChildCount)
|
||||
})
|
||||
})
|
||||
251
web/features/skills/detail/__tests__/file-tree-items.spec.tsx
Normal file
251
web/features/skills/detail/__tests__/file-tree-items.spec.tsx
Normal file
@ -0,0 +1,251 @@
|
||||
import type {
|
||||
SkillDetailResponse,
|
||||
SkillFileResponse,
|
||||
} from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { FileTreeNode } from '../shared'
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { FileTreeItem, FileTreeNameInput } from '../file-tree-items'
|
||||
|
||||
const skillFile: SkillFileResponse = {
|
||||
id: 'file-1',
|
||||
path: 'scripts/example.ts',
|
||||
kind: 'file',
|
||||
storage: 'text',
|
||||
mime_type: 'text/typescript',
|
||||
content: 'export {}\n',
|
||||
tool_file_id: null,
|
||||
size: 10,
|
||||
hash: 'hash-1',
|
||||
}
|
||||
|
||||
const skillDetail = {
|
||||
id: 'skill-1',
|
||||
} as SkillDetailResponse
|
||||
|
||||
function createFileNode(overrides: Partial<FileTreeNode> = {}): FileTreeNode {
|
||||
return {
|
||||
file: skillFile,
|
||||
id: 'node-1',
|
||||
name: 'example.ts',
|
||||
path: 'scripts/example.ts',
|
||||
type: 'file',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function createFolderNode(overrides: Partial<FileTreeNode> = {}): FileTreeNode {
|
||||
return {
|
||||
children: [createFileNode()],
|
||||
id: 'folder-1',
|
||||
name: 'scripts',
|
||||
path: 'scripts',
|
||||
type: 'directory',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function renderFileTreeItem(
|
||||
overrides: Partial<Parameters<typeof FileTreeItem>[0]> = {},
|
||||
node: FileTreeNode = createFileNode(),
|
||||
) {
|
||||
const props: Parameters<typeof FileTreeItem>[0] = {
|
||||
collapsedFolderPaths: [],
|
||||
detail: skillDetail,
|
||||
draggingPaths: [],
|
||||
dropTarget: undefined,
|
||||
inlineAction: undefined,
|
||||
inlineActionLoading: false,
|
||||
node,
|
||||
onCancelInlineAction: vi.fn(),
|
||||
onCopy: vi.fn(),
|
||||
onCreate: vi.fn(),
|
||||
onCut: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onDropFiles: vi.fn(),
|
||||
onExpandFolder: vi.fn(),
|
||||
onItemSelect: vi.fn(),
|
||||
onMove: vi.fn(),
|
||||
onRename: vi.fn(),
|
||||
onSelect: vi.fn(),
|
||||
onSetDraggingPaths: vi.fn(),
|
||||
onSetDropTarget: vi.fn(),
|
||||
onSubmitInlineAction: vi.fn(),
|
||||
onToggleFolder: vi.fn(),
|
||||
onUploadFiles: vi.fn(),
|
||||
readonly: false,
|
||||
selectedPath: undefined,
|
||||
selectedPaths: [],
|
||||
...overrides,
|
||||
}
|
||||
|
||||
return {
|
||||
...render(<FileTreeItem {...props} />),
|
||||
props,
|
||||
}
|
||||
}
|
||||
|
||||
describe('FileTreeItem', () => {
|
||||
it('submits and cancels inline file names from the keyboard', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSubmit = vi.fn()
|
||||
const onCancel = vi.fn()
|
||||
render(
|
||||
<FileTreeNameInput
|
||||
loading={false}
|
||||
nodeType="file"
|
||||
onCancel={onCancel}
|
||||
onSubmit={onSubmit}
|
||||
placeholder="File name"
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = screen.getByPlaceholderText('File name')
|
||||
await user.type(input, ' guide.md {Enter}')
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledOnce()
|
||||
expect(onSubmit).toHaveBeenCalledWith('guide.md')
|
||||
expect(onCancel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('selects only the basename when renaming an existing file', async () => {
|
||||
render(
|
||||
<FileTreeNameInput
|
||||
file={skillFile}
|
||||
initialValue="example.ts"
|
||||
loading={false}
|
||||
nodeType="file"
|
||||
onCancel={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
selectBaseName
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = screen.getByDisplayValue('example.ts') as HTMLInputElement
|
||||
expect(input.selectionStart).toBe(0)
|
||||
expect(input.selectionEnd).toBe('example'.length)
|
||||
})
|
||||
|
||||
it('cancels an empty inline folder name on blur', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSubmit = vi.fn()
|
||||
const onCancel = vi.fn()
|
||||
render(
|
||||
<FileTreeNameInput
|
||||
loading={false}
|
||||
nodeType="directory"
|
||||
onCancel={onCancel}
|
||||
onSubmit={onSubmit}
|
||||
placeholder="Folder name"
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByPlaceholderText('Folder name'))
|
||||
await user.tab()
|
||||
|
||||
expect(onCancel).toHaveBeenCalledOnce()
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('selects and pins file nodes from the file button', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { props } = renderFileTreeItem()
|
||||
|
||||
const button = screen.getByRole('button', { name: 'example.ts' })
|
||||
await user.click(button)
|
||||
await user.dblClick(button)
|
||||
|
||||
expect(props.onItemSelect).toHaveBeenCalledWith(expect.anything(), expect.anything())
|
||||
expect(props.onSelect).toHaveBeenCalledWith('scripts/example.ts', 'preview')
|
||||
expect(props.onSelect).toHaveBeenCalledWith('scripts/example.ts', 'pinned')
|
||||
})
|
||||
|
||||
it('dispatches file action menu commands', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { props } = renderFileTreeItem({}, createFileNode({ name: 'example.ts' }))
|
||||
|
||||
const treeItem = screen.getByText('example.ts').closest('[data-skill-file-tree-item]')
|
||||
expect(treeItem).toBeInstanceOf(HTMLElement)
|
||||
|
||||
await user.click(
|
||||
within(treeItem as HTMLElement).getByRole('button', { name: 'common.operation.more' }),
|
||||
)
|
||||
await user.click(await screen.findByText('skill.skillManagement.detail.copyFile'))
|
||||
await user.click(
|
||||
within(treeItem as HTMLElement).getByRole('button', { name: 'common.operation.more' }),
|
||||
)
|
||||
await user.click(await screen.findByText('skill.skillManagement.detail.cutFile'))
|
||||
await user.click(
|
||||
within(treeItem as HTMLElement).getByRole('button', { name: 'common.operation.more' }),
|
||||
)
|
||||
await user.click(await screen.findByText(/common.operation.rename/))
|
||||
await user.click(
|
||||
within(treeItem as HTMLElement).getByRole('button', { name: 'common.operation.more' }),
|
||||
)
|
||||
await user.click(await screen.findByText('common.operation.delete'))
|
||||
|
||||
expect(props.onCopy).toHaveBeenCalledWith('scripts/example.ts')
|
||||
expect(props.onCut).toHaveBeenCalledWith('scripts/example.ts')
|
||||
expect(props.onRename).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: 'scripts/example.ts' }),
|
||||
)
|
||||
expect(props.onDelete).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: 'scripts/example.ts' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('dispatches folder action menu commands and toggles folders', async () => {
|
||||
const user = userEvent.setup()
|
||||
const folderNode = createFolderNode()
|
||||
const { props } = renderFileTreeItem({}, folderNode)
|
||||
|
||||
const folder = screen.getByText('scripts').closest('[data-skill-file-tree-item]')
|
||||
expect(folder).toBeInstanceOf(HTMLElement)
|
||||
await user.dblClick(folder as HTMLElement)
|
||||
expect(props.onToggleFolder).toHaveBeenCalledWith('scripts')
|
||||
|
||||
await user.click(
|
||||
within(folder as HTMLElement).getByRole('button', { name: 'common.operation.more' }),
|
||||
)
|
||||
await user.click(await screen.findByText('skill.skillManagement.detail.createFileMenu'))
|
||||
await user.click(
|
||||
within(folder as HTMLElement).getByRole('button', { name: 'common.operation.more' }),
|
||||
)
|
||||
await user.click(await screen.findByText('skill.skillManagement.detail.createFolderMenu'))
|
||||
await user.click(
|
||||
within(folder as HTMLElement).getByRole('button', { name: 'common.operation.more' }),
|
||||
)
|
||||
await user.click(await screen.findByText('skill.skillManagement.detail.uploadFilesMenu'))
|
||||
|
||||
expect(props.onCreate).toHaveBeenCalledWith('file', 'scripts')
|
||||
expect(props.onCreate).toHaveBeenCalledWith('directory', 'scripts')
|
||||
})
|
||||
|
||||
it('renders a rename input for the active inline action', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSubmitInlineAction = vi.fn()
|
||||
renderFileTreeItem({
|
||||
inlineAction: {
|
||||
kind: 'rename',
|
||||
nodeType: 'file',
|
||||
path: 'scripts/example.ts',
|
||||
},
|
||||
onSubmitInlineAction,
|
||||
})
|
||||
|
||||
const input = screen.getByDisplayValue('example.ts')
|
||||
await user.clear(input)
|
||||
await user.type(input, 'renamed.ts{Enter}')
|
||||
|
||||
expect(onSubmitInlineAction).toHaveBeenCalledWith('renamed.ts')
|
||||
})
|
||||
|
||||
it('hides file action controls in read-only mode', () => {
|
||||
renderFileTreeItem({ readonly: true })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'example.ts' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'common.operation.more' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
89
web/features/skills/detail/__tests__/shared.spec.ts
Normal file
89
web/features/skills/detail/__tests__/shared.spec.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createUploadItemId,
|
||||
getErrorCode,
|
||||
getErrorDetailNumber,
|
||||
getErrorDetailString,
|
||||
getUploadFileName,
|
||||
getUploadPath,
|
||||
isEditableKeyboardTarget,
|
||||
joinSkillPath,
|
||||
} from '../shared'
|
||||
|
||||
describe('skill detail shared utilities', () => {
|
||||
it('reads error codes and details from supported error shapes', () => {
|
||||
expect(getErrorCode({ code: 'direct' })).toBe('direct')
|
||||
expect(getErrorCode({ data: { code: 'data' } })).toBe('data')
|
||||
expect(getErrorCode({ body: { code: 'body' } })).toBe('body')
|
||||
expect(getErrorCode('error')).toBeUndefined()
|
||||
|
||||
expect(
|
||||
getErrorDetailNumber({ details: { current_updated_at: 12 } }, 'current_updated_at'),
|
||||
).toBe(12)
|
||||
expect(
|
||||
getErrorDetailNumber({ data: { details: { current_updated_at: 13 } } }, 'current_updated_at'),
|
||||
).toBe(13)
|
||||
expect(
|
||||
getErrorDetailString(
|
||||
{ body: { details: { current_file_hash: 'hash' } } },
|
||||
'current_file_hash',
|
||||
),
|
||||
).toBe('hash')
|
||||
expect(
|
||||
getErrorDetailString({ details: { current_file_hash: 1 } }, 'current_file_hash'),
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('normalizes upload paths and names', () => {
|
||||
const plainFile = new File(['guide'], 'guide.md', { type: 'text/markdown' })
|
||||
const nestedFile = new File(['guide'], 'guide.md', { type: 'text/markdown' })
|
||||
Object.defineProperty(nestedFile, 'webkitRelativePath', {
|
||||
configurable: true,
|
||||
value: 'folder/guide.md',
|
||||
})
|
||||
|
||||
expect(joinSkillPath(undefined, '/guide.md')).toBe('guide.md')
|
||||
expect(joinSkillPath('/references/', '/guide.md')).toBe('references/guide.md')
|
||||
expect(getUploadPath(plainFile, 'references')).toBe('references/guide.md')
|
||||
expect(getUploadPath(nestedFile, 'references')).toBe('references/folder/guide.md')
|
||||
expect(getUploadFileName(nestedFile)).toBe('folder/guide.md')
|
||||
})
|
||||
|
||||
it('detects editable keyboard targets', () => {
|
||||
const input = document.createElement('input')
|
||||
const textarea = document.createElement('textarea')
|
||||
const select = document.createElement('select')
|
||||
const editor = document.createElement('div')
|
||||
editor.contentEditable = 'true'
|
||||
const nested = document.createElement('span')
|
||||
editor.appendChild(nested)
|
||||
const plain = document.createElement('button')
|
||||
|
||||
expect(isEditableKeyboardTarget(input)).toBe(true)
|
||||
expect(isEditableKeyboardTarget(textarea)).toBe(true)
|
||||
expect(isEditableKeyboardTarget(select)).toBe(true)
|
||||
expect(isEditableKeyboardTarget(editor)).toBe(true)
|
||||
expect(isEditableKeyboardTarget(nested)).toBe(true)
|
||||
expect(isEditableKeyboardTarget(plain)).toBe(false)
|
||||
expect(isEditableKeyboardTarget(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to a deterministic upload item id when randomUUID is unavailable', () => {
|
||||
const originalCrypto = globalThis.crypto
|
||||
Object.defineProperty(globalThis, 'crypto', {
|
||||
configurable: true,
|
||||
value: {},
|
||||
})
|
||||
try {
|
||||
const file = new File(['guide'], 'guide.md', { type: 'text/markdown' })
|
||||
vi.spyOn(file, 'lastModified', 'get').mockReturnValue(123)
|
||||
|
||||
expect(createUploadItemId(file, 2)).toBe('guide.md-5-123-2')
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'crypto', {
|
||||
configurable: true,
|
||||
value: originalCrypto,
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
34
web/features/skills/detail/__tests__/shell.spec.tsx
Normal file
34
web/features/skills/detail/__tests__/shell.spec.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DetailSkeleton, SkillDetailRightPanelRail } from '../shell'
|
||||
|
||||
describe('Skill detail shell', () => {
|
||||
it('opens right-panel tools from the rail', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onOpenBuilder = vi.fn()
|
||||
const onOpenVersions = vi.fn()
|
||||
|
||||
render(
|
||||
<SkillDetailRightPanelRail onOpenBuilder={onOpenBuilder} onOpenVersions={onOpenVersions} />,
|
||||
)
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'skill.skillManagement.detail.builder.open' }),
|
||||
)
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'skill.skillManagement.detail.versionHistory' }),
|
||||
)
|
||||
|
||||
expect(onOpenBuilder).toHaveBeenCalledOnce()
|
||||
expect(onOpenVersions).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('renders the loading skeleton layout', () => {
|
||||
const { container } = render(<DetailSkeleton />)
|
||||
|
||||
expect(container.firstChild).toHaveClass('flex', 'h-0', 'grow')
|
||||
expect(container.querySelectorAll('.opacity-20')).toHaveLength(2)
|
||||
expect(container.querySelectorAll('.opacity-10')).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
@ -241,6 +241,21 @@ export function SkillBuilderPanel({
|
||||
return nextMessages
|
||||
})
|
||||
}
|
||||
const replaceAssistantMessageWithError = (assistantMessageId: string, errorMessage: string) => {
|
||||
rawAssistantMessagesRef.current.set(assistantMessageId, errorMessage)
|
||||
updateMessages((currentMessages) =>
|
||||
currentMessages.map((message) =>
|
||||
message.id === assistantMessageId
|
||||
? {
|
||||
...message,
|
||||
content: errorMessage,
|
||||
rawContent: errorMessage,
|
||||
tone: 'error',
|
||||
}
|
||||
: message,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
detailRef.current = detail
|
||||
@ -463,7 +478,10 @@ export function SkillBuilderPanel({
|
||||
thinkingElapsedSecondsRef.current = 0
|
||||
isSendingRef.current = false
|
||||
assistAbortControllerRef.current = null
|
||||
if (hasError && errorMessage) toast.error(errorMessage)
|
||||
if (hasError && errorMessage) {
|
||||
replaceAssistantMessageWithError(assistantMessageId, errorMessage)
|
||||
toast.error(errorMessage)
|
||||
}
|
||||
},
|
||||
onError: (errorMessage) => {
|
||||
const thinkingDurationSeconds = thinkingElapsedSecondsRef.current
|
||||
@ -477,7 +495,10 @@ export function SkillBuilderPanel({
|
||||
thinkingElapsedSecondsRef.current = 0
|
||||
isSendingRef.current = false
|
||||
assistAbortControllerRef.current = null
|
||||
if (errorMessage) toast.error(errorMessage)
|
||||
if (errorMessage) {
|
||||
replaceAssistantMessageWithError(assistantMessageId, errorMessage)
|
||||
toast.error(errorMessage)
|
||||
}
|
||||
},
|
||||
}).catch((error: unknown) => {
|
||||
const thinkingDurationSeconds = thinkingElapsedSecondsRef.current
|
||||
@ -578,7 +599,11 @@ export function SkillBuilderPanel({
|
||||
) : (
|
||||
<div
|
||||
key={message.id}
|
||||
className="flex w-full max-w-[720px] flex-col items-start gap-1 text-text-secondary"
|
||||
className={cn(
|
||||
'flex w-full max-w-[720px] flex-col items-start gap-1 text-text-secondary',
|
||||
message.tone === 'error' &&
|
||||
'rounded-xl border border-state-destructive-border bg-state-destructive-hover px-3 py-2 text-text-destructive',
|
||||
)}
|
||||
>
|
||||
{message.thinkingDurationSeconds !== undefined && (
|
||||
<SkillBuilderThinkingMessage
|
||||
|
||||
@ -51,6 +51,7 @@ import {
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { matchesKeyboardEvent, useHotkey } from '@tanstack/react-hotkeys'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
|
||||
@ -250,6 +251,7 @@ export function FileTree({
|
||||
const { t } = useTranslation('skill')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const queryClient = useQueryClient()
|
||||
const sidebarRef = useRef<HTMLElement>(null)
|
||||
const referencesRegionRef = useRef<HTMLDivElement>(null)
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null)
|
||||
const [inlineAction, setInlineAction] = useState<FileTreeInlineAction>()
|
||||
@ -687,6 +689,13 @@ export function FileTree({
|
||||
if (filePaths.length === 0) return
|
||||
|
||||
setClipboard({ mode: 'copy', paths: filePaths })
|
||||
const copiedFile = filePaths.length === 1 ? findFileByPath(files, filePaths[0]) : undefined
|
||||
if (copiedFile && typeof copiedFile.content === 'string') {
|
||||
copy(copiedFile.content)
|
||||
toast.success(t(($) => $['skillManagement.detail.copyContentSuccess']))
|
||||
return
|
||||
}
|
||||
|
||||
toast.success(t(($) => $['skillManagement.detail.copyFileSuccess']))
|
||||
}
|
||||
|
||||
@ -873,6 +882,8 @@ export function FileTree({
|
||||
const shortcutTargetPath = selectedPaths[0] ?? selectedPath
|
||||
const fileShortcutEnabled =
|
||||
!readonly && !!shortcutTargetPath && !fileMutation.isPending && !inlineAction
|
||||
const isInSidebar = (target: EventTarget | null) =>
|
||||
target instanceof Node && !!sidebarRef.current?.contains(target)
|
||||
const handleOpenMenuHotkey = useEffectEvent((event: globalThis.KeyboardEvent) => {
|
||||
if (readonly || fileMutation.isPending || inlineAction) return
|
||||
if (!(event.target instanceof Element) || !event.target.closest('[role="menu"]')) return
|
||||
@ -916,35 +927,40 @@ export function FileTree({
|
||||
skillFileHotkeys.cut.command,
|
||||
(event) => {
|
||||
if (!shortcutTargetPath) return
|
||||
if (!isInSidebar(event.target)) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
handleCut(shortcutTargetPath)
|
||||
},
|
||||
{
|
||||
enabled: fileShortcutEnabled,
|
||||
ignoreInputs: true,
|
||||
preventDefault: true,
|
||||
stopPropagation: true,
|
||||
preventDefault: false,
|
||||
stopPropagation: false,
|
||||
},
|
||||
)
|
||||
useHotkey(
|
||||
skillFileHotkeys.copy.command,
|
||||
(event) => {
|
||||
if (!shortcutTargetPath) return
|
||||
if (!isInSidebar(event.target)) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
handleCopy(shortcutTargetPath)
|
||||
},
|
||||
{
|
||||
enabled: fileShortcutEnabled,
|
||||
ignoreInputs: true,
|
||||
preventDefault: true,
|
||||
stopPropagation: true,
|
||||
preventDefault: false,
|
||||
stopPropagation: false,
|
||||
},
|
||||
)
|
||||
|
||||
const handleNativeCopy = useEffectEvent((event: ClipboardEvent) => {
|
||||
if (!fileShortcutEnabled || !shortcutTargetPath) return
|
||||
if (!isInSidebar(event.target)) return
|
||||
if (isEditableKeyboardTarget(event.target)) return
|
||||
|
||||
event.preventDefault()
|
||||
@ -952,6 +968,7 @@ export function FileTree({
|
||||
})
|
||||
const handleNativeCut = useEffectEvent((event: ClipboardEvent) => {
|
||||
if (!fileShortcutEnabled || !shortcutTargetPath) return
|
||||
if (!isInSidebar(event.target)) return
|
||||
if (isEditableKeyboardTarget(event.target)) return
|
||||
|
||||
event.preventDefault()
|
||||
@ -970,6 +987,7 @@ export function FileTree({
|
||||
useEffect(() => {
|
||||
const handlePasteEvent = (event: ClipboardEvent) => {
|
||||
if (readonly || !clipboard || fileMutation.isPending) return
|
||||
if (!isInSidebar(event.target)) return
|
||||
if (isEditableKeyboardTarget(event.target)) return
|
||||
|
||||
event.preventDefault()
|
||||
@ -1058,6 +1076,7 @@ export function FileTree({
|
||||
return (
|
||||
<>
|
||||
<aside
|
||||
ref={sidebarRef}
|
||||
data-testid="skill-detail-sidebar-shell"
|
||||
className={cn(
|
||||
'relative flex h-full shrink-0 bg-background-body p-1',
|
||||
|
||||
@ -142,6 +142,7 @@ export type BuilderChatMessage = {
|
||||
rawContent?: string
|
||||
role: 'assistant' | 'user'
|
||||
thinkingDurationSeconds?: number
|
||||
tone?: 'error'
|
||||
}
|
||||
|
||||
export type SkillBuilderAttachment = {
|
||||
|
||||
@ -492,8 +492,9 @@
|
||||
"skillManagement.detail.closeFileTab": "Close {{name}}",
|
||||
"skillManagement.detail.closeVersions": "Close versions",
|
||||
"skillManagement.detail.collapseSidebar": "Collapse sidebar",
|
||||
"skillManagement.detail.copyContentSuccess": "Content copied to clipboard.",
|
||||
"skillManagement.detail.copyFile": "Copy",
|
||||
"skillManagement.detail.copyFileSuccess": "File copied to clipboard.",
|
||||
"skillManagement.detail.copyFileSuccess": "File ready to paste.",
|
||||
"skillManagement.detail.copyVersionId": "Copy ID",
|
||||
"skillManagement.detail.copyVersionIdSuccess": "ID copied to clipboard.",
|
||||
"skillManagement.detail.createFile": "New file",
|
||||
@ -507,7 +508,7 @@
|
||||
"skillManagement.detail.createdBy": "Created by {{name}}",
|
||||
"skillManagement.detail.currentDraft": "Current draft",
|
||||
"skillManagement.detail.cutFile": "Cut",
|
||||
"skillManagement.detail.cutFileSuccess": "File cut to clipboard.",
|
||||
"skillManagement.detail.cutFileSuccess": "File ready to move.",
|
||||
"skillManagement.detail.deleteFileConfirm": "Delete this file?",
|
||||
"skillManagement.detail.deleteFileSuccess": "File deleted.",
|
||||
"skillManagement.detail.deleteVersionConfirm": "Delete this version?",
|
||||
|
||||
@ -57,8 +57,9 @@
|
||||
"skillManagement.detail.closeFileTab": "Close {{name}}",
|
||||
"skillManagement.detail.closeVersions": "Close versions",
|
||||
"skillManagement.detail.collapseSidebar": "Collapse sidebar",
|
||||
"skillManagement.detail.copyContentSuccess": "Content copied to clipboard.",
|
||||
"skillManagement.detail.copyFile": "Copy",
|
||||
"skillManagement.detail.copyFileSuccess": "File copied to clipboard.",
|
||||
"skillManagement.detail.copyFileSuccess": "File ready to paste.",
|
||||
"skillManagement.detail.copyVersionId": "Copy ID",
|
||||
"skillManagement.detail.copyVersionIdSuccess": "ID copied to clipboard.",
|
||||
"skillManagement.detail.createFile": "New file",
|
||||
@ -72,7 +73,7 @@
|
||||
"skillManagement.detail.createdBy": "Created by {{name}}",
|
||||
"skillManagement.detail.currentDraft": "Current draft",
|
||||
"skillManagement.detail.cutFile": "Cut",
|
||||
"skillManagement.detail.cutFileSuccess": "File cut to clipboard.",
|
||||
"skillManagement.detail.cutFileSuccess": "File ready to move.",
|
||||
"skillManagement.detail.deleteFileConfirm": "Delete this file?",
|
||||
"skillManagement.detail.deleteFileSuccess": "File deleted.",
|
||||
"skillManagement.detail.deleteVersionConfirm": "Delete this version?",
|
||||
|
||||
@ -51,6 +51,9 @@
|
||||
"skillManagement.detail.cancelAddMetadata": "Cancel adding metadata",
|
||||
"skillManagement.detail.closeFileTab": "Close {{name}}",
|
||||
"skillManagement.detail.collapseSidebar": "サイドバーを折りたたむ",
|
||||
"skillManagement.detail.copyContentSuccess": "内容をクリップボードにコピーしました。",
|
||||
"skillManagement.detail.copyFile": "コピー",
|
||||
"skillManagement.detail.copyFileSuccess": "ファイルを貼り付けできる状態にしました。",
|
||||
"skillManagement.detail.createFile": "New file",
|
||||
"skillManagement.detail.createFileDescription": "Enter a file path relative to the skill root.",
|
||||
"skillManagement.detail.createFileMenu": "New file...",
|
||||
@ -61,6 +64,8 @@
|
||||
"skillManagement.detail.createFolderSuccess": "Folder created.",
|
||||
"skillManagement.detail.createdBy": "Created by {{name}}",
|
||||
"skillManagement.detail.currentDraft": "Current draft",
|
||||
"skillManagement.detail.cutFile": "切り取り",
|
||||
"skillManagement.detail.cutFileSuccess": "ファイルを移動できる状態にしました。",
|
||||
"skillManagement.detail.deleteFileConfirm": "Delete this file?",
|
||||
"skillManagement.detail.deleteFileSuccess": "File deleted.",
|
||||
"skillManagement.detail.deleteVersionConfirm": "Delete this version?",
|
||||
@ -88,6 +93,8 @@
|
||||
"skillManagement.detail.noSearchResults": "一致するファイルはありません。",
|
||||
"skillManagement.detail.noSelectableTags": "利用可能なタグはありません。",
|
||||
"skillManagement.detail.noVersions": "No published versions yet.",
|
||||
"skillManagement.detail.pasteFile": "貼り付け",
|
||||
"skillManagement.detail.pasteFileSuccess": "ファイルを貼り付けました。",
|
||||
"skillManagement.detail.previewUnsupported": "Preview is not supported for this file.",
|
||||
"skillManagement.detail.publish": "Publish",
|
||||
"skillManagement.detail.publishFailed": "Failed to publish skill.",
|
||||
|
||||
@ -492,8 +492,9 @@
|
||||
"skillManagement.detail.closeFileTab": "关闭 {{name}}",
|
||||
"skillManagement.detail.closeVersions": "关闭版本面板",
|
||||
"skillManagement.detail.collapseSidebar": "折叠侧边栏",
|
||||
"skillManagement.detail.copyContentSuccess": "内容已复制到剪贴板。",
|
||||
"skillManagement.detail.copyFile": "复制",
|
||||
"skillManagement.detail.copyFileSuccess": "文件已复制到剪贴板。",
|
||||
"skillManagement.detail.copyFileSuccess": "文件已复制,可粘贴。",
|
||||
"skillManagement.detail.copyVersionId": "复制 ID",
|
||||
"skillManagement.detail.copyVersionIdSuccess": "ID 已复制到剪贴板。",
|
||||
"skillManagement.detail.createFile": "新建文件",
|
||||
@ -507,7 +508,7 @@
|
||||
"skillManagement.detail.createdBy": "由 {{name}} 创建",
|
||||
"skillManagement.detail.currentDraft": "当前草稿",
|
||||
"skillManagement.detail.cutFile": "剪切",
|
||||
"skillManagement.detail.cutFileSuccess": "文件已剪切到剪贴板。",
|
||||
"skillManagement.detail.cutFileSuccess": "文件已剪切,可移动。",
|
||||
"skillManagement.detail.deleteFileConfirm": "删除这个文件?",
|
||||
"skillManagement.detail.deleteFileSuccess": "文件已删除。",
|
||||
"skillManagement.detail.deleteVersionConfirm": "删除这个版本?",
|
||||
|
||||
@ -57,8 +57,9 @@
|
||||
"skillManagement.detail.closeFileTab": "关闭 {{name}}",
|
||||
"skillManagement.detail.closeVersions": "关闭版本面板",
|
||||
"skillManagement.detail.collapseSidebar": "折叠侧边栏",
|
||||
"skillManagement.detail.copyContentSuccess": "内容已复制到剪贴板。",
|
||||
"skillManagement.detail.copyFile": "复制",
|
||||
"skillManagement.detail.copyFileSuccess": "文件已复制到剪贴板。",
|
||||
"skillManagement.detail.copyFileSuccess": "文件已复制,可粘贴。",
|
||||
"skillManagement.detail.copyVersionId": "复制 ID",
|
||||
"skillManagement.detail.copyVersionIdSuccess": "ID 已复制到剪贴板。",
|
||||
"skillManagement.detail.createFile": "新建文件",
|
||||
@ -72,7 +73,7 @@
|
||||
"skillManagement.detail.createdBy": "由 {{name}} 创建",
|
||||
"skillManagement.detail.currentDraft": "当前草稿",
|
||||
"skillManagement.detail.cutFile": "剪切",
|
||||
"skillManagement.detail.cutFileSuccess": "文件已剪切到剪贴板。",
|
||||
"skillManagement.detail.cutFileSuccess": "文件已剪切,可移动。",
|
||||
"skillManagement.detail.deleteFileConfirm": "删除这个文件?",
|
||||
"skillManagement.detail.deleteFileSuccess": "文件已删除。",
|
||||
"skillManagement.detail.deleteVersionConfirm": "删除这个版本?",
|
||||
|
||||
@ -459,6 +459,43 @@ describe('ssePost and sseGet', () => {
|
||||
expect(toast.error).toHaveBeenCalledWith('Error: stream lost')
|
||||
})
|
||||
|
||||
it('should not notify stream reader failures when silent', async () => {
|
||||
const onError = vi.fn()
|
||||
const onCompleted = vi.fn()
|
||||
const mockReader = {
|
||||
read: vi.fn().mockRejectedValueOnce(new Error('stream lost')),
|
||||
}
|
||||
const response = {
|
||||
status: 200,
|
||||
ok: true,
|
||||
body: {
|
||||
getReader: () => mockReader,
|
||||
},
|
||||
} as unknown as Response
|
||||
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(response)
|
||||
|
||||
await ssePost(
|
||||
'/chat-messages',
|
||||
{
|
||||
body: {
|
||||
query: 'hello',
|
||||
},
|
||||
},
|
||||
{
|
||||
onError,
|
||||
onCompleted,
|
||||
silent: true,
|
||||
},
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onError).toHaveBeenCalledWith('Error: stream lost', 'stream_read_error')
|
||||
})
|
||||
expect(onCompleted).toHaveBeenCalledWith(true, 'Error: stream lost')
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not notify when the stream reader is aborted', async () => {
|
||||
const onError = vi.fn()
|
||||
const onCompleted = vi.fn()
|
||||
|
||||
@ -556,6 +556,7 @@ export const ssePost = async (
|
||||
onDataSourceNodeCompleted,
|
||||
onDataSourceNodeError,
|
||||
onUnhandledEvent,
|
||||
silent,
|
||||
} = otherOptions
|
||||
const abortController = new AbortController()
|
||||
|
||||
@ -617,7 +618,7 @@ export const ssePost = async (
|
||||
}
|
||||
} else {
|
||||
res.json().then((data) => {
|
||||
toast.error(data.message || 'Server Error')
|
||||
if (!silent) toast.error(data.message || 'Server Error')
|
||||
})
|
||||
onError?.('Server Error')
|
||||
}
|
||||
@ -629,7 +630,8 @@ export const ssePost = async (
|
||||
if (moreInfo.errorMessage) {
|
||||
onError?.(moreInfo.errorMessage, moreInfo.errorCode)
|
||||
// These errors can happen when a stream is intentionally stopped or its page is left.
|
||||
if (shouldNotifyStreamError(moreInfo.errorMessage)) toast.error(moreInfo.errorMessage)
|
||||
if (!silent && shouldNotifyStreamError(moreInfo.errorMessage))
|
||||
toast.error(moreInfo.errorMessage)
|
||||
return
|
||||
}
|
||||
onData?.(str, isFirstMessage, moreInfo)
|
||||
@ -670,7 +672,7 @@ export const ssePost = async (
|
||||
})
|
||||
.catch((e) => {
|
||||
const errorMessage = String(e)
|
||||
if (shouldNotifyStreamError(e)) toast.error(errorMessage)
|
||||
if (!silent && shouldNotifyStreamError(e)) toast.error(errorMessage)
|
||||
onError?.(errorMessage)
|
||||
})
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user