mirror of
https://github.com/langgenius/dify.git
synced 2026-02-13 07:01:23 -05: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:
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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.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…",
|
||||
|
||||
@@ -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": "搜索…",
|
||||
|
||||
@@ -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
8
web/pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user