diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index 668a722bf76904..08c2d477467b6c 100644 --- a/api/controllers/console/app/workflow.py +++ b/api/controllers/console/app/workflow.py @@ -109,6 +109,34 @@ def post(self, app_model: App): } +class DraftWorkflowImportApi(Resource): + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) + @marshal_with(workflow_fields) + def post(self, app_model: App): + """ + Import draft workflow + """ + # The role of the current user in the ta table must be admin, owner, or editor + if not current_user.is_editor: + raise Forbidden() + + parser = reqparse.RequestParser() + parser.add_argument('data', type=str, required=True, nullable=False, location='json') + args = parser.parse_args() + + workflow_service = WorkflowService() + workflow = workflow_service.import_draft_workflow( + app_model=app_model, + data=args['data'], + account=current_user + ) + + return workflow + + class AdvancedChatDraftWorkflowRunApi(Resource): @setup_required @login_required @@ -439,6 +467,7 @@ def post(self, app_model: App): api.add_resource(DraftWorkflowApi, '/apps//workflows/draft') +api.add_resource(DraftWorkflowImportApi, '/apps//workflows/draft/import') api.add_resource(AdvancedChatDraftWorkflowRunApi, '/apps//advanced-chat/workflows/draft/run') api.add_resource(DraftWorkflowRunApi, '/apps//workflows/draft/run') api.add_resource(WorkflowTaskStopApi, '/apps//workflow-runs/tasks//stop') diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index 6235ecf0a36543..025c1090b47925 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -3,6 +3,8 @@ from datetime import datetime, timezone from typing import Optional +import yaml + from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfigManager from core.app.apps.workflow.app_config_manager import WorkflowAppConfigManager from core.model_runtime.utils.encoders import jsonable_encoder @@ -112,6 +114,56 @@ def sync_draft_workflow(self, app_model: App, # return draft workflow return workflow + def import_draft_workflow(self, app_model: App, + data: str, + account: Account) -> Workflow: + """ + Import draft workflow + :param app_model: App instance + :param data: import data + :param account: Account instance + :return: + """ + try: + import_data = yaml.safe_load(data) + except yaml.YAMLError as e: + raise ValueError("Invalid YAML format in data argument.") + + app_data = import_data.get('app') + workflow = import_data.get('workflow') + + if not app_data: + raise ValueError("Missing app in data argument") + + app_mode = AppMode.value_of(app_data.get('mode')) + if app_mode not in [AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]: + raise ValueError("Only support import workflow in advanced-chat or workflow app.") + + if app_data.get('mode') != app_model.mode: + raise ValueError(f"App mode {app_data.get('mode')} is not matched with current app mode {app_model.mode}") + + if not workflow: + raise ValueError("Missing workflow in data argument " + "when app mode is advanced-chat or workflow") + + # fetch draft workflow by app_model + current_draft_workflow = self.get_draft_workflow(app_model=app_model) + if current_draft_workflow: + unique_hash = current_draft_workflow.unique_hash + else: + unique_hash = None + + # sync draft workflow + draft_workflow = self.sync_draft_workflow( + app_model=app_model, + graph=workflow.get('graph'), + features=workflow.get('features'), + unique_hash=unique_hash, + account=account + ) + + return draft_workflow + def publish_workflow(self, app_model: App, account: Account, draft_workflow: Optional[Workflow] = None) -> Workflow: diff --git a/web/app/components/app-sidebar/app-info.tsx b/web/app/components/app-sidebar/app-info.tsx index 89d5a93dbec35f..c2f3bfc9dd0da7 100644 --- a/web/app/components/app-sidebar/app-info.tsx +++ b/web/app/components/app-sidebar/app-info.tsx @@ -27,6 +27,7 @@ import { Route } from '@/app/components/base/icons/src/vender/solid/mapsAndTrave import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal' import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { getRedirection } from '@/utils/app-redirection' +import UpdateDSLModal from '@/app/components/workflow/update-dsl-modal' export type IAppInfoProps = { expand: boolean @@ -45,6 +46,7 @@ const AppInfo = ({ expand }: IAppInfoProps) => { const [showConfirmDelete, setShowConfirmDelete] = useState(false) const [showSwitchTip, setShowSwitchTip] = useState('') const [showSwitchModal, setShowSwitchModal] = useState(false) + const [showImportDSLModal, setShowImportDSLModal] = useState(false) const mutateApps = useContextSelector( AppsContext, @@ -295,9 +297,6 @@ const AppInfo = ({ expand }: IAppInfoProps) => { }}> {t('app.duplicate')} -
- {t('app.export')} -
{(appDetail.mode === 'completion' || appDetail.mode === 'chat') && ( <> @@ -315,6 +314,22 @@ const AppInfo = ({ expand }: IAppInfoProps) => { )} +
+ {t('app.export')} +
+ { + (appDetail.mode === 'advanced-chat' || appDetail.mode === 'workflow') && ( +
{ + setOpen(false) + setShowImportDSLModal(true) + }}> + {t('workflow.common.importDSL')} +
+ ) + } +
{ setOpen(false) setShowConfirmDelete(true) @@ -388,6 +403,14 @@ const AppInfo = ({ expand }: IAppInfoProps) => { onCancel={() => setShowConfirmDelete(false)} /> )} + { + showImportDSLModal && ( + setShowImportDSLModal(false)} + onBackup={onExport} + /> + ) + }
) diff --git a/web/app/components/app/create-from-dsl-modal/uploader.tsx b/web/app/components/app/create-from-dsl-modal/uploader.tsx index 3aff1fd2120b2d..39c50d3ba87e5f 100644 --- a/web/app/components/app/create-from-dsl-modal/uploader.tsx +++ b/web/app/components/app/create-from-dsl-modal/uploader.tsx @@ -15,11 +15,13 @@ import Button from '@/app/components/base/button' export type Props = { file: File | undefined updateFile: (file?: File) => void + className?: string } const Uploader: FC = ({ file, updateFile, + className, }) => { const { t } = useTranslation() const { notify } = useContext(ToastContext) @@ -83,7 +85,7 @@ const Uploader: FC = ({ }, []) return ( -
+
{ const workflowStore = useWorkflowStore() @@ -71,3 +75,29 @@ export const useWorkflowUpdate = () => { handleRefreshWorkflowDraft, } } + +export const useDSL = () => { + const { t } = useTranslation() + const { notify } = useToastContext() + const appDetail = useAppStore(s => s.appDetail) + + const handleExportDSL = useCallback(async () => { + if (!appDetail) + return + try { + const { data } = await exportAppConfig(appDetail.id) + const a = document.createElement('a') + const file = new Blob([data], { type: 'application/yaml' }) + a.href = URL.createObjectURL(file) + a.download = `${appDetail.name}.yml` + a.click() + } + catch (e) { + notify({ type: 'error', message: t('app.exportFailed') }) + } + }, [appDetail, notify, t]) + + return { + handleExportDSL, + } +} diff --git a/web/app/components/workflow/index.tsx b/web/app/components/workflow/index.tsx index 79e681b5613885..57a6e3909c8166 100644 --- a/web/app/components/workflow/index.tsx +++ b/web/app/components/workflow/index.tsx @@ -20,6 +20,7 @@ import ReactFlow, { useEdgesState, useNodesState, useOnViewportChange, + useReactFlow, } from 'reactflow' import type { Viewport, @@ -32,6 +33,7 @@ import type { } from './types' import { WorkflowContextProvider } from './context' import { + useDSL, useEdgesInteractions, useNodesInteractions, useNodesReadOnly, @@ -58,6 +60,7 @@ import CandidateNode from './candidate-node' import PanelContextmenu from './panel-contextmenu' import NodeContextmenu from './node-contextmenu' import SyncingDataModal from './syncing-data-modal' +import UpdateDSLModal from './update-dsl-modal' import { useStore, useWorkflowStore, @@ -76,6 +79,7 @@ import { import Loading from '@/app/components/base/loading' import { FeaturesProvider } from '@/app/components/base/features' import type { Features as FeaturesData } from '@/app/components/base/features/types' +import { useFeaturesStore } from '@/app/components/base/features/hooks' import { useEventEmitterContextContext } from '@/context/event-emitter' import Confirm from '@/app/components/base/confirm/common' @@ -99,15 +103,20 @@ const Workflow: FC = memo(({ }) => { const workflowContainerRef = useRef(null) const workflowStore = useWorkflowStore() + const reactflow = useReactFlow() + const featuresStore = useFeaturesStore() const [nodes, setNodes] = useNodesState(originalNodes) const [edges, setEdges] = useEdgesState(originalEdges) const showFeaturesPanel = useStore(state => state.showFeaturesPanel) const controlMode = useStore(s => s.controlMode) const nodeAnimation = useStore(s => s.nodeAnimation) const showConfirm = useStore(s => s.showConfirm) + const showImportDSLModal = useStore(s => s.showImportDSLModal) const { setShowConfirm, setControlPromptEditorRerenderKey, + setShowImportDSLModal, + setSyncWorkflowDraftHash, } = workflowStore.getState() const { handleSyncWorkflowDraft, @@ -122,6 +131,19 @@ const Workflow: FC = memo(({ if (v.type === WORKFLOW_DATA_UPDATE) { setNodes(v.payload.nodes) setEdges(v.payload.edges) + + if (v.payload.viewport) + reactflow.setViewport(v.payload.viewport) + + if (v.payload.features && featuresStore) { + const { setFeatures } = featuresStore.getState() + + setFeatures(v.payload.features) + } + + if (v.payload.hash) + setSyncWorkflowDraftHash(v.payload.hash) + setTimeout(() => setControlPromptEditorRerenderKey(Date.now())) } }) @@ -204,11 +226,15 @@ const Workflow: FC = memo(({ } = useSelectionInteractions() const { handlePaneContextMenu, + handlePaneContextmenuCancel, } = usePanelInteractions() const { isValidConnection, } = useWorkflow() const { handleStartWorkflowRun } = useWorkflowStartRun() + const { + handleExportDSL, + } = useDSL() useOnViewportChange({ onEnd: () => { @@ -266,6 +292,15 @@ const Workflow: FC = memo(({ /> ) } + { + showImportDSLModal && ( + setShowImportDSLModal(false)} + onBackup={handleExportDSL} + onImport={handlePaneContextmenuCancel} + /> + ) + } { const { t } = useTranslation() - const { notify } = useToastContext() const ref = useRef(null) const panelMenu = useStore(s => s.panelMenu) const clipboardElements = useStore(s => s.clipboardElements) - const appDetail = useAppStore(s => s.appDetail) + const setShowImportDSLModal = useStore(s => s.setShowImportDSLModal) const { handleNodesPaste } = useNodesInteractions() const { handlePaneContextmenuCancel } = usePanelInteractions() const { handleStartWorkflowRun } = useWorkflowStartRun() const { handleAddNote } = useOperator() + const { handleExportDSL } = useDSL() useClickAway(() => { handlePaneContextmenuCancel() }, ref) - const onExport = async () => { - if (!appDetail) - return - try { - const { data } = await exportAppConfig(appDetail.id) - const a = document.createElement('a') - const file = new Blob([data], { type: 'application/yaml' }) - a.href = URL.createObjectURL(file) - a.download = `${appDetail.name}.yml` - a.click() - } - catch (e) { - notify({ type: 'error', message: t('app.exportFailed') }) - } - } - const renderTrigger = () => { return (
{
onExport()} + onClick={() => handleExportDSL()} > {t('app.export')}
+
setShowImportDSLModal(true)} + > + {t('workflow.common.importDSL')} +
) diff --git a/web/app/components/workflow/store.ts b/web/app/components/workflow/store.ts index 6bf31c5c8525b8..2bf6de5b591997 100644 --- a/web/app/components/workflow/store.ts +++ b/web/app/components/workflow/store.ts @@ -129,6 +129,8 @@ type Shape = { setIsSyncingWorkflowDraft: (isSyncingWorkflowDraft: boolean) => void controlPromptEditorRerenderKey: number setControlPromptEditorRerenderKey: (controlPromptEditorRerenderKey: number) => void + showImportDSLModal: boolean + setShowImportDSLModal: (showImportDSLModal: boolean) => void } export const createWorkflowStore = () => { @@ -217,6 +219,8 @@ export const createWorkflowStore = () => { setIsSyncingWorkflowDraft: isSyncingWorkflowDraft => set(() => ({ isSyncingWorkflowDraft })), controlPromptEditorRerenderKey: 0, setControlPromptEditorRerenderKey: controlPromptEditorRerenderKey => set(() => ({ controlPromptEditorRerenderKey })), + showImportDSLModal: false, + setShowImportDSLModal: showImportDSLModal => set(() => ({ showImportDSLModal })), })) } diff --git a/web/app/components/workflow/update-dsl-modal.tsx b/web/app/components/workflow/update-dsl-modal.tsx new file mode 100644 index 00000000000000..184b47c476c743 --- /dev/null +++ b/web/app/components/workflow/update-dsl-modal.tsx @@ -0,0 +1,154 @@ +'use client' + +import type { MouseEventHandler } from 'react' +import { + memo, + useCallback, + useRef, + useState, +} from 'react' +import { useContext } from 'use-context-selector' +import { useTranslation } from 'react-i18next' +import { + RiAlertLine, + RiCloseLine, +} from '@remixicon/react' +import { WORKFLOW_DATA_UPDATE } from './constants' +import { + initialEdges, + initialNodes, +} from './utils' +import Uploader from '@/app/components/app/create-from-dsl-modal/uploader' +import Button from '@/app/components/base/button' +import Modal from '@/app/components/base/modal' +import { ToastContext } from '@/app/components/base/toast' +import { updateWorkflowDraftFromDSL } from '@/service/workflow' +import { useEventEmitterContextContext } from '@/context/event-emitter' +import { useStore as useAppStore } from '@/app/components/app/store' + +type UpdateDSLModalProps = { + onCancel: () => void + onBackup: () => void + onImport?: () => void +} + +const UpdateDSLModal = ({ + onCancel, + onBackup, + onImport, +}: UpdateDSLModalProps) => { + const { t } = useTranslation() + const { notify } = useContext(ToastContext) + const appDetail = useAppStore(s => s.appDetail) + const [currentFile, setDSLFile] = useState() + const [fileContent, setFileContent] = useState() + const [loading, setLoading] = useState(false) + const { eventEmitter } = useEventEmitterContextContext() + + const readFile = (file: File) => { + const reader = new FileReader() + reader.onload = function (event) { + const content = event.target?.result + setFileContent(content as string) + } + reader.readAsText(file) + } + + const handleFile = (file?: File) => { + setDSLFile(file) + if (file) + readFile(file) + if (!file) + setFileContent('') + } + + const isCreatingRef = useRef(false) + const handleImport: MouseEventHandler = useCallback(async () => { + if (isCreatingRef.current) + return + isCreatingRef.current = true + if (!currentFile) + return + try { + if (appDetail && fileContent) { + setLoading(true) + const { + graph, + features, + hash, + } = await updateWorkflowDraftFromDSL(appDetail.id, fileContent) + const { nodes, edges, viewport } = graph + eventEmitter?.emit({ + type: WORKFLOW_DATA_UPDATE, + payload: { + nodes: initialNodes(nodes, edges), + edges: initialEdges(edges, nodes), + viewport, + features, + hash, + }, + } as any) + if (onImport) + onImport() + notify({ type: 'success', message: t('workflow.common.importSuccess') }) + setLoading(false) + onCancel() + } + } + catch (e) { + setLoading(false) + notify({ type: 'error', message: t('workflow.common.importFailure') }) + } + isCreatingRef.current = false + }, [currentFile, fileContent, onCancel, notify, t, eventEmitter, appDetail, onImport]) + + return ( + {}} + > +
+
{t('workflow.common.importDSL')}
+
+ +
+
+
+ +
+
{t('workflow.common.importDSLTip')}
+ +
+
+
+
+ {t('workflow.common.chooseDSL')} +
+ +
+
+ + +
+
+ ) +} + +export default memo(UpdateDSLModal) diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index 5d0edcf6ce95b3..db20206065143b 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -68,6 +68,13 @@ const translation = { workflowAsToolTip: 'Tool reconfiguration is required after the workflow update.', viewDetailInTracingPanel: 'View details', syncingData: 'Syncing data, just a few seconds.', + importDSL: 'Import DSL', + importDSLTip: 'Current draft will be overwritten. Export workflow as backup before importing.', + backupCurrentDraft: 'Backup Current Draft', + chooseDSL: 'Choose DSL(yml) file', + overwriteAndImport: 'Overwrite and Import', + importFailure: 'Import failure', + importSuccess: 'Import success', }, errorMsg: { fieldRequired: '{{field}} is required', diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts index 1fbaf38cc5e6fe..1f26f30faab4bb 100644 --- a/web/i18n/zh-Hans/workflow.ts +++ b/web/i18n/zh-Hans/workflow.ts @@ -68,6 +68,13 @@ const translation = { workflowAsToolTip: '工作流更新后需要重新配置工具参数', viewDetailInTracingPanel: '查看详细信息', syncingData: '同步数据中,只需几秒钟。', + importDSL: '导入 DSL', + importDSLTip: '当前草稿将被覆盖。在导入之前请导出工作流作为备份。', + backupCurrentDraft: '备份当前草稿', + chooseDSL: '选择 DSL(yml) 文件', + overwriteAndImport: '覆盖并导入', + importFailure: '导入失败', + importSuccess: '导入成功', }, errorMsg: { fieldRequired: '{{field}} 不能为空', diff --git a/web/service/workflow.ts b/web/service/workflow.ts index 3cf524d481b7c5..4a47c999478023 100644 --- a/web/service/workflow.ts +++ b/web/service/workflow.ts @@ -54,3 +54,7 @@ export const fetchNodeDefault = (appId: string, blockType: BlockEnum, query = {} params: { q: JSON.stringify(query) }, }) } + +export const updateWorkflowDraftFromDSL = (appId: string, data: string) => { + return post(`apps/${appId}/workflows/draft/import`, { body: { data } }) +}