mirror of
https://github.com/langgenius/dify.git
synced 2026-03-17 12:57:51 +08:00
feat(web): add app bundle import/export UI support
This commit is contained in:
@ -27,7 +27,7 @@ import { useProviderContext } from '@/context/provider-context'
|
||||
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps'
|
||||
import { copyApp, deleteApp, exportAppBundle, exportAppConfig, updateAppInfo } from '@/service/apps'
|
||||
import { fetchInstalledAppList } from '@/service/explore'
|
||||
import { fetchWorkflowDraft } from '@/service/workflow'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
@ -193,6 +193,39 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
|
||||
}
|
||||
}
|
||||
|
||||
const onExportBundle = async (include = false) => {
|
||||
try {
|
||||
await exportAppBundle({
|
||||
appID: app.id,
|
||||
include,
|
||||
})
|
||||
}
|
||||
catch {
|
||||
notify({ type: 'error', message: t('exportBundleFailed', { ns: 'app' }) })
|
||||
}
|
||||
}
|
||||
|
||||
const [secretEnvListForBundle, setSecretEnvListForBundle] = useState<EnvironmentVariable[]>([])
|
||||
|
||||
const exportBundleCheck = async () => {
|
||||
if (app.mode !== AppModeEnum.WORKFLOW && app.mode !== AppModeEnum.ADVANCED_CHAT) {
|
||||
onExportBundle()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const workflowDraft = await fetchWorkflowDraft(`/apps/${app.id}/workflows/draft`)
|
||||
const list = (workflowDraft.environment_variables || []).filter(env => env.value_type === 'secret')
|
||||
if (list.length === 0) {
|
||||
onExportBundle()
|
||||
return
|
||||
}
|
||||
setSecretEnvListForBundle(list)
|
||||
}
|
||||
catch {
|
||||
notify({ type: 'error', message: t('exportBundleFailed', { ns: 'app' }) })
|
||||
}
|
||||
}
|
||||
|
||||
const onSwitch = () => {
|
||||
if (onRefresh)
|
||||
onRefresh()
|
||||
@ -228,6 +261,12 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
|
||||
e.preventDefault()
|
||||
exportCheck()
|
||||
}
|
||||
const onClickExportBundle = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
props.onClick?.()
|
||||
e.preventDefault()
|
||||
exportBundleCheck()
|
||||
}
|
||||
const onClickSwitch = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
props.onClick?.()
|
||||
@ -278,6 +317,9 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
|
||||
<button type="button" className="mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover" onClick={onClickExport}>
|
||||
<span className="system-sm-regular text-text-secondary">{t('export', { ns: 'app' })}</span>
|
||||
</button>
|
||||
<button type="button" className="mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover" onClick={onClickExportBundle}>
|
||||
<span className="system-sm-regular text-text-secondary">{t('exportBundle', { ns: 'app' })}</span>
|
||||
</button>
|
||||
{(app.mode === AppModeEnum.COMPLETION || app.mode === AppModeEnum.CHAT) && (
|
||||
<>
|
||||
<Divider className="my-1" />
|
||||
@ -514,6 +556,13 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
|
||||
onClose={() => setSecretEnvList([])}
|
||||
/>
|
||||
)}
|
||||
{secretEnvListForBundle.length > 0 && (
|
||||
<DSLExportConfirmModal
|
||||
envList={secretEnvListForBundle}
|
||||
onConfirm={onExportBundle}
|
||||
onClose={() => setSecretEnvListForBundle([])}
|
||||
/>
|
||||
)}
|
||||
{showAccessControl && (
|
||||
<AccessControl app={app} onConfirm={onUpdateAccessControl} onClose={() => setShowAccessControl(false)} />
|
||||
)}
|
||||
|
||||
@ -1,12 +1,15 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export type DroppedFileType = 'dsl' | 'bundle'
|
||||
|
||||
type DSLDragDropHookProps = {
|
||||
onDSLFileDropped: (file: File) => void
|
||||
onBundleFileDropped?: (file: File) => void
|
||||
containerRef: React.RefObject<HTMLDivElement | null>
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export const useDSLDragDrop = ({ onDSLFileDropped, containerRef, enabled = true }: DSLDragDropHookProps) => {
|
||||
export const useDSLDragDrop = ({ onDSLFileDropped, onBundleFileDropped, containerRef, enabled = true }: DSLDragDropHookProps) => {
|
||||
const [dragging, setDragging] = useState(false)
|
||||
|
||||
const handleDragEnter = (e: DragEvent) => {
|
||||
@ -41,8 +44,14 @@ export const useDSLDragDrop = ({ onDSLFileDropped, containerRef, enabled = true
|
||||
return
|
||||
|
||||
const file = files[0]
|
||||
if (file.name.toLowerCase().endsWith('.yaml') || file.name.toLowerCase().endsWith('.yml'))
|
||||
const fileName = file.name.toLowerCase()
|
||||
|
||||
if (fileName.endsWith('.yaml') || fileName.endsWith('.yml')) {
|
||||
onDSLFileDropped(file)
|
||||
}
|
||||
else if (fileName.endsWith('.zip') && onBundleFileDropped) {
|
||||
onBundleFileDropped(file)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@ -17,17 +17,22 @@ import {
|
||||
import { parseAsString, useQueryState } from 'nuqs'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import Input from '@/app/components/base/input'
|
||||
import TabSliderNew from '@/app/components/base/tab-slider-new'
|
||||
import TagFilter from '@/app/components/base/tag-management/filter'
|
||||
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import CheckboxWithLabel from '@/app/components/datasets/create/website/base/checkbox-with-label'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { CheckModal } from '@/hooks/use-pay'
|
||||
import { DSLImportStatus } from '@/models/app'
|
||||
import { importAppBundle } from '@/service/apps'
|
||||
import { useInfiniteAppList } from '@/service/use-apps'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { getRedirection } from '@/utils/app-redirection'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import AppCard from './app-card'
|
||||
import { AppCardSkeleton } from './app-card-skeleton'
|
||||
@ -61,8 +66,10 @@ const List: FC<Props> = ({
|
||||
controlRefreshList = 0,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useContext(ToastContext)
|
||||
const { systemFeatures } = useGlobalPublicStore()
|
||||
const router = useRouter()
|
||||
const { push } = useRouter()
|
||||
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
|
||||
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
|
||||
const [activeTab, setActiveTab] = useQueryState(
|
||||
@ -90,8 +97,41 @@ const List: FC<Props> = ({
|
||||
setShowCreateFromDSLModal(true)
|
||||
}, [])
|
||||
|
||||
const [importingBundle, setImportingBundle] = useState(false)
|
||||
|
||||
const handleBundleFileDropped = useCallback(async (file: File) => {
|
||||
if (importingBundle)
|
||||
return
|
||||
setImportingBundle(true)
|
||||
try {
|
||||
const response = await importAppBundle({ file })
|
||||
const { status, app_id, app_mode } = response
|
||||
|
||||
if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) {
|
||||
notify({
|
||||
type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning',
|
||||
message: t(status === DSLImportStatus.COMPLETED ? 'newApp.appCreated' : 'newApp.caution', { ns: 'app' }),
|
||||
})
|
||||
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
|
||||
if (app_id)
|
||||
getRedirection(isCurrentWorkspaceEditor, { id: app_id, mode: app_mode }, push)
|
||||
}
|
||||
else {
|
||||
notify({ type: 'error', message: t('importBundleFailed', { ns: 'app' }) })
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
const error = e as Error
|
||||
notify({ type: 'error', message: error.message || t('importBundleFailed', { ns: 'app' }) })
|
||||
}
|
||||
finally {
|
||||
setImportingBundle(false)
|
||||
}
|
||||
}, [importingBundle, isCurrentWorkspaceEditor, notify, push, t])
|
||||
|
||||
const { dragging } = useDSLDragDrop({
|
||||
onDSLFileDropped: handleDSLFileDropped,
|
||||
onBundleFileDropped: handleBundleFileDropped,
|
||||
containerRef,
|
||||
enabled: isCurrentWorkspaceEditor,
|
||||
})
|
||||
|
||||
@ -46,6 +46,8 @@
|
||||
"editDone": "App info updated",
|
||||
"editFailed": "Failed to update app info",
|
||||
"export": "Export DSL",
|
||||
"exportBundle": "Export Bundle",
|
||||
"exportBundleFailed": "Export bundle failed.",
|
||||
"exportFailed": "Export DSL failed.",
|
||||
"gotoAnything.actions.accountDesc": "Navigate to account page",
|
||||
"gotoAnything.actions.communityDesc": "Open Discord community",
|
||||
@ -115,6 +117,7 @@
|
||||
"iconPicker.emoji": "Emoji",
|
||||
"iconPicker.image": "Image",
|
||||
"iconPicker.ok": "OK",
|
||||
"importBundleFailed": "Import bundle failed.",
|
||||
"importDSL": "Import DSL file",
|
||||
"importFromDSL": "Import from DSL",
|
||||
"importFromDSLFile": "From DSL file",
|
||||
|
||||
@ -46,6 +46,8 @@
|
||||
"editDone": "应用信息已更新",
|
||||
"editFailed": "更新应用信息失败",
|
||||
"export": "导出 DSL",
|
||||
"exportBundle": "导出 Bundle",
|
||||
"exportBundleFailed": "导出 Bundle 失败",
|
||||
"exportFailed": "导出 DSL 失败",
|
||||
"gotoAnything.actions.accountDesc": "导航到账户页面",
|
||||
"gotoAnything.actions.communityDesc": "打开 Discord 社区",
|
||||
@ -115,6 +117,7 @@
|
||||
"iconPicker.emoji": "表情符号",
|
||||
"iconPicker.image": "图片",
|
||||
"iconPicker.ok": "确认",
|
||||
"importBundleFailed": "导入 Bundle 失败",
|
||||
"importDSL": "导入 DSL 文件",
|
||||
"importFromDSL": "导入 DSL",
|
||||
"importFromDSLFile": "文件",
|
||||
|
||||
@ -92,6 +92,44 @@ export const exportAppConfig = ({ appID, include = false, workflowID }: { appID:
|
||||
return get<{ data: string }>(`apps/${appID}/export?${params.toString()}`)
|
||||
}
|
||||
|
||||
export const exportAppBundle = async ({ appID, include = false, workflowID }: { appID: string, include?: boolean, workflowID?: string }): Promise<void> => {
|
||||
const { API_PREFIX, CSRF_COOKIE_NAME, CSRF_HEADER_NAME } = await import('@/config')
|
||||
const Cookies = (await import('js-cookie')).default
|
||||
const params = new URLSearchParams({
|
||||
include_secret: include.toString(),
|
||||
})
|
||||
if (workflowID)
|
||||
params.append('workflow_id', workflowID)
|
||||
|
||||
const url = `${API_PREFIX}/apps/${appID}/export-bundle?${params.toString()}`
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
[CSRF_HEADER_NAME]: Cookies.get(CSRF_COOKIE_NAME()) || '',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok)
|
||||
throw new Error('Export bundle failed')
|
||||
|
||||
const blob = await response.blob()
|
||||
const contentDisposition = response.headers.get('content-disposition')
|
||||
let filename = `app-${appID}.zip`
|
||||
if (contentDisposition) {
|
||||
const filenameMatch = contentDisposition.match(/filename="?([^";\n]+)"?/)
|
||||
if (filenameMatch)
|
||||
filename = filenameMatch[1]
|
||||
}
|
||||
|
||||
const downloadUrl = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = downloadUrl
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(downloadUrl)
|
||||
}
|
||||
|
||||
export const importDSL = ({ mode, yaml_content, yaml_url, app_id, name, description, icon_type, icon, icon_background }: { mode: DSLImportMode, yaml_content?: string, yaml_url?: string, app_id?: string, name?: string, description?: string, icon_type?: AppIconType, icon?: string, icon_background?: string }): Promise<DSLImportResponse> => {
|
||||
return post<DSLImportResponse>('apps/imports', { body: { mode, yaml_content, yaml_url, app_id, name, description, icon, icon_type, icon_background } })
|
||||
}
|
||||
@ -100,6 +138,54 @@ export const importDSLConfirm = ({ import_id }: { import_id: string }): Promise<
|
||||
return post<DSLImportResponse>(`apps/imports/${import_id}/confirm`, { body: {} })
|
||||
}
|
||||
|
||||
export const importAppBundle = async ({
|
||||
file,
|
||||
name,
|
||||
description,
|
||||
icon_type,
|
||||
icon,
|
||||
icon_background,
|
||||
}: {
|
||||
file: File
|
||||
name?: string
|
||||
description?: string
|
||||
icon_type?: string
|
||||
icon?: string
|
||||
icon_background?: string
|
||||
}): Promise<DSLImportResponse> => {
|
||||
const { API_PREFIX, CSRF_COOKIE_NAME, CSRF_HEADER_NAME } = await import('@/config')
|
||||
const Cookies = (await import('js-cookie')).default
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
if (name)
|
||||
formData.append('name', name)
|
||||
if (description)
|
||||
formData.append('description', description)
|
||||
if (icon_type)
|
||||
formData.append('icon_type', icon_type)
|
||||
if (icon)
|
||||
formData.append('icon', icon)
|
||||
if (icon_background)
|
||||
formData.append('icon_background', icon_background)
|
||||
|
||||
const response = await fetch(`${API_PREFIX}/apps/imports-bundle`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
[CSRF_HEADER_NAME]: Cookies.get(CSRF_COOKIE_NAME()) || '',
|
||||
},
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Import bundle failed')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export const switchApp = ({ appID, name, icon_type, icon, icon_background }: { appID: string, name: string, icon_type: AppIconType, icon: string, icon_background?: string | null }): Promise<{ new_app_id: string }> => {
|
||||
return post<{ new_app_id: string }>(`apps/${appID}/convert-to-workflow`, { body: { name, icon_type, icon, icon_background } })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user