This commit is contained in:
2025-12-01 17:21:38 +08:00
parent 32fee2b8ab
commit fab8c13cb3
7511 changed files with 996300 additions and 0 deletions

View File

@@ -0,0 +1,198 @@
import {
memo,
useCallback,
useState,
} from 'react'
import type { EnvironmentVariable } from '@/app/components/workflow/types'
import { DSL_EXPORT_CHECK } from '@/app/components/workflow/constants'
import { START_INITIAL_POSITION } from '@/app/components/workflow/constants'
import { generateNewNode } from '@/app/components/workflow/utils'
import { useStore } from '@/app/components/workflow/store'
import { useStoreApi } from 'reactflow'
import PluginDependency from '../../workflow/plugin-dependency'
import {
useAutoGenerateWebhookUrl,
useDSL,
usePanelInteractions,
} from '@/app/components/workflow/hooks'
import { useNodesSyncDraft } from '@/app/components/workflow/hooks/use-nodes-sync-draft'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import WorkflowHeader from './workflow-header'
import WorkflowPanel from './workflow-panel'
import dynamic from 'next/dynamic'
import { BlockEnum } from '@/app/components/workflow/types'
import type {
PluginDefaultValue,
TriggerDefaultValue,
} from '@/app/components/workflow/block-selector/types'
import { useAutoOnboarding } from '../hooks/use-auto-onboarding'
import { useAvailableNodesMetaData } from '../hooks'
const Features = dynamic(() => import('@/app/components/workflow/features'), {
ssr: false,
})
const UpdateDSLModal = dynamic(() => import('@/app/components/workflow/update-dsl-modal'), {
ssr: false,
})
const DSLExportConfirmModal = dynamic(() => import('@/app/components/workflow/dsl-export-confirm-modal'), {
ssr: false,
})
const WorkflowOnboardingModal = dynamic(() => import('./workflow-onboarding-modal'), {
ssr: false,
})
const getTriggerPluginNodeData = (
triggerConfig: TriggerDefaultValue,
fallbackTitle?: string,
fallbackDesc?: string,
) => {
return {
plugin_id: triggerConfig.plugin_id,
provider_id: triggerConfig.provider_name,
provider_type: triggerConfig.provider_type,
provider_name: triggerConfig.provider_name,
event_name: triggerConfig.event_name,
event_label: triggerConfig.event_label,
event_description: triggerConfig.event_description,
title: triggerConfig.event_label || triggerConfig.title || fallbackTitle,
desc: triggerConfig.event_description || fallbackDesc,
output_schema: { ...triggerConfig.output_schema },
parameters_schema: triggerConfig.paramSchemas ? [...triggerConfig.paramSchemas] : [],
config: { ...triggerConfig.params },
subscription_id: triggerConfig.subscription_id,
plugin_unique_identifier: triggerConfig.plugin_unique_identifier,
is_team_authorization: triggerConfig.is_team_authorization,
meta: triggerConfig.meta ? { ...triggerConfig.meta } : undefined,
}
}
const WorkflowChildren = () => {
const { eventEmitter } = useEventEmitterContextContext()
const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariable[]>([])
const showFeaturesPanel = useStore(s => s.showFeaturesPanel)
const showImportDSLModal = useStore(s => s.showImportDSLModal)
const setShowImportDSLModal = useStore(s => s.setShowImportDSLModal)
const showOnboarding = useStore(s => s.showOnboarding)
const setShowOnboarding = useStore(s => s.setShowOnboarding)
const setHasSelectedStartNode = useStore(s => s.setHasSelectedStartNode)
const setShouldAutoOpenStartNodeSelector = useStore(s => s.setShouldAutoOpenStartNodeSelector)
const reactFlowStore = useStoreApi()
const availableNodesMetaData = useAvailableNodesMetaData()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const { handleOnboardingClose } = useAutoOnboarding()
const {
handlePaneContextmenuCancel,
} = usePanelInteractions()
const {
exportCheck,
handleExportDSL,
} = useDSL()
eventEmitter?.useSubscription((v: any) => {
if (v.type === DSL_EXPORT_CHECK)
setSecretEnvList(v.payload.data as EnvironmentVariable[])
})
const autoGenerateWebhookUrl = useAutoGenerateWebhookUrl()
const handleCloseOnboarding = useCallback(() => {
handleOnboardingClose()
}, [handleOnboardingClose])
const handleSelectStartNode = useCallback((nodeType: BlockEnum, toolConfig?: PluginDefaultValue) => {
const nodeDefault = availableNodesMetaData.nodesMap?.[nodeType]
if (!nodeDefault?.defaultValue)
return
const baseNodeData = { ...nodeDefault.defaultValue }
const mergedNodeData = (() => {
if (nodeType !== BlockEnum.TriggerPlugin || !toolConfig) {
return {
...baseNodeData,
...toolConfig,
}
}
const triggerNodeData = getTriggerPluginNodeData(
toolConfig as TriggerDefaultValue,
baseNodeData.title,
baseNodeData.desc,
)
return {
...baseNodeData,
...triggerNodeData,
config: {
...(baseNodeData as { config?: Record<string, any> }).config,
...triggerNodeData.config,
},
}
})()
const { newNode } = generateNewNode({
data: {
...mergedNodeData,
} as any,
position: START_INITIAL_POSITION,
})
const { setNodes, setEdges } = reactFlowStore.getState()
setNodes([newNode])
setEdges([])
setShowOnboarding?.(false)
setHasSelectedStartNode?.(true)
setShouldAutoOpenStartNodeSelector?.(true)
handleSyncWorkflowDraft(true, false, {
onSuccess: () => {
autoGenerateWebhookUrl(newNode.id)
console.log('Node successfully saved to draft')
},
onError: () => {
console.error('Failed to save node to draft')
},
})
}, [availableNodesMetaData, setShowOnboarding, setHasSelectedStartNode, reactFlowStore, handleSyncWorkflowDraft])
return (
<>
<PluginDependency />
{
showFeaturesPanel && <Features />
}
{
showOnboarding && (
<WorkflowOnboardingModal
isShow={showOnboarding}
onClose={handleCloseOnboarding}
onSelectStartNode={handleSelectStartNode}
/>
)
}
{
showImportDSLModal && (
<UpdateDSLModal
onCancel={() => setShowImportDSLModal(false)}
onBackup={exportCheck!}
onImport={handlePaneContextmenuCancel}
/>
)
}
{
secretEnvList.length > 0 && (
<DSLExportConfirmModal
envList={secretEnvList}
onConfirm={handleExportDSL!}
onClose={() => setSecretEnvList([])}
/>
)
}
<WorkflowHeader />
<WorkflowPanel />
</>
)
}
export default memo(WorkflowChildren)

View File

@@ -0,0 +1,17 @@
import { memo } from 'react'
import ChatVariableButton from '@/app/components/workflow/header/chat-variable-button'
import {
useNodesReadOnly,
} from '@/app/components/workflow/hooks'
import { useIsChatMode } from '../../hooks'
const ChatVariableTrigger = () => {
const { nodesReadOnly } = useNodesReadOnly()
const isChatMode = useIsChatMode()
if (!isChatMode)
return null
return <ChatVariableButton disabled={nodesReadOnly} />
}
export default memo(ChatVariableTrigger)

View File

