From 5a13489fdd2e730bc9d85c5a5d22d959569bf405 Mon Sep 17 00:00:00 2001 From: fatelei Date: Tue, 4 Aug 2026 13:38:56 +0800 Subject: [PATCH] fix: fix some bugs --- api/services/skill_management_service.py | 96 ++++++++++++-- .../services/test_skill_management_service.py | 75 +++++++++-- .../skills/__tests__/detail-page.spec.tsx | 123 +++++++++--------- .../skills/detail/__tests__/shared.spec.ts | 116 +++++++++++++++++ web/features/skills/detail/builder-panel.tsx | 88 ++++++++----- web/features/skills/detail/file-editor.tsx | 71 +++------- web/features/skills/detail/shared.tsx | 107 ++++++++++++++- .../detail/skill-display-name-editor.tsx | 40 +----- web/features/skills/detail/upload-dialogs.tsx | 2 +- 9 files changed, 519 insertions(+), 199 deletions(-) diff --git a/api/services/skill_management_service.py b/api/services/skill_management_service.py index 8fc98e00bd1..57c1a65c7d4 100644 --- a/api/services/skill_management_service.py +++ b/api/services/skill_management_service.py @@ -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: diff --git a/api/tests/unit_tests/services/test_skill_management_service.py b/api/tests/unit_tests/services/test_skill_management_service.py index c3fd322ebea..29e5625670c 100644 --- a/api/tests/unit_tests/services/test_skill_management_service.py +++ b/api/tests/unit_tests/services/test_skill_management_service.py @@ -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: diff --git a/web/features/skills/__tests__/detail-page.spec.tsx b/web/features/skills/__tests__/detail-page.spec.tsx index 32a8946910f..505c14deadb 100644 --- a/web/features/skills/__tests__/detail-page.spec.tsx +++ b/web/features/skills/__tests__/detail-page.spec.tsx @@ -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', }), ) }) diff --git a/web/features/skills/detail/__tests__/shared.spec.ts b/web/features/skills/detail/__tests__/shared.spec.ts index 5097febbd9c..c60e48ba3b4 100644 --- a/web/features/skills/detail/__tests__/shared.spec.ts +++ b/web/features/skills/detail/__tests__/shared.spec.ts @@ -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[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[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[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', + }, + ], + }, + ], + }) + }) }) diff --git a/web/features/skills/detail/builder-panel.tsx b/web/features/skills/detail/builder-panel.tsx index 1300f801e5f..f64f49473e5 100644 --- a/web/features/skills/detail/builder-panel.tsx +++ b/web/features/skills/detail/builder-panel.tsx @@ -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({ -
-
+
+
0 ? 'flex-1' : 'shrink-0', + )} + > {messages.length > 0 ? (
{messages.map((message, messageIndex) => @@ -653,6 +675,23 @@ export function SkillBuilderPanel({
+ {!!message.suggestions?.length && ( +
+ {message.suggestions.map((suggestion) => ( + + ))} +
+ )} ) : message.thinkingDurationSeconds === undefined ? ( @@ -660,36 +699,21 @@ export function SkillBuilderPanel({
), )} - {hasBuilderConversation && ( -
- {followUpSuggestions.map((suggestion) => ( - - ))} -
- )}
) : ( -
-
+
+
-

+

{t(($) => $['skillManagement.detail.builder.promptTitle'])}

-

+

{t(($) => $['skillManagement.detail.builder.promptDescription'])}

-
+

{t(($) => $['skillManagement.detail.builder.tryExample'])} @@ -713,7 +737,13 @@ export function SkillBuilderPanel({

)}
-
+
0 && + 'bg-gradient-to-b from-components-chat-input-bg-mask-1 to-components-chat-input-bg-mask-2', + )} + >
('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(null) const metadataKeyDraftRef = useRef('') const metadataValueDraftRef = useRef('') - const pendingDisplayNameRenameRef = useRef(false) - const displayNameDraftRef = useRef(displayNameDraft) const liveBodyTextareaRef = useRef(null) const liveBodyEditorRef = useRef(null) const sourceTextareaRef = useRef(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) && ( - { - displayNameDraftRef.current = nextDisplayName - setDisplayNameDraft(nextDisplayName) - } - } - /> - )} {markdownContent.metadata.map((entry) => { const removable = !readonly && !isProtectedMarkdownMetadataKey(entry.key) diff --git a/web/features/skills/detail/shared.tsx b/web/features/skills/detail/shared.tsx index f753c408cc5..57508d6ab04 100644 --- a/web/features/skills/detail/shared.tsx +++ b/web/features/skills/detail/shared.tsx @@ -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 = '' 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, + 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 = (page: TPage): TPage => { + if (!page.data?.some((skill) => skill.id === detail.id)) return page + + return { + ...page, + data: page.data.map(updateSkill), + } + } + + queryClient.setQueriesData( + { + queryKey: consoleQuery.workspaces.current.skills.get.key({ type: 'query' }), + }, + (page) => (page ? updatePage(page) : page), + ) + queryClient.setQueriesData>( + { + queryKey: consoleQuery.workspaces.current.skills.get.key({ type: 'infinite' }), + }, + (cache) => + cache + ? { + ...cache, + pages: cache.pages.map(updatePage), + } + : cache, + ) } function refetchSkillDetail(skillId: string) { diff --git a/web/features/skills/detail/skill-display-name-editor.tsx b/web/features/skills/detail/skill-display-name-editor.tsx index ad3abed467e..c0ff6a6b6f0 100644 --- a/web/features/skills/detail/skill-display-name-editor.tsx +++ b/web/features/skills/detail/skill-display-name-editor.tsx @@ -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, }, }) }, diff --git a/web/features/skills/detail/upload-dialogs.tsx b/web/features/skills/detail/upload-dialogs.tsx index a04daef6829..ef76074736e 100644 --- a/web/features/skills/detail/upload-dialogs.tsx +++ b/web/features/skills/detail/upload-dialogs.tsx @@ -49,7 +49,7 @@ function UploadRowShell({ {message}
- {actions &&
{actions}
} + {!!actions &&
{actions}
}
) }