Allow empty workflows and improve workflow validation (#24627)

This commit is contained in:
lyzno1
2025-08-27 17:49:09 +08:00
committed by GitHub
parent 73e65fd838
commit 87abfbf515
16 changed files with 72 additions and 45 deletions

View File

@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'
import AppIcon from '../base/app-icon'
import Tooltip from '@/app/components/base/tooltip'
import {
Code,
ApiAggregate,
WindowCursor,
} from '@/app/components/base/icons/src/vender/workflow'
@ -40,8 +40,8 @@ const NotionSvg = <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xm
const ICON_MAP = {
app: <AppIcon className='border !border-[rgba(0,0,0,0.05)]' />,
api: <div className='rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-violet-violet-500 p-1 shadow-md'>
<Code className='h-4 w-4 text-text-primary-on-surface' />
api: <div className='rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-brand-blue-brand-500 p-1 shadow-md'>
<ApiAggregate className='h-4 w-4 text-text-primary-on-surface' />
</div>,
dataset: <AppIcon innerIcon={DatasetSvg} className='!border-[0.5px] !border-indigo-100 !bg-indigo-25' />,
webapp: <div className='rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-brand-blue-brand-500 p-1 shadow-md'>
@ -56,12 +56,12 @@ export default function AppBasic({ icon, icon_background, name, isExternal, type
return (
<div className="flex grow items-center">
{icon && icon_background && iconType === 'app' && (
<div className='mr-3 shrink-0'>
<div className='mr-2 shrink-0'>
<AppIcon icon={icon} background={icon_background} />
</div>
)}
{iconType !== 'app'
&& <div className='mr-3 shrink-0'>
&& <div className='mr-2 shrink-0'>
{ICON_MAP[iconType]}
</div>

View File

@ -101,7 +101,7 @@ function AppCard({
const isApp = cardType === 'webapp'
const basicName = isApp
? appInfo?.site?.title
? t('appOverview.overview.appInfo.title')
: t('appOverview.overview.apiInfo.title')
const hasStartNode = currentWorkflow?.graph?.nodes.find(node => node.data.type === BlockEnum.Start)
const isWorkflowAndMissingStart = appInfo.mode === 'workflow' && !hasStartNode

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.92578 11.0094C5.92578 10.0174 5.12163 9.21256 4.12956 9.21256C3.13752 9.2126 2.33333 10.0174 2.33333 11.0094C2.33349 12.0014 3.13762 12.8056 4.12956 12.8057C5.12153 12.8057 5.92562 12.0014 5.92578 11.0094ZM13.6667 11.0094C13.6667 10.0174 12.8625 9.2126 11.8704 9.21256C10.8784 9.21256 10.0742 10.0174 10.0742 11.0094C10.0744 12.0014 10.8785 12.8057 11.8704 12.8057C12.8624 12.8056 13.6665 12.0014 13.6667 11.0094ZM9.79622 4.32389C9.79619 3.33186 8.99205 2.52767 8 2.52767C7.00796 2.52767 6.20382 3.33186 6.20378 4.32389C6.20378 5.31596 7.00793 6.12012 8 6.12012C8.99207 6.12012 9.79622 5.31596 9.79622 4.32389ZM11.1296 4.32389C11.1296 5.82351 10.0748 7.07628 8.66667 7.38184V7.9196L9.74284 8.71387C10.3012 8.19607 11.0489 7.87923 11.8704 7.87923C13.5989 7.87927 15 9.28101 15 11.0094C14.9998 12.7377 13.5988 14.139 11.8704 14.139C10.1421 14.139 8.74104 12.7378 8.74089 11.0094C8.74089 10.5837 8.82585 10.1776 8.97982 9.80762L8 9.08366L7.01953 9.80762C7.17356 10.1777 7.25911 10.5836 7.25911 11.0094C7.25896 12.7378 5.85791 14.139 4.12956 14.139C2.40124 14.139 1.00016 12.7377 1 11.0094C1 9.28101 2.40114 7.87927 4.12956 7.87923C4.95094 7.87923 5.69819 8.19627 6.25651 8.71387L7.33333 7.9196V7.38184C5.92523 7.07628 4.87044 5.82351 4.87044 4.32389C4.87048 2.59548 6.27158 1.19434 8 1.19434C9.72843 1.19434 11.1295 2.59548 11.1296 4.32389Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,26 @@
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "16",
"height": "16",
"viewBox": "0 0 16 16",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"d": "M5.92578 11.0094C5.92578 10.0174 5.12163 9.21256 4.12956 9.21256C3.13752 9.2126 2.33333 10.0174 2.33333 11.0094C2.33349 12.0014 3.13762 12.8056 4.12956 12.8057C5.12153 12.8057 5.92562 12.0014 5.92578 11.0094ZM13.6667 11.0094C13.6667 10.0174 12.8625 9.2126 11.8704 9.21256C10.8784 9.21256 10.0742 10.0174 10.0742 11.0094C10.0744 12.0014 10.8785 12.8057 11.8704 12.8057C12.8624 12.8056 13.6665 12.0014 13.6667 11.0094ZM9.79622 4.32389C9.79619 3.33186 8.99205 2.52767 8 2.52767C7.00796 2.52767 6.20382 3.33186 6.20378 4.32389C6.20378 5.31596 7.00793 6.12012 8 6.12012C8.99207 6.12012 9.79622 5.31596 9.79622 4.32389ZM11.1296 4.32389C11.1296 5.82351 10.0748 7.07628 8.66667 7.38184V7.9196L9.74284 8.71387C10.3012 8.19607 11.0489 7.87923 11.8704 7.87923C13.5989 7.87927 15 9.28101 15 11.0094C14.9998 12.7377 13.5988 14.139 11.8704 14.139C10.1421 14.139 8.74104 12.7378 8.74089 11.0094C8.74089 10.5837 8.82585 10.1776 8.97982 9.80762L8 9.08366L7.01953 9.80762C7.17356 10.1777 7.25911 10.5836 7.25911 11.0094C7.25896 12.7378 5.85791 14.139 4.12956 14.139C2.40124 14.139 1.00016 12.7377 1 11.0094C1 9.28101 2.40114 7.87927 4.12956 7.87923C4.95094 7.87923 5.69819 8.19627 6.25651 8.71387L7.33333 7.9196V7.38184C5.92523 7.07628 4.87044 5.82351 4.87044 4.32389C4.87048 2.59548 6.27158 1.19434 8 1.19434C9.72843 1.19434 11.1295 2.59548 11.1296 4.32389Z",
"fill": "currentColor"
},
"children": []
}
]
},
"name": "ApiAggregate"
}

View File

@ -0,0 +1,20 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import * as React from 'react'
import data from './ApiAggregate.json'
import IconBase from '@/app/components/base/icons/IconBase'
import type { IconData } from '@/app/components/base/icons/IconBase'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'ApiAggregate'
export default Icon

View File

@ -1,5 +1,6 @@
export { default as Agent } from './Agent'
export { default as Answer } from './Answer'
export { default as ApiAggregate } from './ApiAggregate'
export { default as Assigner } from './Assigner'
export { default as Asterisk } from './Asterisk'
export { default as CalendarCheckLine } from './CalendarCheckLine'

View File

@ -142,7 +142,7 @@ function MCPServiceCard({
<div className='flex w-full flex-col items-start justify-center gap-3 self-stretch border-b-[0.5px] border-divider-subtle p-3'>
<div className='flex w-full items-center gap-3 self-stretch'>
<div className='flex grow items-center'>
<div className='mr-3 shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-indigo-indigo-500 p-1 shadow-md'>
<div className='mr-2 shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-indigo-indigo-500 p-1 shadow-md'>
<Mcp className='h-4 w-4 text-text-primary-on-surface' />
</div>
<div className="group w-full">

View File

@ -5,7 +5,6 @@ import { useParams } from 'next/navigation'
import {
useWorkflowStore,
} from '@/app/components/workflow/store'
import { BlockEnum } from '@/app/components/workflow/types'
import {
useNodesReadOnly,
} from '@/app/components/workflow/hooks/use-workflow'
@ -38,16 +37,7 @@ export const useNodesSyncDraft = () => {
if (appId) {
const nodes = getNodes()
const startNodeTypes = [
BlockEnum.Start,
BlockEnum.TriggerSchedule,
BlockEnum.TriggerWebhook,
BlockEnum.TriggerPlugin,
]
const hasStartNode = nodes.some(node => startNodeTypes.includes(node.data.type))
if (!hasStartNode)
return
// Allow empty workflows - sync restrictions removed to support empty workflow editing
const features = featuresStore!.getState().features
const producedNodes = produce(nodes, (draft) => {

View File

@ -166,7 +166,7 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
list.push({
id: 'start-node-required',
type: BlockEnum.Start,
title: t('workflow.blocks.start'),
title: t('workflow.panel.startNode'),
errorMessage: t('workflow.common.needStartNode'),
})
}

View File

@ -18,7 +18,6 @@ import {
} from 'reactflow'
import { unionBy } from 'lodash-es'
import type { ToolDefaultValue } from '../block-selector/types'
import { ENTRY_NODE_TYPES } from '../block-selector/constants'
import type {
Edge,
Node,
@ -64,23 +63,7 @@ import { WorkflowHistoryEvent, useWorkflowHistory } from './use-workflow-history
import useInspectVarsCrud from './use-inspect-vars-crud'
import { getNodeUsedVars } from '../nodes/_base/components/variable/utils'
// Helper function to check if a node is an entry node
const isEntryNode = (nodeType: BlockEnum): boolean => {
return ENTRY_NODE_TYPES.includes(nodeType as any)
}
// Helper function to check if entry node can be deleted
const canDeleteEntryNode = (nodes: Node[], nodeId: string): boolean => {
const targetNode = nodes.find(node => node.id === nodeId)
if (!targetNode || !isEntryNode(targetNode.data.type))
return true // Non-entry nodes can always be deleted
// Count all entry nodes
const entryNodes = nodes.filter(node => isEntryNode(node.data.type))
// Can delete if there's more than one entry node
return entryNodes.length > 1
}
// Entry node deletion restriction has been removed to allow empty workflows
export const useNodesInteractions = () => {
const { t } = useTranslation()
@ -568,9 +551,7 @@ export const useNodesInteractions = () => {
const nodes = getNodes()
// Check if entry node can be deleted (must keep at least one entry node)
if (!canDeleteEntryNode(nodes, nodeId))
return // Cannot delete the last entry node
// Allow deleting any node including the last entry node
const currentNodeIndex = nodes.findIndex(node => node.id === nodeId)
const currentNode = nodes[currentNodeIndex]
@ -1410,7 +1391,7 @@ export const useNodesInteractions = () => {
const nodes = getNodes()
const bundledNodes = nodes.filter(node =>
node.data._isBundled && canDeleteEntryNode(nodes, node.id),
node.data._isBundled,
)
if (bundledNodes.length) {
@ -1424,7 +1405,7 @@ export const useNodesInteractions = () => {
return
const selectedNode = nodes.find(node =>
node.data.selected && canDeleteEntryNode(nodes, node.id),
node.data.selected,
)
if (selectedNode)