From bc3ef4de713a108102b0a0b116053a04fae0d031 Mon Sep 17 00:00:00 2001 From: zxhlyh Date: Fri, 7 Aug 2026 11:22:34 +0800 Subject: [PATCH 1/2] fix(skills): align publish bar expansion --- web/features/skills/detail/file-editor.tsx | 11 +++++------ web/features/skills/detail/publish-bar.tsx | 12 ++++++++++++ web/features/skills/detail/skill-metadata.tsx | 14 +++++++++----- .../detail/skill-publish-confirm-panel.stories.tsx | 5 +++-- 4 files changed, 29 insertions(+), 13 deletions(-) diff --git a/web/features/skills/detail/file-editor.tsx b/web/features/skills/detail/file-editor.tsx index e11f5fb993a..4ffcd9c88f3 100644 --- a/web/features/skills/detail/file-editor.tsx +++ b/web/features/skills/detail/file-editor.tsx @@ -34,7 +34,7 @@ import { ReferenceFilesPicker, VersionActionBar, } from './markdown-editor' -import { SkillPublishBar } from './publish-bar' +import { SkillPublishBar, SkillPublishBottomActions } from './publish-bar' import { addMarkdownMetadata, findBrokenMarkdownReferenceRangeAtCaret, @@ -1368,8 +1368,8 @@ export function FileEditor({ )} {!readonly && ( -
-
+ +
{ @@ -1386,9 +1386,8 @@ export function FileEditor({ skillId={skillId} />
-
+ )} {readonly && selectedVersion && ( +
+
{children}
+
+ ) +} + export function SkillPublishShortcut() { return ( diff --git a/web/features/skills/detail/skill-metadata.tsx b/web/features/skills/detail/skill-metadata.tsx index 11086bc22ce..b6564ca2f3a 100644 --- a/web/features/skills/detail/skill-metadata.tsx +++ b/web/features/skills/detail/skill-metadata.tsx @@ -417,7 +417,7 @@ export function SkillReferencesList({ data-scrollable={isScrollable ? true : undefined} className={cn( compact - ? 'flex flex-col gap-px rounded-xl border border-divider-subtle p-1' + ? 'flex flex-col gap-px rounded-xl border border-divider-subtle p-[3px]' : 'w-max max-w-[480px] space-y-0.5 py-1', isScrollable && `${maxHeight} overflow-y-auto`, )} @@ -470,13 +470,17 @@ export function SkillPublishConfirmPanel({
-
-

+
+

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

-

+

{t(($) => $['skillManagement.detail.publishReferencesDescription'], { count: referenceCount, })} diff --git a/web/features/skills/detail/skill-publish-confirm-panel.stories.tsx b/web/features/skills/detail/skill-publish-confirm-panel.stories.tsx index 75d03c8672a..90d565e28de 100644 --- a/web/features/skills/detail/skill-publish-confirm-panel.stories.tsx +++ b/web/features/skills/detail/skill-publish-confirm-panel.stories.tsx @@ -2,6 +2,7 @@ import type { SkillReferenceResponse } from '@dify/contracts/api/console/workspa import type { Meta, StoryObj } from '@storybook/nextjs-vite' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { consoleQuery } from '@/service/client' +import { SkillPublishBottomActions } from './publish-bar' import { SkillPublishConfirmPanel } from './skill-metadata' const scrollSkillId = 'publish-confirm-scroll-visual-test' @@ -61,9 +62,9 @@ const meta = { (Story) => (

-
+ -
+
), From 6c052bb9b58d77041bdbc7480a26fdc5c8013e89 Mon Sep 17 00:00:00 2001 From: fatelei Date: Fri, 7 Aug 2026 11:46:20 +0800 Subject: [PATCH 2/2] fix: fix some issue --- api/services/skill_management_service.py | 136 +++++++++++++++++- .../services/test_skill_management_service.py | 92 ++++++++++-- .../skills/__tests__/index.spec.tsx | 17 +-- .../components/orchestrate/skills/index.tsx | 5 +- web/features/skills/__tests__/page.spec.tsx | 10 ++ web/features/skills/page.tsx | 19 +-- 6 files changed, 240 insertions(+), 39 deletions(-) diff --git a/api/services/skill_management_service.py b/api/services/skill_management_service.py index b33a9c3e398..f501856439a 100644 --- a/api/services/skill_management_service.py +++ b/api/services/skill_management_service.py @@ -134,13 +134,22 @@ 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. +Use a low-friction progressive flow for a new Skill. Do not complete all four +authoring stages in one turn. Follow the current authoring stage supplied in the +request: +1. Scenario: ask what the Skill handles and how users describe the trigger. + Update only the description; do not write the body, create files, or choose a + final name. +2. Workflow: ask about steps, decision points, rules, and thresholds. Update + the SKILL.md body only; preserve the placeholder name and display name. +3. Resources: ask whether scripts, templates, or reference documents are + needed. Create only the files the user confirms; preserve the name. +4. Finalize: summarize the completed Skill and suggest a display name and a + lowercase kebab-case name. Only at this stage may the name be changed. +Ask at most one focused question per turn and do not invent missing business +rules or thresholds. Every reply should include 2-3 short suggested user replies +that the UI can show as clickable chips. The suggestions must be concrete next +replies for the current stage, not generic commands. Respond with JSON only: { @@ -922,7 +931,9 @@ class SkillManagementService: tenant_id=tenant_id, model_payload=model_payload, ) + authoring_stage = self._assistant_authoring_stage(skill=skill, files=files) prompt_parts = [f"\n{context}\n"] + prompt_parts.append(f"{authoring_stage}") if target_path: prompt_parts.append(f"{target_path}") if attachment_context: @@ -1044,6 +1055,12 @@ class SkillManagementService: status_code=422, details={"raw_response": raw_text[:2_000]}, ) from exc + plan = self._constrain_progressive_assistant_plan( + plan=plan, + stage=authoring_stage, + skill=skill, + files=files, + ) if not any(suggestion.strip() for suggestion in plan.suggestions): plan = plan.model_copy( update={ @@ -2534,6 +2551,111 @@ class SkillManagementService: "created_at": int(version.created_at.timestamp()), } + @staticmethod + def _assistant_authoring_stage(*, skill: Skill, files: list[SkillDraftFile]) -> str: + """Return the progressive stage for a newly created, untitled Skill.""" + if not (skill.display_name == _UNTITLED_DISPLAY_NAME and not skill.name_manually_edited): + return "existing_skill" + + skill_md = next((file for file in files if file.path == _SKILL_MD), None) + content = skill_md.content_text if skill_md is not None else "" + has_description = bool(skill.description.strip()) + body = _FRONTMATTER_RE.sub("", content, count=1).strip() + has_body = bool(body and body != _EMPTY_SKILL_DRAFT_CONTENT.strip()) + has_resources = any(file.path != _SKILL_MD for file in files) + + if not has_description: + return "scenario" + if not has_body: + return "workflow" + if not has_resources: + return "resources" + return "finalize" + + @classmethod + def _constrain_progressive_assistant_plan( + cls, + *, + plan: SkillAssistActionPlan, + stage: str, + skill: Skill, + files: list[SkillDraftFile], + ) -> SkillAssistActionPlan: + if stage in {"existing_skill", "finalize"}: + return plan + + skill_md = next((file for file in files if file.path == _SKILL_MD), None) + current_content = skill_md.content_text if skill_md is not None else _EMPTY_SKILL_DRAFT_CONTENT + operations: list[SkillAssistDraftOperationPayload] = [] + for operation in plan.operations: + if operation.path == _SKILL_MD and operation.operation == "upsert_text": + content = operation.content or current_content + if stage == "scenario": + content = cls._assistant_description_only_skill_md( + skill=skill, + current_content=current_content, + candidate_content=content, + ) + else: + content = cls._preserve_assistant_skill_identity( + skill=skill, + current_content=current_content, + candidate_content=content, + ) + operations.append(operation.model_copy(update={"content": content})) + elif stage == "resources" and operation.path != _SKILL_MD: + operations.append(operation) + + return plan.model_copy(update={"operations": operations}) + + @classmethod + def _assistant_description_only_skill_md( + cls, + *, + skill: Skill, + current_content: str, + candidate_content: str, + ) -> str: + try: + frontmatter = cls._parse_frontmatter(candidate_content) + except SkillManagementServiceError: + return current_content + description = frontmatter.get("description") + if not isinstance(description, str) or not description.strip(): + return current_content + return cls._build_skill_md( + name=skill.name, + description=description.strip()[:_MAX_SKILL_DESCRIPTION_LENGTH], + display_name=skill.display_name, + body=_EMPTY_SKILL_DRAFT_CONTENT, + ) + + @classmethod + def _preserve_assistant_skill_identity( + cls, + *, + skill: Skill, + current_content: str, + candidate_content: str, + ) -> str: + try: + frontmatter_match = _FRONTMATTER_RE.match(candidate_content) + if frontmatter_match is None: + return current_content + frontmatter = cls._parse_frontmatter(candidate_content) + except SkillManagementServiceError: + return current_content + + metadata = frontmatter.get("metadata") + if not isinstance(metadata, dict): + metadata = {} + metadata["display-name"] = skill.display_name + frontmatter["name"] = skill.name + frontmatter["metadata"] = metadata + serialized = yaml.safe_dump(frontmatter, allow_unicode=True, sort_keys=False).rstrip() + body = candidate_content[frontmatter_match.end() :].lstrip("\r\n") + return f"---\n{serialized}\n---\n\n{body}" if body else f"---\n{serialized}\n---\n" + @staticmethod def _build_assistant_context(*, skill: Skill, files: list[SkillDraftFile]) -> str: """Build a bounded, text-only Skill draft snapshot for assistant context.""" 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 c86357c4d2e..50a14785f90 100644 --- a/api/tests/unit_tests/services/test_skill_management_service.py +++ b/api/tests/unit_tests/services/test_skill_management_service.py @@ -435,21 +435,85 @@ 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] == [ + "skill_assistant_progress", + "skill_assistant_progress", "message", "skill_assistant_suggestions", + "skill_assistant_progress", + "skill_assistant_progress", "skill_detail_updated", "message_end", ] - assert events[0]["answer"] == "Created the refund policy reference." - 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"]) + assert events[2]["answer"] == "Created the refund policy reference." + assert events[3]["suggestions"] == ["Add escalation rules", "Include refund examples"] + assert events[6]["operations"] == [{"operation": "upsert_text", "path": "references/refund-policy.md"}] + assert any(file["path"] == "references/refund-policy.md" for file in events[6]["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") assert reference["content"] == "# Refund Policy\n" +def test_new_skill_builder_stays_in_scenario_stage_on_first_turn() -> None: + service = SkillManagementService(tool_file_manager=_FakeToolFileManager()) + created = service.create_skill(tenant_id=TENANT, user_id=USER, payload=SkillCreatePayload()) + model_output = json.dumps( + { + "reply": "Created a complete Customer Issue Triage skill.", + "suggestions": ["Describe the customer issue trigger"], + "operations": [ + { + "operation": "upsert_text", + "path": "SKILL.md", + "mime_type": "text/markdown", + "content": ( + "---\n" + "name: customer-issue-triage\n" + "description: Classify customer feedback into P0-P3 priorities.\n" + "metadata:\n" + " display-name: Customer Issue Triage\n" + "---\n" + "# Customer Issue Triage\n\n" + "Invented escalation rules and routing thresholds.\n" + ), + }, + { + "operation": "upsert_text", + "path": "references/example.md", + "mime_type": "text/markdown", + "content": "# Example\n", + }, + ], + } + ) + model = SimpleNamespace( + invoke_llm=lambda **_kwargs: SimpleNamespace( + message=SimpleNamespace(get_text_content=lambda: model_output), + ) + ) + manager = SimpleNamespace(get_model_instance=lambda **_kwargs: model) + + with patch("services.skill_management_service.ModelManager.for_tenant", return_value=manager): + events = [ + json.loads(chunk.removeprefix("data: ").strip()) + for chunk in service.create_assistant_action_stream( + tenant_id=TENANT, + user_id=USER, + skill_id=created["id"], + message="Customer issue triage", + model_payload=SkillAssistModelPayload(provider="test", model="test"), + ) + ] + + detail = next(event["detail"] for event in events if event["event"] == "skill_detail_updated") + skill_md = next(file for file in detail["files"] if file["path"] == "SKILL.md") + assert detail["name"] == created["name"] + assert detail["display_name"] == "Untitled skill" + assert detail["description"] == "Classify customer feedback into P0-P3 priorities." + assert "Invented escalation rules" not in skill_md["content"] + assert not any(file["path"] == "references/example.md" for file in detail["files"]) + + def test_create_assistant_action_stream_strips_skill_frontmatter_from_reference_files() -> None: service = SkillManagementService(tool_file_manager=_FakeToolFileManager()) created = service.create_skill( @@ -544,12 +608,16 @@ def test_create_assistant_action_stream_generates_missing_suggestions() -> None: events = [json.loads(chunk.removeprefix("data: ").strip()) for chunk in response] assert [event["event"] for event in events] == [ + "skill_assistant_progress", + "skill_assistant_progress", "message", "skill_assistant_suggestions", + "skill_assistant_progress", + "skill_assistant_progress", "skill_detail_updated", "message_end", ] - assert events[1]["suggestions"] == ["Add SLA tiers", "Include refund denial templates"] + assert events[3]["suggestions"] == ["Add SLA tiers", "Include refund denial templates"] def test_create_assistant_action_stream_reports_skill_name_database_conflict() -> None: @@ -609,10 +677,16 @@ def test_create_assistant_action_stream_reports_skill_name_database_conflict() - ) 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"} + assert [event["event"] for event in events] == [ + "skill_assistant_progress", + "skill_assistant_progress", + "message", + "skill_assistant_progress", + "error", + ] + assert events[4]["code"] == "skill_name_conflict" + assert events[4]["message"] == 'Skill name "customer-issue-triage" already exists. Please choose a different name.' + assert events[4]["details"] == {"name": "customer-issue-triage"} def test_sync_assistant_model_config_updates_debugger_draft() -> None: diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/__tests__/index.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/__tests__/index.spec.tsx index 15d3d8a4e31..283ddc269ca 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/__tests__/index.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/__tests__/index.spec.tsx @@ -846,7 +846,7 @@ describe('AgentSkills', () => { }) }) - it('should mark already bound workspace skills as added and prevent duplicate binding', async () => { + it('should hide draft workspace skills and mark published skills as added', async () => { const user = userEvent.setup() mocks.agentSkillBindingsQueryOptions.mockImplementation((options) => { const { input } = options as { input: { params: { agent_id: string } } } @@ -913,25 +913,16 @@ describe('AgentSkills', () => { expect( await screen.findByText('agentV2.agentDetail.configure.skills.workspaceSelector.added'), ).toBeInTheDocument() + expect(screen.queryByText('Draft skill')).not.toBeInTheDocument() expect( - screen.getByText('agentV2.agentDetail.configure.skills.workspaceSelector.draft'), - ).toBeInTheDocument() + screen.queryByText('agentV2.agentDetail.configure.skills.workspaceSelector.draft'), + ).not.toBeInTheDocument() const addedSkillButton = screen .getByText('agentV2.agentDetail.configure.skills.workspaceSelector.added') .closest('button') - const draftSkillButton = screen - .getByText('agentV2.agentDetail.configure.skills.workspaceSelector.draft') - .closest('button') expect(addedSkillButton).not.toBeDisabled() expect(addedSkillButton).toHaveAttribute('aria-disabled', 'true') - expect(draftSkillButton).not.toBeDisabled() - expect(draftSkillButton).toHaveAttribute('aria-disabled', 'true') - - await user.hover(draftSkillButton!) - expect(await screen.findByText('Draft skill description.')).toBeInTheDocument() - - await user.click(draftSkillButton!) await user.click(addedSkillButton!) expect(mocks.replaceAgentSkillBindingsMutationFn).not.toHaveBeenCalled() diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/index.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/index.tsx index fd01b2f35ea..6650e15bc80 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/index.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/index.tsx @@ -228,7 +228,10 @@ function WorkspaceSkillSelector({ }), }) const boundSkillIdSet = useMemo(() => new Set(boundSkillIds), [boundSkillIds]) - const skills = skillsQuery.data?.pages.flatMap((page) => page.data ?? []) ?? [] + const skills = + skillsQuery.data?.pages + .flatMap((page) => page.data ?? []) + .filter((skill) => Boolean(skill.latest_published_version_id)) ?? [] const previewSkill = skills.find((skill) => skill.id === previewSkillId) ?? skills[0] const hasNextPage = skillsQuery.hasNextPage ?? false const isFetchingNextPage = skillsQuery.isFetchingNextPage diff --git a/web/features/skills/__tests__/page.spec.tsx b/web/features/skills/__tests__/page.spec.tsx index 833cea0d4c2..c1889999329 100644 --- a/web/features/skills/__tests__/page.spec.tsx +++ b/web/features/skills/__tests__/page.spec.tsx @@ -99,6 +99,12 @@ vi.mock('@/hooks/use-document-title', () => ({ default: vi.fn(), })) +vi.mock('@/hooks/use-format-time-from-now', () => ({ + useFormatTimeFromNow: () => ({ + formatTimeFromNow: () => '2 hours ago', + }), +})) + vi.mock('@/hooks/use-timestamp', () => ({ default: () => ({ formatTime: () => '2026-07-22 10:00', @@ -199,6 +205,7 @@ function createSkill(overrides: Partial = {}): SkillResponse { tags: ['support'], visibility: 'workspace', latest_published_version_id: 'version-1', + latest_published_at: 1784638400, reference_count: 2, created_at: 1784631405, updated_at: 1784638487, @@ -304,6 +311,9 @@ describe('SkillsPage', () => { expect(screen.getByText('Handle refund requests.')).toBeInTheDocument() expect(screen.getByText('support')).toBeInTheDocument() expect(screen.getByText('skill.skillManagement.referenceCount:{"count":2}')).toBeInTheDocument() + expect( + screen.getByText('skill.skillManagement.publishedAt:{"time":"2 hours ago"}'), + ).toBeInTheDocument() }) it('passes keyword and selected tags to the list query', async () => { diff --git a/web/features/skills/page.tsx b/web/features/skills/page.tsx index 82101add23d..454450382d7 100644 --- a/web/features/skills/page.tsx +++ b/web/features/skills/page.tsx @@ -39,6 +39,7 @@ import { SkeletonRectangle } from '@/app/components/base/skeleton' import { SkillCardTags } from '@/features/tag-management/components/skill-card-tags' import { TagFilter } from '@/features/tag-management/components/tag-filter' import useDocumentTitle from '@/hooks/use-document-title' +import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' import useTimestamp from '@/hooks/use-timestamp' import Link from '@/next/link' import { useRouter } from '@/next/navigation' @@ -68,14 +69,10 @@ function invalidateSkillListQueries(queryClient: QueryClient) { }) } -function SkillIcon({ icon }: { icon?: string }) { +function SkillIcon() { return ( -
- {icon ? ( - {icon} - ) : ( - - )} +
+
) } @@ -324,6 +321,7 @@ function SkillCard({ const { t } = useTranslation('skill') const { t: tCommon } = useTranslation('common') const { formatTime } = useTimestamp() + const { formatTimeFromNow } = useFormatTimeFromNow() const queryClient = useQueryClient() const [isDeleteOpen, setIsDeleteOpen] = useState(false) const duplicateMutation = useMutation( @@ -343,6 +341,9 @@ function SkillCard({ skill.updated_at, t(($) => $['skillManagement.dateTimeFormat']), ) + const publishedAt = skill.latest_published_at + ? formatTimeFromNow(skill.latest_published_at) + : undefined const handleDuplicate = () => { if (duplicateMutation.isPending) return @@ -379,7 +380,7 @@ function SkillCard({ className="block min-w-0 shrink-0 cursor-pointer outline-hidden" >
- +

{skill.display_name} @@ -412,7 +413,7 @@ function SkillCard({ {isDraft ? t(($) => $['skillManagement.editedAt'], { time: updatedAt }) - : t(($) => $['skillManagement.publishedAt'], { time: updatedAt })} + : t(($) => $['skillManagement.publishedAt'], { time: publishedAt })}