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

Co-authored-by: StyleZhang <jasonapring2015@outlook.com>
This commit is contained in:
takatost 2024-06-25 15:46:12 +08:00 committed by GitHub
parent cdc2a6f637
commit ec1d3ddee2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 361 additions and 26 deletions

View File

@ -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')

View File

@ -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:

View File

@ -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>
)

View File

@ -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' }}

View File

@ -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,
}
}

View File

@ -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}

View File

@ -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>
)

View File

@ -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 })),
}))
}

View File

@ -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)

View File

@ -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',

View File

@ -68,6 +68,13 @@ const translation = {
workflowAsToolTip: '工作流更新后需要重新配置工具参数',
viewDetailInTracingPanel: '查看详细信息',
syncingData: '同步数据中,只需几秒钟。',
importDSL: '导入 DSL',
importDSLTip: '当前草稿将被覆盖。在导入之前请导出工作流作为备份。',
backupCurrentDraft: '备份当前草稿',
chooseDSL: '选择 DSL(yml) 文件',
overwriteAndImport: '覆盖并导入',
importFailure: '导入失败',
importSuccess: '导入成功',
},
errorMsg: {
fieldRequired: '{{field}} 不能为空',

View File

@ -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 } })
}