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 499c29f112..a3c984864a 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,14 +1,18 @@ 'use client' import { RiAddCircleFill, RiUploadLine } from '@remixicon/react' +import dynamic from 'next/dynamic' import { memo, useState } from 'react' import { useTranslation } from 'react-i18next' import ActionCard from './action-card' import CreateBlankSkillModal from './create-blank-skill-modal' +const ImportSkillModal = dynamic(() => import('./import-skill-modal')) + const CreateImportSection = () => { const { t } = useTranslation('workflow') - const [isModalOpen, setIsModalOpen] = useState(false) + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false) + const [isImportModalOpen, setIsImportModalOpen] = useState(false) return ( <> @@ -17,17 +21,22 @@ const CreateImportSection = () => { icon={} title={t('skill.startTab.createBlankSkill')} description={t('skill.startTab.createBlankSkillDesc')} - onClick={() => setIsModalOpen(true)} + onClick={() => setIsCreateModalOpen(true)} /> } title={t('skill.startTab.importSkill')} description={t('skill.startTab.importSkillDesc')} + onClick={() => setIsImportModalOpen(true)} /> setIsModalOpen(false)} + isOpen={isCreateModalOpen} + onClose={() => setIsCreateModalOpen(false)} + /> + setIsImportModalOpen(false)} /> ) diff --git a/web/app/components/workflow/skill/start-tab/import-skill-modal.tsx b/web/app/components/workflow/skill/start-tab/import-skill-modal.tsx new file mode 100644 index 0000000000..b12d42de00 --- /dev/null +++ b/web/app/components/workflow/skill/start-tab/import-skill-modal.tsx @@ -0,0 +1,237 @@ +'use client' + +import type { ChangeEvent, DragEvent } from 'react' +import { RiUploadCloud2Line } from '@remixicon/react' +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 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 { extractAndValidateZip, ZipValidationError } from '../utils/zip-extract' +import { buildUploadDataFromZip } from '../utils/zip-to-upload-tree' + +const NS = 'workflow' +const PREFIX = 'skill.startTab.importModal' + +const ZIP_ERROR_I18N_KEYS = { + zip_too_large: `${PREFIX}.fileTooLarge`, + extracted_too_large: `${PREFIX}.errorExtractedTooLarge`, + too_many_files: `${PREFIX}.errorTooManyFiles`, + path_traversal: `${PREFIX}.errorPathTraversal`, + empty_zip: `${PREFIX}.errorEmptyZip`, + invalid_zip: `${PREFIX}.errorInvalidZip`, + no_root_folder: `${PREFIX}.errorNoRootFolder`, +} as const + +type ImportSkillModalProps = { + isOpen: boolean + onClose: () => void +} + +function formatFileSize(bytes: number): string { + if (bytes < 1024) + return `${bytes} B` + if (bytes < 1024 * 1024) + return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +const ImportSkillModal = ({ isOpen, onClose }: ImportSkillModalProps) => { + const { t } = useTranslation() + const [selectedFile, setSelectedFile] = useState(null) + const [isImporting, setIsImporting] = useState(false) + const [isDragOver, setIsDragOver] = 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 fileInputRef = useRef(null) + + const handleClose = useCallback(() => { + if (isImporting) + return + setSelectedFile(null) + onClose() + }, [isImporting, onClose]) + + const validateAndSetFile = useCallback((file: File) => { + if (!file.name.toLowerCase().endsWith('.zip')) { + Toast.notify({ type: 'error', message: t(`${PREFIX}.invalidFileType`, { ns: NS }) }) + return + } + setSelectedFile(file) + }, [t]) + + const handleFileChange = useCallback((e: ChangeEvent) => { + const file = e.target.files?.[0] + if (file) + validateAndSetFile(file) + e.target.value = '' + }, [validateAndSetFile]) + + const handleDragOver = useCallback((e: DragEvent) => { + e.preventDefault() + setIsDragOver(true) + }, []) + + const handleDragLeave = useCallback((e: DragEvent) => { + e.preventDefault() + setIsDragOver(false) + }, []) + + const handleDrop = useCallback((e: DragEvent) => { + e.preventDefault() + setIsDragOver(false) + const file = e.dataTransfer.files[0] + if (file) + validateAndSetFile(file) + }, [validateAndSetFile]) + + const handleImport = useCallback(async () => { + if (!selectedFile || !appId) + return + + setIsImporting(true) + storeApi.getState().setUploadStatus('uploading') + storeApi.getState().setUploadProgress({ uploaded: 0, total: 0, failed: 0 }) + + try { + const zipData = await selectedFile.arrayBuffer() + const extracted = await extractAndValidateZip(zipData) + + if (existingNames?.has(extracted.rootFolderName)) { + Toast.notify({ type: 'error', message: t(`${PREFIX}.nameDuplicate`, { ns: NS }) }) + setIsImporting(false) + storeApi.getState().setUploadStatus('partial_error') + return + } + + const { tree, files } = await buildUploadDataFromZip(extracted) + + storeApi.getState().setUploadProgress({ uploaded: 0, total: files.size, failed: 0 }) + + 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 skillFolder = createdNodes?.[0] + const skillMd = skillFolder?.children?.find(c => c.name === 'SKILL.md') + if (skillMd?.id) + storeApi.getState().openTab(skillMd.id, { pinned: true }) + + Toast.notify({ type: 'success', message: t(`${PREFIX}.importSuccess`, { ns: NS, name: extracted.rootFolderName }) }) + onClose() + } + catch (error) { + storeApi.getState().setUploadStatus('partial_error') + if (error instanceof ZipValidationError) { + const i18nKey = ZIP_ERROR_I18N_KEYS[error.code as keyof typeof ZIP_ERROR_I18N_KEYS] + Toast.notify({ type: 'error', message: i18nKey ? t(i18nKey, { ns: NS }) : error.message }) + } + else { + Toast.notify({ type: 'error', message: t(`${PREFIX}.errorInvalidZip`, { ns: NS }) }) + } + } + finally { + setIsImporting(false) + setSelectedFile(null) + } + }, [selectedFile, appId, storeApi, existingNames, t, onClose]) + + return ( + +
+ {!selectedFile + ? ( +
fileInputRef.current?.click()} + > + +

+ {t(`${PREFIX}.dropHint`, { ns: NS })} + {' '} + + {t(`${PREFIX}.browseFiles`, { ns: NS })} + +

+
+ ) + : ( +
+
+ {selectedFile.name} + {formatFileSize(selectedFile.size)} +
+ +
+ )} + +
+
+ + +
+
+ ) +} + +export default memo(ImportSkillModal) diff --git a/web/app/components/workflow/skill/utils/zip-extract.ts b/web/app/components/workflow/skill/utils/zip-extract.ts new file mode 100644 index 0000000000..686758327f --- /dev/null +++ b/web/app/components/workflow/skill/utils/zip-extract.ts @@ -0,0 +1,116 @@ +import { unzip } from 'fflate' + +const MAX_ZIP_SIZE = 50 * 1024 * 1024 +const MAX_EXTRACTED_SIZE = 200 * 1024 * 1024 +const MAX_FILE_COUNT = 200 + +type ZipValidationErrorCode + = 'zip_too_large' + | 'extracted_too_large' + | 'too_many_files' + | 'path_traversal' + | 'empty_zip' + | 'invalid_zip' + | 'no_root_folder' + +export class ZipValidationError extends Error { + code: ZipValidationErrorCode + constructor(code: ZipValidationErrorCode, message: string) { + super(message) + this.name = 'ZipValidationError' + this.code = code + } +} + +export type ExtractedZipResult = { + rootFolderName: string + files: Map +} + +const SYSTEM_FILES = new Set(['.DS_Store', 'Thumbs.db', 'desktop.ini']) + +function isSystemEntry(name: string): boolean { + if (name.startsWith('__MACOSX/')) + return true + const basename = name.split('/').pop()! + return SYSTEM_FILES.has(basename) +} + +function hasUnsafePath(name: string): boolean { + return name.split('/').some(s => s === '..' || s === '.') +} + +export async function extractAndValidateZip(zipData: ArrayBuffer): Promise { + if (zipData.byteLength > MAX_ZIP_SIZE) + throw new ZipValidationError('zip_too_large', `ZIP file exceeds ${MAX_ZIP_SIZE / 1024 / 1024}MB limit`) + + let filterError: ZipValidationError | null = null + let fileCount = 0 + let estimatedSize = 0 + + let raw: Record + try { + raw = await new Promise((resolve, reject) => { + unzip(new Uint8Array(zipData), { + filter(file) { + if (file.name.endsWith('/')) + return false + + if (isSystemEntry(file.name)) + return false + + if (hasUnsafePath(file.name)) { + filterError ??= new ZipValidationError('path_traversal', `Unsafe path detected: ${file.name}`) + return false + } + + fileCount++ + if (fileCount > MAX_FILE_COUNT) { + filterError ??= new ZipValidationError('too_many_files', `ZIP contains more than ${MAX_FILE_COUNT} files`) + return false + } + + estimatedSize += file.originalSize + if (estimatedSize > MAX_EXTRACTED_SIZE) { + filterError ??= new ZipValidationError('extracted_too_large', `Extracted content exceeds ${MAX_EXTRACTED_SIZE / 1024 / 1024}MB limit`) + return false + } + + return true + }, + }, (err, result) => { + if (err) + reject(err) + else + resolve(result) + }) + }) + } + catch { + throw filterError ?? new ZipValidationError('invalid_zip', 'Failed to decompress ZIP file') + } + + if (filterError) + throw filterError + + const files = new Map() + let actualSize = 0 + for (const [path, data] of Object.entries(raw)) { + actualSize += data.byteLength + if (actualSize > MAX_EXTRACTED_SIZE) + throw new ZipValidationError('extracted_too_large', `Extracted content exceeds ${MAX_EXTRACTED_SIZE / 1024 / 1024}MB limit`) + files.set(path, data) + } + + if (files.size === 0) + throw new ZipValidationError('empty_zip', 'ZIP file contains no files') + + const rootFolders = new Set() + for (const path of files.keys()) + rootFolders.add(path.split('/')[0]) + + if (rootFolders.size !== 1) + throw new ZipValidationError('no_root_folder', 'ZIP must contain exactly one root folder') + + return { rootFolderName: [...rootFolders][0], files } +} diff --git a/web/app/components/workflow/skill/utils/zip-to-upload-tree.ts b/web/app/components/workflow/skill/utils/zip-to-upload-tree.ts new file mode 100644 index 0000000000..5840c3eb98 --- /dev/null +++ b/web/app/components/workflow/skill/utils/zip-to-upload-tree.ts @@ -0,0 +1,71 @@ +import type { ExtractedZipResult } from './zip-extract' +import type { BatchUploadNodeInput } from '@/types/app-asset' +import { getFileExtension } from './file-utils' +import { prepareSkillUploadFile } from './skill-upload-utils' + +export type ZipUploadData = { + tree: BatchUploadNodeInput[] + files: Map +} + +function uint8ArrayToFile(data: Uint8Array, name: string): File { + const ext = getFileExtension(name) + const type = ext === 'md' || ext === 'markdown' || ext === 'mdx' + ? 'text/markdown' + : 'application/octet-stream' + const buffer = new ArrayBuffer(data.byteLength) + new Uint8Array(buffer).set(data) + return new File([buffer], name, { type }) +} + +export async function buildUploadDataFromZip(extracted: ExtractedZipResult): Promise { + const fileMap = new Map() + const tree: BatchUploadNodeInput[] = [] + const folderMap = new Map() + + const entries = await Promise.all( + Array.from(extracted.files.entries()).map(async ([path, data]) => { + const fileName = path.split('/').pop()! + const rawFile = uint8ArrayToFile(data, fileName) + const prepared = await prepareSkillUploadFile(rawFile) + return { path, prepared } + }), + ) + + for (const { path, prepared } of entries) { + fileMap.set(path, prepared) + + const parts = path.split('/') + let currentLevel = tree + let currentPath = '' + + for (let i = 0; i < parts.length; i++) { + const part = parts[i] + const isLastPart = i === parts.length - 1 + currentPath = currentPath ? `${currentPath}/${part}` : part + + if (isLastPart) { + currentLevel.push({ + name: part, + node_type: 'file', + size: prepared.size, + }) + } + else { + let folder = folderMap.get(currentPath) + if (!folder) { + folder = { + name: part, + node_type: 'folder', + children: [], + } + folderMap.set(currentPath, folder) + currentLevel.push(folder) + } + currentLevel = folder.children! + } + } + } + + return { tree, files: fileMap } +} diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index 4db19ed8ac..612d244b7b 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -1081,6 +1081,21 @@ "skill.startTab.createModal.title": "Create Blank Skill", "skill.startTab.createSuccess": "Skill \"{{name}}\" created successfully", "skill.startTab.filesIncluded": "{{count}} files included", + "skill.startTab.importModal.browseFiles": "Browse Files", + "skill.startTab.importModal.changeFile": "Change File", + "skill.startTab.importModal.dropHint": "Drop a .zip file here, or", + "skill.startTab.importModal.errorEmptyZip": "ZIP file contains no files", + "skill.startTab.importModal.errorExtractedTooLarge": "Extracted content is too large", + "skill.startTab.importModal.errorInvalidZip": "Invalid ZIP file", + "skill.startTab.importModal.errorNoRootFolder": "ZIP must contain exactly one root folder", + "skill.startTab.importModal.errorPathTraversal": "ZIP contains unsafe file paths", + "skill.startTab.importModal.errorTooManyFiles": "ZIP contains too many files", + "skill.startTab.importModal.fileTooLarge": "ZIP file exceeds 50MB limit", + "skill.startTab.importModal.importButton": "Import", + "skill.startTab.importModal.importSuccess": "Skill \"{{name}}\" imported successfully", + "skill.startTab.importModal.invalidFileType": "Please select a .zip file", + "skill.startTab.importModal.nameDuplicate": "A skill with this name already exists", + "skill.startTab.importModal.title": "Import Skill", "skill.startTab.importSkill": "Import Skill", "skill.startTab.importSkillDesc": "Import skill from skill.zip file", "skill.startTab.searchPlaceholder": "Search…", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index 1c55e52880..8e90bb0815 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -1073,6 +1073,21 @@ "skill.startTab.createModal.title": "创建空白 Skill", "skill.startTab.createSuccess": "Skill \"{{name}}\" 创建成功", "skill.startTab.filesIncluded": "包含 {{count}} 个文件", + "skill.startTab.importModal.browseFiles": "浏览文件", + "skill.startTab.importModal.changeFile": "更换文件", + "skill.startTab.importModal.dropHint": "将 .zip 文件拖放到此处,或", + "skill.startTab.importModal.errorEmptyZip": "ZIP 文件中没有文件", + "skill.startTab.importModal.errorExtractedTooLarge": "解压后内容过大", + "skill.startTab.importModal.errorInvalidZip": "无效的 ZIP 文件", + "skill.startTab.importModal.errorNoRootFolder": "ZIP 必须包含且仅包含一个根文件夹", + "skill.startTab.importModal.errorPathTraversal": "ZIP 包含不安全的文件路径", + "skill.startTab.importModal.errorTooManyFiles": "ZIP 包含的文件数量过多", + "skill.startTab.importModal.fileTooLarge": "ZIP 文件超过 50MB 限制", + "skill.startTab.importModal.importButton": "导入", + "skill.startTab.importModal.importSuccess": "Skill \"{{name}}\" 导入成功", + "skill.startTab.importModal.invalidFileType": "请选择 .zip 文件", + "skill.startTab.importModal.nameDuplicate": "已存在同名 Skill", + "skill.startTab.importModal.title": "导入 Skill", "skill.startTab.importSkill": "导入 Skill", "skill.startTab.importSkillDesc": "从 skill.zip 文件导入", "skill.startTab.searchPlaceholder": "搜索…", diff --git a/web/package.json b/web/package.json index c16b40283e..f4de22222f 100644 --- a/web/package.json +++ b/web/package.json @@ -104,6 +104,7 @@ "emoji-mart": "5.6.0", "es-toolkit": "1.43.0", "fast-deep-equal": "3.1.3", + "fflate": "0.8.2", "foxact": "0.2.52", "html-entities": "2.6.0", "html-to-image": "1.11.13", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 8dff77ca7b..3503492bd3 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -191,6 +191,9 @@ importers: fast-deep-equal: specifier: 3.1.3 version: 3.1.3 + fflate: + specifier: 0.8.2 + version: 0.8.2 foxact: specifier: 0.2.52 version: 0.2.52(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -4797,6 +4800,9 @@ packages: fflate@0.4.8: resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -12335,6 +12341,8 @@ snapshots: fflate@0.4.8: {} + fflate@0.8.2: {} + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1