@@ -0,0 +1,219 @@
import {
memo,
useCallback,
useMemo,
} from 'react'
import { useEdges } from 'reactflow'
import { RiApps2AddLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import {
useStore,
useWorkflowStore,
} from '@/app/components/workflow/store'
import {
useChecklist,
useChecklistBeforePublish,
useNodesReadOnly,
useNodesSyncDraft,
// useWorkflowRunValidation,
} from '@/app/components/workflow/hooks'
import Button from '@/app/components/base/button'
import AppPublisher from '@/app/components/app/app-publisher'
import { useFeatures } from '@/app/components/base/features/hooks'
import type {
CommonEdgeType,
Node,
} from '@/app/components/workflow/types'
import {
BlockEnum,
InputVarType,
isTriggerNode,
} from '@/app/components/workflow/types'
import { useToastContext } from '@/app/components/base/toast'
import { useInvalidateAppWorkflow, usePublishWorkflow, useResetWorkflowVersionHistory } from '@/service/use-workflow'
import { useInvalidateAppTriggers } from '@/service/use-tools'
import type { PublishWorkflowParams } from '@/types/workflow'
import { fetchAppDetail } from '@/service/apps'
import { useStore as useAppStore } from '@/app/components/app/store'
import useTheme from '@/hooks/use-theme'
import cn from '@/utils/classnames'
import { useIsChatMode } from '@/app/components/workflow/hooks'
import type { StartNodeType } from '@/app/components/workflow/nodes/start/types'
import { useProviderContext } from '@/context/provider-context'
import { Plan } from '@/app/components/billing/type'
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
const FeaturesTrigger = () => {
const { t } = useTranslation()
const { theme } = useTheme()
const isChatMode = useIsChatMode()
const workflowStore = useWorkflowStore()
const appDetail = useAppStore(s => s.appDetail)
const appID = appDetail?.id
const setAppDetail = useAppStore(s => s.setAppDetail)
const { nodesReadOnly, getNodesReadOnly } = useNodesReadOnly()
const { plan, isFetchedPlan } = useProviderContext()
const publishedAt = useStore(s => s.publishedAt)
const draftUpdatedAt = useStore(s => s.draftUpdatedAt)
const toolPublished = useStore(s => s.toolPublished)
const lastPublishedHasUserInput = useStore(s => s.lastPublishedHasUserInput)
const nodes = useNodes()
const hasWorkflowNodes = nodes.length > 0
const startNode = nodes.find(node => node.data.type === BlockEnum.Start)
const startVariables = (startNode as Node<StartNodeType>)?.data?.variables
const edges = useEdges<CommonEdgeType>()
const fileSettings = useFeatures(s => s.features.file)
const variables = useMemo(() => {
const data = startVariables || []
if (fileSettings?.image?.enabled) {
return [
...data,
{
type: InputVarType.files,
variable: '__image',
required: false,
label: 'files',
},
]
}
return data
}, [fileSettings?.image?.enabled, startVariables])
const { handleCheckBeforePublish } = useChecklistBeforePublish()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const { notify } = useToastContext()
const startNodeIds = useMemo(
() => nodes.filter(node => node.data.type === BlockEnum.Start).map(node => node.id),
[nodes],
)
const hasUserInputNode = useMemo(() => {
if (!startNodeIds.length)
return false
return edges.some(edge => startNodeIds.includes(edge.source))
}, [edges, startNodeIds])
// Track trigger presence so the publisher can adjust UI (e.g. hide missing start section).
const hasTriggerNode = useMemo(() => (
nodes.some(node => isTriggerNode(node.data.type as BlockEnum))
), [nodes])
const startNodeLimitExceeded = useMemo(() => {
const entryCount = nodes.reduce((count, node) => {
const nodeType = node.data.type as BlockEnum
if (nodeType === BlockEnum.Start || isTriggerNode(nodeType))
return count + 1
return count
}, 0)
return isFetchedPlan && plan.type === Plan.sandbox && entryCount > 2
}, [nodes, plan.type, isFetchedPlan])
const resetWorkflowVersionHistory = useResetWorkflowVersionHistory()
const invalidateAppTriggers = useInvalidateAppTriggers()
const handleShowFeatures = useCallback(() => {
const {
showFeaturesPanel,
isRestoring,
setShowFeaturesPanel,
} = workflowStore.getState()
if (getNodesReadOnly() && !isRestoring)
return
setShowFeaturesPanel(!showFeaturesPanel)
}, [workflowStore, getNodesReadOnly])
const updateAppDetail = useCallback(async () => {
try {
const res = await fetchAppDetail({ url: '/apps', id: appID! })
setAppDetail({ ...res })
}
catch (error) {
console.error(error)
}
}, [appID, setAppDetail])
const { mutateAsync: publishWorkflow } = usePublishWorkflow()
// const { validateBeforeRun } = useWorkflowRunValidation()
const needWarningNodes = useChecklist(nodes, edges)
const updatePublishedWorkflow = useInvalidateAppWorkflow()
const onPublish = useCallback(async (params?: PublishWorkflowParams) => {
// First check if there are any items in the checklist
// if (!validateBeforeRun())
// throw new Error('Checklist has unresolved items')
if (needWarningNodes.length > 0) {
notify({ type: 'error', message: t('workflow.panel.checklistTip') })
throw new Error('Checklist has unresolved items')
}
// Then perform the detailed validation
if (await handleCheckBeforePublish()) {
const res = await publishWorkflow({
url: `/apps/${appID}/workflows/publish`,
title: params?.title || '',
releaseNotes: params?.releaseNotes || '',
})
if (res) {
notify({ type: 'success', message: t('common.api.actionSuccess') })
updatePublishedWorkflow(appID!)
updateAppDetail()
invalidateAppTriggers(appID!)
workflowStore.getState().setPublishedAt(res.created_at)
workflowStore.getState().setLastPublishedHasUserInput(hasUserInputNode)
resetWorkflowVersionHistory()
}
}
else {
throw new Error('Checklist failed')
}
}, [needWarningNodes, handleCheckBeforePublish, publishWorkflow, notify, appID, t, updatePublishedWorkflow, updateAppDetail, workflowStore, resetWorkflowVersionHistory, invalidateAppTriggers])
const onPublisherToggle = useCallback((state: boolean) => {
if (state)
handleSyncWorkflowDraft(true)
}, [handleSyncWorkflowDraft])
const handleToolConfigureUpdate = useCallback(() => {
workflowStore.setState({ toolPublished: true })
}, [workflowStore])
return (
<>
{/* Feature button is only visible in chatflow mode (advanced-chat) */}
{isChatMode && (
<Button
className={cn(
'text-components-button-secondary-text',
theme === 'dark' && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm',
)}
onClick={handleShowFeatures}
>
<RiApps2AddLine className='mr-1 h-4 w-4 text-components-button-secondary-text' />
{t('workflow.common.features')}
</Button>
)}
<AppPublisher
{...{
publishedAt,
draftUpdatedAt,
disabled: nodesReadOnly || !hasWorkflowNodes,
toolPublished,
inputs: variables,
onRefreshData: handleToolConfigureUpdate,
onPublish,
onToggle: onPublisherToggle,
workflowToolAvailable: lastPublishedHasUserInput,
crossAxisOffset: 4,
missingStartNode: !startNode,
hasTriggerNode,
startNodeLimitExceeded,
publishDisabled: !hasWorkflowNodes || startNodeLimitExceeded,
}}
/>
</>
)
}
export default memo(FeaturesTrigger)

View File

@@ -0,0 +1,66 @@
import {
memo,
useCallback,
useMemo,
} from 'react'
import { useShallow } from 'zustand/react/shallow'
import type { HeaderProps } from '@/app/components/workflow/header'
import Header from '@/app/components/workflow/header'
import { useStore as useAppStore } from '@/app/components/app/store'
import {
fetchWorkflowRunHistory,
} from '@/service/workflow'
import ChatVariableTrigger from './chat-variable-trigger'
import FeaturesTrigger from './features-trigger'
import { useResetWorkflowVersionHistory } from '@/service/use-workflow'
import { useIsChatMode } from '../../hooks'
const WorkflowHeader = () => {
const { appDetail, setCurrentLogItem, setShowMessageLogModal } = useAppStore(useShallow(state => ({
appDetail: state.appDetail,
setCurrentLogItem: state.setCurrentLogItem,
setShowMessageLogModal: state.setShowMessageLogModal,
})))
const resetWorkflowVersionHistory = useResetWorkflowVersionHistory()
const isChatMode = useIsChatMode()
const handleClearLogAndMessageModal = useCallback(() => {
setCurrentLogItem()
setShowMessageLogModal(false)
}, [setCurrentLogItem, setShowMessageLogModal])
const viewHistoryProps = useMemo(() => {
return {
onClearLogAndMessageModal: handleClearLogAndMessageModal,
historyUrl: isChatMode ? `/apps/${appDetail!.id}/advanced-chat/workflow-runs` : `/apps/${appDetail!.id}/workflow-runs`,
historyFetcher: fetchWorkflowRunHistory,
}
}, [appDetail, isChatMode, handleClearLogAndMessageModal])
const headerProps: HeaderProps = useMemo(() => {
return {
normal: {
components: {
middle: <FeaturesTrigger />,
chatVariableTrigger: <ChatVariableTrigger />,
},
runAndHistoryProps: {
showRunButton: !isChatMode,
showPreviewButton: isChatMode,
viewHistoryProps,
},
},
viewHistory: {
viewHistoryProps,
},
restoring: {
onRestoreSettled: resetWorkflowVersionHistory,
},
}
}, [resetWorkflowVersionHistory, isChatMode, viewHistoryProps])
return (
<Header {...headerProps} />
)
}
export default memo(WorkflowHeader)

View File

@@ -0,0 +1,191 @@
import {
useCallback,
useMemo,
} from 'react'
import { useFeaturesStore } from '@/app/components/base/features/hooks'
import { WorkflowWithInnerContext } from '@/app/components/workflow'
import type { WorkflowProps } from '@/app/components/workflow'
import WorkflowChildren from './workflow-children'
import {
useAvailableNodesMetaData,
useConfigsMap,
useDSL,
useGetRunAndTraceUrl,
useInspectVarsCrud,
useNodesSyncDraft,
useSetWorkflowVarsWithValue,
useWorkflowRefreshDraft,
useWorkflowRun,
useWorkflowStartRun,
} from '../hooks'
import { useWorkflowStore } from '@/app/components/workflow/store'
type WorkflowMainProps = Pick<WorkflowProps, 'nodes' | 'edges' | 'viewport'>
const WorkflowMain = ({
nodes,
edges,
viewport,
}: WorkflowMainProps) => {
const featuresStore = useFeaturesStore()
const workflowStore = useWorkflowStore()
const handleWorkflowDataUpdate = useCallback((payload: any) => {
const {
features,
conversation_variables,
environment_variables,
} = payload
if (features && featuresStore) {
const { setFeatures } = featuresStore.getState()
setFeatures(features)
}
if (conversation_variables) {
const { setConversationVariables } = workflowStore.getState()
setConversationVariables(conversation_variables)
}
if (environment_variables) {
const { setEnvironmentVariables } = workflowStore.getState()
setEnvironmentVariables(environment_variables)
}
}, [featuresStore, workflowStore])
const {
doSyncWorkflowDraft,
syncWorkflowDraftWhenPageClose,
} = useNodesSyncDraft()
const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft()
const {
handleBackupDraft,
handleLoadBackupDraft,
handleRestoreFromPublishedWorkflow,
handleRun,
handleStopRun,
} = useWorkflowRun()
const {
handleStartWorkflowRun,
handleWorkflowStartRunInChatflow,
handleWorkflowStartRunInWorkflow,
handleWorkflowTriggerScheduleRunInWorkflow,
handleWorkflowTriggerWebhookRunInWorkflow,
handleWorkflowTriggerPluginRunInWorkflow,
handleWorkflowRunAllTriggersInWorkflow,
} = useWorkflowStartRun()
const availableNodesMetaData = useAvailableNodesMetaData()
const { getWorkflowRunAndTraceUrl } = useGetRunAndTraceUrl()
const {
exportCheck,
handleExportDSL,
} = useDSL()
const configsMap = useConfigsMap()
const { fetchInspectVars } = useSetWorkflowVarsWithValue({
...configsMap,
})
const {
hasNodeInspectVars,
hasSetInspectVar,
fetchInspectVarValue,
editInspectVarValue,
renameInspectVarName,
appendNodeInspectVars,
deleteInspectVar,
deleteNodeInspectorVars,
deleteAllInspectorVars,
isInspectVarEdited,
resetToLastRunVar,
invalidateSysVarValues,
resetConversationVar,
invalidateConversationVarValues,
} = useInspectVarsCrud()
const hooksStore = useMemo(() => {
return {
syncWorkflowDraftWhenPageClose,
doSyncWorkflowDraft,
handleRefreshWorkflowDraft,
handleBackupDraft,
handleLoadBackupDraft,
handleRestoreFromPublishedWorkflow,
handleRun,
handleStopRun,
handleStartWorkflowRun,
handleWorkflowStartRunInChatflow,
handleWorkflowStartRunInWorkflow,
handleWorkflowTriggerScheduleRunInWorkflow,
handleWorkflowTriggerWebhookRunInWorkflow,
handleWorkflowTriggerPluginRunInWorkflow,
handleWorkflowRunAllTriggersInWorkflow,
availableNodesMetaData,
getWorkflowRunAndTraceUrl,
exportCheck,
handleExportDSL,
fetchInspectVars,
hasNodeInspectVars,
hasSetInspectVar,
fetchInspectVarValue,
editInspectVarValue,
renameInspectVarName,
appendNodeInspectVars,
deleteInspectVar,
deleteNodeInspectorVars,
deleteAllInspectorVars,
isInspectVarEdited,
resetToLastRunVar,
invalidateSysVarValues,
resetConversationVar,
invalidateConversationVarValues,
configsMap,
}
}, [
syncWorkflowDraftWhenPageClose,
doSyncWorkflowDraft,
handleRefreshWorkflowDraft,
handleBackupDraft,
handleLoadBackupDraft,
handleRestoreFromPublishedWorkflow,
handleRun,
handleStopRun,
handleStartWorkflowRun,
handleWorkflowStartRunInChatflow,
handleWorkflowStartRunInWorkflow,
handleWorkflowTriggerScheduleRunInWorkflow,
handleWorkflowTriggerWebhookRunInWorkflow,
handleWorkflowTriggerPluginRunInWorkflow,
handleWorkflowRunAllTriggersInWorkflow,
availableNodesMetaData,
getWorkflowRunAndTraceUrl,
exportCheck,
handleExportDSL,
fetchInspectVars,
hasNodeInspectVars,
hasSetInspectVar,
fetchInspectVarValue,
editInspectVarValue,
renameInspectVarName,
appendNodeInspectVars,
deleteInspectVar,
deleteNodeInspectorVars,
deleteAllInspectorVars,
isInspectVarEdited,
resetToLastRunVar,
invalidateSysVarValues,
resetConversationVar,
invalidateConversationVarValues,
configsMap,
])
return (
<WorkflowWithInnerContext
nodes={nodes}
edges={edges}
viewport={viewport}
onWorkflowDataUpdate={handleWorkflowDataUpdate}
hooksStore={hooksStore as any}
>
<WorkflowChildren />
</WorkflowWithInnerContext>
)
}
export default WorkflowMain

View File

@@ -0,0 +1,99 @@
'use client'
import type { FC } from 'react'
import {
useCallback,
useEffect,
} from 'react'
import { useTranslation } from 'react-i18next'
import { BlockEnum } from '@/app/components/workflow/types'
import type { PluginDefaultValue } from '@/app/components/workflow/block-selector/types'
import Modal from '@/app/components/base/modal'
import StartNodeSelectionPanel from './start-node-selection-panel'
import { useDocLink } from '@/context/i18n'
type WorkflowOnboardingModalProps = {
isShow: boolean
onClose: () => void
onSelectStartNode: (nodeType: BlockEnum, toolConfig?: PluginDefaultValue) => void
}
const WorkflowOnboardingModal: FC<WorkflowOnboardingModalProps> = ({
isShow,
onClose,
onSelectStartNode,
}) => {
const { t } = useTranslation()
const docLink = useDocLink()
const handleSelectUserInput = useCallback(() => {
onSelectStartNode(BlockEnum.Start)
onClose() // Close modal after selection
}, [onSelectStartNode, onClose])
const handleTriggerSelect = useCallback((nodeType: BlockEnum, toolConfig?: PluginDefaultValue) => {
onSelectStartNode(nodeType, toolConfig)
onClose() // Close modal after selection
}, [onSelectStartNode, onClose])
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isShow)
onClose()
}
document.addEventListener('keydown', handleEsc)
return () => document.removeEventListener('keydown', handleEsc)
}, [isShow, onClose])
return (
<>
<Modal
isShow={isShow}
onClose={onClose}
className="w-[618px] max-w-[618px] rounded-2xl border border-effects-highlight bg-background-default-subtle shadow-lg"
overlayOpacity
closable
clickOutsideNotClose
>
<div className="pb-4">
{/* Header */}
<div className="mb-6">
<h3 className="title-2xl-semi-bold mb-2 text-text-primary">
{t('workflow.onboarding.title')}
</h3>
<div className="body-xs-regular leading-4 text-text-tertiary">
{t('workflow.onboarding.description')}{' '}
<a
href={docLink('/guides/workflow/node/start')}
target="_blank"
rel="noopener noreferrer"
className="hover:text-text-accent-hover cursor-pointer text-text-accent underline"
>
{t('workflow.onboarding.learnMore')}
</a>{' '}
{t('workflow.onboarding.aboutStartNode')}
</div>
</div>
{/* Content */}
<StartNodeSelectionPanel
onSelectUserInput={handleSelectUserInput}
onSelectTrigger={handleTriggerSelect}
/>
</div>
</Modal>
{/* ESC tip below modal */}
{isShow && (
<div className="body-xs-regular pointer-events-none fixed left-1/2 top-1/2 z-[70] flex -translate-x-1/2 translate-y-[165px] items-center gap-1 text-text-quaternary">
<span>{t('workflow.onboarding.escTip.press')}</span>
<kbd className="system-kbd inline-flex h-4 min-w-4 items-center justify-center rounded bg-components-kbd-bg-gray px-1 text-text-tertiary">
{t('workflow.onboarding.escTip.key')}
</kbd>
<span>{t('workflow.onboarding.escTip.toDismiss')}</span>
</div>
)}
</>
)
}
export default WorkflowOnboardingModal

