fix: fix some bugs

This commit is contained in:
fatelei 2026-08-04 13:38:56 +08:00
parent 2f5fee324a
commit 5a13489fdd
No known key found for this signature in database
GPG Key ID: 2F91DA05646F4EED
9 changed files with 519 additions and 199 deletions

View File

@ -133,9 +133,21 @@ 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.
Use a low-friction progressive flow. If the user's intent is clear enough,
create or revise a useful draft immediately instead of blocking on questions.
If important details are missing, make conservative assumptions and mention at
most one optional clarification in the reply. Every reply should include 2-3
short suggested user replies that the UI can show as clickable chips. The
suggestions must be concrete next refinements the user could choose, not generic
commands.
Respond with JSON only:
{
"reply": "short user-facing summary",
"suggestions": [
"Use this for ecommerce refund escalation",
"Ask me about required inputs first"
],
"operations": [
{
"operation": "upsert_text",
@ -398,6 +410,7 @@ class SkillAssistActionPlan(BaseModel):
model_config = ConfigDict(extra="forbid")
reply: str = Field(default="", max_length=4_000)
suggestions: list[str] = Field(default_factory=list, max_length=3)
operations: list[SkillAssistDraftOperationPayload] = Field(default_factory=list, max_length=10)
@ -788,6 +801,15 @@ class SkillManagementService:
"answer": reply,
}
)
suggestions = [suggestion.strip() for suggestion in plan.suggestions if suggestion.strip()]
if suggestions:
yield self._assistant_sse(
{
"event": "skill_assistant_suggestions",
"id": message_id,
"suggestions": suggestions[:3],
}
)
detail: dict[str, Any] | None = None
applied_operations: list[dict[str, str]] = []
@ -914,7 +936,7 @@ class SkillManagementService:
except json.JSONDecodeError:
parsed = json_repair.loads(raw_text)
try:
return SkillAssistActionPlan.model_validate(parsed)
plan = SkillAssistActionPlan.model_validate(parsed)
except ValidationError as exc:
raise SkillManagementServiceError(
"invalid_skill_assistant_response",
@ -922,6 +944,70 @@ class SkillManagementService:
status_code=422,
details={"raw_response": raw_text[:2_000]},
) from exc
if not any(suggestion.strip() for suggestion in plan.suggestions):
plan = plan.model_copy(
update={
"suggestions": self._generate_assistant_suggestions(
model_instance=model_instance,
model_parameters=model_parameters,
user_message=message,
assistant_reply=plan.reply,
)
}
)
return plan
def _generate_assistant_suggestions(
self,
*,
model_instance: Any,
model_parameters: dict[str, Any],
user_message: str,
assistant_reply: str,
) -> list[str]:
prompt = (
"Generate 2-3 concise clickable follow-up replies the user could send next.\n"
"They must be specific to the user's Skill Builder request and the assistant reply.\n"
"Do not include generic actions like continue, ok, or looks good.\n"
"Return exactly one JSON object and no markdown fences, prose, or explanation.\n"
"Required schema: {\"suggestions\": [\"...\", \"...\"]}\n\n"
f"User request:\n{user_message}\n\n"
f"Assistant reply:\n{assistant_reply}"
)
try:
response = model_instance.invoke_llm(
prompt_messages=[
SystemPromptMessage(content="You generate concise suggested user replies for Dify Skill Builder."),
UserPromptMessage(content=prompt),
],
model_parameters=model_parameters,
stream=False,
)
raw_text = response.message.get_text_content()
try:
parsed = json.loads(raw_text)
except json.JSONDecodeError:
parsed = json_repair.loads(raw_text)
except Exception:
logger.warning("skill_assistant_suggestions_failed", exc_info=True)
return []
if isinstance(parsed, list):
suggestions = parsed
elif isinstance(parsed, dict):
suggestions = (
parsed.get("suggestions")
or parsed.get("suggested_replies")
or parsed.get("follow_up_suggestions")
or parsed.get("quick_replies")
)
else:
return []
if not isinstance(suggestions, list):
return []
return [suggestion.strip() for suggestion in suggestions if isinstance(suggestion, str) and suggestion.strip()][
:3
]
def _resolve_assistant_model(
self,
@ -1199,14 +1285,6 @@ class SkillManagementService:
self._check_expected_updated_at(skill, payload.expected_updated_at)
if payload.display_name is not None:
skill.display_name = payload.display_name
if self._should_auto_sync_name(skill):
skill.name = self._generate_name_from_display_name(
session,
tenant_id=tenant_id,
display_name=payload.display_name,
current_skill_id=skill.id,
)
self._sync_skill_md_text_file(session, skill=skill)
if payload.icon is not None:
skill.icon = payload.icon
if payload.tags is not None:

View File

@ -404,6 +404,7 @@ def test_create_assistant_action_stream_applies_file_operations_and_returns_deta
model_output = json.dumps(
{
"reply": "Created the refund policy reference.",
"suggestions": ["Add escalation rules", "Include refund examples"],
"operations": [
{
"operation": "upsert_text",
@ -433,10 +434,16 @@ def test_create_assistant_action_stream_applies_file_operations_and_returns_deta
)
events = [json.loads(chunk.removeprefix("data: ").strip()) for chunk in response]
assert [event["event"] for event in events] == ["message", "skill_detail_updated", "message_end"]
assert [event["event"] for event in events] == [
"message",
"skill_assistant_suggestions",
"skill_detail_updated",
"message_end",
]
assert events[0]["answer"] == "Created the refund policy reference."
assert events[1]["operations"] == [{"operation": "upsert_text", "path": "references/refund-policy.md"}]
assert any(file["path"] == "references/refund-policy.md" for file in events[1]["detail"]["files"])
assert events[1]["suggestions"] == ["Add escalation rules", "Include refund examples"]
assert events[2]["operations"] == [{"operation": "upsert_text", "path": "references/refund-policy.md"}]
assert any(file["path"] == "references/refund-policy.md" for file in events[2]["detail"]["files"])
draft = service.get_skill(tenant_id=TENANT, skill_id=created["id"])
reference = next(file for file in draft["files"] if file["path"] == "references/refund-policy.md")
@ -493,6 +500,58 @@ def test_create_assistant_action_stream_strips_skill_frontmatter_from_reference_
assert reference["content"] == "# Refund Policy\n"
def test_create_assistant_action_stream_generates_missing_suggestions() -> None:
service = SkillManagementService(tool_file_manager=_FakeToolFileManager())
created = service.create_skill(
tenant_id=TENANT,
user_id=USER,
payload=SkillCreatePayload(name="refund-sop", description="Handle refund requests."),
)
model_outputs = [
json.dumps(
{
"reply": "Created the refund policy reference.",
"operations": [
{
"operation": "upsert_text",
"path": "references/refund-policy.md",
"mime_type": "text/markdown",
"content": "# Refund Policy\n",
}
],
}
),
json.dumps({"follow_up_suggestions": ["Add SLA tiers", "Include refund denial templates"]}),
]
def invoke_llm(**_kwargs):
return SimpleNamespace(
message=SimpleNamespace(get_text_content=lambda: model_outputs.pop(0)),
)
model = SimpleNamespace(invoke_llm=invoke_llm)
manager = SimpleNamespace(get_default_model_instance=lambda **_kwargs: model)
with patch("services.skill_management_service.ModelManager.for_tenant", return_value=manager):
response = list(
service.create_assistant_action_stream(
tenant_id=TENANT,
user_id=USER,
skill_id=created["id"],
message="新建 references/refund-policy.md",
)
)
events = [json.loads(chunk.removeprefix("data: ").strip()) for chunk in response]
assert [event["event"] for event in events] == [
"message",
"skill_assistant_suggestions",
"skill_detail_updated",
"message_end",
]
assert events[1]["suggestions"] == ["Add SLA tiers", "Include refund denial templates"]
def test_create_assistant_action_stream_reports_skill_name_database_conflict() -> None:
service = SkillManagementService(tool_file_manager=_FakeToolFileManager())
created = service.create_skill(
@ -677,9 +736,10 @@ def test_sync_assistant_model_config_updates_draft_without_active_snapshot() ->
assert agent.active_config_has_model is True
def test_update_display_name_auto_syncs_name_for_unpublished_placeholder() -> None:
def test_update_display_name_keeps_name_and_draft_content_unchanged() -> None:
service = SkillManagementService(tool_file_manager=_FakeToolFileManager())
created = service.create_skill(tenant_id=TENANT, user_id=USER, payload=SkillCreatePayload())
original_skill_md = next(item for item in service.get_skill(tenant_id=TENANT, skill_id=created["id"])["files"])
updated = service.update_metadata(
tenant_id=TENANT,
@ -689,11 +749,10 @@ def test_update_display_name_auto_syncs_name_for_unpublished_placeholder() -> No
)
assert updated["display_name"] == "Finance Audit"
assert updated["name"] == "finance-audit"
assert updated["name"] == created["name"]
assert updated["name_manually_edited"] is False
skill_md = next(item for item in service.get_skill(tenant_id=TENANT, skill_id=created["id"])["files"])
assert "name: finance-audit" in skill_md["content"]
assert "display-name: Finance Audit" in skill_md["content"]
assert skill_md["content"] == original_skill_md["content"]
def test_frontmatter_name_change_marks_manual_takeover_and_stops_display_name_sync() -> None:
@ -724,7 +783,7 @@ def test_frontmatter_name_change_marks_manual_takeover_and_stops_display_name_sy
assert updated["display_name"] == "Finance Audit"
skill_md = next(item for item in service.get_skill(tenant_id=TENANT, skill_id=created["id"])["files"])
assert "name: manual-name" in skill_md["content"]
assert "display-name: Finance Audit" in skill_md["content"]
assert "display-name: Finance Audit" not in skill_md["content"]
def test_delete_unreferenced_placeholder_skill_deletes_initial_draft() -> None:

View File

@ -1620,39 +1620,25 @@ describe('SkillDetailPage', () => {
expect(screen.getByText(/skill\.skillManagement\.detail\.saveFailed/)).toBeInTheDocument()
})
it('saves the live display name into SKILL.md before publishing', async () => {
it('does not expose display-name editing in the SKILL.md metadata editor before publishing', async () => {
const user = userEvent.setup()
renderSkillDetailPage()
const displayNameInput = await screen.findByDisplayValue('Untitled skill')
await user.clear(displayNameInput)
await user.type(displayNameInput, '333333333')
expect(await screen.findByText('name')).toBeInTheDocument()
expect(screen.getByText('description')).toBeInTheDocument()
expect(screen.queryByText('display-name')).not.toBeInTheDocument()
await user.click(
screen.getByRole('button', { name: 'skill.skillManagement.detail.publishUpdate' }),
)
await waitFor(
() => {
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalled()
},
{ timeout: 2500 },
)
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({
content: expect.stringContaining('display-name: 333333333'),
operation: 'upsert_text',
path: 'SKILL.md',
}),
}),
expect.anything(),
)
expect(mocks.saveDraftFileMutationFn).not.toHaveBeenCalled()
await waitFor(() => {
expect(mocks.publishSkillMutationFn).toHaveBeenCalled()
})
})
it('renames the skill from the sidebar title and keeps SKILL.md metadata in sync', async () => {
it('renames the skill from the sidebar title without changing SKILL.md content', async () => {
const user = userEvent.setup()
renderSkillDetailPage()
@ -1675,23 +1661,17 @@ describe('SkillDetailPage', () => {
await user.type(renameInput, 'Renamed skill{Enter}')
await waitFor(() => {
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledWith(
expect(mocks.skillMetadataMutationFn).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({
content: expect.stringMatching(
/name: github-actions-failure-debugging[\s\S]*display-name: Renamed skill/,
),
operation: 'upsert_text',
path: 'SKILL.md',
display_name: 'Renamed skill',
}),
}),
expect.anything(),
)
})
const savedContent = mocks.saveDraftFileMutationFn.mock.calls[0]?.[0].body.content
expect(savedContent).not.toContain('name: renamed-skill')
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledTimes(1)
expect(mocks.skillMetadataMutationFn).not.toHaveBeenCalled()
expect(mocks.saveDraftFileMutationFn).not.toHaveBeenCalled()
expect(mocks.skillMetadataMutationFn).toHaveBeenCalledTimes(1)
expect(toast.success).toHaveBeenCalledWith('skill.skillManagement.detail.renameSkillSuccess')
await waitFor(() => {
expect(screen.getByRole('button', { name: 'common.operation.rename' })).toHaveTextContent(
@ -1700,28 +1680,14 @@ describe('SkillDetailPage', () => {
})
})
it('updates display-name from the manifest editor without changing name', async () => {
const user = userEvent.setup()
it('keeps display-name hidden in the SKILL.md metadata editor', async () => {
renderSkillDetailPage()
const displayNameInput = await screen.findByDisplayValue('Untitled skill')
await user.clear(displayNameInput)
await user.type(displayNameInput, 'Editor Display Name')
await user.tab()
await waitFor(
() => {
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalled()
},
{ timeout: 2500 },
)
const savedContent = mocks.saveDraftFileMutationFn.mock.calls.at(-1)?.[0].body.content
expect(savedContent).toMatch(
/name: github-actions-failure-debugging[\s\S]*display-name: Editor Display Name/,
)
expect(savedContent).not.toContain('name: editor-display-name')
expect(await screen.findByText('name')).toBeInTheDocument()
expect(screen.getByText('description')).toBeInTheDocument()
expect(screen.queryByText('display-name')).not.toBeInTheDocument()
expect(mocks.saveDraftFileMutationFn).not.toHaveBeenCalled()
expect(mocks.skillMetadataMutationFn).not.toHaveBeenCalled()
expect(toast.success).toHaveBeenCalledWith('skill.skillManagement.detail.renameSkillSuccess')
})
it('cancels an empty sidebar rename when the field loses focus', async () => {
@ -1764,9 +1730,12 @@ describe('SkillDetailPage', () => {
expect(publishButton).toBeDisabled()
expect(publishButton).toHaveAccessibleName('skill.skillManagement.detail.published')
const displayNameInput = screen.getByDisplayValue('Untitled skill')
await user.clear(displayNameInput)
await user.type(displayNameInput, 'Updated skill')
await user.click(
screen.getByRole('button', {
name: 'skill.skillManagement.detail.markdownSourceMode',
}),
)
await user.type(getSourceEditor(), '\nUpdated published instructions')
expect(publishButton).toBeEnabled()
expect(publishButton).toHaveAccessibleName('skill.skillManagement.detail.publishUpdate')
@ -2419,6 +2388,38 @@ describe('SkillDetailPage', () => {
})
})
it('checks references before publishing when the cached reference count is stale', async () => {
const user = userEvent.setup()
mocks.skillDetail = createSkillDetail({ reference_count: 0 })
mocks.skillReferencesQueryOptions.mockImplementation((options) => ({
queryKey: ['skill-references', options],
queryFn: async () => ({
data: [
createAgentReference({
display_name: 'Stale Count Agent',
name: 'stale-count-agent',
}),
],
}),
}))
renderSkillDetailPage()
await user.click(
await screen.findByRole('button', {
name: 'skill.skillManagement.detail.publishUpdate',
}),
)
expect(
await screen.findByRole('dialog', {
name: 'skill.skillManagement.detail.publishReferencesTitle',
}),
).toBeInTheDocument()
expect(await screen.findByText('Stale Count Agent')).toBeInTheDocument()
expect(mocks.publishSkillMutationFn).not.toHaveBeenCalled()
})
it('cancels publishing from the referenced skill confirmation dialog', async () => {
const user = userEvent.setup()
mocks.skillDetail = createSkillDetail({ reference_count: 1 })
@ -3282,12 +3283,6 @@ describe('SkillDetailPage', () => {
await screen.findByPlaceholderText('skill.skillManagement.detail.builder.modifyPlaceholder'),
).toBeDisabled()
await user.click(
screen.getByRole('button', {
name: 'skill.skillManagement.detail.builder.followUpDisplayName',
}),
)
expect(mocks.sendSkillAssistMessage).toHaveBeenCalledTimes(1)
})
@ -3389,8 +3384,12 @@ describe('SkillDetailPage', () => {
it('sends Skill Builder follow-up suggestions after an assistant reply', async () => {
const user = userEvent.setup()
mocks.skillDetail = createDefaultSkillDraftDetail()
mocks.sendSkillAssistMessage.mockImplementation(({ onCompleted, onData }) => {
mocks.sendSkillAssistMessage.mockImplementation(({ onCompleted, onData, onUnhandledEvent }) => {
onData?.('Drafted the skill.', true, {})
onUnhandledEvent?.({
event: 'skill_assistant_suggestions',
suggestions: ['Ask me about required inputs first'],
})
onCompleted?.()
return Promise.resolve()
})
@ -3406,14 +3405,14 @@ describe('SkillDetailPage', () => {
await user.click(
screen.getByRole('button', {
name: 'skill.skillManagement.detail.builder.followUpDisplayName',
name: 'Ask me about required inputs first',
}),
)
await waitFor(() => {
expect(mocks.sendSkillAssistMessage).toHaveBeenCalledWith(
expect.objectContaining({
message: 'skill.skillManagement.detail.builder.followUpDisplayName',
message: 'Ask me about required inputs first',
}),
)
})

View File

@ -1,6 +1,9 @@
import { QueryClient } from '@tanstack/react-query'
import { describe, expect, it, vi } from 'vitest'
import { consoleQuery } from '@/service/client'
import {
createUploadItemId,
deriveSkillDetailFromDraftFiles,
getErrorCode,
getErrorDetailNumber,
getErrorDetailString,
@ -8,6 +11,7 @@ import {
getUploadPath,
isEditableKeyboardTarget,
joinSkillPath,
setSkillDetailCache,
} from '../shared'
describe('skill detail shared utilities', () => {
@ -86,4 +90,116 @@ describe('skill detail shared utilities', () => {
})
}
})
it('does not derive entity name or display name from SKILL.md draft content', () => {
expect(
deriveSkillDetailFromDraftFiles({
description: 'Entity description',
display_name: 'Entity Display Name',
files: [
{
content:
'---\nname: skill-md-name\ndescription: Skill.md description\nmetadata:\n display-name: Skill.md Display Name\n---\n# Body\n',
kind: 'file',
mime_type: 'text/markdown',
path: 'SKILL.md',
storage: 'text',
},
],
latest_published_version_id: null,
name: 'entity-name',
name_manually_edited: false,
} as Parameters<typeof deriveSkillDetailFromDraftFiles>[0]),
).toMatchObject({
description: 'Skill.md description',
display_name: 'Entity Display Name',
name: 'entity-name',
})
})
it('derives untitled builder draft name and display name from SKILL.md content', () => {
expect(
deriveSkillDetailFromDraftFiles({
description: 'Entity description',
display_name: 'Untitled skill',
files: [
{
content:
'---\nname: untitled-skill-12345678\ndescription: Skill.md description\nmetadata:\n display-name: Untitled skill\n---\n# Customer Issue Triage\n',
kind: 'file',
mime_type: 'text/markdown',
path: 'SKILL.md',
storage: 'text',
},
],
latest_published_version_id: null,
name: 'untitled-skill-12345678',
name_manually_edited: false,
} as Parameters<typeof deriveSkillDetailFromDraftFiles>[0]),
).toMatchObject({
description: 'Skill.md description',
display_name: 'Customer Issue Triage',
name: 'customer-issue-triage',
})
})
it('updates cached skill list entries when detail cache changes', () => {
const queryClient = new QueryClient()
const detail = {
id: 'skill-1',
name: 'customer-issue-triage',
display_name: 'Customer Issue Triage',
icon: '📄',
description: 'Classify customer issues.',
tags: [],
name_manually_edited: false,
visibility: 'workspace',
latest_published_version_id: null,
latest_published_version_number: null,
latest_published_at: null,
reference_count: 0,
created_by: 'user-1',
created_by_name: 'Fate',
updated_by: 'user-1',
updated_by_name: 'Fate',
created_at: 1,
updated_at: 2,
files: [],
} as Parameters<typeof setSkillDetailCache>[2]
const infiniteKey = consoleQuery.workspaces.current.skills.get.key({ type: 'infinite' })
queryClient.setQueryData(infiniteKey, {
pageParams: [1],
pages: [
{
data: [
{
...detail,
name: 'untitled-skill-12345678',
display_name: 'Untitled skill',
latest_published_version_number: null,
},
],
has_more: false,
limit: 20,
page: 1,
total: 1,
},
],
})
setSkillDetailCache(queryClient, detail.id, detail)
expect(queryClient.getQueryData(infiniteKey)).toMatchObject({
pages: [
{
data: [
{
display_name: 'Customer Issue Triage',
name: 'customer-issue-triage',
},
],
},
],
})
})
})

View File

@ -222,16 +222,10 @@ export function SkillBuilderPanel({
t(($) => $['skillManagement.detail.builder.exampleSalesFollowUp']),
t(($) => $['skillManagement.detail.builder.exampleOnboarding']),
]
const followUpSuggestions = [
t(($) => $['skillManagement.detail.builder.followUpNameIcon']),
t(($) => $['skillManagement.detail.builder.followUpDisplayName']),
t(($) => $['skillManagement.detail.builder.exampleIssueTriage']),
]
const inputPlaceholder =
messages.length > 0
? t(($) => $['skillManagement.detail.builder.modifyPlaceholder'])
: t(($) => $['skillManagement.detail.builder.placeholder'])
const hasBuilderConversation = messages.some((message) => message.role === 'user')
const updateMessages = (
updater: (currentMessages: BuilderChatMessage[]) => BuilderChatMessage[],
@ -460,6 +454,24 @@ export function SkillBuilderPanel({
)
},
onUnhandledEvent: (event) => {
if (event.event === 'skill_assistant_suggestions') {
const nextSuggestions = Array.isArray(event.suggestions)
? event.suggestions.filter(
(suggestion): suggestion is string => typeof suggestion === 'string',
)
: []
updateMessages((currentMessages) =>
currentMessages.map((message) =>
message.id === assistantMessageId
? {
...message,
suggestions: nextSuggestions,
}
: message,
),
)
return
}
if (event.event !== 'skill_detail_updated' || !isRecord(event.detail)) return
const nextDetail = event.detail as SkillDetailResponse
@ -579,8 +591,18 @@ export function SkillBuilderPanel({
</button>
</div>
</div>
<div className="relative z-10 flex min-h-0 flex-1 flex-col">
<div className="min-h-0 flex-1 scrollbar-thin overflow-y-auto px-4 pt-4 pb-[11px]">
<div
className={cn(
'relative z-10 flex min-h-0 flex-1 flex-col',
messages.length === 0 && 'justify-center',
)}
>
<div
className={cn(
'min-h-0 scrollbar-thin overflow-y-auto px-4 pt-4 pb-[11px]',
messages.length > 0 ? 'flex-1' : 'shrink-0',
)}
>
{messages.length > 0 ? (
<div className="flex flex-col gap-3">
{messages.map((message, messageIndex) =>
@ -653,6 +675,23 @@ export function SkillBuilderPanel({
<span aria-hidden className="i-ri-restart-line size-4" />
</button>
</div>
{!!message.suggestions?.length && (
<div className="mt-3 flex w-full flex-wrap items-end justify-end gap-1 py-2">
{message.suggestions.map((suggestion) => (
<button
key={suggestion}
type="button"
className="max-w-full cursor-pointer rounded-md border-[0.5px] border-divider-subtle bg-background-default px-2 py-1 text-right system-xs-medium text-text-secondary shadow-xs outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:opacity-50"
disabled={
isSending || isUploadingAttachment || !canSendBuilderMessage
}
onClick={() => handleSend(suggestion)}
>
{suggestion}
</button>
))}
</div>
)}
</>
) : message.thinkingDurationSeconds === undefined ? (
<SkillBuilderThinkingMessage seconds={thinkingElapsedSeconds} />
@ -660,36 +699,21 @@ export function SkillBuilderPanel({
</div>
),
)}
{hasBuilderConversation && (
<div className="flex w-full flex-wrap items-end justify-end gap-1 py-2">
{followUpSuggestions.map((suggestion) => (
<button
key={suggestion}
type="button"
className="max-w-full cursor-pointer rounded-md border-[0.5px] border-divider-subtle bg-background-default px-2 py-1 text-right system-xs-medium text-text-secondary shadow-xs outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:opacity-50"
disabled={isSending || isUploadingAttachment || !canSendBuilderMessage}
onClick={() => handleSend(suggestion)}
>
{suggestion}
</button>
))}
</div>
)}
</div>
) : (
<div className="flex min-h-full flex-col justify-end">
<div className="mb-[27px] flex flex-col items-start px-3 text-left">
<div className="flex flex-col px-3 text-left">
<div className="mb-7 flex flex-col items-start">
<div className="mb-4">
<SkillBuilderEmptyIcon />
</div>
<h3 className="system-sm-semibold text-text-secondary">
<h3 className="system-md-semibold text-text-secondary">
{t(($) => $['skillManagement.detail.builder.promptTitle'])}
</h3>
<p className="mt-1 max-w-64 system-xs-regular text-text-tertiary">
<p className="mt-1 max-w-[280px] system-xs-regular text-text-tertiary">
{t(($) => $['skillManagement.detail.builder.promptDescription'])}
</p>
</div>
<div className="space-y-1.5 px-3">
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<p className="shrink-0 system-2xs-semibold-uppercase text-text-quaternary">
{t(($) => $['skillManagement.detail.builder.tryExample'])}
@ -713,7 +737,13 @@ export function SkillBuilderPanel({
</div>
)}
</div>
<div className="relative flex shrink-0 items-end justify-end bg-gradient-to-b from-components-chat-input-bg-mask-1 to-components-chat-input-bg-mask-2 px-4 pt-3 pb-4">
<div
className={cn(
'relative flex shrink-0 items-end justify-end px-4 pt-3 pb-4',
messages.length > 0 &&
'bg-gradient-to-b from-components-chat-input-bg-mask-1 to-components-chat-input-bg-mask-2',
)}
>
<div className="flex w-full flex-col items-end justify-end">
<div className="relative flex w-full flex-col items-start overflow-hidden rounded-xl border border-components-chat-input-border bg-background-default p-3 shadow-lg">
<input

View File

@ -73,7 +73,6 @@ import {
replaceMarkdownBody,
runSkillFileMutation,
serializeMarkdownLiveEditorNode,
setMarkdownDisplayName,
setMarkdownFrontmatterField,
setMarkdownLiveEditorSelectionOffset,
setSkillDetailCache,
@ -138,7 +137,6 @@ export function FileEditor({
const [draftContent, setDraftContent] = useState(initialContent)
const [markdownMode, setMarkdownMode] = useState<'live' | 'source'>('live')
const [metadataAdding, setMetadataAdding] = useState(false)
const [displayNameDraft, setDisplayNameDraft] = useState('')
const [metadataKey, setMetadataKey] = useState('')
const [metadataValue, setMetadataValue] = useState('')
const [referencePicker, setReferencePicker] = useState<{
@ -162,8 +160,6 @@ export function FileEditor({
const metadataKeyInputRef = useRef<HTMLInputElement>(null)
const metadataKeyDraftRef = useRef('')
const metadataValueDraftRef = useRef('')
const pendingDisplayNameRenameRef = useRef(false)
const displayNameDraftRef = useRef(displayNameDraft)
const liveBodyTextareaRef = useRef<HTMLTextAreaElement>(null)
const liveBodyEditorRef = useRef<HTMLDivElement>(null)
const sourceTextareaRef = useRef<HTMLTextAreaElement>(null)
@ -198,7 +194,6 @@ export function FileEditor({
const hasPublishedVersion = !!detail?.latest_published_version_id
const latestPublishedAt = detail?.latest_published_at
const hasUnpublishedChanges =
displayNameDraft !== markdownContent.displayName ||
saveStatus === 'dirty' ||
saveStatus === 'saving' ||
saveStatus === 'error' ||
@ -227,7 +222,6 @@ export function FileEditor({
isSkillManifestFile &&
(markdownContent.name ||
markdownContent.description ||
markdownContent.displayName ||
markdownContent.metadata.length > 0 ||
!readonly)
const referenceQuery = referencePicker?.query.trim().toLowerCase() ?? ''
@ -245,11 +239,6 @@ export function FileEditor({
})
}, [referencePicker?.currentDirectory, referenceQuery, referenceTargets])
useEffect(() => {
displayNameDraftRef.current = markdownContent.displayName
setDisplayNameDraft(markdownContent.displayName)
}, [markdownContent.displayName])
useEffect(() => {
if (editableDraftContent === draftContent) return
@ -330,8 +319,6 @@ export function FileEditor({
setHasSaveConflict(false)
setSaveStatus('saving')
const shouldNotifyDisplayNameRename =
currentFile.path === 'SKILL.md' && pendingDisplayNameRenameRef.current
try {
const nextCachedDetail = await runSkillFileMutation(
fileMutationCoordinator,
@ -362,10 +349,6 @@ export function FileEditor({
setSaveStatus(draftContentRef.current === content ? 'saved' : 'dirty')
setSkillDetailCache(queryClient, skillId, nextCachedDetail)
onDraftDetailChange(nextCachedDetail)
if (shouldNotifyDisplayNameRename) {
pendingDisplayNameRenameRef.current = false
toast.success(t(($) => $['skillManagement.detail.renameSkillSuccess']))
}
return true
} catch (error) {
const errorPayload = await getAsyncSkillErrorPayload(error)
@ -868,14 +851,6 @@ export function FileEditor({
setMetadataAdding(false)
}
const handleDisplayNameCommit = () => {
const nextDisplayName = displayNameDraftRef.current
if (!isSkillManifestFile || readonly || nextDisplayName === markdownContent.displayName) return
pendingDisplayNameRenameRef.current = true
updateDraftContent(setMarkdownDisplayName(draftContentRef.current, nextDisplayName))
}
const handleRemoveMetadata = (key: string) => {
if (!isSkillManifestFile) return
@ -895,18 +870,29 @@ export function FileEditor({
return
}
let contentToPublish = draftContentRef.current
if (canEdit && isSkillManifestFile && displayNameDraft !== markdownContent.displayName) {
contentToPublish = setMarkdownDisplayName(contentToPublish, displayNameDraft)
updateDraftContent(contentToPublish)
}
const contentToPublish = draftContentRef.current
if (canEdit && contentToPublish !== lastSavedContentRef.current) {
const saved = await saveDraftContent(contentToPublish)
if (!saved) return
}
if ((detail?.reference_count ?? 0) > 0) {
const referenceCount = detail?.reference_count ?? 0
if (referenceCount > 0) {
setPublishConfirmOpen(true)
return
}
const references = await queryClient.fetchQuery(
consoleQuery.workspaces.current.skills.bySkillId.references.get.queryOptions({
input: {
params: {
skill_id: skillId,
},
},
}),
)
if ((references.data?.length ?? 0) > 0) {
setPublishConfirmOpen(true)
return
}
@ -915,14 +901,12 @@ export function FileEditor({
}, [
canEdit,
detail?.reference_count,
displayNameDraft,
isSkillManifestFile,
markdownContent.displayName,
onPublish,
publishDisabled,
queryClient,
saveDraftContent,
saveStatus,
updateDraftContent,
skillId,
])
useEffect(() => {
@ -1056,23 +1040,6 @@ export function FileEditor({
}
/>
)}
{(markdownContent.displayName || !readonly) && (
<EditableMetadataField
label="display-name"
value={displayNameDraft}
valuePlaceholder={detail?.display_name ?? ''}
readOnly={readonly}
onBlurCapture={handleDisplayNameCommit}
onValueChange={
readonly
? undefined
: (nextDisplayName) => {
displayNameDraftRef.current = nextDisplayName
setDisplayNameDraft(nextDisplayName)
}
}
/>
)}
{markdownContent.metadata.map((entry) => {
const removable = !readonly && !isProtectedMarkdownMetadataKey(entry.key)

View File

@ -3,9 +3,10 @@
import type {
SkillDetailResponse,
SkillFileResponse,
SkillResponse,
SkillVersionResponse,
} from '@dify/contracts/api/console/workspaces/types.gen'
import type { useQueryClient } from '@tanstack/react-query'
import type { InfiniteData, useQueryClient } from '@tanstack/react-query'
import type {
DefaultModel,
FormValue,
@ -141,6 +142,7 @@ export type BuilderChatMessage = {
id: string
rawContent?: string
role: 'assistant' | 'user'
suggestions?: string[]
thinkingDurationSeconds?: number
tone?: 'error'
}
@ -182,6 +184,8 @@ const defaultSkillDescription = 'Describe what this Skill does and when an Agent
const defaultSkillBody =
'# Untitled skill\n\nDescribe what this Skill does, when an Agent should use it, and any step-by-step instructions it must follow.'
const untitledSkillDisplayName = 'Untitled skill'
const emptySkillDraftContentPlaceholder = '<!-- dify-skill-empty-draft -->'
function isLegacyUntitledSkillDraftContent(content: string) {
@ -1318,7 +1322,7 @@ export function isDefaultSkillBuilderDraft(detail: SkillDetailResponse) {
return (
detail.latest_published_version_id == null &&
detail.name.startsWith('untitled-skill') &&
detail.display_name === 'Untitled skill' &&
detail.display_name === untitledSkillDisplayName &&
(description === '' || description === defaultSkillDescription) &&
(skillMdBody === '' || skillMdBody === defaultSkillBody)
)
@ -1329,15 +1333,52 @@ export function deriveSkillDetailFromDraftFiles(detail: SkillDetailResponse) {
if (!skillMd || !isTextFile(skillMd) || !skillMd.content) return detail
const parsedSkillMd = parseMarkdownContent(skillMd.content)
const shouldDeriveUntitledSkillName =
detail.latest_published_version_id == null &&
!detail.name_manually_edited &&
detail.name.startsWith('untitled-skill') &&
detail.display_name === untitledSkillDisplayName
const derivedDisplayName = shouldDeriveUntitledSkillName
? getDraftSkillDisplayName(parsedSkillMd)
: undefined
const derivedName = derivedDisplayName
? getDraftSkillName(parsedSkillMd, derivedDisplayName)
: undefined
return {
...detail,
description: parsedSkillMd.description || detail.description,
display_name: parsedSkillMd.displayName || detail.display_name,
name: parsedSkillMd.name || detail.name,
...(derivedDisplayName ? { display_name: derivedDisplayName } : {}),
...(derivedName ? { name: derivedName } : {}),
}
}
function getDraftSkillDisplayName(parsedSkillMd: ParsedMarkdownContent) {
if (parsedSkillMd.displayName && parsedSkillMd.displayName !== untitledSkillDisplayName)
return parsedSkillMd.displayName
const headingLine = parsedSkillMd.body
.split('\n')
.find((line) => line.startsWith('# ') && line.slice(2).trim())
const heading = headingLine?.slice(2).trim()
if (!heading || heading === untitledSkillDisplayName) return undefined
return heading
}
function getDraftSkillName(parsedSkillMd: ParsedMarkdownContent, displayName: string) {
if (parsedSkillMd.name && !parsedSkillMd.name.startsWith('untitled-skill'))
return parsedSkillMd.name
const generatedName = displayName
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
return generatedName || undefined
}
function parseServerMessage(message: string) {
const trimmedMessage = message.trim()
if (!trimmedMessage.startsWith('{')) return trimmedMessage
@ -1492,6 +1533,64 @@ export function setSkillDetailCache(
}),
detail,
)
updateSkillListCache(queryClient, detail)
}
type SkillListCachePage = {
data?: SkillResponse[]
}
function updateSkillListCache(
queryClient: ReturnType<typeof useQueryClient>,
detail: SkillDetailResponse,
) {
const updateSkill = (skill: SkillResponse): SkillResponse => {
if (skill.id !== detail.id) return skill
return {
...skill,
description: detail.description,
display_name: detail.display_name,
icon: detail.icon,
latest_published_at: detail.latest_published_at,
latest_published_version_id: detail.latest_published_version_id,
latest_published_version_number: detail.latest_published_version_number,
name: detail.name,
name_manually_edited: detail.name_manually_edited,
reference_count: detail.reference_count,
tags: detail.tags,
updated_at: detail.updated_at,
updated_by: detail.updated_by,
updated_by_name: detail.updated_by_name,
}
}
const updatePage = <TPage extends SkillListCachePage>(page: TPage): TPage => {
if (!page.data?.some((skill) => skill.id === detail.id)) return page
return {
...page,
data: page.data.map(updateSkill),
}
}
queryClient.setQueriesData<SkillListCachePage>(
{
queryKey: consoleQuery.workspaces.current.skills.get.key({ type: 'query' }),
},
(page) => (page ? updatePage(page) : page),
)
queryClient.setQueriesData<InfiniteData<SkillListCachePage>>(
{
queryKey: consoleQuery.workspaces.current.skills.get.key({ type: 'infinite' }),
},
(cache) =>
cache
? {
...cache,
pages: cache.pages.map(updatePage),
}
: cache,
)
}
function refetchSkillDetail(skillId: string) {

View File

@ -7,14 +7,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useLayoutEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { fetchSkillFileBlob } from '../client'
import {
findFileByPath,
invalidateSkillDetail,
runSkillFileMutation,
setMarkdownDisplayName,
setSkillDetailCache,
} from './shared'
import { invalidateSkillDetail, runSkillFileMutation, setSkillDetailCache } from './shared'
export function SkillDisplayNameEditor({
detail,
@ -38,12 +31,12 @@ export function SkillDisplayNameEditor({
next: string
}>()
const [draftName, setDraftName] = useState(detail.display_name)
const fileMutation = useMutation(
consoleQuery.workspaces.current.skills.bySkillId.files.patch.mutationOptions({
const metadataMutation = useMutation(
consoleQuery.workspaces.current.skills.bySkillId.patch.mutationOptions({
context: { silent: true },
}),
)
const saving = fileMutation.isPending
const saving = metadataMutation.isPending
const displayName =
displayNameOverride?.base === detail.display_name
? displayNameOverride.next
@ -70,39 +63,18 @@ export function SkillDisplayNameEditor({
return
}
const skillFile = findFileByPath(detail.files ?? [], 'SKILL.md')
if (!skillFile) {
toast.error(t(($) => $['skillManagement.detail.fileMissing']))
return
}
submittingRef.current = true
try {
const currentContent =
skillFile.content ??
(await (
await fetchSkillFileBlob({
path: skillFile.path,
skillId,
versionId: null,
})
).text())
const nextContent = setMarkdownDisplayName(currentContent, nextDisplayName)
const nextDetail = await runSkillFileMutation(
fileMutationCoordinator,
async (expectedUpdatedAt) => {
return fileMutation.mutateAsync({
return metadataMutation.mutateAsync({
params: {
skill_id: skillId,
},
body: {
content: nextContent,
display_name: nextDisplayName,
expected_updated_at: expectedUpdatedAt,
hash: skillFile.hash,
mime_type: skillFile.mime_type,
operation: 'upsert_text',
path: skillFile.path,
size: new Blob([nextContent]).size,
},
})
},

View File

@ -49,7 +49,7 @@ function UploadRowShell({
{message}
</div>
</div>
{actions && <div className="flex shrink-0 items-center gap-1">{actions}</div>}
{!!actions && <div className="flex shrink-0 items-center gap-1">{actions}</div>}
</div>
)
}