mirror of
https://github.com/langgenius/dify.git
synced 2026-05-01 16:08:04 +08:00
feat(skill): add create blank skill modal with name validation
Wire up the "Create Blank Skill" action card to open a modal where users enter a skill name. The modal validates against existing skill names in real-time and creates a folder with a SKILL.md file via batchUpload, then opens the file as a pinned tab.
This commit is contained in:
@ -0,0 +1,162 @@
|
||||
'use client'
|
||||
|
||||
import type { BatchUploadNodeInput } from '@/types/app-asset'
|
||||
import { memo, useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { useBatchUpload } from '@/service/use-app-asset'
|
||||
import { useExistingSkillNames } from '../hooks/use-skill-asset-tree'
|
||||
import { useSkillTreeUpdateEmitter } from '../hooks/use-skill-tree-collaboration'
|
||||
import { prepareSkillUploadFile } from '../utils/skill-upload-utils'
|
||||
|
||||
const SKILL_MD_TEMPLATE = (name: string) => `---
|
||||
name: ${name}
|
||||
description:
|
||||
---
|
||||
|
||||
# ${name}
|
||||
`
|
||||
|
||||
type CreateBlankSkillModalProps = {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const CreateBlankSkillModal = ({ isOpen, onClose }: CreateBlankSkillModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [skillName, setSkillName] = useState('')
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
|
||||
const appDetail = useAppStore(s => s.appDetail)
|
||||
const appId = appDetail?.id || ''
|
||||
const storeApi = useWorkflowStore()
|
||||
|
||||
const batchUpload = useBatchUpload()
|
||||
const batchUploadRef = useRef(batchUpload)
|
||||
batchUploadRef.current = batchUpload
|
||||
|
||||
const emitTreeUpdate = useSkillTreeUpdateEmitter()
|
||||
const emitTreeUpdateRef = useRef(emitTreeUpdate)
|
||||
emitTreeUpdateRef.current = emitTreeUpdate
|
||||
|
||||
const { data: existingNames } = useExistingSkillNames()
|
||||
|
||||
const trimmedName = skillName.trim()
|
||||
const isDuplicate = !!trimmedName && (existingNames?.has(trimmedName) ?? false)
|
||||
const canCreate = !!trimmedName && !isDuplicate && !isCreating
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (isCreating)
|
||||
return
|
||||
setSkillName('')
|
||||
onClose()
|
||||
}, [isCreating, onClose])
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
if (!canCreate || !appId)
|
||||
return
|
||||
|
||||
setIsCreating(true)
|
||||
storeApi.getState().setUploadStatus('uploading')
|
||||
storeApi.getState().setUploadProgress({ uploaded: 0, total: 1, failed: 0 })
|
||||
|
||||
try {
|
||||
const content = SKILL_MD_TEMPLATE(trimmedName)
|
||||
const rawFile = new File([content], 'SKILL.md', { type: 'text/markdown' })
|
||||
const preparedFile = await prepareSkillUploadFile(rawFile)
|
||||
|
||||
const tree: BatchUploadNodeInput[] = [{
|
||||
name: trimmedName,
|
||||
node_type: 'folder',
|
||||
children: [{ name: 'SKILL.md', node_type: 'file', size: preparedFile.size }],
|
||||
}]
|
||||
|
||||
const files = new Map<string, File>()
|
||||
files.set(`${trimmedName}/SKILL.md`, preparedFile)
|
||||
|
||||
const createdNodes = await batchUploadRef.current.mutateAsync({
|
||||
appId,
|
||||
tree,
|
||||
files,
|
||||
parentId: null,
|
||||
onProgress: (uploaded, total) => {
|
||||
storeApi.getState().setUploadProgress({ uploaded, total, failed: 0 })
|
||||
},
|
||||
})
|
||||
|
||||
storeApi.getState().setUploadStatus('success')
|
||||
emitTreeUpdateRef.current()
|
||||
|
||||
const skillMdId = createdNodes?.[0]?.children?.[0]?.id
|
||||
if (skillMdId)
|
||||
storeApi.getState().openTab(skillMdId, { pinned: true })
|
||||
|
||||
Toast.notify({ type: 'success', message: t('skill.startTab.createSuccess', { ns: 'workflow', name: trimmedName }) })
|
||||
onClose()
|
||||
}
|
||||
catch {
|
||||
storeApi.getState().setUploadStatus('partial_error')
|
||||
Toast.notify({ type: 'error', message: t('skill.startTab.createError', { ns: 'workflow' }) })
|
||||
}
|
||||
finally {
|
||||
setIsCreating(false)
|
||||
setSkillName('')
|
||||
}
|
||||
}, [canCreate, appId, trimmedName, storeApi, onClose, t])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isShow={isOpen}
|
||||
onClose={handleClose}
|
||||
title={t('skill.startTab.createModal.title', { ns: 'workflow' })}
|
||||
closable={!isCreating}
|
||||
clickOutsideNotClose={isCreating}
|
||||
>
|
||||
<div className="mt-6 flex flex-col gap-1">
|
||||
<label className="system-sm-semibold text-text-secondary">
|
||||
{t('skill.startTab.createModal.nameLabel', { ns: 'workflow' })}
|
||||
</label>
|
||||
<Input
|
||||
value={skillName}
|
||||
onChange={e => setSkillName(e.target.value)}
|
||||
placeholder={t('skill.startTab.createModal.namePlaceholder', { ns: 'workflow' }) || ''}
|
||||
destructive={isDuplicate}
|
||||
disabled={isCreating}
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && canCreate)
|
||||
handleCreate()
|
||||
}}
|
||||
/>
|
||||
{isDuplicate && (
|
||||
<p className="system-xs-regular text-text-destructive">
|
||||
{t('skill.startTab.createModal.nameDuplicate', { ns: 'workflow' })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<Button
|
||||
onClick={handleClose}
|
||||
disabled={isCreating}
|
||||
>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleCreate}
|
||||
disabled={!canCreate}
|
||||
loading={isCreating}
|
||||
>
|
||||
{t('operation.create', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(CreateBlankSkillModal)
|
||||
@ -1,26 +1,35 @@
|
||||
'use client'
|
||||
|
||||
import { RiAddCircleFill, RiUploadLine } from '@remixicon/react'
|
||||
import { memo } from 'react'
|
||||
import { memo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionCard from './action-card'
|
||||
import CreateBlankSkillModal from './create-blank-skill-modal'
|
||||
|
||||
const CreateImportSection = () => {
|
||||
const { t } = useTranslation('workflow')
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-2 px-6 pb-4 pt-6">
|
||||
<ActionCard
|
||||
icon={<RiAddCircleFill className="size-5 text-text-accent" />}
|
||||
title={t('skill.startTab.createBlankSkill')}
|
||||
description={t('skill.startTab.createBlankSkillDesc')}
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-2 px-6 pb-4 pt-6">
|
||||
<ActionCard
|
||||
icon={<RiAddCircleFill className="size-5 text-text-accent" />}
|
||||
title={t('skill.startTab.createBlankSkill')}
|
||||
description={t('skill.startTab.createBlankSkillDesc')}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
/>
|
||||
<ActionCard
|
||||
icon={<RiUploadLine className="size-5 text-text-accent" />}
|
||||
title={t('skill.startTab.importSkill')}
|
||||
description={t('skill.startTab.importSkillDesc')}
|
||||
/>
|
||||
</div>
|
||||
<CreateBlankSkillModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
/>
|
||||
<ActionCard
|
||||
icon={<RiUploadLine className="size-5 text-text-accent" />}
|
||||
title={t('skill.startTab.importSkill')}
|
||||
description={t('skill.startTab.importSkillDesc')}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1074,6 +1074,12 @@
|
||||
"singleRun.testRunLoop": "Test Run Loop",
|
||||
"skill.startTab.createBlankSkill": "Create Blank Skill",
|
||||
"skill.startTab.createBlankSkillDesc": "Start with an empty folder structure",
|
||||
"skill.startTab.createError": "Failed to create skill",
|
||||
"skill.startTab.createModal.nameDuplicate": "A skill with this name already exists",
|
||||
"skill.startTab.createModal.nameLabel": "Skill Name",
|
||||
"skill.startTab.createModal.namePlaceholder": "Enter skill name",
|
||||
"skill.startTab.createModal.title": "Create Blank Skill",
|
||||
"skill.startTab.createSuccess": "Skill \"{{name}}\" created successfully",
|
||||
"skill.startTab.filesIncluded": "{{count}} files included",
|
||||
"skill.startTab.importSkill": "Import Skill",
|
||||
"skill.startTab.importSkillDesc": "Import skill from skill.zip file",
|
||||
|
||||
@ -1066,6 +1066,12 @@
|
||||
"singleRun.testRunLoop": "测试运行循环",
|
||||
"skill.startTab.createBlankSkill": "创建空白 Skill",
|
||||
"skill.startTab.createBlankSkillDesc": "从空文件夹结构开始",
|
||||
"skill.startTab.createError": "Skill 创建失败",
|
||||
"skill.startTab.createModal.nameDuplicate": "已存在同名 Skill",
|
||||
"skill.startTab.createModal.nameLabel": "Skill 名称",
|
||||
"skill.startTab.createModal.namePlaceholder": "输入 Skill 名称",
|
||||
"skill.startTab.createModal.title": "创建空白 Skill",
|
||||
"skill.startTab.createSuccess": "Skill \"{{name}}\" 创建成功",
|
||||
"skill.startTab.filesIncluded": "包含 {{count}} 个文件",
|
||||
"skill.startTab.importSkill": "导入 Skill",
|
||||
"skill.startTab.importSkillDesc": "从 skill.zip 文件导入",
|
||||
|
||||
@ -312,7 +312,7 @@ export const useBatchUpload = () => {
|
||||
files: Map<string, File>
|
||||
parentId?: string | null
|
||||
onProgress?: (uploaded: number, total: number) => void
|
||||
}): Promise<void> => {
|
||||
}): Promise<BatchUploadNodeOutput[]> => {
|
||||
const response = await consoleClient.appAsset.batchUpload({
|
||||
params: { appId },
|
||||
body: { children: tree, parent_id: parentId },
|
||||
@ -348,6 +348,8 @@ export const useBatchUpload = () => {
|
||||
onProgress?.(completed, total)
|
||||
}),
|
||||
)
|
||||
|
||||
return response.children
|
||||
},
|
||||
onSettled: (_, __, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
|
||||
Reference in New Issue
Block a user