fix: fix some ui issue

This commit is contained in:
fatelei 2026-08-03 14:46:07 +08:00
parent 3ac6e1b115
commit f0fb69f4c7
No known key found for this signature in database
GPG Key ID: 2F91DA05646F4EED
19 changed files with 1192 additions and 41 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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()
})
})

View 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,
})
}
})
})

View 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)
})
})

View File

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

View File

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

View File

@ -142,6 +142,7 @@ export type BuilderChatMessage = {
rawContent?: string
role: 'assistant' | 'user'
thinkingDurationSeconds?: number
tone?: 'error'
}
export type SkillBuilderAttachment = {

View File

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

View File

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

View File

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

View File

@ -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": "删除这个版本?",

View File

@ -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": "删除这个版本?",

View File

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

View File

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