feat: add ZIP skill import with client-side extraction

Add import skill modal that accepts .zip files via drag-and-drop or
file picker, extracts them client-side using fflate, validates structure
and security constraints, then batch uploads via presigned URLs.

- Add fflate dependency for browser-side ZIP decompression
- Create zip-extract.ts with fflate filter API for validation
- Create zip-to-upload-tree.ts for BatchUploadNodeInput tree building
- Create import-skill-modal.tsx with drag-and-drop support
- Lazy-load ImportSkillModal via next/dynamic for bundle optimization
- Add en-US and zh-Hans i18n keys for import modal
This commit is contained in:
yyh
2026-01-30 21:49:45 +08:00
parent ea91f96924
commit ea88bcfbd2
8 changed files with 476 additions and 4 deletions

View File

@@ -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={<RiAddCircleFill className="size-5 text-text-accent" />}
title={t('skill.startTab.createBlankSkill')}
description={t('skill.startTab.createBlankSkillDesc')}
onClick={() => setIsModalOpen(true)}
onClick={() => setIsCreateModalOpen(true)}
/>
<ActionCard
icon={<RiUploadLine className="size-5 text-text-accent" />}
title={t('skill.startTab.importSkill')}
description={t('skill.startTab.importSkillDesc')}
onClick={() => setIsImportModalOpen(true)}
/>
</div>
<CreateBlankSkillModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
/>
<ImportSkillModal
isOpen={isImportModalOpen}
onClose={() => setIsImportModalOpen(false)}
/>
</>
)

View File

@@ -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<File | null>(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<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<Modal
isShow={isOpen}
onClose={handleClose}
title={t(`${PREFIX}.title`, { ns: NS })}
closable={!isImporting}
clickOutsideNotClose={isImporting}
>
<div className="mt-6">
{!selectedFile
? (
<div
className={`flex cursor-pointer flex-col items-center justify-center rounded-xl border border-dashed p-8 transition-colors ${isDragOver ? 'border-components-button-primary-border bg-state-accent-hover' : 'border-divider-regular bg-components-panel-bg-blur'}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<RiUploadCloud2Line className="mb-2 size-8 text-text-tertiary" />
<p className="system-sm-regular text-text-tertiary">
{t(`${PREFIX}.dropHint`, { ns: NS })}
{' '}
<span className="system-sm-medium text-text-accent">
{t(`${PREFIX}.browseFiles`, { ns: NS })}
</span>
</p>
</div>
)
: (
<div className="flex items-center justify-between rounded-xl border border-divider-regular bg-components-panel-bg-blur px-4 py-3">
<div className="flex min-w-0 flex-col">
<span className="system-sm-medium truncate text-text-secondary">{selectedFile.name}</span>
<span className="system-xs-regular text-text-tertiary">{formatFileSize(selectedFile.size)}</span>
</div>
<Button
size="small"
onClick={() => fileInputRef.current?.click()}
disabled={isImporting}
>
{t(`${PREFIX}.changeFile`, { ns: NS })}
</Button>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept=".zip"
className="hidden"
onChange={handleFileChange}
/>
</div>
<div className="mt-6 flex justify-end gap-2">
<Button
onClick={handleClose}
disabled={isImporting}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button
variant="primary"
onClick={handleImport}
disabled={!selectedFile || isImporting}
loading={isImporting}
>
{t(`${PREFIX}.importButton`, { ns: NS })}
</Button>
</div>
</Modal>
)
}
export default memo(ImportSkillModal)

View File

@@ -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<string, Uint8Array>
}
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<ExtractedZipResult> {
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<string, Uint8Array>
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<string, Uint8Array>()
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<string>()
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 }
}

View File

@@ -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<string, File>
}
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<ZipUploadData> {
const fileMap = new Map<string, File>()
const tree: BatchUploadNodeInput[] = []
const folderMap = new Map<string, BatchUploadNodeInput>()
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 }
}

View File

@@ -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…",

View File

@@ -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": "搜索…",

View File

@@ -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",

8
web/pnpm-lock.yaml generated
View File

@@ -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