View File

@@ -0,0 +1,53 @@
'use client'
import type { FC, ReactNode } from 'react'
import cn from '@/utils/classnames'
type StartNodeOptionProps = {
icon: ReactNode
title: string
subtitle?: string
description: string
onClick: () => void
}
const StartNodeOption: FC<StartNodeOptionProps> = ({
icon,
title,
subtitle,
description,
onClick,
}) => {
return (
<div
onClick={onClick}
className={cn(
'hover:border-components-panel-border-active flex h-40 w-[280px] cursor-pointer flex-col gap-2 rounded-xl border-[0.5px] border-components-option-card-option-border bg-components-panel-on-panel-item-bg p-4 shadow-sm transition-all hover:shadow-md',
)}
>
{/* Icon */}
<div className="shrink-0">
{icon}
</div>
{/* Text content */}
<div className="flex h-[74px] flex-col gap-1 py-0.5">
<div className="h-5 leading-5">
<h3 className="system-md-semi-bold text-text-primary">
{title}
{subtitle && (
<span className="system-md-regular text-text-quaternary"> {subtitle}</span>
)}
</h3>
</div>
<div className="h-12 leading-4">
<p className="system-xs-regular text-text-tertiary">
{description}
</p>
</div>
</div>
</div>
)
}
export default StartNodeOption

View File

@@ -0,0 +1,80 @@
'use client'
import type { FC } from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import StartNodeOption from './start-node-option'
import NodeSelector from '@/app/components/workflow/block-selector'
import { Home } from '@/app/components/base/icons/src/vender/workflow'
import { TriggerAll } from '@/app/components/base/icons/src/vender/workflow'
import { BlockEnum } from '@/app/components/workflow/types'
import type { PluginDefaultValue } from '@/app/components/workflow/block-selector/types'
import { TabsEnum } from '@/app/components/workflow/block-selector/types'
type StartNodeSelectionPanelProps = {
onSelectUserInput: () => void
onSelectTrigger: (nodeType: BlockEnum, toolConfig?: PluginDefaultValue) => void
}
const StartNodeSelectionPanel: FC<StartNodeSelectionPanelProps> = ({
onSelectUserInput,
onSelectTrigger,
}) => {
const { t } = useTranslation()
const [showTriggerSelector, setShowTriggerSelector] = useState(false)
const handleTriggerClick = useCallback(() => {
setShowTriggerSelector(true)
}, [])
const handleTriggerSelect = useCallback((nodeType: BlockEnum, toolConfig?: PluginDefaultValue) => {
setShowTriggerSelector(false)
onSelectTrigger(nodeType, toolConfig)
}, [onSelectTrigger])
return (
<div className="grid grid-cols-2 gap-4">
<StartNodeOption
icon={
<div className="flex h-9 w-9 items-center justify-center rounded-[10px] border-[0.5px] border-transparent bg-util-colors-blue-brand-blue-brand-500 p-2">
<Home className="h-5 w-5 text-white" />
</div>
}
title={t('workflow.onboarding.userInputFull')}
description={t('workflow.onboarding.userInputDescription')}
onClick={onSelectUserInput}
/>
<NodeSelector
open={showTriggerSelector}
onOpenChange={setShowTriggerSelector}
onSelect={handleTriggerSelect}
placement="right"
offset={-200}
noBlocks={true}
showStartTab={true}
defaultActiveTab={TabsEnum.Start}
forceShowStartContent={true}
availableBlocksTypes={[
BlockEnum.TriggerSchedule,
BlockEnum.TriggerWebhook,
BlockEnum.TriggerPlugin,
]}
trigger={() => (
<StartNodeOption
icon={
<div className="flex h-9 w-9 items-center justify-center rounded-[10px] border-[0.5px] border-transparent bg-util-colors-blue-brand-blue-brand-500 p-2">
<TriggerAll className="h-5 w-5 text-white" />
</div>
}
title={t('workflow.onboarding.trigger')}
description={t('workflow.onboarding.triggerDescription')}
onClick={handleTriggerClick}
/>
)}
popupClassName="z-[1200]"
/>
</div>
)
}
export default StartNodeSelectionPanel

View File

@@ -0,0 +1,133 @@
import {
memo,
useMemo,
} from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useStore } from '@/app/components/workflow/store'
import {
useIsChatMode,
} from '../hooks'
import { useStore as useAppStore } from '@/app/components/app/store'
import type { PanelProps } from '@/app/components/workflow/panel'
import Panel from '@/app/components/workflow/panel'
import dynamic from 'next/dynamic'
const MessageLogModal = dynamic(() => import('@/app/components/base/message-log-modal'), {
ssr: false,
})
const Record = dynamic(() => import('@/app/components/workflow/panel/record'), {
ssr: false,
})
const ChatRecord = dynamic(() => import('@/app/components/workflow/panel/chat-record'), {
ssr: false,
})
const DebugAndPreview = dynamic(() => import('@/app/components/workflow/panel/debug-and-preview'), {
ssr: false,
})
const WorkflowPreview = dynamic(() => import('@/app/components/workflow/panel/workflow-preview'), {
ssr: false,
})
const ChatVariablePanel = dynamic(() => import('@/app/components/workflow/panel/chat-variable-panel'), {
ssr: false,
})
const GlobalVariablePanel = dynamic(() => import('@/app/components/workflow/panel/global-variable-panel'), {
ssr: false,
})
const WorkflowPanelOnLeft = () => {
const { currentLogItem, setCurrentLogItem, showMessageLogModal, setShowMessageLogModal, currentLogModalActiveTab } = useAppStore(useShallow(state => ({
currentLogItem: state.currentLogItem,
setCurrentLogItem: state.setCurrentLogItem,
showMessageLogModal: state.showMessageLogModal,
setShowMessageLogModal: state.setShowMessageLogModal,
currentLogModalActiveTab: state.currentLogModalActiveTab,
})))
return (
<>
{
showMessageLogModal && (
<MessageLogModal
fixedWidth
width={400}
currentLogItem={currentLogItem}
onCancel={() => {
setCurrentLogItem()
setShowMessageLogModal(false)
}}
defaultTab={currentLogModalActiveTab}
/>
)
}
</>
)
}
const WorkflowPanelOnRight = () => {
const isChatMode = useIsChatMode()
const historyWorkflowData = useStore(s => s.historyWorkflowData)
const showDebugAndPreviewPanel = useStore(s => s.showDebugAndPreviewPanel)
const showChatVariablePanel = useStore(s => s.showChatVariablePanel)
const showGlobalVariablePanel = useStore(s => s.showGlobalVariablePanel)
return (
<>
{
historyWorkflowData && !isChatMode && (
<Record />
)
}
{
historyWorkflowData && isChatMode && (
<ChatRecord />
)
}
{
showDebugAndPreviewPanel && isChatMode && (
<DebugAndPreview />
)
}
{
showDebugAndPreviewPanel && !isChatMode && (
<WorkflowPreview />
)
}
{
showChatVariablePanel && isChatMode && (
<ChatVariablePanel />
)
}
{
showGlobalVariablePanel && (
<GlobalVariablePanel />
)
}
</>
)
}
const WorkflowPanel = () => {
const appDetail = useAppStore(s => s.appDetail)
const versionHistoryPanelProps = useMemo(() => {
const appId = appDetail?.id
return {
getVersionListUrl: `/apps/${appId}/workflows`,
deleteVersionUrl: (versionId: string) => `/apps/${appId}/workflows/${versionId}`,
updateVersionUrl: (versionId: string) => `/apps/${appId}/workflows/${versionId}`,
latestVersionId: appDetail?.workflow?.id,
}
}, [appDetail?.id, appDetail?.workflow?.id])
const panelProps: PanelProps = useMemo(() => {
return {
components: {
left: <WorkflowPanelOnLeft />,
right: <WorkflowPanelOnRight />,
},
versionHistoryPanelProps,
}
}, [versionHistoryPanelProps])
return (
<Panel {...panelProps} />
)
}
export default memo(WorkflowPanel)

View File

@@ -0,0 +1,13 @@
export * from './use-workflow-init'
export * from './use-workflow-template'
export * from './use-nodes-sync-draft'
export * from './use-workflow-run'
export * from './use-workflow-start-run'
export * from './use-is-chat-mode'
export * from './use-available-nodes-meta-data'
export * from './use-workflow-refresh-draft'
export * from './use-get-run-and-trace-url'
export * from './use-DSL'
export * from '../../workflow/hooks/use-fetch-workflow-inspect-vars'
export * from './use-inspect-vars-crud'
export * from './use-configs-map'

View File

@@ -0,0 +1,82 @@
import {
useCallback,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
DSL_EXPORT_CHECK,
} from '@/app/components/workflow/constants'
import { useNodesSyncDraft } from './use-nodes-sync-draft'
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 useDSL = () => {
const { t } = useTranslation()
const { notify } = useToastContext()
const { eventEmitter } = useEventEmitterContextContext()
const [exporting, setExporting] = useState(false)
const { doSyncWorkflowDraft } = useNodesSyncDraft()
const appDetail = useAppStore(s => s.appDetail)
const handleExportDSL = useCallback(async (include = false, workflowId?: string) => {
if (!appDetail)
return
if (exporting)
return
try {
setExporting(true)
await doSyncWorkflowDraft()
const { data } = await exportAppConfig({
appID: appDetail.id,
include,
workflowID: workflowId,
})
const a = document.createElement('a')
const file = new Blob([data], { type: 'application/yaml' })
const url = URL.createObjectURL(file)
a.href = url
a.download = `${appDetail.name}.yml`
a.click()
URL.revokeObjectURL(url)
}
catch {
notify({ type: 'error', message: t('app.exportFailed') })
}
finally {
setExporting(false)
}
}, [appDetail, notify, t, doSyncWorkflowDraft, exporting])
const exportCheck = useCallback(async () => {
if (!appDetail)
return
try {
const workflowDraft = await fetchWorkflowDraft(`/apps/${appDetail?.id}/workflows/draft`)
const list = (workflowDraft.environment_variables || []).filter(env => env.value_type === 'secret')
if (list.length === 0) {
handleExportDSL()
return
}
eventEmitter?.emit({
type: DSL_EXPORT_CHECK,
payload: {
data: list,
},
} as any)
}
catch {
notify({ type: 'error', message: t('app.exportFailed') })
}
}, [appDetail, eventEmitter, handleExportDSL, notify, t])
return {
exportCheck,
handleExportDSL,
}
}

View File

