diff --git a/web/app/components/workflow/skill/start-tab/create-blank-skill-modal.tsx b/web/app/components/workflow/skill/start-tab/create-blank-skill-modal.tsx new file mode 100644 index 0000000000..fe5e6c2556 --- /dev/null +++ b/web/app/components/workflow/skill/start-tab/create-blank-skill-modal.tsx @@ -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() + 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 ( + +
+ + 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 && ( +

+ {t('skill.startTab.createModal.nameDuplicate', { ns: 'workflow' })} +

+ )} +
+
+ + +
+
+ ) +} + +export default memo(CreateBlankSkillModal) diff --git a/web/app/components/workflow/skill/start-tab/create-import-section.tsx b/web/app/components/workflow/skill/start-tab/create-import-section.tsx index 12967c2221..499c29f112 100644 --- a/web/app/components/workflow/skill/start-tab/create-import-section.tsx +++ b/web/app/components/workflow/skill/start-tab/create-import-section.tsx @@ -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 ( -
- } - title={t('skill.startTab.createBlankSkill')} - description={t('skill.startTab.createBlankSkillDesc')} + <> +
+ } + title={t('skill.startTab.createBlankSkill')} + description={t('skill.startTab.createBlankSkillDesc')} + onClick={() => setIsModalOpen(true)} + /> + } + title={t('skill.startTab.importSkill')} + description={t('skill.startTab.importSkillDesc')} + /> +
+ setIsModalOpen(false)} /> - } - title={t('skill.startTab.importSkill')} - description={t('skill.startTab.importSkillDesc')} - /> -
+ ) } diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index 723027fc9d..4db19ed8ac 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -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", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index 8371beed46..1c55e52880 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -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 文件导入", diff --git a/web/service/use-app-asset.ts b/web/service/use-app-asset.ts index d42925343f..b39b869011 100644 --- a/web/service/use-app-asset.ts +++ b/web/service/use-app-asset.ts @@ -312,7 +312,7 @@ export const useBatchUpload = () => { files: Map parentId?: string | null onProgress?: (uploaded: number, total: number) => void - }): Promise => { + }): Promise => { 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({