Browse Source

feat: support importing and overwriting workflow DSL (#5511)

Co-authored-by: StyleZhang <jasonapring2015@outlook.com>
takatost 9 months ago
parent
commit
ec1d3ddee2

+ 29 - 0
api/controllers/console/app/workflow.py

@@ -109,6 +109,34 @@ class DraftWorkflowApi(Resource):
         }
 
 
+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 @@ class ConvertToWorkflowApi(Resource):
 
 
 api.add_resource(DraftWorkflowApi, '/apps/<uuid:app_id>/workflows/draft')
+api.add_resource(DraftWorkflowImportApi, '/apps/<uuid:app_id>/workflows/draft/import')
 api.add_resource(AdvancedChatDraftWorkflowRunApi, '/apps/<uuid:app_id>/advanced-chat/workflows/draft/run')
 api.add_resource(DraftWorkflowRunApi, '/apps/<uuid:app_id>/workflows/draft/run')
 api.add_resource(WorkflowTaskStopApi, '/apps/<uuid:app_id>/workflow-runs/tasks/<string:task_id>/stop')

+ 52 - 0
api/services/workflow_service.py

@@ -3,6 +3,8 @@ import time
 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 @@ class WorkflowService:
         # 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:

+ 26 - 3
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<string>('')
   const [showSwitchModal, setShowSwitchModal] = useState<boolean>(false)
+  const [showImportDSLModal, setShowImportDSLModal] = useState<boolean>(false)
 
   const mutateApps = useContextSelector(
     AppsContext,
@@ -295,9 +297,6 @@ const AppInfo = ({ expand }: IAppInfoProps) => {
               }}>
                 <span className='text-gray-700 text-sm leading-5'>{t('app.duplicate')}</span>
               </div>