@@ -0,0 +1,68 @@
import { useCallback, useEffect } from 'react'
import { useStoreApi } from 'reactflow'
import { useWorkflowStore } from '@/app/components/workflow/store'
export const useAutoOnboarding = () => {
const store = useStoreApi()
const workflowStore = useWorkflowStore()
const checkAndShowOnboarding = useCallback(() => {
const { getNodes } = store.getState()
const {
showOnboarding,
hasShownOnboarding,
notInitialWorkflow,
setShowOnboarding,
setHasShownOnboarding,
setShouldAutoOpenStartNodeSelector,
} = workflowStore.getState()
// Skip if already showing onboarding or it's the initial workflow creation
if (showOnboarding || notInitialWorkflow)
return
const nodes = getNodes()
// Check if canvas is completely empty (no nodes at all)
// Only trigger onboarding when canvas is completely blank to avoid data loss
const isCompletelyEmpty = nodes.length === 0
// Show onboarding only if canvas is completely empty and we haven't shown it before in this session
if (isCompletelyEmpty && !hasShownOnboarding) {
setShowOnboarding?.(true)
setHasShownOnboarding?.(true)
setShouldAutoOpenStartNodeSelector?.(true)
}
}, [store, workflowStore])
const handleOnboardingClose = useCallback(() => {
const {
setShowOnboarding,
setHasShownOnboarding,
setShouldAutoOpenStartNodeSelector,
hasSelectedStartNode,
setHasSelectedStartNode,
} = workflowStore.getState()
setShowOnboarding?.(false)
setHasShownOnboarding?.(true)
if (hasSelectedStartNode)
setHasSelectedStartNode?.(false)
else
setShouldAutoOpenStartNodeSelector?.(false)
}, [workflowStore])
// Check on mount and when nodes change
useEffect(() => {
// Small delay to ensure the workflow data is loaded
const timer = setTimeout(() => {
checkAndShowOnboarding()
}, 500)
return () => clearTimeout(timer)
}, [checkAndShowOnboarding])
return {
checkAndShowOnboarding,
handleOnboardingClose,
}
}

View File

@@ -0,0 +1,78 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useDocLink } from '@/context/i18n'
import StartDefault from '@/app/components/workflow/nodes/start/default'
import TriggerWebhookDefault from '@/app/components/workflow/nodes/trigger-webhook/default'
import TriggerScheduleDefault from '@/app/components/workflow/nodes/trigger-schedule/default'
import TriggerPluginDefault from '@/app/components/workflow/nodes/trigger-plugin/default'
import EndDefault from '@/app/components/workflow/nodes/end/default'
import AnswerDefault from '@/app/components/workflow/nodes/answer/default'
import { WORKFLOW_COMMON_NODES } from '@/app/components/workflow/constants/node'
import type { AvailableNodesMetaData } from '@/app/components/workflow/hooks-store/store'
import { useIsChatMode } from './use-is-chat-mode'
import { BlockEnum } from '@/app/components/workflow/types'
export const useAvailableNodesMetaData = () => {
const { t } = useTranslation()
const isChatMode = useIsChatMode()
const docLink = useDocLink()
const startNodeMetaData = useMemo(() => ({
...StartDefault,
metaData: {
...StartDefault.metaData,
isUndeletable: isChatMode, // start node is undeletable in chat mode, @use-nodes-interactions: handleNodeDelete function
},
}), [isChatMode])
const mergedNodesMetaData = useMemo(() => [
...WORKFLOW_COMMON_NODES,
startNodeMetaData,
...(
isChatMode
? [AnswerDefault]
: [
EndDefault,
TriggerWebhookDefault,
TriggerScheduleDefault,
TriggerPluginDefault,
]
),
], [isChatMode, startNodeMetaData])
const availableNodesMetaData = useMemo(() => mergedNodesMetaData.map((node) => {
const { metaData } = node
const title = t(`workflow.blocks.${metaData.type}`)
const description = t(`workflow.blocksAbout.${metaData.type}`)
const helpLinkPath = `guides/workflow/node/${metaData.helpLinkUri}`
return {
...node,
metaData: {
...metaData,
title,
description,
helpLinkUri: docLink(helpLinkPath),
},
defaultValue: {
...node.defaultValue,
type: metaData.type,
title,
},
}
}), [mergedNodesMetaData, t, docLink])
const availableNodesMetaDataMap = useMemo(() => availableNodesMetaData.reduce((acc, node) => {
acc![node.metaData.type] = node
return acc
}, {} as AvailableNodesMetaData['nodesMap']), [availableNodesMetaData])
return useMemo(() => {
return {
nodes: availableNodesMetaData,
nodesMap: {
...availableNodesMetaDataMap,
[BlockEnum.VariableAssigner]: availableNodesMetaDataMap?.[BlockEnum.VariableAggregator],
},
}
}, [availableNodesMetaData, availableNodesMetaDataMap])
}

View File

@@ -0,0 +1,16 @@
import { useMemo } from 'react'
import { useStore } from '@/app/components/workflow/store'
import { FlowType } from '@/types/common'
import { useFeatures } from '@/app/components/base/features/hooks'
export const useConfigsMap = () => {
const appId = useStore(s => s.appId)
const fileSettings = useFeatures(s => s.features.file)
return useMemo(() => {
return {
flowId: appId!,
flowType: FlowType.appFlow,
fileSettings,
}
}, [appId])
}

View File

@@ -0,0 +1,18 @@
import { useCallback } from 'react'
import { useWorkflowStore } from '@/app/components/workflow/store'
export const useGetRunAndTraceUrl = () => {
const workflowStore = useWorkflowStore()
const getWorkflowRunAndTraceUrl = useCallback((runId: string) => {
const { appId } = workflowStore.getState()
return {
runUrl: `/apps/${appId}/workflow-runs/${runId}`,
traceUrl: `/apps/${appId}/workflow-runs/${runId}/node-executions`,
}
}, [workflowStore])
return {
getWorkflowRunAndTraceUrl,
}
}

View File

@@ -0,0 +1,13 @@
import { useInspectVarsCrudCommon } from '../../workflow/hooks/use-inspect-vars-crud-common'
import { useConfigsMap } from './use-configs-map'
export const useInspectVarsCrud = () => {
const configsMap = useConfigsMap()
const apis = useInspectVarsCrudCommon({
...configsMap,
})
return {
...apis,
}
}

View File

@@ -0,0 +1,8 @@
import { useStore as useAppStore } from '@/app/components/app/store'
import { AppModeEnum } from '@/types/app'
export const useIsChatMode = () => {
const appDetail = useAppStore(s => s.appDetail)
return appDetail?.mode === AppModeEnum.ADVANCED_CHAT
}

View File

@@ -0,0 +1,135 @@
import { useCallback } from 'react'
import { produce } from 'immer'
import { useStoreApi } from 'reactflow'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { useNodesReadOnly } from '@/app/components/workflow/hooks/use-workflow'
import { useSerialAsyncCallback } from '@/app/components/workflow/hooks/use-serial-async-callback'
import { syncWorkflowDraft } from '@/service/workflow'
import { useFeaturesStore } from '@/app/components/base/features/hooks'
import { API_PREFIX } from '@/config'
import { useWorkflowRefreshDraft } from '.'
export const useNodesSyncDraft = () => {
const store = useStoreApi()
const workflowStore = useWorkflowStore()
const featuresStore = useFeaturesStore()
const { getNodesReadOnly } = useNodesReadOnly()
const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft()
const getPostParams = useCallback(() => {
const {
getNodes,
edges,
transform,
} = store.getState()
const nodes = getNodes().filter(node => !node.data?._isTempNode)
const [x, y, zoom] = transform
const {
appId,
conversationVariables,
environmentVariables,
syncWorkflowDraftHash,
isWorkflowDataLoaded,
} = workflowStore.getState()
if (!appId || !isWorkflowDataLoaded)
return null
const features = featuresStore!.getState().features
const producedNodes = produce(nodes, (draft) => {
draft.forEach((node) => {
Object.keys(node.data).forEach((key) => {
if (key.startsWith('_'))
delete node.data[key]
})
})
})
const producedEdges = produce(edges.filter(edge => !edge.data?._isTemp), (draft) => {
draft.forEach((edge) => {
Object.keys(edge.data).forEach((key) => {
if (key.startsWith('_'))
delete edge.data[key]
})
})
})
const viewport = { x, y, zoom }
return {
url: `/apps/${appId}/workflows/draft`,
params: {
graph: {
nodes: producedNodes,
edges: producedEdges,
viewport,
},
features: {
opening_statement: features.opening?.enabled ? (features.opening?.opening_statement || '') : '',
suggested_questions: features.opening?.enabled ? (features.opening?.suggested_questions || []) : [],
suggested_questions_after_answer: features.suggested,
text_to_speech: features.text2speech,
speech_to_text: features.speech2text,
retriever_resource: features.citation,
sensitive_word_avoidance: features.moderation,
file_upload: features.file,
},
environment_variables: environmentVariables,
conversation_variables: conversationVariables,
hash: syncWorkflowDraftHash,
},
}
}, [store, featuresStore, workflowStore])
const syncWorkflowDraftWhenPageClose = useCallback(() => {
if (getNodesReadOnly())
return
const postParams = getPostParams()
if (postParams)
navigator.sendBeacon(`${API_PREFIX}${postParams.url}`, JSON.stringify(postParams.params))
}, [getPostParams, getNodesReadOnly])
const performSync = useCallback(async (
notRefreshWhenSyncError?: boolean,
callback?: {
onSuccess?: () => void
onError?: () => void
onSettled?: () => void
},
) => {
if (getNodesReadOnly())
return
const postParams = getPostParams()
if (postParams) {
const {
setSyncWorkflowDraftHash,
setDraftUpdatedAt,
} = workflowStore.getState()
try {
const res = await syncWorkflowDraft(postParams)
setSyncWorkflowDraftHash(res.hash)
setDraftUpdatedAt(res.updated_at)
callback?.onSuccess?.()
}
catch (error: any) {
if (error && error.json && !error.bodyUsed) {
error.json().then((err: any) => {
if (err.code === 'draft_workflow_not_sync' && !notRefreshWhenSyncError)
handleRefreshWorkflowDraft()
})
}
callback?.onError?.()
}
finally {
callback?.onSettled?.()
}
}
}, [workflowStore, getPostParams, getNodesReadOnly, handleRefreshWorkflowDraft])
const doSyncWorkflowDraft = useSerialAsyncCallback(performSync, getNodesReadOnly)
return {
doSyncWorkflowDraft,
syncWorkflowDraftWhenPageClose,
}
}

View File

