From 71f811930f0010df2b750dbd4c6d2fe753f98f63 Mon Sep 17 00:00:00 2001 From: Harry Date: Fri, 23 Jan 2026 01:09:05 +0800 Subject: [PATCH] feat(web): add app bundle import/export UI support --- web/app/components/apps/app-card.tsx | 51 ++++++++++- .../apps/hooks/use-dsl-drag-drop.ts | 13 ++- web/app/components/apps/list.tsx | 40 +++++++++ web/i18n/en-US/app.json | 3 + web/i18n/zh-Hans/app.json | 3 + web/service/apps.ts | 86 +++++++++++++++++++ 6 files changed, 193 insertions(+), 3 deletions(-) diff --git a/web/app/components/apps/app-card.tsx b/web/app/components/apps/app-card.tsx index f1eadb9d05..3dc48f93d6 100644 --- a/web/app/components/apps/app-card.tsx +++ b/web/app/components/apps/app-card.tsx @@ -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([]) + + 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) => { + e.stopPropagation() + props.onClick?.() + e.preventDefault() + exportBundleCheck() + } const onClickSwitch = async (e: React.MouseEvent) => { e.stopPropagation() props.onClick?.() @@ -278,6 +317,9 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { + {(app.mode === AppModeEnum.COMPLETION || app.mode === AppModeEnum.CHAT) && ( <> @@ -514,6 +556,13 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { onClose={() => setSecretEnvList([])} /> )} + {secretEnvListForBundle.length > 0 && ( + setSecretEnvListForBundle([])} + /> + )} {showAccessControl && ( setShowAccessControl(false)} /> )} diff --git a/web/app/components/apps/hooks/use-dsl-drag-drop.ts b/web/app/components/apps/hooks/use-dsl-drag-drop.ts index 77d89b87da..b7ccc18f7b 100644 --- a/web/app/components/apps/hooks/use-dsl-drag-drop.ts +++ b/web/app/components/apps/hooks/use-dsl-drag-drop.ts @@ -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 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(() => { diff --git a/web/app/components/apps/list.tsx b/web/app/components/apps/list.tsx index 6bf79b7338..6255fa50e5 100644 --- a/web/app/components/apps/list.tsx +++ b/web/app/components/apps/list.tsx @@ -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 = ({ 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 = ({ 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, }) diff --git a/web/i18n/en-US/app.json b/web/i18n/en-US/app.json index d54cd21a05..f4eb48e129 100644 --- a/web/i18n/en-US/app.json +++ b/web/i18n/en-US/app.json @@ -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", diff --git a/web/i18n/zh-Hans/app.json b/web/i18n/zh-Hans/app.json index 464eec60e3..3cdc452e2d 100644 --- a/web/i18n/zh-Hans/app.json +++ b/web/i18n/zh-Hans/app.json @@ -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": "文件", diff --git a/web/service/apps.ts b/web/service/apps.ts index db79141ec6..278f58de78 100644 --- a/web/service/apps.ts +++ b/web/service/apps.ts @@ -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 => { + 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 => { return post('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(`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 => { + 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 } }) }