diff --git a/api/controllers/console/workspace/skills.py b/api/controllers/console/workspace/skills.py index 9974f498d78..e84f78b02c2 100644 --- a/api/controllers/console/workspace/skills.py +++ b/api/controllers/console/workspace/skills.py @@ -546,6 +546,7 @@ class WorkspaceSkillAssistMessageApi(Resource): user_id=current_user.id, message=payload.message, attachments=payload.attachments, + history=payload.history, model_payload=payload.model, target_path=payload.target_path, ) diff --git a/api/services/skill_management_service.py b/api/services/skill_management_service.py index 9d8ec92f5a0..7b4b90a120b 100644 --- a/api/services/skill_management_service.py +++ b/api/services/skill_management_service.py @@ -142,26 +142,54 @@ 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 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. +Use a low-friction progressive flow for a new Skill. The current stage and the +previous conversation are supplied in the request. Complete only the current +stage and move forward after the user confirms it; never generate the whole +SKILL.md, write a final name, or create all resources in the first turn. +1. Scenario (1-3 turns): ask what the Skill handles and how users describe the + trigger. Once the user provides or confirms concrete trigger examples, you + MUST emit one upsert_text operation for SKILL.md that updates the + frontmatter description. Do not write the body, create files, or choose a + final name. Never claim that the description was updated unless that + operation is present. +2. Workflow (2-5 turns): ask about key steps, decision points, rules, and + thresholds. Once clear, you MUST emit one upsert_text operation for SKILL.md + that updates the body only; preserve the placeholder name and display name. + Never claim that workflow content was added unless that operation is present. +3. Resources (0-2 turns): ask whether scripts, templates, or reference + documents are needed. Create only resources the user confirms under + scripts/, references/, or assets/. If none are needed, proceed to finalization + without inventing files. 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. + lowercase kebab-case name. Only after the user confirms the name may it be + written to SKILL.md. +Ask at most one focused question per turn, use the previous conversation to +avoid repeating questions, 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. Suggestions must operate +on the current Skill only. Never suggest creating, opening, or switching to +another Skill, because one Builder session cannot create multiple Skills. +If the user asks you to provide, draft, or propose examples, provide those +examples yourself in the reply and apply the corresponding operation when the +stage allows it. Do not ask the user to provide the same examples again. +Never repeat the current question in a suggestion. Suggestions must move the +conversation forward, such as reviewing the generated examples, adding a +channel, or proceeding to the next stage. +For example, when the user says "Provide 3-5 trigger phrases" and specifies +chat, email, and phone, immediately return 3-5 concise phrases covering those +channels. Do not respond with "please provide 3-5 trigger phrases" or ask the +user to confirm the same requirement. If the generated triggers are sufficient +for the Scenario stage, apply them to the description in the same response. +You may infer a proposed name early and return it as suggested_name and +suggested_display_name. These are hidden planning metadata: never write them +into SKILL.md or any Skill detail until the Finalize stage and user confirmation. Respond with JSON only: { "reply": "short user-facing summary", + "suggested_name": "customer-issue-triage", + "suggested_display_name": "Customer Issue Triage", "suggestions": [ "Use this for ecommerce refund escalation", "Ask me about required inputs first" @@ -384,6 +412,15 @@ class SkillAssistAttachmentPayload(BaseModel): size: int | None = Field(default=None, ge=0) +class SkillAssistHistoryMessagePayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + role: Literal["assistant", "user"] + content: str = Field(min_length=1, max_length=8_000) + suggested_name: str | None = Field(default=None, max_length=128) + suggested_display_name: str | None = Field(default=None, max_length=128) + + class SkillAssistMessagePayload(BaseModel): """One user message and optional uploaded context for the Skill Authoring assistant.""" @@ -391,6 +428,7 @@ class SkillAssistMessagePayload(BaseModel): message: str = Field(min_length=1, max_length=8_000) attachments: list[SkillAssistAttachmentPayload] = Field(default_factory=list, max_length=_MAX_ASSISTANT_ATTACHMENTS) + history: list[SkillAssistHistoryMessagePayload] = Field(default_factory=list, max_length=20) model: SkillAssistModelPayload | None = None target_path: str | None = None @@ -429,6 +467,8 @@ class SkillAssistActionPlan(BaseModel): reply: str = Field(default="", max_length=4_000) suggestions: list[str] = Field(default_factory=list, max_length=3) + suggested_name: str | None = Field(default=None, max_length=128) + suggested_display_name: str | None = Field(default=None, max_length=128) operations: list[SkillAssistDraftOperationPayload] = Field(default_factory=list, max_length=10) @@ -806,6 +846,7 @@ class SkillManagementService: skill_id: str, message: str, attachments: list[SkillAssistAttachmentPayload] | None = None, + history: list[SkillAssistHistoryMessagePayload] | None = None, model_payload: SkillAssistModelPayload | None = None, target_path: str | None = None, ) -> Generator[str, None, None]: @@ -823,10 +864,20 @@ class SkillManagementService: message_id=message_id, message=message, attachments=attachments or [], + history=history or [], model_payload=model_payload, target_path=target_path, ) plan = result.plan + if plan.suggested_name or plan.suggested_display_name: + yield self._assistant_sse( + { + "event": "skill_assistant_name_suggestion", + "id": message_id, + "name": plan.suggested_name, + "display_name": plan.suggested_display_name, + } + ) reply = plan.reply.strip() or "Done." yield self._assistant_sse( { @@ -923,6 +974,7 @@ class SkillManagementService: message_id: str, message: str, attachments: list[SkillAssistAttachmentPayload], + history: list[SkillAssistHistoryMessagePayload], model_payload: SkillAssistModelPayload | None, target_path: str | None, ) -> Generator[str, None, SkillAssistActionResult]: @@ -945,7 +997,7 @@ class SkillManagementService: tenant_id=tenant_id, model_payload=model_payload, ) - authoring_stage = self._assistant_authoring_stage(skill=skill, files=files) + authoring_stage = self._assistant_authoring_stage(skill=skill, files=files, history=history) supports_vision = self._model_supports_vision(model_instance) attachment_context = self._build_assistant_attachment_context( tenant_id=tenant_id, @@ -961,6 +1013,17 @@ class SkillManagementService: prompt_parts.append(f"{authoring_stage}") if target_path: prompt_parts.append(f"{target_path}") + if history: + history_text = "\n".join( + f"{item.role}: {item.content}" + + ( + f" [suggested_name={item.suggested_name}, suggested_display_name={item.suggested_display_name}]" + if item.suggested_name or item.suggested_display_name + else "" + ) + for item in history[-12:] + ) + prompt_parts.append(f"\n{history_text}\n") if attachment_context: prompt_parts.append(f"\n{attachment_context}\n") prompt_parts.append(f"User request:\n{message}") @@ -1097,6 +1160,8 @@ class SkillManagementService: stage=authoring_stage, skill=skill, files=files, + user_message=message, + history=history, ) if not any(suggestion.strip() for suggestion in plan.suggestions): plan = plan.model_copy( @@ -1222,6 +1287,8 @@ class SkillManagementService: "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" + "Only suggest actions for the current Skill. Never suggest creating another Skill, " + "starting a new Skill, or switching Skills.\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" @@ -1258,9 +1325,6 @@ class SkillManagementService: 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, @@ -1592,6 +1656,14 @@ class SkillManagementService: except IntegrityError as exc: session.rollback() raise SkillManagementServiceError("skill_name_conflict", "skill name already exists") from exc + logger.info( + "skill_assistant_operation_applied skill_id=%s operation=%s path=%s name=%s display_name=%s", + skill_id, + payload.operation, + payload.path, + skill.name, + skill.display_name, + ) return { **self._serialize_skill( skill, @@ -2578,16 +2650,23 @@ class SkillManagementService: } @staticmethod - def _assistant_authoring_stage(*, skill: Skill, files: list[SkillDraftFile]) -> str: + def _assistant_authoring_stage( + *, + skill: Skill, + files: list[SkillDraftFile], + history: list[SkillAssistHistoryMessagePayload] | None = None, + ) -> 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()) + has_description = bool( + skill.description.strip() and skill.description.strip() != _UNTITLED_SKILL_DESCRIPTION + ) body = _FRONTMATTER_RE.sub("", content, count=1).strip() - has_body = bool(body and body != _EMPTY_SKILL_DRAFT_CONTENT.strip()) + has_body = bool(body and body not in {_EMPTY_SKILL_DRAFT_CONTENT.strip(), _UNTITLED_SKILL_MD_BODY.strip()}) has_resources = any(file.path != _SKILL_MD for file in files) if not has_description: @@ -2595,9 +2674,33 @@ class SkillManagementService: if not has_body: return "workflow" if not has_resources: + if SkillManagementService._conversation_declined_resources(history or []): + return "finalize" return "resources" return "finalize" + @staticmethod + def _conversation_declined_resources(history: list[SkillAssistHistoryMessagePayload]) -> bool: + """Advance to naming after the user explicitly declines extra resources.""" + for index in range(len(history) - 1, 0, -1): + current = history[index] + previous = history[index - 1] + if current.role != "user" or previous.role != "assistant": + continue + assistant_text = previous.content.lower() + user_text = current.content.strip().lower() + asked_for_resources = any( + marker in assistant_text + for marker in ("resource", "script", "template", "reference", "资源", "脚本", "模板", "参考") + ) + declined = bool( + re.search(r"\b(?:no|none|not needed|don't)\b", user_text) + or any(marker in user_text for marker in ("无需", "不需要", "不用", "没有")) + ) + if asked_for_resources and declined: + return True + return False + @classmethod def _constrain_progressive_assistant_plan( cls, @@ -2606,8 +2709,10 @@ class SkillManagementService: stage: str, skill: Skill, files: list[SkillDraftFile], + user_message: str = "", + history: list[SkillAssistHistoryMessagePayload] | None = None, ) -> SkillAssistActionPlan: - if stage in {"existing_skill", "finalize"}: + if stage == "existing_skill": return plan skill_md = next((file for file in files if file.path == _SKILL_MD), None) @@ -2622,6 +2727,19 @@ class SkillManagementService: current_content=current_content, candidate_content=content, ) + content = cls._apply_assistant_suggested_identity( + skill=skill, + content=content, + suggested_name=plan.suggested_name, + suggested_display_name=plan.suggested_display_name, + ) + elif stage == "finalize": + if not cls._user_confirmed_skill_name(user_message, history or []): + content = cls._preserve_assistant_skill_identity( + skill=skill, + current_content=current_content, + candidate_content=content, + ) else: content = cls._preserve_assistant_skill_identity( skill=skill, @@ -2632,8 +2750,85 @@ class SkillManagementService: elif stage == "resources" and operation.path != _SKILL_MD: operations.append(operation) + logger.info( + "skill_assistant_plan_constrained skill_id=%s stage=%s suggested_name=%s " + "suggested_display_name=%s operations=%s", + skill.id, + stage, + plan.suggested_name, + plan.suggested_display_name, + [operation.path for operation in operations], + ) return plan.model_copy(update={"operations": operations}) + @classmethod + def _apply_assistant_suggested_identity( + cls, + *, + skill: Skill, + content: str, + suggested_name: str | None, + suggested_display_name: str | None, + ) -> str: + """Materialize a model-proposed identity for an untitled Builder draft.""" + if not cls._is_placeholder_skill_name(skill.name) or not suggested_name: + return content + + try: + name = validate_skill_name(suggested_name) + except ValueError: + return content + + display_name = suggested_display_name or name.replace("-", " ").title() + try: + frontmatter = cls._parse_frontmatter(content) + except SkillManagementServiceError: + return content + metadata = frontmatter.get("metadata") + if not isinstance(metadata, dict): + metadata = {} + frontmatter["name"] = name + metadata["display-name"] = display_name[:128] + frontmatter["metadata"] = metadata + serialized = yaml.safe_dump(frontmatter, allow_unicode=True, sort_keys=False).rstrip() + frontmatter_match = _FRONTMATTER_RE.match(content) + if frontmatter_match is None: + return content + body = 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 _user_confirmed_skill_name( + message: str, + history: list[SkillAssistHistoryMessagePayload], + ) -> bool: + """Only accept a generated name after an explicit user confirmation.""" + confirmation_messages = [message, *(item.content for item in history if item.role == "user")] + for content in confirmation_messages: + normalized = content.strip().lower() + if not normalized: + continue + if re.search( + r"\b(?:yes|y|confirm|confirmed|use it|use this|use the suggested name|call it|name it)\b", + normalized, + ): + return True + if re.search(r"\b(?:set|use)\b.{0,80}\bname\b", normalized): + return True + if any( + marker in normalized + for marker in ("就叫", "叫做", "名称为", "名字为", "改成", "定为", "使用这个名称", "确认名称") + ): + return True + + asked_for_name = any( + item.role == "assistant" + and any(marker in item.content.lower() for marker in ("name", "名称", "名字", "命名")) + for item in history[-3:] + ) + normalized_message = message.strip().lower() + return asked_for_name and not re.search(r"[??。.!!]", normalized_message) and len(normalized_message) <= 128 + @classmethod def _assistant_description_only_skill_md( cls, @@ -3565,7 +3760,7 @@ class SkillManagementService: session = object_session(skill) auto_generated_name = False if ( - self._should_auto_sync_name(skill) + self._is_placeholder_skill_name(skill.name) and display_name is not None and display_name != _UNTITLED_DISPLAY_NAME and session is not None @@ -3598,7 +3793,7 @@ class SkillManagementService: saved so the editor, detail payload, and future export all show the same generated kebab-case name. """ - if not self._should_auto_sync_name(skill): + if not self._is_placeholder_skill_name(skill.name): return content try: @@ -3635,11 +3830,18 @@ class SkillManagementService: return re.sub(r"(?m)^name:\s*.*$", f"name: {generated_name}", next_content, count=1) + @staticmethod + def _is_placeholder_skill_name(name: str) -> bool: + return name.startswith(f"{_UNTITLED_SKILL_NAME_PREFIX}-") + def _display_name_from_draft_skill_md(self, *, frontmatter: dict[str, Any], content: str) -> str | None: display_name = self._display_name_override_from_frontmatter(frontmatter) if display_name is not None and display_name != _UNTITLED_DISPLAY_NAME: return display_name - if display_name is None: + name = frontmatter.get("name") + if display_name is None and not ( + isinstance(name, str) and name.strip().startswith(_UNTITLED_SKILL_NAME_PREFIX) + ): return None heading = self._first_markdown_heading(content) @@ -3911,6 +4113,7 @@ class SkillManagementService: __all__ = [ "PublishedSkillArchive", "SkillAssistAttachmentPayload", + "SkillAssistHistoryMessagePayload", "SkillAssistMessagePayload", "SkillAssistModelPayload", "SkillCreatePayload", diff --git a/packages/contracts/generated/api/console/workspaces/types.gen.ts b/packages/contracts/generated/api/console/workspaces/types.gen.ts index c4180373ffe..6ecd38a0c0b 100644 --- a/packages/contracts/generated/api/console/workspaces/types.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/types.gen.ts @@ -749,11 +749,19 @@ export type SkillAssistAttachmentPayload = { export type SkillAssistMessagePayload = { attachments?: Array + history?: Array message: string model?: SkillAssistModelPayload | null target_path?: string | null } +export type SkillAssistHistoryMessagePayload = { + content: string + role: 'assistant' | 'user' + suggested_display_name?: string | null + suggested_name?: string | null +} + export type SkillDraftFileOperationPayload = { content?: string | null expected_updated_at?: number | null diff --git a/packages/contracts/generated/api/console/workspaces/zod.gen.ts b/packages/contracts/generated/api/console/workspaces/zod.gen.ts index 29193771983..917cb3c3d47 100644 --- a/packages/contracts/generated/api/console/workspaces/zod.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/zod.gen.ts @@ -1528,8 +1528,16 @@ export const zSkillAssistModelPayload = z.object({ * * One user message and optional uploaded context for the Skill Authoring assistant. */ +export const zSkillAssistHistoryMessagePayload = z.object({ + content: z.string().min(1).max(8000), + role: z.enum(['assistant', 'user']), + suggested_display_name: z.string().max(128).nullish(), + suggested_name: z.string().max(128).nullish(), +}) + export const zSkillAssistMessagePayload = z.object({ attachments: z.array(zSkillAssistAttachmentPayload).max(10).optional(), + history: z.array(zSkillAssistHistoryMessagePayload).max(20).optional(), message: z.string().min(1).max(8000), model: zSkillAssistModelPayload.nullish(), target_path: z.string().nullish(), diff --git a/web/features/skills/client.ts b/web/features/skills/client.ts index 5a548779d72..eff4db7a521 100644 --- a/web/features/skills/client.ts +++ b/web/features/skills/client.ts @@ -1,5 +1,6 @@ import type { SkillAssistAttachmentPayload, + SkillAssistHistoryMessagePayload, SkillFileUploadResponse, } from '@dify/contracts/api/console/workspaces/types.gen' import type { @@ -161,8 +162,10 @@ export function sendSkillAssistMessage({ onUnhandledEvent, skillId, targetPath, + history, }: { attachments?: SkillAssistAttachmentPayload[] + history?: SkillAssistHistoryMessagePayload[] getAbortController?: (abortController: AbortController) => void message: string model?: DefaultModel & { @@ -181,6 +184,7 @@ export function sendSkillAssistMessage({ { body: { attachments, + history, message, model, target_path: targetPath, diff --git a/web/features/skills/detail/builder-panel.tsx b/web/features/skills/detail/builder-panel.tsx index 148b79fcc76..bb1190ba5eb 100644 --- a/web/features/skills/detail/builder-panel.tsx +++ b/web/features/skills/detail/builder-panel.tsx @@ -520,6 +520,15 @@ export function SkillBuilderPanel({ isSendingRef.current = true const requestMessage = trimmedPrompt || t(($) => $['skillManagement.detail.builder.attachmentOnlyMessage']) + const conversationHistory = messagesRef.current + .filter((message) => message.content.trim() && message.id !== `assistant-${skillId}-intro`) + .slice(-12) + .map(({ content, role, suggestedDisplayName, suggestedName }) => ({ + content, + role, + suggested_display_name: suggestedDisplayName, + suggested_name: suggestedName, + })) const userMessage: BuilderChatMessage = { attachments: attachedFiles, @@ -554,6 +563,7 @@ export function SkillBuilderPanel({ void sendSkillAssistMessage({ skillId, + history: conversationHistory, attachments: attachedFiles.map((attachment) => ({ mime_type: attachment.mimeType, name: attachment.name, @@ -619,6 +629,20 @@ export function SkillBuilderPanel({ ) return } + if (event.event === 'skill_assistant_name_suggestion') { + const suggestedName = typeof event.name === 'string' ? event.name : undefined + const suggestedDisplayName = + typeof event.display_name === 'string' ? event.display_name : undefined + if (!suggestedName && !suggestedDisplayName) return + updateMessages((currentMessages) => + currentMessages.map((message) => + message.id === assistantMessageId + ? { ...message, suggestedName, suggestedDisplayName } + : message, + ), + ) + return + } if (event.event === 'skill_assistant_suggestions') { const nextSuggestions = Array.isArray(event.suggestions) ? event.suggestions.filter( diff --git a/web/features/skills/detail/shared.tsx b/web/features/skills/detail/shared.tsx index 2cad4988055..fa0501785a4 100644 --- a/web/features/skills/detail/shared.tsx +++ b/web/features/skills/detail/shared.tsx @@ -145,6 +145,8 @@ export type BuilderChatMessage = { rawContent?: string reasoningContent?: string role: 'assistant' | 'user' + suggestedDisplayName?: string + suggestedName?: string suggestions?: string[] thinkingDurationSeconds?: number tone?: 'error'