@@ -0,0 +1,154 @@
import {
useCallback,
useEffect,
useState,
} from 'react'
import {
useStore,
useWorkflowStore,
} from '@/app/components/workflow/store'
import { useWorkflowTemplate } from './use-workflow-template'
import { useStore as useAppStore } from '@/app/components/app/store'
import {
fetchNodesDefaultConfigs,
fetchPublishedWorkflow,
fetchWorkflowDraft,
syncWorkflowDraft,
} from '@/service/workflow'
import type { FetchWorkflowDraftResponse } from '@/types/workflow'
import { useWorkflowConfig } from '@/service/use-workflow'
import type { FileUploadConfigResponse } from '@/models/common'
import type { Edge, Node } from '@/app/components/workflow/types'
import { BlockEnum } from '@/app/components/workflow/types'
import { AppModeEnum } from '@/types/app'
const hasConnectedUserInput = (nodes: Node[] = [], edges: Edge[] = []): boolean => {
const startNodeIds = nodes
.filter(node => node?.data?.type === BlockEnum.Start)
.map(node => node.id)
if (!startNodeIds.length)
return false
return edges.some(edge => startNodeIds.includes(edge.source))
}
export const useWorkflowInit = () => {
const workflowStore = useWorkflowStore()
const {
nodes: nodesTemplate,
edges: edgesTemplate,
} = useWorkflowTemplate()
const appDetail = useAppStore(state => state.appDetail)!
const setSyncWorkflowDraftHash = useStore(s => s.setSyncWorkflowDraftHash)
const [data, setData] = useState<FetchWorkflowDraftResponse>()
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
workflowStore.setState({ appId: appDetail.id, appName: appDetail.name })
}, [appDetail.id, workflowStore])
const handleUpdateWorkflowFileUploadConfig = useCallback((config: FileUploadConfigResponse) => {
const { setFileUploadConfig } = workflowStore.getState()
setFileUploadConfig(config)
}, [workflowStore])
const {
data: fileUploadConfigResponse,
isLoading: isFileUploadConfigLoading,
} = useWorkflowConfig('/files/upload', handleUpdateWorkflowFileUploadConfig)
const handleGetInitialWorkflowData = useCallback(async () => {
try {
const res = await fetchWorkflowDraft(`/apps/${appDetail.id}/workflows/draft`)
setData(res)
workflowStore.setState({
envSecrets: (res.environment_variables || []).filter(env => env.value_type === 'secret').reduce((acc, env) => {
acc[env.id] = env.value
return acc
}, {} as Record<string, string>),
environmentVariables: res.environment_variables?.map(env => env.value_type === 'secret' ? { ...env, value: '[__HIDDEN__]' } : env) || [],
conversationVariables: res.conversation_variables || [],
isWorkflowDataLoaded: true,
})
setSyncWorkflowDraftHash(res.hash)
setIsLoading(false)
}
catch (error: any) {
if (error && error.json && !error.bodyUsed && appDetail) {
error.json().then((err: any) => {
if (err.code === 'draft_workflow_not_exist') {
const isAdvancedChat = appDetail.mode === AppModeEnum.ADVANCED_CHAT
workflowStore.setState({
notInitialWorkflow: true,
showOnboarding: !isAdvancedChat,
shouldAutoOpenStartNodeSelector: !isAdvancedChat,
hasShownOnboarding: false,
})
const nodesData = isAdvancedChat ? nodesTemplate : []
const edgesData = isAdvancedChat ? edgesTemplate : []
syncWorkflowDraft({
url: `/apps/${appDetail.id}/workflows/draft`,
params: {
graph: {
nodes: nodesData,
edges: edgesData,
},
features: {
retriever_resource: { enabled: true },
},
environment_variables: [],
conversation_variables: [],
},
}).then((res) => {
workflowStore.getState().setDraftUpdatedAt(res.updated_at)
handleGetInitialWorkflowData()
})
}
})
}
}
}, [appDetail, nodesTemplate, edgesTemplate, workflowStore, setSyncWorkflowDraftHash])
useEffect(() => {
handleGetInitialWorkflowData()
}, [])
const handleFetchPreloadData = useCallback(async () => {
try {
const nodesDefaultConfigsData = await fetchNodesDefaultConfigs(`/apps/${appDetail?.id}/workflows/default-workflow-block-configs`)
const publishedWorkflow = await fetchPublishedWorkflow(`/apps/${appDetail?.id}/workflows/publish`)
workflowStore.setState({
nodesDefaultConfigs: nodesDefaultConfigsData.reduce((acc, block) => {
if (!acc[block.type])
acc[block.type] = { ...block.config }
return acc
}, {} as Record<string, any>),
})
workflowStore.getState().setPublishedAt(publishedWorkflow?.created_at)
const graph = publishedWorkflow?.graph
workflowStore.getState().setLastPublishedHasUserInput(
hasConnectedUserInput(graph?.nodes, graph?.edges),
)
}
catch (e) {
console.error(e)
workflowStore.getState().setLastPublishedHasUserInput(false)
}
}, [workflowStore, appDetail])
useEffect(() => {
handleFetchPreloadData()
}, [handleFetchPreloadData])
useEffect(() => {
if (data) {
workflowStore.getState().setDraftUpdatedAt(data.updated_at)
workflowStore.getState().setToolPublished(data.tool_published)
}
}, [data, workflowStore])
return {
data,
isLoading: isLoading || isFileUploadConfigLoading,
fileUploadConfigResponse,
}
}

View File

@@ -0,0 +1,61 @@
import { useCallback } from 'react'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { fetchWorkflowDraft } from '@/service/workflow'
import type { WorkflowDataUpdater } from '@/app/components/workflow/types'
import { useWorkflowUpdate } from '@/app/components/workflow/hooks'
export const useWorkflowRefreshDraft = () => {
const workflowStore = useWorkflowStore()
const { handleUpdateWorkflowCanvas } = useWorkflowUpdate()
const handleRefreshWorkflowDraft = useCallback(() => {
const {
appId,
setSyncWorkflowDraftHash,
setIsSyncingWorkflowDraft,
setEnvironmentVariables,
setEnvSecrets,
setConversationVariables,
setIsWorkflowDataLoaded,
isWorkflowDataLoaded,
debouncedSyncWorkflowDraft,
} = workflowStore.getState()
if (debouncedSyncWorkflowDraft && typeof (debouncedSyncWorkflowDraft as any).cancel === 'function')
(debouncedSyncWorkflowDraft as any).cancel()
const wasLoaded = isWorkflowDataLoaded
if (wasLoaded)
setIsWorkflowDataLoaded(false)
setIsSyncingWorkflowDraft(true)
fetchWorkflowDraft(`/apps/${appId}/workflows/draft`)
.then((response) => {
// Ensure we have a valid workflow structure with viewport
const workflowData: WorkflowDataUpdater = {
nodes: response.graph?.nodes || [],
edges: response.graph?.edges || [],
viewport: response.graph?.viewport || { x: 0, y: 0, zoom: 1 },
}
handleUpdateWorkflowCanvas(workflowData)
setSyncWorkflowDraftHash(response.hash)
setEnvSecrets((response.environment_variables || []).filter(env => env.value_type === 'secret').reduce((acc, env) => {
acc[env.id] = env.value
return acc
}, {} as Record<string, string>))
setEnvironmentVariables(response.environment_variables?.map(env => env.value_type === 'secret' ? { ...env, value: '[__HIDDEN__]' } : env) || [])
setConversationVariables(response.conversation_variables || [])
setIsWorkflowDataLoaded(true)
})
.catch(() => {
if (wasLoaded)
setIsWorkflowDataLoaded(true)
})
.finally(() => {
setIsSyncingWorkflowDraft(false)
})
}, [handleUpdateWorkflowCanvas, workflowStore])
return {
handleRefreshWorkflowDraft,
}
}

View File

