mirror of https://github.com/langgenius/dify.git
feat: support importing and overwriting workflow DSL (#5511)
Co-authored-by: StyleZhang <jasonapring2015@outlook.com>
This commit is contained in:
parent
cdc2a6f637
commit
ec1d3ddee2
|
@ -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):
|
class AdvancedChatDraftWorkflowRunApi(Resource):
|
||||||
@setup_required
|
@setup_required
|
||||||
@login_required
|
@login_required
|
||||||
|
@ -439,6 +467,7 @@ class ConvertToWorkflowApi(Resource):
|
||||||
|
|
||||||
|
|
||||||
api.add_resource(DraftWorkflowApi, '/apps/<uuid:app_id>/workflows/draft')
|
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(AdvancedChatDraftWorkflowRunApi, '/apps/<uuid:app_id>/advanced-chat/workflows/draft/run')
|
||||||
api.add_resource(DraftWorkflowRunApi, '/apps/<uuid:app_id>/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')
|
api.add_resource(WorkflowTaskStopApi, '/apps/<uuid:app_id>/workflow-runs/tasks/<string:task_id>/stop')
|
||||||
|
|
|
@ -3,6 +3,8 @@ import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfigManager
|
from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfigManager
|
||||||
from core.app.apps.workflow.app_config_manager import WorkflowAppConfigManager
|
from core.app.apps.workflow.app_config_manager import WorkflowAppConfigManager
|
||||||
from core.model_runtime.utils.encoders import jsonable_encoder
|
from core.model_runtime.utils.encoders import jsonable_encoder
|
||||||
|
@ -112,6 +114,56 @@ class WorkflowService:
|
||||||
# return draft workflow
|
# return draft workflow
|
||||||
return 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,
|
def publish_workflow(self, app_model: App,
|
||||||
account: Account,
|
account: Account,
|
||||||
draft_workflow: Optional[Workflow] = None) -> Workflow:
|
draft_workflow: Optional[Workflow] = None) -> Workflow:
|
||||||
|
|
|
@ -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 type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
|
||||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||||
import { getRedirection } from '@/utils/app-redirection'
|
import { getRedirection } from '@/utils/app-redirection'
|
||||||
|
import UpdateDSLModal from '@/app/components/workflow/update-dsl-modal'
|
||||||
|
|
||||||
export type IAppInfoProps = {
|
export type IAppInfoProps = {
|
||||||
expand: boolean
|
expand: boolean
|
||||||
|
@ -45,6 +46,7 @@ const AppInfo = ({ expand }: IAppInfoProps) => {
|
||||||
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
|
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
|
||||||
const [showSwitchTip, setShowSwitchTip] = useState<string>('')
|
const [showSwitchTip, setShowSwitchTip] = useState<string>('')
|
||||||
const [showSwitchModal, setShowSwitchModal] = useState<boolean>(false)
|
const [showSwitchModal, setShowSwitchModal] = useState<boolean>(false)
|
||||||
|
const [showImportDSLModal, setShowImportDSLModal] = useState<boolean>(false)
|
||||||
|
|
||||||
const mutateApps = useContextSelector(
|
const mutateApps = useContextSelector(
|
||||||
AppsContext,
|
AppsContext,
|
||||||
|
@ -295,9 +297,6 @@ const AppInfo = ({ expand }: IAppInfoProps) => {
|
||||||
}}>
|
}}>
|
||||||
<span className='text-gray-700 text-sm leading-5'>{t('app.duplicate')}</span>
|
<span className='text-gray-700 text-sm leading-5'>{t('app.duplicate')}</span>
|
||||||
</div>
|
</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') && (
|
{(appDetail.mode === 'completion' || appDetail.mode === 'chat') && (
|
||||||
<>
|
<>
|
||||||
<Divider className="!my-1" />
|
<Divider className="!my-1" />
|
||||||
|
@ -315,6 +314,22 @@ const AppInfo = ({ expand }: IAppInfoProps) => {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Divider className="!my-1" />
|
<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={() => {
|
<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)
|
setOpen(false)
|
||||||
setShowConfirmDelete(true)
|
setShowConfirmDelete(true)
|
||||||
|
@ -388,6 +403,14 @@ const AppInfo = ({ expand }: IAppInfoProps) => {
|
||||||
onCancel={() => setShowConfirmDelete(false)}
|
onCancel={() => setShowConfirmDelete(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{
|
||||||
|
showImportDSLModal && (
|
||||||
|
<UpdateDSLModal
|
||||||
|
onCancel={() => setShowImportDSLModal(false)}
|
||||||
|
onBackup={onExport}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</PortalToFollowElem>
|
</PortalToFollowElem>
|
||||||
)
|
)
|
||||||
|
|
|
@ -15,11 +15,13 @@ import Button from '@/app/components/base/button'
|
||||||
export type Props = {
|
export type Props = {
|
||||||
file: File | undefined
|
file: File | undefined
|
||||||
updateFile: (file?: File) => void
|
updateFile: (file?: File) => void
|
||||||
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const Uploader: FC<Props> = ({
|
const Uploader: FC<Props> = ({
|
||||||
file,
|
file,
|
||||||
updateFile,
|
updateFile,
|
||||||
|
className,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { notify } = useContext(ToastContext)
|
const { notify } = useContext(ToastContext)
|
||||||
|
@ -83,7 +85,7 @@ const Uploader: FC<Props> = ({
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='mt-6'>
|
<div className={cn('mt-6', className)}>
|
||||||
<input
|
<input
|
||||||
ref={fileUploader}
|
ref={fileUploader}
|
||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useReactFlow } from 'reactflow'
|
import { useReactFlow } from 'reactflow'
|
||||||
import { useWorkflowStore } from '../store'
|
import { useWorkflowStore } from '../store'
|
||||||
import { WORKFLOW_DATA_UPDATE } from '../constants'
|
import { WORKFLOW_DATA_UPDATE } from '../constants'
|
||||||
|
@ -11,6 +12,9 @@ import { useEdgesInteractions } from './use-edges-interactions'
|
||||||
import { useNodesInteractions } from './use-nodes-interactions'
|
import { useNodesInteractions } from './use-nodes-interactions'
|
||||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||||
import { fetchWorkflowDraft } from '@/service/workflow'
|
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 = () => {
|
export const useWorkflowInteractions = () => {
|
||||||
const workflowStore = useWorkflowStore()
|
const workflowStore = useWorkflowStore()
|
||||||
|
@ -71,3 +75,29 @@ export const useWorkflowUpdate = () => {
|
||||||
handleRefreshWorkflowDraft,
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -20,6 +20,7 @@ import ReactFlow, {
|
||||||
useEdgesState,
|
useEdgesState,
|
||||||
useNodesState,
|
useNodesState,
|
||||||
useOnViewportChange,
|
useOnViewportChange,
|
||||||
|
useReactFlow,
|
||||||
} from 'reactflow'
|
} from 'reactflow'
|
||||||
import type {
|
import type {
|
||||||
Viewport,
|
Viewport,
|
||||||
|
@ -32,6 +33,7 @@ import type {
|
||||||
} from './types'
|
} from './types'
|
||||||
import { WorkflowContextProvider } from './context'
|
import { WorkflowContextProvider } from './context'
|
||||||
import {
|
import {
|
||||||
|
useDSL,
|
||||||
useEdgesInteractions,
|
useEdgesInteractions,
|
||||||
useNodesInteractions,
|
useNodesInteractions,
|
||||||
useNodesReadOnly,
|
useNodesReadOnly,
|
||||||
|
@ -58,6 +60,7 @@ import CandidateNode from './candidate-node'
|
||||||
import PanelContextmenu from './panel-contextmenu'
|
import PanelContextmenu from './panel-contextmenu'
|
||||||
import NodeContextmenu from './node-contextmenu'
|
import NodeContextmenu from './node-contextmenu'
|
||||||
import SyncingDataModal from './syncing-data-modal'
|
import SyncingDataModal from './syncing-data-modal'
|
||||||
|
import UpdateDSLModal from './update-dsl-modal'
|
||||||
import {
|
import {
|
||||||
useStore,
|
useStore,
|
||||||
useWorkflowStore,
|
useWorkflowStore,
|
||||||
|
@ -76,6 +79,7 @@ import {
|
||||||
import Loading from '@/app/components/base/loading'
|
import Loading from '@/app/components/base/loading'
|
||||||
import { FeaturesProvider } from '@/app/components/base/features'
|
import { FeaturesProvider } from '@/app/components/base/features'
|
||||||
import type { Features as FeaturesData } from '@/app/components/base/features/types'
|
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 { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||||
import Confirm from '@/app/components/base/confirm/common'
|
import Confirm from '@/app/components/base/confirm/common'
|
||||||
|
|
||||||
|
@ -99,15 +103,20 @@ const Workflow: FC<WorkflowProps> = memo(({
|
||||||
}) => {
|
}) => {
|
||||||
const workflowContainerRef = useRef<HTMLDivElement>(null)
|
const workflowContainerRef = useRef<HTMLDivElement>(null)
|
||||||
const workflowStore = useWorkflowStore()
|
const workflowStore = useWorkflowStore()
|
||||||
|
const reactflow = useReactFlow()
|
||||||
|
const featuresStore = useFeaturesStore()
|
||||||
const [nodes, setNodes] = useNodesState(originalNodes)
|
const [nodes, setNodes] = useNodesState(originalNodes)
|
||||||
const [edges, setEdges] = useEdgesState(originalEdges)
|
const [edges, setEdges] = useEdgesState(originalEdges)
|
||||||
const showFeaturesPanel = useStore(state => state.showFeaturesPanel)
|
const showFeaturesPanel = useStore(state => state.showFeaturesPanel)
|
||||||
const controlMode = useStore(s => s.controlMode)
|
const controlMode = useStore(s => s.controlMode)
|
||||||
const nodeAnimation = useStore(s => s.nodeAnimation)
|
const nodeAnimation = useStore(s => s.nodeAnimation)
|
||||||
const showConfirm = useStore(s => s.showConfirm)
|
const showConfirm = useStore(s => s.showConfirm)
|
||||||
|
const showImportDSLModal = useStore(s => s.showImportDSLModal)
|
||||||
const {
|
const {
|
||||||
setShowConfirm,
|
setShowConfirm,
|
||||||
setControlPromptEditorRerenderKey,
|
setControlPromptEditorRerenderKey,
|
||||||
|
setShowImportDSLModal,
|
||||||
|
setSyncWorkflowDraftHash,
|
||||||
} = workflowStore.getState()
|
} = workflowStore.getState()
|
||||||
const {
|
const {
|
||||||
handleSyncWorkflowDraft,
|
handleSyncWorkflowDraft,
|
||||||
|
@ -122,6 +131,19 @@ const Workflow: FC<WorkflowProps> = memo(({
|
||||||
if (v.type === WORKFLOW_DATA_UPDATE) {
|
if (v.type === WORKFLOW_DATA_UPDATE) {
|
||||||
setNodes(v.payload.nodes)
|
setNodes(v.payload.nodes)
|
||||||
setEdges(v.payload.edges)
|
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()))
|
setTimeout(() => setControlPromptEditorRerenderKey(Date.now()))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -204,11 +226,15 @@ const Workflow: FC<WorkflowProps> = memo(({
|
||||||
} = useSelectionInteractions()
|
} = useSelectionInteractions()
|
||||||
const {
|
const {
|
||||||
handlePaneContextMenu,
|
handlePaneContextMenu,
|
||||||
|
handlePaneContextmenuCancel,
|
||||||
} = usePanelInteractions()
|
} = usePanelInteractions()
|
||||||
const {
|
const {
|
||||||
isValidConnection,
|
isValidConnection,
|
||||||
} = useWorkflow()
|
} = useWorkflow()
|
||||||
const { handleStartWorkflowRun } = useWorkflowStartRun()
|
const { handleStartWorkflowRun } = useWorkflowStartRun()
|
||||||
|
const {
|
||||||
|
handleExportDSL,
|
||||||
|
} = useDSL()
|
||||||
|
|
||||||
useOnViewportChange({
|
useOnViewportChange({
|
||||||
onEnd: () => {
|
onEnd: () => {
|
||||||
|
@ -266,6 +292,15 @@ const Workflow: FC<WorkflowProps> = memo(({
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
showImportDSLModal && (
|
||||||
|
<UpdateDSLModal
|
||||||
|
onCancel={() => setShowImportDSLModal(false)}
|
||||||
|
onBackup={handleExportDSL}
|
||||||
|
onImport={handlePaneContextmenuCancel}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
edgeTypes={edgeTypes}
|
edgeTypes={edgeTypes}
|
||||||
|
|
|
@ -8,48 +8,30 @@ import { useClickAway } from 'ahooks'
|
||||||
import ShortcutsName from './shortcuts-name'
|
import ShortcutsName from './shortcuts-name'
|
||||||
import { useStore } from './store'
|
import { useStore } from './store'
|
||||||
import {
|
import {
|
||||||
|
useDSL,
|
||||||
useNodesInteractions,
|
useNodesInteractions,
|
||||||
usePanelInteractions,
|
usePanelInteractions,
|
||||||
useWorkflowStartRun,
|
useWorkflowStartRun,
|
||||||
} from './hooks'
|
} from './hooks'
|
||||||
import AddBlock from './operator/add-block'
|
import AddBlock from './operator/add-block'
|
||||||
import { useOperator } from './operator/hooks'
|
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 PanelContextmenu = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { notify } = useToastContext()
|
|
||||||
const ref = useRef(null)
|
const ref = useRef(null)
|
||||||
const panelMenu = useStore(s => s.panelMenu)
|
const panelMenu = useStore(s => s.panelMenu)
|
||||||
const clipboardElements = useStore(s => s.clipboardElements)
|
const clipboardElements = useStore(s => s.clipboardElements)
|
||||||
const appDetail = useAppStore(s => s.appDetail)
|
const setShowImportDSLModal = useStore(s => s.setShowImportDSLModal)
|
||||||
const { handleNodesPaste } = useNodesInteractions()
|
const { handleNodesPaste } = useNodesInteractions()
|
||||||
const { handlePaneContextmenuCancel } = usePanelInteractions()
|
const { handlePaneContextmenuCancel } = usePanelInteractions()
|
||||||
const { handleStartWorkflowRun } = useWorkflowStartRun()
|
const { handleStartWorkflowRun } = useWorkflowStartRun()
|
||||||
const { handleAddNote } = useOperator()
|
const { handleAddNote } = useOperator()
|
||||||
|
const { handleExportDSL } = useDSL()
|
||||||
|
|
||||||
useClickAway(() => {
|
useClickAway(() => {
|
||||||
handlePaneContextmenuCancel()
|
handlePaneContextmenuCancel()
|
||||||
}, ref)
|
}, 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 = () => {
|
const renderTrigger = () => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
@ -123,10 +105,16 @@ const PanelContextmenu = () => {
|
||||||
<div className='p-1'>
|
<div className='p-1'>
|
||||||
<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'
|
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')}
|
{t('app.export')}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
@ -129,6 +129,8 @@ type Shape = {
|
||||||
setIsSyncingWorkflowDraft: (isSyncingWorkflowDraft: boolean) => void
|
setIsSyncingWorkflowDraft: (isSyncingWorkflowDraft: boolean) => void
|
||||||
controlPromptEditorRerenderKey: number
|
controlPromptEditorRerenderKey: number
|
||||||
setControlPromptEditorRerenderKey: (controlPromptEditorRerenderKey: number) => void
|
setControlPromptEditorRerenderKey: (controlPromptEditorRerenderKey: number) => void
|
||||||
|
showImportDSLModal: boolean
|
||||||
|
setShowImportDSLModal: (showImportDSLModal: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createWorkflowStore = () => {
|
export const createWorkflowStore = () => {
|
||||||
|
@ -217,6 +219,8 @@ export const createWorkflowStore = () => {
|
||||||
setIsSyncingWorkflowDraft: isSyncingWorkflowDraft => set(() => ({ isSyncingWorkflowDraft })),
|
setIsSyncingWorkflowDraft: isSyncingWorkflowDraft => set(() => ({ isSyncingWorkflowDraft })),
|
||||||
controlPromptEditorRerenderKey: 0,
|
controlPromptEditorRerenderKey: 0,
|
||||||
setControlPromptEditorRerenderKey: controlPromptEditorRerenderKey => set(() => ({ controlPromptEditorRerenderKey })),
|
setControlPromptEditorRerenderKey: controlPromptEditorRerenderKey => set(() => ({ controlPromptEditorRerenderKey })),
|
||||||
|
showImportDSLModal: false,
|
||||||
|
setShowImportDSLModal: showImportDSLModal => set(() => ({ showImportDSLModal })),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
|
@ -68,6 +68,13 @@ const translation = {
|
||||||
workflowAsToolTip: 'Tool reconfiguration is required after the workflow update.',
|
workflowAsToolTip: 'Tool reconfiguration is required after the workflow update.',
|
||||||
viewDetailInTracingPanel: 'View details',
|
viewDetailInTracingPanel: 'View details',
|
||||||
syncingData: 'Syncing data, just a few seconds.',
|
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: {
|
errorMsg: {
|
||||||
fieldRequired: '{{field}} is required',
|
fieldRequired: '{{field}} is required',
|
||||||
|
|
|
@ -68,6 +68,13 @@ const translation = {
|
||||||
workflowAsToolTip: '工作流更新后需要重新配置工具参数',
|
workflowAsToolTip: '工作流更新后需要重新配置工具参数',
|
||||||
viewDetailInTracingPanel: '查看详细信息',
|
viewDetailInTracingPanel: '查看详细信息',
|
||||||
syncingData: '同步数据中,只需几秒钟。',
|
syncingData: '同步数据中,只需几秒钟。',
|
||||||
|
importDSL: '导入 DSL',
|
||||||
|
importDSLTip: '当前草稿将被覆盖。在导入之前请导出工作流作为备份。',
|
||||||
|
backupCurrentDraft: '备份当前草稿',
|
||||||
|
chooseDSL: '选择 DSL(yml) 文件',
|
||||||
|
overwriteAndImport: '覆盖并导入',
|
||||||
|
importFailure: '导入失败',
|
||||||
|
importSuccess: '导入成功',
|
||||||
},
|
},
|
||||||
errorMsg: {
|
errorMsg: {
|
||||||
fieldRequired: '{{field}} 不能为空',
|
fieldRequired: '{{field}} 不能为空',
|
||||||
|
|
|
@ -54,3 +54,7 @@ export const fetchNodeDefault = (appId: string, blockType: BlockEnum, query = {}
|
||||||
params: { q: JSON.stringify(query) },
|
params: { q: JSON.stringify(query) },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const updateWorkflowDraftFromDSL = (appId: string, data: string) => {
|
||||||
|
return post<FetchWorkflowDraftResponse>(`apps/${appId}/workflows/draft/import`, { body: { data } })
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue