mirror of
https://github.com/langgenius/dify.git
synced 2026-09-08 11:04:27 +08:00
fix: fix some issue
This commit is contained in:
parent
bc3ef4de71
commit
6c052bb9b5
@ -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."""
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user