@@ -0,0 +1,764 @@
import { useCallback, useRef } from 'react'
import {
useReactFlow,
useStoreApi,
} from 'reactflow'
import { produce } from 'immer'
import { v4 as uuidV4 } from 'uuid'
import { usePathname } from 'next/navigation'
import { useWorkflowStore } from '@/app/components/workflow/store'
import type { Node } from '@/app/components/workflow/types'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { useWorkflowUpdate } from '@/app/components/workflow/hooks/use-workflow-interactions'
import { useWorkflowRunEvent } from '@/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event'
import { useStore as useAppStore } from '@/app/components/app/store'
import type { IOtherOptions } from '@/service/base'
import Toast from '@/app/components/base/toast'
import { handleStream, ssePost } from '@/service/base'
import { stopWorkflowRun } from '@/service/workflow'
import { useFeaturesStore } from '@/app/components/base/features/hooks'
import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player.manager'
import type AudioPlayer from '@/app/components/base/audio-btn/audio'
import type { VersionHistory } from '@/types/workflow'
import { noop } from 'lodash-es'
import { useNodesSyncDraft } from './use-nodes-sync-draft'
import { useInvalidAllLastRun } from '@/service/use-workflow'
import { useSetWorkflowVarsWithValue } from '../../workflow/hooks/use-fetch-workflow-inspect-vars'
import { useConfigsMap } from './use-configs-map'
import { post } from '@/service/base'
import { ContentType } from '@/service/fetch'
import { TriggerType } from '@/app/components/workflow/header/test-run-menu'
import { AppModeEnum } from '@/types/app'
type HandleRunMode = TriggerType
type HandleRunOptions = {
mode?: HandleRunMode
scheduleNodeId?: string
webhookNodeId?: string
pluginNodeId?: string
allNodeIds?: string[]
}
type DebuggableTriggerType = Exclude<TriggerType, TriggerType.UserInput>
const controllerKeyMap: Record<DebuggableTriggerType, string> = {
[TriggerType.Webhook]: '__webhookDebugAbortController',
[TriggerType.Plugin]: '__pluginDebugAbortController',
[TriggerType.All]: '__allTriggersDebugAbortController',
[TriggerType.Schedule]: '__scheduleDebugAbortController',
}
const debugLabelMap: Record<DebuggableTriggerType, string> = {
[TriggerType.Webhook]: 'Webhook',
[TriggerType.Plugin]: 'Plugin',
[TriggerType.All]: 'All',
[TriggerType.Schedule]: 'Schedule',
}
export const useWorkflowRun = () => {
const store = useStoreApi()
const workflowStore = useWorkflowStore()
const reactflow = useReactFlow()
const featuresStore = useFeaturesStore()
const { doSyncWorkflowDraft } = useNodesSyncDraft()
const { handleUpdateWorkflowCanvas } = useWorkflowUpdate()
const pathname = usePathname()
const configsMap = useConfigsMap()
const { flowId, flowType } = configsMap
const invalidAllLastRun = useInvalidAllLastRun(flowType, flowId)
const { fetchInspectVars } = useSetWorkflowVarsWithValue({
...configsMap,
})
const abortControllerRef = useRef<AbortController | null>(null)
const {
handleWorkflowStarted,
handleWorkflowFinished,
handleWorkflowFailed,
handleWorkflowNodeStarted,
handleWorkflowNodeFinished,
handleWorkflowNodeIterationStarted,
handleWorkflowNodeIterationNext,
handleWorkflowNodeIterationFinished,
handleWorkflowNodeLoopStarted,
handleWorkflowNodeLoopNext,
handleWorkflowNodeLoopFinished,
handleWorkflowNodeRetry,
handleWorkflowAgentLog,
handleWorkflowTextChunk,
handleWorkflowTextReplace,
} = useWorkflowRunEvent()
const handleBackupDraft = useCallback(() => {
const {
getNodes,
edges,
} = store.getState()
const { getViewport } = reactflow
const {
backupDraft,
setBackupDraft,
environmentVariables,
} = workflowStore.getState()
const { features } = featuresStore!.getState()
if (!backupDraft) {
setBackupDraft({
nodes: getNodes(),
edges,
viewport: getViewport(),
features,
environmentVariables,
})
doSyncWorkflowDraft()
}
}, [reactflow, workflowStore, store, featuresStore, doSyncWorkflowDraft])
const handleLoadBackupDraft = useCallback(() => {
const {
backupDraft,
setBackupDraft,
setEnvironmentVariables,
} = workflowStore.getState()
if (backupDraft) {
const {
nodes,
edges,
viewport,
features,
environmentVariables,
} = backupDraft
handleUpdateWorkflowCanvas({
nodes,
edges,
viewport,
})
setEnvironmentVariables(environmentVariables)
featuresStore!.setState({ features })
setBackupDraft(undefined)
}
}, [handleUpdateWorkflowCanvas, workflowStore, featuresStore])
const handleRun = useCallback(async (
params: any,
callback?: IOtherOptions,
options?: HandleRunOptions,
) => {
const runMode: HandleRunMode = options?.mode ?? TriggerType.UserInput
const resolvedParams = params ?? {}
const {
getNodes,
setNodes,
} = store.getState()
const newNodes = produce(getNodes(), (draft: Node[]) => {
draft.forEach((node) => {
node.data.selected = false
node.data._runningStatus = undefined
})
})
setNodes(newNodes)
await doSyncWorkflowDraft()
const {
onWorkflowStarted,
onWorkflowFinished,
onNodeStarted,
onNodeFinished,
onIterationStart,
onIterationNext,
onIterationFinish,
onLoopStart,
onLoopNext,
onLoopFinish,
onNodeRetry,
onAgentLog,
onError,
onCompleted,
...restCallback
} = callback || {}
workflowStore.setState({ historyWorkflowData: undefined })
const appDetail = useAppStore.getState().appDetail
const workflowContainer = document.getElementById('workflow-container')
const {
clientWidth,
clientHeight,
} = workflowContainer!
const isInWorkflowDebug = appDetail?.mode === AppModeEnum.WORKFLOW
let url = ''
if (runMode === TriggerType.Plugin || runMode === TriggerType.Webhook || runMode === TriggerType.Schedule) {
if (!appDetail?.id) {
console.error('handleRun: missing app id for trigger plugin run')
return
}
url = `/apps/${appDetail.id}/workflows/draft/trigger/run`
}
else if (runMode === TriggerType.All) {
if (!appDetail?.id) {
console.error('handleRun: missing app id for trigger run all')
return
}
url = `/apps/${appDetail.id}/workflows/draft/trigger/run-all`
}
else if (appDetail?.mode === AppModeEnum.ADVANCED_CHAT) {
url = `/apps/${appDetail.id}/advanced-chat/workflows/draft/run`
}
else if (isInWorkflowDebug && appDetail?.id) {
url = `/apps/${appDetail.id}/workflows/draft/run`
}
let requestBody = {}
if (runMode === TriggerType.Schedule)
requestBody = { node_id: options?.scheduleNodeId }
else if (runMode === TriggerType.Webhook)
requestBody = { node_id: options?.webhookNodeId }
else if (runMode === TriggerType.Plugin)
requestBody = { node_id: options?.pluginNodeId }
else if (runMode === TriggerType.All)
requestBody = { node_ids: options?.allNodeIds }
else
requestBody = resolvedParams
if (!url)
return
if (runMode === TriggerType.Schedule && !options?.scheduleNodeId) {
console.error('handleRun: schedule trigger run requires node id')
return
}
if (runMode === TriggerType.Webhook && !options?.webhookNodeId) {
console.error('handleRun: webhook trigger run requires node id')
return
}
if (runMode === TriggerType.Plugin && !options?.pluginNodeId) {
console.error('handleRun: plugin trigger run requires node id')
return
}
if (runMode === TriggerType.All && !options?.allNodeIds && options?.allNodeIds?.length === 0) {
console.error('handleRun: all trigger run requires node ids')
return
}
abortControllerRef.current?.abort()
abortControllerRef.current = null
const {
setWorkflowRunningData,
setIsListening,
setShowVariableInspectPanel,
setListeningTriggerType,
setListeningTriggerNodeIds,
setListeningTriggerIsAll,
setListeningTriggerNodeId,
} = workflowStore.getState()
if (
runMode === TriggerType.Webhook
|| runMode === TriggerType.Plugin
|| runMode === TriggerType.All
|| runMode === TriggerType.Schedule
) {
setIsListening(true)
setShowVariableInspectPanel(true)
setListeningTriggerIsAll(runMode === TriggerType.All)
if (runMode === TriggerType.All)
setListeningTriggerNodeIds(options?.allNodeIds ?? [])
else if (runMode === TriggerType.Webhook && options?.webhookNodeId)
setListeningTriggerNodeIds([options.webhookNodeId])
else if (runMode === TriggerType.Schedule && options?.scheduleNodeId)
setListeningTriggerNodeIds([options.scheduleNodeId])
else if (runMode === TriggerType.Plugin && options?.pluginNodeId)
setListeningTriggerNodeIds([options.pluginNodeId])
else
setListeningTriggerNodeIds([])
setWorkflowRunningData({
result: {
status: WorkflowRunningStatus.Running,
inputs_truncated: false,
process_data_truncated: false,
outputs_truncated: false,
},
tracing: [],
resultText: '',
})
}
else {
setIsListening(false)
setListeningTriggerType(null)
setListeningTriggerNodeId(null)
setListeningTriggerNodeIds([])
setListeningTriggerIsAll(false)
setWorkflowRunningData({
result: {
status: WorkflowRunningStatus.Running,
inputs_truncated: false,
process_data_truncated: false,
outputs_truncated: false,
},
tracing: [],
resultText: '',
})
}
let ttsUrl = ''
let ttsIsPublic = false
if (resolvedParams.token) {
ttsUrl = '/text-to-audio'
ttsIsPublic = true
}
else if (resolvedParams.appId) {
if (pathname.search('explore/installed') > -1)
ttsUrl = `/installed-apps/${resolvedParams.appId}/text-to-audio`
else
ttsUrl = `/apps/${resolvedParams.appId}/text-to-audio`
}
// Lazy initialization: Only create AudioPlayer when TTS is actually needed
// This prevents opening audio channel unnecessarily
let player: AudioPlayer | null = null
const getOrCreatePlayer = () => {
if (!player)
player = AudioPlayerManager.getInstance().getAudioPlayer(ttsUrl, ttsIsPublic, uuidV4(), 'none', 'none', noop)
return player
}
const clearAbortController = () => {
abortControllerRef.current = null
delete (window as any).__webhookDebugAbortController
delete (window as any).__pluginDebugAbortController
delete (window as any).__scheduleDebugAbortController
delete (window as any).__allTriggersDebugAbortController
}
const clearListeningState = () => {
const state = workflowStore.getState()
state.setIsListening(false)
state.setListeningTriggerType(null)
state.setListeningTriggerNodeId(null)
state.setListeningTriggerNodeIds([])
state.setListeningTriggerIsAll(false)
}
const wrappedOnError = (params: any) => {
clearAbortController()
handleWorkflowFailed()
clearListeningState()
if (onError)
onError(params)
}
const wrappedOnCompleted: IOtherOptions['onCompleted'] = async (hasError?: boolean, errorMessage?: string) => {
clearAbortController()
clearListeningState()
if (onCompleted)
onCompleted(hasError, errorMessage)
}
const baseSseOptions: IOtherOptions = {
...restCallback,
onWorkflowStarted: (params) => {
const state = workflowStore.getState()
if (state.workflowRunningData) {
state.setWorkflowRunningData(produce(state.workflowRunningData, (draft) => {
draft.resultText = ''
}))
}
handleWorkflowStarted(params)
if (onWorkflowStarted)
onWorkflowStarted(params)
},
onWorkflowFinished: (params) => {
clearListeningState()
handleWorkflowFinished(params)
if (onWorkflowFinished)
onWorkflowFinished(params)
if (isInWorkflowDebug) {
fetchInspectVars({})
invalidAllLastRun()
}
},
onNodeStarted: (params) => {
handleWorkflowNodeStarted(
params,
{
clientWidth,
clientHeight,
},
)
if (onNodeStarted)
onNodeStarted(params)
},
onNodeFinished: (params) => {
handleWorkflowNodeFinished(params)
if (onNodeFinished)
onNodeFinished(params)
},
onIterationStart: (params) => {
handleWorkflowNodeIterationStarted(
params,
{
clientWidth,
clientHeight,
},
)
if (onIterationStart)
onIterationStart(params)
},
onIterationNext: (params) => {
handleWorkflowNodeIterationNext(params)
if (onIterationNext)
onIterationNext(params)
},
onIterationFinish: (params) => {
handleWorkflowNodeIterationFinished(params)
if (onIterationFinish)
onIterationFinish(params)
},
onLoopStart: (params) => {
handleWorkflowNodeLoopStarted(
params,
{
clientWidth,
clientHeight,
},
)
if (onLoopStart)
onLoopStart(params)
},
onLoopNext: (params) => {
handleWorkflowNodeLoopNext(params)
if (onLoopNext)
onLoopNext(params)
},
onLoopFinish: (params) => {
handleWorkflowNodeLoopFinished(params)
if (onLoopFinish)
onLoopFinish(params)
},
onNodeRetry: (params) => {
handleWorkflowNodeRetry(params)
if (onNodeRetry)
onNodeRetry(params)
},
onAgentLog: (params) => {
handleWorkflowAgentLog(params)
if (onAgentLog)
onAgentLog(params)
},
onTextChunk: (params) => {
handleWorkflowTextChunk(params)
},
onTextReplace: (params) => {
handleWorkflowTextReplace(params)
},
onTTSChunk: (messageId: string, audio: string) => {
if (!audio || audio === '')
return
const audioPlayer = getOrCreatePlayer()
if (audioPlayer) {
audioPlayer.playAudioWithAudio(audio, true)
AudioPlayerManager.getInstance().resetMsgId(messageId)
}
},
onTTSEnd: (messageId: string, audio: string) => {
const audioPlayer = getOrCreatePlayer()
if (audioPlayer)
audioPlayer.playAudioWithAudio(audio, false)
},
onError: wrappedOnError,
onCompleted: wrappedOnCompleted,
}
const waitWithAbort = (signal: AbortSignal, delay: number) => new Promise<void>((resolve) => {
const timer = window.setTimeout(resolve, delay)
signal.addEventListener('abort', () => {
clearTimeout(timer)
resolve()
}, { once: true })
})
const runTriggerDebug = async (debugType: DebuggableTriggerType) => {
const controller = new AbortController()
abortControllerRef.current = controller
const controllerKey = controllerKeyMap[debugType]
; (window as any)[controllerKey] = controller
const debugLabel = debugLabelMap[debugType]
const poll = async (): Promise<void> => {
try {
const response = await post<Response>(url, {
body: requestBody,
signal: controller.signal,
}, {
needAllResponseContent: true,
})
if (controller.signal.aborted)
return
if (!response) {
const message = `${debugLabel} debug request failed`
Toast.notify({ type: 'error', message })
clearAbortController()
return
}
const contentType = response.headers.get('content-type') || ''
if (contentType.includes(ContentType.json)) {
let data: any = null
try {
data = await response.json()
}
catch (jsonError) {
console.error(`handleRun: ${debugLabel.toLowerCase()} debug response parse error`, jsonError)
Toast.notify({ type: 'error', message: `${debugLabel} debug request failed` })
clearAbortController()
clearListeningState()
return
}
if (controller.signal.aborted)
return
if (data?.status === 'waiting') {
const delay = Number(data.retry_in) || 2000
await waitWithAbort(controller.signal, delay)
if (controller.signal.aborted)
return
await poll()
return
}
const errorMessage = data?.message || `${debugLabel} debug failed`
Toast.notify({ type: 'error', message: errorMessage })
clearAbortController()
setWorkflowRunningData({
result: {
status: WorkflowRunningStatus.Failed,
error: errorMessage,
inputs_truncated: false,
process_data_truncated: false,
outputs_truncated: false,
},
tracing: [],
})
clearListeningState()
return
}
clearListeningState()
handleStream(
response,
baseSseOptions.onData ?? noop,
baseSseOptions.onCompleted,
baseSseOptions.onThought,
baseSseOptions.onMessageEnd,
baseSseOptions.onMessageReplace,
baseSseOptions.onFile,
baseSseOptions.onWorkflowStarted,
baseSseOptions.onWorkflowFinished,
baseSseOptions.onNodeStarted,
baseSseOptions.onNodeFinished,
baseSseOptions.onIterationStart,
baseSseOptions.onIterationNext,
baseSseOptions.onIterationFinish,
baseSseOptions.onLoopStart,
baseSseOptions.onLoopNext,
baseSseOptions.onLoopFinish,
baseSseOptions.onNodeRetry,
baseSseOptions.onParallelBranchStarted,
baseSseOptions.onParallelBranchFinished,
baseSseOptions.onTextChunk,
baseSseOptions.onTTSChunk,
baseSseOptions.onTTSEnd,
baseSseOptions.onTextReplace,
baseSseOptions.onAgentLog,
baseSseOptions.onDataSourceNodeProcessing,
baseSseOptions.onDataSourceNodeCompleted,
baseSseOptions.onDataSourceNodeError,
)
}
catch (error) {
if (controller.signal.aborted)
return
if (error instanceof Response) {
const data = await error.clone().json() as Record<string, any>
const { error: respError } = data || {}
Toast.notify({ type: 'error', message: respError })
clearAbortController()
setWorkflowRunningData({
result: {
status: WorkflowRunningStatus.Failed,
error: respError,
inputs_truncated: false,
process_data_truncated: false,
outputs_truncated: false,
},
tracing: [],
})
}
clearListeningState()
}
}
await poll()
}
if (runMode === TriggerType.Schedule) {
await runTriggerDebug(TriggerType.Schedule)
return
}
if (runMode === TriggerType.Webhook) {
await runTriggerDebug(TriggerType.Webhook)
return
}
if (runMode === TriggerType.Plugin) {
await runTriggerDebug(TriggerType.Plugin)
return
}
if (runMode === TriggerType.All) {
await runTriggerDebug(TriggerType.All)
return
}
ssePost(
url,
{
body: requestBody,
},
{
...baseSseOptions,
getAbortController: (controller: AbortController) => {
abortControllerRef.current = controller
},
},
)
}, [store, doSyncWorkflowDraft, workflowStore, pathname, handleWorkflowStarted, handleWorkflowFinished, fetchInspectVars, invalidAllLastRun, handleWorkflowFailed, handleWorkflowNodeStarted, handleWorkflowNodeFinished, handleWorkflowNodeIterationStarted, handleWorkflowNodeIterationNext, handleWorkflowNodeIterationFinished, handleWorkflowNodeLoopStarted, handleWorkflowNodeLoopNext, handleWorkflowNodeLoopFinished, handleWorkflowNodeRetry, handleWorkflowAgentLog, handleWorkflowTextChunk, handleWorkflowTextReplace],
)
const handleStopRun = useCallback((taskId: string) => {
const setStoppedState = () => {
const {
setWorkflowRunningData,
setIsListening,
setShowVariableInspectPanel,
setListeningTriggerType,
setListeningTriggerNodeId,
} = workflowStore.getState()
setWorkflowRunningData({
result: {
status: WorkflowRunningStatus.Stopped,
inputs_truncated: false,
process_data_truncated: false,
outputs_truncated: false,
},
tracing: [],
resultText: '',
})
setIsListening(false)
setListeningTriggerType(null)
setListeningTriggerNodeId(null)
setShowVariableInspectPanel(true)
}
if (taskId) {
const appId = useAppStore.getState().appDetail?.id
stopWorkflowRun(`/apps/${appId}/workflow-runs/tasks/${taskId}/stop`)
setStoppedState()
return
}
// Try webhook debug controller from global variable first
const webhookController = (window as any).__webhookDebugAbortController
if (webhookController)
webhookController.abort()
const pluginController = (window as any).__pluginDebugAbortController
if (pluginController)
pluginController.abort()
const scheduleController = (window as any).__scheduleDebugAbortController
if (scheduleController)
scheduleController.abort()
const allTriggerController = (window as any).__allTriggersDebugAbortController
if (allTriggerController)
allTriggerController.abort()
// Also try the ref
if (abortControllerRef.current)
abortControllerRef.current.abort()
abortControllerRef.current = null
setStoppedState()
}, [workflowStore])
const handleRestoreFromPublishedWorkflow = useCallback((publishedWorkflow: VersionHistory) => {
const nodes = publishedWorkflow.graph.nodes.map(node => ({ ...node, selected: false, data: { ...node.data, selected: false } }))
const edges = publishedWorkflow.graph.edges
const viewport = publishedWorkflow.graph.viewport!
handleUpdateWorkflowCanvas({
nodes,
edges,
viewport,
})
const mappedFeatures = {
opening: {
enabled: !!publishedWorkflow.features.opening_statement || !!publishedWorkflow.features.suggested_questions.length,
opening_statement: publishedWorkflow.features.opening_statement,
suggested_questions: publishedWorkflow.features.suggested_questions,
},
suggested: publishedWorkflow.features.suggested_questions_after_answer,
text2speech: publishedWorkflow.features.text_to_speech,
speech2text: publishedWorkflow.features.speech_to_text,
citation: publishedWorkflow.features.retriever_resource,
moderation: publishedWorkflow.features.sensitive_word_avoidance,
file: publishedWorkflow.features.file_upload,
}
featuresStore?.setState({ features: mappedFeatures })
workflowStore.getState().setEnvironmentVariables(publishedWorkflow.environment_variables || [])
}, [featuresStore, handleUpdateWorkflowCanvas, workflowStore])
return {
handleBackupDraft,
handleLoadBackupDraft,
handleRun,
handleStopRun,
handleRestoreFromPublishedWorkflow,
}
}

View File

@@ -0,0 +1,302 @@
import { useCallback } from 'react'
import { useStoreApi } from 'reactflow'
import { useWorkflowStore } from '@/app/components/workflow/store'
import {
BlockEnum,
WorkflowRunningStatus,
} from '@/app/components/workflow/types'
import { useWorkflowInteractions } from '@/app/components/workflow/hooks'
import { useFeaturesStore } from '@/app/components/base/features/hooks'
import {
useIsChatMode,
useNodesSyncDraft,
useWorkflowRun,
} from '.'
import { TriggerType } from '@/app/components/workflow/header/test-run-menu'
export const useWorkflowStartRun = () => {
const store = useStoreApi()
const workflowStore = useWorkflowStore()
const featuresStore = useFeaturesStore()
const isChatMode = useIsChatMode()
const { handleCancelDebugAndPreviewPanel } = useWorkflowInteractions()
const { handleRun } = useWorkflowRun()
const { doSyncWorkflowDraft } = useNodesSyncDraft()
const handleWorkflowStartRunInWorkflow = useCallback(async () => {
const {
workflowRunningData,
} = workflowStore.getState()
if (workflowRunningData?.result.status === WorkflowRunningStatus.Running)
return
const { getNodes } = store.getState()
const nodes = getNodes()
const startNode = nodes.find(node => node.data.type === BlockEnum.Start)
const startVariables = startNode?.data.variables || []
const fileSettings = featuresStore!.getState().features.file
const {
showDebugAndPreviewPanel,
setShowDebugAndPreviewPanel,
setShowInputsPanel,
setShowEnvPanel,
setShowGlobalVariablePanel,
} = workflowStore.getState()
setShowEnvPanel(false)
setShowGlobalVariablePanel(false)
if (showDebugAndPreviewPanel) {
handleCancelDebugAndPreviewPanel()
return
}
if (!startVariables.length && !fileSettings?.image?.enabled) {
await doSyncWorkflowDraft()
handleRun({ inputs: {}, files: [] })
setShowDebugAndPreviewPanel(true)
setShowInputsPanel(false)
}
else {
setShowDebugAndPreviewPanel(true)
setShowInputsPanel(true)
}
}, [store, workflowStore, featuresStore, handleCancelDebugAndPreviewPanel, handleRun, doSyncWorkflowDraft])
const handleWorkflowTriggerScheduleRunInWorkflow = useCallback(async (nodeId?: string) => {
if (!nodeId)
return
const {
workflowRunningData,
showDebugAndPreviewPanel,
setShowDebugAndPreviewPanel,
setShowInputsPanel,
setShowEnvPanel,
setShowGlobalVariablePanel,
setListeningTriggerType,
setListeningTriggerNodeId,
setListeningTriggerNodeIds,
setListeningTriggerIsAll,
} = workflowStore.getState()
if (workflowRunningData?.result.status === WorkflowRunningStatus.Running)
return
const { getNodes } = store.getState()
const nodes = getNodes()
const scheduleNode = nodes.find(node => node.id === nodeId && node.data.type === BlockEnum.TriggerSchedule)
if (!scheduleNode) {
console.warn('handleWorkflowTriggerScheduleRunInWorkflow: schedule node not found', nodeId)
return
}
setShowEnvPanel(false)
setShowGlobalVariablePanel(false)
if (showDebugAndPreviewPanel) {
handleCancelDebugAndPreviewPanel()
return
}
setListeningTriggerType(BlockEnum.TriggerSchedule)
setListeningTriggerNodeId(nodeId)
setListeningTriggerNodeIds([nodeId])
setListeningTriggerIsAll(false)
await doSyncWorkflowDraft()
handleRun(
{},
undefined,
{
mode: TriggerType.Schedule,
scheduleNodeId: nodeId,
},
)
setShowDebugAndPreviewPanel(true)
setShowInputsPanel(false)
}, [store, workflowStore, handleCancelDebugAndPreviewPanel, handleRun, doSyncWorkflowDraft])
const handleWorkflowTriggerWebhookRunInWorkflow = useCallback(async ({ nodeId }: { nodeId: string }) => {
if (!nodeId)
return
const {
workflowRunningData,
showDebugAndPreviewPanel,
setShowDebugAndPreviewPanel,
setShowInputsPanel,
setShowEnvPanel,
setShowGlobalVariablePanel,
setListeningTriggerType,
setListeningTriggerNodeId,
setListeningTriggerNodeIds,
setListeningTriggerIsAll,
} = workflowStore.getState()
if (workflowRunningData?.result.status === WorkflowRunningStatus.Running)
return
const { getNodes } = store.getState()
const nodes = getNodes()
const webhookNode = nodes.find(node => node.id === nodeId && node.data.type === BlockEnum.TriggerWebhook)
if (!webhookNode) {
console.warn('handleWorkflowTriggerWebhookRunInWorkflow: webhook node not found', nodeId)
return
}
setShowEnvPanel(false)
setShowGlobalVariablePanel(false)
if (!showDebugAndPreviewPanel)
setShowDebugAndPreviewPanel(true)
setShowInputsPanel(false)
setListeningTriggerType(BlockEnum.TriggerWebhook)
setListeningTriggerNodeId(nodeId)
setListeningTriggerNodeIds([nodeId])
setListeningTriggerIsAll(false)
await doSyncWorkflowDraft()
handleRun(
{ node_id: nodeId },
undefined,
{
mode: TriggerType.Webhook,
webhookNodeId: nodeId,
},
)
}, [store, workflowStore, handleRun, doSyncWorkflowDraft])
const handleWorkflowTriggerPluginRunInWorkflow = useCallback(async (nodeId?: string) => {
if (!nodeId)
return
const {
workflowRunningData,
showDebugAndPreviewPanel,
setShowDebugAndPreviewPanel,
setShowInputsPanel,
setShowEnvPanel,
setShowGlobalVariablePanel,
setListeningTriggerType,
setListeningTriggerNodeId,
setListeningTriggerNodeIds,
setListeningTriggerIsAll,
} = workflowStore.getState()
if (workflowRunningData?.result.status === WorkflowRunningStatus.Running)
return
const { getNodes } = store.getState()
const nodes = getNodes()
const pluginNode = nodes.find(node => node.id === nodeId && node.data.type === BlockEnum.TriggerPlugin)
if (!pluginNode) {
console.warn('handleWorkflowTriggerPluginRunInWorkflow: plugin node not found', nodeId)
return
}
setShowEnvPanel(false)
setShowGlobalVariablePanel(false)
if (!showDebugAndPreviewPanel)
setShowDebugAndPreviewPanel(true)
setShowInputsPanel(false)
setListeningTriggerType(BlockEnum.TriggerPlugin)
setListeningTriggerNodeId(nodeId)
setListeningTriggerNodeIds([nodeId])
setListeningTriggerIsAll(false)
await doSyncWorkflowDraft()
handleRun(
{ node_id: nodeId },
undefined,
{
mode: TriggerType.Plugin,
pluginNodeId: nodeId,
},
)
}, [store, workflowStore, handleRun, doSyncWorkflowDraft])
const handleWorkflowRunAllTriggersInWorkflow = useCallback(async (nodeIds: string[]) => {
if (!nodeIds.length)
return
const {
workflowRunningData,
showDebugAndPreviewPanel,
setShowDebugAndPreviewPanel,
setShowInputsPanel,
setShowEnvPanel,
setShowGlobalVariablePanel,
setListeningTriggerIsAll,
setListeningTriggerNodeIds,
setListeningTriggerNodeId,
} = workflowStore.getState()
if (workflowRunningData?.result.status === WorkflowRunningStatus.Running)
return
setShowEnvPanel(false)
setShowGlobalVariablePanel(false)
setShowInputsPanel(false)
setListeningTriggerIsAll(true)
setListeningTriggerNodeIds(nodeIds)
setListeningTriggerNodeId(null)
if (!showDebugAndPreviewPanel)
setShowDebugAndPreviewPanel(true)
await doSyncWorkflowDraft()
handleRun(
{ node_ids: nodeIds },
undefined,
{
mode: TriggerType.All,
allNodeIds: nodeIds,
},
)
}, [store, workflowStore, handleRun, doSyncWorkflowDraft])
const handleWorkflowStartRunInChatflow = useCallback(async () => {
const {
showDebugAndPreviewPanel,
setShowDebugAndPreviewPanel,
setHistoryWorkflowData,
setShowEnvPanel,
setShowChatVariablePanel,
setShowGlobalVariablePanel,
} = workflowStore.getState()
setShowEnvPanel(false)
setShowChatVariablePanel(false)
setShowGlobalVariablePanel(false)
if (showDebugAndPreviewPanel)
handleCancelDebugAndPreviewPanel()
else
setShowDebugAndPreviewPanel(true)
setHistoryWorkflowData(undefined)
}, [workflowStore, handleCancelDebugAndPreviewPanel])
const handleStartWorkflowRun = useCallback(() => {
if (!isChatMode)
handleWorkflowStartRunInWorkflow()
else
handleWorkflowStartRunInChatflow()
}, [isChatMode, handleWorkflowStartRunInWorkflow, handleWorkflowStartRunInChatflow])
return {
handleStartWorkflowRun,
handleWorkflowStartRunInWorkflow,
handleWorkflowStartRunInChatflow,
handleWorkflowTriggerScheduleRunInWorkflow,
handleWorkflowTriggerWebhookRunInWorkflow,
handleWorkflowTriggerPluginRunInWorkflow,
handleWorkflowRunAllTriggersInWorkflow,
}
}

