mirror of
https://github.com/langgenius/dify.git
synced 2026-05-13 08:57:28 +08:00
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:
parent
ea91f96924
commit
ea88bcfbd2
@ -1,14 +1,18 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { RiAddCircleFill, RiUploadLine } from '@remixicon/react'
|
import { RiAddCircleFill, RiUploadLine } from '@remixicon/react'
|
||||||
|
import dynamic from 'next/dynamic'
|
||||||
import { memo, useState } from 'react'
|
import { memo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import ActionCard from './action-card'
|
import ActionCard from './action-card'
|
||||||
import CreateBlankSkillModal from './create-blank-skill-modal'
|
import CreateBlankSkillModal from './create-blank-skill-modal'
|
||||||
|
|
||||||
|
const ImportSkillModal = dynamic(() => import('./import-skill-modal'))
|
||||||
|
|
||||||
const CreateImportSection = () => {
|
const CreateImportSection = () => {
|
||||||
const { t } = useTranslation('workflow')
|
const { t } = useTranslation('workflow')
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
|
||||||
|
const [isImportModalOpen, setIsImportModalOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -17,17 +21,22 @@ const CreateImportSection = () => {
|
|||||||
icon={<RiAddCircleFill className="size-5 text-text-accent" />}
|
icon={<RiAddCircleFill className="size-5 text-text-accent" />}
|
||||||
title={t('skill.startTab.createBlankSkill')}
|
title={t('skill.startTab.createBlankSkill')}
|
||||||
description={t('skill.startTab.createBlankSkillDesc')}
|
description={t('skill.startTab.createBlankSkillDesc')}
|
||||||
onClick={() => setIsModalOpen(true)}
|
onClick={() => setIsCreateModalOpen(true)}
|
||||||
/>
|
/>
|
||||||
<ActionCard
|
<ActionCard
|
||||||
icon={<RiUploadLine className="size-5 text-text-accent" />}
|
icon={<RiUploadLine className="size-5 text-text-accent" />}
|
||||||
title={t('skill.startTab.importSkill')}
|
title={t('skill.startTab.importSkill')}
|
||||||
description={t('skill.startTab.importSkillDesc')}
|
description={t('skill.startTab.importSkillDesc')}
|
||||||
|
onClick={() => setIsImportModalOpen(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<CreateBlankSkillModal
|
<CreateBlankSkillModal
|
||||||
isOpen={isModalOpen}
|
isOpen={isCreateModalOpen}
|
||||||
onClose={() => setIsModalOpen(false)}
|
onClose={() => setIsCreateModalOpen(false)}
|
||||||
|
/>
|
||||||
|
<ImportSkillModal
|
||||||
|
isOpen={isImportModalOpen}
|
||||||
|
onClose={() => setIsImportModalOpen(false)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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)
|
||||||
116
web/app/components/workflow/skill/utils/zip-extract.ts
Normal file
116
web/app/components/workflow/skill/utils/zip-extract.ts
Normal 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 }
|
||||||
|
}
|
||||||
@ -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 }
|
||||||
|
}
|
||||||
@ -1081,6 +1081,21 @@
|
|||||||
"skill.startTab.createModal.title": "Create Blank Skill",
|
"skill.startTab.createModal.title": "Create Blank Skill",
|
||||||
"skill.startTab.createSuccess": "Skill \"{{name}}\" created successfully",
|
"skill.startTab.createSuccess": "Skill \"{{name}}\" created successfully",
|
||||||
"skill.startTab.filesIncluded": "{{count}} files included",
|
"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.importSkill": "Import Skill",
|
||||||
"skill.startTab.importSkillDesc": "Import skill from skill.zip file",
|
"skill.startTab.importSkillDesc": "Import skill from skill.zip file",
|
||||||
"skill.startTab.searchPlaceholder": "Search…",
|
"skill.startTab.searchPlaceholder": "Search…",
|
||||||
|
|||||||
@ -1073,6 +1073,21 @@
|
|||||||
"skill.startTab.createModal.title": "创建空白 Skill",
|
"skill.startTab.createModal.title": "创建空白 Skill",
|
||||||
"skill.startTab.createSuccess": "Skill \"{{name}}\" 创建成功",
|
"skill.startTab.createSuccess": "Skill \"{{name}}\" 创建成功",
|
||||||
"skill.startTab.filesIncluded": "包含 {{count}} 个文件",
|
"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.importSkill": "导入 Skill",
|
||||||
"skill.startTab.importSkillDesc": "从 skill.zip 文件导入",
|
"skill.startTab.importSkillDesc": "从 skill.zip 文件导入",
|
||||||
"skill.startTab.searchPlaceholder": "搜索…",
|
"skill.startTab.searchPlaceholder": "搜索…",
|
||||||
|
|||||||
@ -104,6 +104,7 @@
|
|||||||
"emoji-mart": "5.6.0",
|
"emoji-mart": "5.6.0",
|
||||||
"es-toolkit": "1.43.0",
|
"es-toolkit": "1.43.0",
|
||||||
"fast-deep-equal": "3.1.3",
|
"fast-deep-equal": "3.1.3",
|
||||||
|
"fflate": "0.8.2",
|
||||||
"foxact": "0.2.52",
|
"foxact": "0.2.52",
|
||||||
"html-entities": "2.6.0",
|
"html-entities": "2.6.0",
|
||||||
"html-to-image": "1.11.13",
|
"html-to-image": "1.11.13",
|
||||||
|
|||||||
8
web/pnpm-lock.yaml
generated
8
web/pnpm-lock.yaml
generated
@ -191,6 +191,9 @@ importers:
|
|||||||
fast-deep-equal:
|
fast-deep-equal:
|
||||||
specifier: 3.1.3
|
specifier: 3.1.3
|
||||||
version: 3.1.3
|
version: 3.1.3
|
||||||
|
fflate:
|
||||||
|
specifier: 0.8.2
|
||||||
|
version: 0.8.2
|
||||||
foxact:
|
foxact:
|
||||||
specifier: 0.2.52
|
specifier: 0.2.52
|
||||||
version: 0.2.52(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
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:
|
fflate@0.4.8:
|
||||||
resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==}
|
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:
|
file-entry-cache@8.0.0:
|
||||||
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
|
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
|
||||||
engines: {node: '>=16.0.0'}
|
engines: {node: '>=16.0.0'}
|
||||||
@ -12335,6 +12341,8 @@ snapshots:
|
|||||||
|
|
||||||
fflate@0.4.8: {}
|
fflate@0.4.8: {}
|
||||||
|
|
||||||
|
fflate@0.8.2: {}
|
||||||
|
|
||||||
file-entry-cache@8.0.0:
|
file-entry-cache@8.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
flat-cache: 4.0.1
|
flat-cache: 4.0.1
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user