-              <div className='h-9 py-2 px-3 mx-1 flex items-center hover:bg-gray-50 rounded-lg cursor-pointer' onClick={onExport}>
-                <span className='text-gray-700 text-sm leading-5'>{t('app.export')}</span>
-              </div>
               {(appDetail.mode === 'completion' || appDetail.mode === 'chat') && (
                 <>
                   <Divider className="!my-1" />
@@ -315,6 +314,22 @@ const AppInfo = ({ expand }: IAppInfoProps) => {
                 </>
               )}
               <Divider className="!my-1" />
+              <div className='h-9 py-2 px-3 mx-1 flex items-center hover:bg-gray-50 rounded-lg cursor-pointer' onClick={onExport}>
+                <span className='text-gray-700 text-sm leading-5'>{t('app.export')}</span>
+              </div>
+              {
+                (appDetail.mode === 'advanced-chat' || appDetail.mode === 'workflow') && (
+                  <div
+                    className='h-9 py-2 px-3 mx-1 flex items-center hover:bg-gray-50 rounded-lg cursor-pointer'
+                    onClick={() => {
+                      setOpen(false)
+                      setShowImportDSLModal(true)
+                    }}>
+                    <span className='text-gray-700 text-sm leading-5'>{t('workflow.common.importDSL')}</span>
+                  </div>
+                )
+              }
+              <Divider className="!my-1" />
               <div className='group h-9 py-2 px-3 mx-1 flex items-center hover:bg-red-50 rounded-lg cursor-pointer' onClick={() => {
                 setOpen(false)
                 setShowConfirmDelete(true)
@@ -388,6 +403,14 @@ const AppInfo = ({ expand }: IAppInfoProps) => {
             onCancel={() => setShowConfirmDelete(false)}
           />
         )}
+        {
+          showImportDSLModal && (
+            <UpdateDSLModal
+              onCancel={() => setShowImportDSLModal(false)}
+              onBackup={onExport}
+            />
+          )
+        }
       </div>
     </PortalToFollowElem>
   )

+ 3 - 1
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<Props> = ({
   file,
   updateFile,
+  className,
 }) => {
   const { t } = useTranslation()
   const { notify } = useContext(ToastContext)
@@ -83,7 +85,7 @@ const Uploader: FC<Props> = ({
   }, [])
 
   return (
-    <div className='mt-6'>
+    <div className={cn('mt-6', className)}>
       <input
         ref={fileUploader}
         style={{ display: 'none' }}

+ 30 - 0
web/app/components/workflow/hooks/use-workflow-interactions.ts

@@ -1,4 +1,5 @@
 import { useCallback } from 'react'
+import { useTranslation } from 'react-i18next'
 import { useReactFlow } from 'reactflow'
 import { useWorkflowStore } from '../store'
 import { WORKFLOW_DATA_UPDATE } from '../constants'
@@ -11,6 +12,9 @@ import { useEdgesInteractions } from './use-edges-interactions'
 import { useNodesInteractions } from './use-nodes-interactions'
 import { useEventEmitterContextContext } from '@/context/event-emitter'
 import { fetchWorkflowDraft } from '@/service/workflow'
+import { exportAppConfig } from '@/service/apps'
+import { useToastContext } from '@/app/components/base/toast'
+import { useStore as useAppStore } from '@/app/components/app/store'
 
 export const useWorkflowInteractions = () => {
   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,
+  }
+}

+ 35 - 0
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<WorkflowProps> = memo(({
 }) => {
   const workflowContainerRef = useRef<HTMLDivElement>(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<WorkflowProps> = 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<WorkflowProps> = memo(({
   } = useSelectionInteractions()
   const {
     handlePaneContextMenu,
+    handlePaneContextmenuCancel,
   } = usePanelInteractions()
   const {
     isValidConnection,
   } = useWorkflow()
   const { handleStartWorkflowRun } = useWorkflowStartRun()
+  const {
+    handleExportDSL,
+  } = useDSL()
 
   useOnViewportChange({
     onEnd: () => {
@@ -266,6 +292,15 @@ const Workflow: FC<WorkflowProps> = memo(({
           />
         )
       }
+      {
+        showImportDSLModal && (
+          <UpdateDSLModal
+            onCancel={() => setShowImportDSLModal(false)}
+            onBackup={handleExportDSL}
+            onImport={handlePaneContextmenuCancel}
+          />
+        )
+      }
       <ReactFlow
         nodeTypes={nodeTypes}
         edgeTypes={edgeTypes}

+ 10 - 22
web/app/components/workflow/panel-contextmenu.tsx

@@ -8,48 +8,30 @@ import { useClickAway } from 'ahooks'
 import ShortcutsName from './shortcuts-name'
 import { useStore } from './store'
 import {
+  useDSL,
   useNodesInteractions,
   usePanelInteractions,
   useWorkflowStartRun,
 } from './hooks'
 import AddBlock from './operator/add-block'
 import { useOperator } from './operator/hooks'
-import { exportAppConfig } from '@/service/apps'
-import { useToastContext } from '@/app/components/base/toast'
-import { useStore as useAppStore } from '@/app/components/app/store'
 
 const PanelContextmenu = () => {
   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 (
       <div
@@ -123,10 +105,16 @@ const PanelContextmenu = () => {
       <div className='p-1'>
         <div
           className='flex items-center justify-between px-3 h-8 text-sm text-gray-700 rounded-lg cursor-pointer hover:bg-gray-50'
-          onClick={() => onExport()}
+          onClick={() => handleExportDSL()}
         >
           {t('app.export')}
         </div>
+        <div
+          className='flex items-center justify-between px-3 h-8 text-sm text-gray-700 rounded-lg cursor-pointer hover:bg-gray-50'
+          onClick={() => setShowImportDSLModal(true)}
+        >
+          {t('workflow.common.importDSL')}
+        </div>
       </div>
     </div>
   )

+ 4 - 0
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 })),
   }))
 }
 

+ 154 - 0
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<File>()
+  const [fileContent, setFileContent] = useState<string>()
+  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 (
+    <Modal
+      className='p-6 w-[520px] rounded-2xl'
+      isShow={true}
+      onClose={() => {}}
+    >
+      <div className='flex items-center justify-between mb-6'>
+        <div className='text-2xl font-semibold text-[#101828]'>{t('workflow.common.importDSL')}</div>
+        <div className='flex items-center justify-center w-[22px] h-[22px] cursor-pointer' onClick={onCancel}>
+          <RiCloseLine className='w-5 h-5 text-gray-500' />
+        </div>
+      </div>
+      <div className='flex mb-4 px-4 py-3 bg-[#FFFAEB] rounded-xl border border-[#FEDF89]'>
+        <RiAlertLine className='shrink-0 mt-0.5 mr-2 w-4 h-4 text-[#F79009]' />
+        <div>
+          <div className='mb-2 text-sm font-medium text-[#354052]'>{t('workflow.common.importDSLTip')}</div>
+          <Button
+            variant='secondary-accent'
+            onClick={onBackup}
+          >
+            {t('workflow.common.backupCurrentDraft')}
+          </Button>
+        </div>
+      </div>
+      <div className='mb-8'>
+        <div className='mb-1 text-[13px] font-semibold text-[#354052]'>
+          {t('workflow.common.chooseDSL')}
+        </div>
+        <Uploader
+          file={currentFile}
+          updateFile={handleFile}
+          className='!mt-0'
+        />
+      </div>
+      <div className='flex justify-end'>
+        <Button className='mr-2' onClick={onCancel}>{t('app.newApp.Cancel')}</Button>
+        <Button
+          disabled={!currentFile || loading}
+          variant='warning'
+          onClick={handleImport}
+          loading={loading}
+        >
+          {t('workflow.common.overwriteAndImport')}
+        </Button>
+      </div>
+    </Modal>
+  )
+}
+
+export default memo(UpdateDSLModal)

+ 7 - 0
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',

+ 7 - 0
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}} 不能为空',

+ 4 - 0
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<FetchWorkflowDraftResponse>(`apps/${appId}/workflows/draft/import`, { body: { data } })
+}