View File

@@ -0,0 +1,86 @@
import { useTranslation } from 'react-i18next'
import { generateNewNode } from '@/app/components/workflow/utils'
import {
NODE_WIDTH_X_OFFSET,
START_INITIAL_POSITION,
} from '@/app/components/workflow/constants'
import { useIsChatMode } from './use-is-chat-mode'
import type { StartNodeType } from '@/app/components/workflow/nodes/start/types'
import startDefault from '@/app/components/workflow/nodes/start/default'
import llmDefault from '@/app/components/workflow/nodes/llm/default'
import answerDefault from '@/app/components/workflow/nodes/answer/default'
export const useWorkflowTemplate = () => {
const isChatMode = useIsChatMode()
const { t } = useTranslation()
const { newNode: startNode } = generateNewNode({
data: {
...startDefault.defaultValue as StartNodeType,
type: startDefault.metaData.type,
title: t(`workflow.blocks.${startDefault.metaData.type}`),
},
position: START_INITIAL_POSITION,
})
if (isChatMode) {
const { newNode: llmNode } = generateNewNode({
id: 'llm',
data: {
...llmDefault.defaultValue,
memory: {
window: { enabled: false, size: 10 },
query_prompt_template: '{{#sys.query#}}\n\n{{#sys.files#}}',
},
selected: true,
type: llmDefault.metaData.type,
title: t(`workflow.blocks.${llmDefault.metaData.type}`),
},
position: {
x: START_INITIAL_POSITION.x + NODE_WIDTH_X_OFFSET,
y: START_INITIAL_POSITION.y,
},
} as any)
const { newNode: answerNode } = generateNewNode({
id: 'answer',
data: {
...answerDefault.defaultValue,
answer: `{{#${llmNode.id}.text#}}`,
type: answerDefault.metaData.type,
title: t(`workflow.blocks.${answerDefault.metaData.type}`),
},
position: {
x: START_INITIAL_POSITION.x + NODE_WIDTH_X_OFFSET * 2,
y: START_INITIAL_POSITION.y,
},
} as any)
const startToLlmEdge = {
id: `${startNode.id}-${llmNode.id}`,
source: startNode.id,
sourceHandle: 'source',
target: llmNode.id,
targetHandle: 'target',
}
const llmToAnswerEdge = {
id: `${llmNode.id}-${answerNode.id}`,
source: llmNode.id,
sourceHandle: 'source',
target: answerNode.id,
targetHandle: 'target',
}
return {
nodes: [startNode, llmNode, answerNode],
edges: [startToLlmEdge, llmToAnswerEdge],
}
}
else {
return {
nodes: [startNode],
edges: [],
}
}
}

View File

@@ -0,0 +1,223 @@
'use client'
import {
useEffect,
useMemo,
} from 'react'
import {
SupportUploadFileTypes,
} from '@/app/components/workflow/types'
import {
useWorkflowInit,
} from './hooks/use-workflow-init'
import { useAppTriggers } from '@/service/use-tools'
import { useTriggerStatusStore } from '@/app/components/workflow/store/trigger-status'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useWorkflowStore } from '@/app/components/workflow/store'
import {
initialEdges,
initialNodes,
} from '@/app/components/workflow/utils'
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 { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import { useAppContext } from '@/context/app-context'
import WorkflowWithDefaultContext from '@/app/components/workflow'
import {
WorkflowContextProvider,
} from '@/app/components/workflow/context'
import type { InjectWorkflowStoreSliceFn } from '@/app/components/workflow/store'
import { createWorkflowSlice } from './store/workflow/workflow-slice'
import WorkflowAppMain from './components/workflow-main'
import { useSearchParams } from 'next/navigation'
import { fetchRunDetail } from '@/service/log'
import { useGetRunAndTraceUrl } from './hooks/use-get-run-and-trace-url'
import { AppModeEnum } from '@/types/app'
const WorkflowAppWithAdditionalContext = () => {
const {
data,
isLoading,
fileUploadConfigResponse,
} = useWorkflowInit()
const workflowStore = useWorkflowStore()
const { isLoadingCurrentWorkspace, currentWorkspace } = useAppContext()
// Initialize trigger status at application level
const { setTriggerStatuses } = useTriggerStatusStore()
const appDetail = useAppStore(s => s.appDetail)
const appId = appDetail?.id
const isWorkflowMode = appDetail?.mode === AppModeEnum.WORKFLOW
const { data: triggersResponse } = useAppTriggers(isWorkflowMode ? appId : undefined, {
staleTime: 5 * 60 * 1000, // 5 minutes cache
refetchOnWindowFocus: false,
})
// Sync trigger statuses to store when data loads
useEffect(() => {
if (triggersResponse?.data) {
// Map API status to EntryNodeStatus: 'enabled' stays 'enabled', all others become 'disabled'
const statusMap = triggersResponse.data.reduce((acc, trigger) => {
acc[trigger.node_id] = trigger.status === 'enabled' ? 'enabled' : 'disabled'
return acc
}, {} as Record<string, 'enabled' | 'disabled'>)
setTriggerStatuses(statusMap)
}
}, [triggersResponse?.data, setTriggerStatuses])
// Cleanup on unmount
useEffect(() => {
return () => {
// Reset the loaded flag when component unmounts
workflowStore.setState({ isWorkflowDataLoaded: false })
// Cancel any pending debounced sync operations
const { debouncedSyncWorkflowDraft } = workflowStore.getState()
// The debounced function from lodash has a cancel method
if (debouncedSyncWorkflowDraft && 'cancel' in debouncedSyncWorkflowDraft)
(debouncedSyncWorkflowDraft as any).cancel()
}
}, [workflowStore])
const nodesData = useMemo(() => {
if (data)
return initialNodes(data.graph.nodes, data.graph.edges)
return []
}, [data])
const edgesData = useMemo(() => {
if (data)
return initialEdges(data.graph.edges, data.graph.nodes)
return []
}, [data])
const searchParams = useSearchParams()
const { getWorkflowRunAndTraceUrl } = useGetRunAndTraceUrl()
const replayRunId = searchParams.get('replayRunId')
useEffect(() => {
if (!replayRunId)
return
const { runUrl } = getWorkflowRunAndTraceUrl(replayRunId)
if (!runUrl)
return
fetchRunDetail(runUrl).then((res) => {
const { setInputs, setShowInputsPanel, setShowDebugAndPreviewPanel } = workflowStore.getState()
const rawInputs = res.inputs
let parsedInputs: Record<string, unknown> | null = null
if (typeof rawInputs === 'string') {
try {
const maybeParsed = JSON.parse(rawInputs) as unknown
if (maybeParsed && typeof maybeParsed === 'object' && !Array.isArray(maybeParsed))
parsedInputs = maybeParsed as Record<string, unknown>
}
catch (error) {
console.error('Failed to parse workflow run inputs', error)
}
}
else if (rawInputs && typeof rawInputs === 'object' && !Array.isArray(rawInputs)) {
parsedInputs = rawInputs as Record<string, unknown>
}
if (!parsedInputs)
return
const userInputs: Record<string, string | number | boolean> = {}
Object.entries(parsedInputs).forEach(([key, value]) => {
if (key.startsWith('sys.'))
return
if (value == null) {
userInputs[key] = ''
return
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
userInputs[key] = value
return
}
try {
userInputs[key] = JSON.stringify(value)
}
catch {
userInputs[key] = String(value)
}
})
if (!Object.keys(userInputs).length)
return
setInputs(userInputs)
setShowInputsPanel(true)
setShowDebugAndPreviewPanel(true)
})
}, [replayRunId, workflowStore, getWorkflowRunAndTraceUrl])
if (!data || isLoading || isLoadingCurrentWorkspace || !currentWorkspace.id) {
return (
<div className='relative flex h-full w-full items-center justify-center'>
<Loading />
</div>
)
}
const features = data.features || {}
const initialFeatures: FeaturesData = {
file: {
image: {
enabled: !!features.file_upload?.image?.enabled,
number_limits: features.file_upload?.image?.number_limits || 3,
transfer_methods: features.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'],
},
enabled: !!(features.file_upload?.enabled || features.file_upload?.image?.enabled),
allowed_file_types: features.file_upload?.allowed_file_types || [SupportUploadFileTypes.image],
allowed_file_extensions: features.file_upload?.allowed_file_extensions || FILE_EXTS[SupportUploadFileTypes.image].map(ext => `.${ext}`),
allowed_file_upload_methods: features.file_upload?.allowed_file_upload_methods || features.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'],
number_limits: features.file_upload?.number_limits || features.file_upload?.image?.number_limits || 3,
fileUploadConfig: fileUploadConfigResponse,
},
opening: {
enabled: !!features.opening_statement,
opening_statement: features.opening_statement,
suggested_questions: features.suggested_questions,
},
suggested: features.suggested_questions_after_answer || { enabled: false },
speech2text: features.speech_to_text || { enabled: false },
text2speech: features.text_to_speech || { enabled: false },
citation: features.retriever_resource || { enabled: false },
moderation: features.sensitive_word_avoidance || { enabled: false },
}
return (
<WorkflowWithDefaultContext
edges={edgesData}
nodes={nodesData}
>
<FeaturesProvider features={initialFeatures}>
<WorkflowAppMain
nodes={nodesData}
edges={edgesData}
viewport={data.graph.viewport}
/>
</FeaturesProvider>
</WorkflowWithDefaultContext>
)
}
const WorkflowAppWrapper = () => {
return (
<WorkflowContextProvider
injectWorkflowStoreSliceFn={createWorkflowSlice as InjectWorkflowStoreSliceFn}
>
<WorkflowAppWithAdditionalContext />
</WorkflowContextProvider>
)
}
export default WorkflowAppWrapper

View File

@@ -0,0 +1,36 @@
import type { StateCreator } from 'zustand'
export type WorkflowSliceShape = {
appId: string
appName: string
notInitialWorkflow: boolean
setNotInitialWorkflow: (notInitialWorkflow: boolean) => void
shouldAutoOpenStartNodeSelector: boolean
setShouldAutoOpenStartNodeSelector: (shouldAutoOpen: boolean) => void
nodesDefaultConfigs: Record<string, any>
setNodesDefaultConfigs: (nodesDefaultConfigs: Record<string, any>) => void
showOnboarding: boolean
setShowOnboarding: (showOnboarding: boolean) => void
hasSelectedStartNode: boolean
setHasSelectedStartNode: (hasSelectedStartNode: boolean) => void
hasShownOnboarding: boolean
setHasShownOnboarding: (hasShownOnboarding: boolean) => void
}
export type CreateWorkflowSlice = StateCreator<WorkflowSliceShape>
export const createWorkflowSlice: StateCreator<WorkflowSliceShape> = set => ({
appId: '',
appName: '',
notInitialWorkflow: false,
setNotInitialWorkflow: notInitialWorkflow => set(() => ({ notInitialWorkflow })),
shouldAutoOpenStartNodeSelector: false,
setShouldAutoOpenStartNodeSelector: shouldAutoOpenStartNodeSelector => set(() => ({ shouldAutoOpenStartNodeSelector })),
nodesDefaultConfigs: {},
setNodesDefaultConfigs: nodesDefaultConfigs => set(() => ({ nodesDefaultConfigs })),
showOnboarding: false,
setShowOnboarding: showOnboarding => set(() => ({ showOnboarding })),
hasSelectedStartNode: false,
setHasSelectedStartNode: hasSelectedStartNode => set(() => ({ hasSelectedStartNode })),
hasShownOnboarding: false,
setHasShownOnboarding: hasShownOnboarding => set(() => ({ hasShownOnboarding })),
})