Merge branch 'skill' into deploy/dev

This commit is contained in:
fatelei 2026-08-07 11:49:02 +08:00
commit 8b456fb4df
No known key found for this signature in database
GPG Key ID: 2F91DA05646F4EED
10 changed files with 269 additions and 52 deletions

View File

@ -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"<skill_draft>\n{context}\n</skill_draft>"]
prompt_parts.append(f"<authoring_stage>{authoring_stage}</authoring_stage>")
if target_path:
prompt_parts.append(f"<current_editor_path>{target_path}</current_editor_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."""

View File

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

View File

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

View File

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

View File

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

View File

@ -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({
)}
</div>
{!readonly && (
<div className="pointer-events-none absolute inset-x-0 bottom-3 z-20 flex justify-center px-4">
<div className="relative flex max-w-[calc(100%-2rem)] justify-center">
<SkillPublishBottomActions>
<div className="flex max-w-[calc(100%-2rem)] flex-col items-center justify-end">
<SkillPublishConfirmPanel
loading={publishing}
onCancel={() => {
@ -1386,9 +1386,8 @@ export function FileEditor({
skillId={skillId}
/>
<div
className={cn(publishConfirmOpen && 'pointer-events-none')}
className={cn(publishConfirmOpen && 'hidden')}
aria-hidden={publishConfirmOpen || undefined}
style={{ visibility: publishConfirmOpen ? 'hidden' : undefined }}
>
<SkillPublishBar
metaLabel={publishMetaText}
@ -1398,7 +1397,7 @@ export function FileEditor({
/>
</div>
</div>
</div>
</SkillPublishBottomActions>
)}
{readonly && selectedVersion && (
<VersionActionBar

View File

@ -20,6 +20,18 @@ type SkillPublishBarProps = {
state: SkillPublishState
}
export function SkillPublishBottomActions({ children }: { children: ReactNode }) {
return (
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 flex flex-col items-center justify-end px-4 pt-4 pb-2">
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-0 bg-linear-to-t from-components-panel-bg to-components-panel-bg-transparent mask-[linear-gradient(to_top,black,transparent)] backdrop-blur-[2px] [-webkit-mask-image:linear-gradient(to_top,black,transparent)]"
/>
<div className="relative z-10 flex w-full flex-col items-center justify-end">{children}</div>
</div>
)
}
export function SkillPublishShortcut() {
return (
<KbdGroup aria-hidden>

View File

@ -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({
<div
role="dialog"
aria-labelledby="skill-publish-confirm-title"
className="pointer-events-auto absolute right-0 bottom-[calc(100%+10px)] z-50 flex w-96 max-w-[calc(100vw-32px)] flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg shadow-shadow-shadow-5"
data-open
className="pointer-events-auto relative flex w-96 max-w-[calc(100vw-32px)] flex-col overflow-hidden rounded-xl bg-components-panel-bg-blur shadow-lg inset-ring-[0.5px] shadow-shadow-shadow-5 inset-ring-components-panel-border backdrop-blur-[5px]"
>
<div className="px-4 pt-4 pb-2">
<h2 id="skill-publish-confirm-title" className="system-xl-semibold text-text-primary">
<div className="px-3 pt-3.5 pb-1">
<h2
id="skill-publish-confirm-title"
className="px-1 pr-8 system-xl-semibold text-text-primary"
>
{t(($) => $['skillManagement.detail.publishReferencesTitle'])}
</h2>
<p className="mt-1 system-xs-regular text-util-colors-warning-warning-600">
<p className="mt-0.5 px-1 system-xs-regular text-util-colors-warning-warning-600">
{t(($) => $['skillManagement.detail.publishReferencesDescription'], {
count: referenceCount,
})}

View File

@ -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) => (
<QueryClientProvider client={queryClient}>
<div className="relative h-[680px] w-[800px] bg-background-default">
<div className="absolute right-8 bottom-8 h-12 w-96">
<SkillPublishBottomActions>
<Story />
</div>
</SkillPublishBottomActions>
</div>
</QueryClientProvider>
),

View File

@ -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 (
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] border-[0.5px] border-divider-regular bg-background-default-dodge">
{icon ? (
<span className="system-lg-medium text-text-secondary">{icon}</span>
) : (
<span aria-hidden className="i-ri-box-3-line size-5 text-text-tertiary" />
)}
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] border-[0.5px] border-divider-regular bg-background-default">
<span aria-hidden className="i-custom-vender-main-nav-skill size-5 text-text-secondary" />
</div>
)
}
@ -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"
>
<div className="flex items-center gap-3 px-4 pt-4 pb-2">
<SkillIcon icon={skill.icon} />
<SkillIcon />
<div className="flex min-w-0 flex-1 flex-col gap-0.5 py-px">
<h2 className="truncate system-md-semibold text-text-secondary">
{skill.display_name}
@ -412,7 +413,7 @@ function SkillCard({
<span className="min-w-0 truncate">
{isDraft
? t(($) => $['skillManagement.editedAt'], { time: updatedAt })
: t(($) => $['skillManagement.publishedAt'], { time: updatedAt })}
: t(($) => $['skillManagement.publishedAt'], { time: publishedAt })}
</span>
</div>
</div>