소스 검색

Feat/workflow retry (#11885)

zxhlyh 4 달 전
부모
커밋
0c0120ef27
31개의 변경된 파일690개의 추가작업 그리고 51개의 파일을 삭제
  1. 7 0
      web/app/components/base/chat/chat/answer/workflow-process.tsx
  2. 9 0
      web/app/components/base/input/index.tsx
  3. 2 0
      web/app/components/workflow/constants.ts
  4. 41 1
      web/app/components/workflow/hooks/use-workflow-run.ts
  5. 70 37
      web/app/components/workflow/nodes/_base/components/before-run-form/index.tsx
  6. 0 2
      web/app/components/workflow/nodes/_base/components/error-handle/error-handle-on-panel.tsx
  7. 41 0
      web/app/components/workflow/nodes/_base/components/retry/hooks.ts
  8. 88 0
      web/app/components/workflow/nodes/_base/components/retry/retry-on-node.tsx
  9. 117 0
      web/app/components/workflow/nodes/_base/components/retry/retry-on-panel.tsx
  10. 5 0
      web/app/components/workflow/nodes/_base/components/retry/style.module.css
  11. 5 0
      web/app/components/workflow/nodes/_base/components/retry/types.ts
  12. 0 0
      web/app/components/workflow/nodes/_base/components/retry/utils.ts
  13. 13 1
      web/app/components/workflow/nodes/_base/node.tsx
  14. 12 0
      web/app/components/workflow/nodes/_base/panel.tsx
  15. 9 1
      web/app/components/workflow/nodes/http/default.ts
  16. 11 3
      web/app/components/workflow/nodes/http/panel.tsx
  17. 9 1
      web/app/components/workflow/nodes/llm/panel.tsx
  18. 12 1
      web/app/components/workflow/nodes/tool/panel.tsx
  19. 26 0
      web/app/components/workflow/panel/debug-and-preview/hooks.ts
  20. 21 2
      web/app/components/workflow/panel/workflow-preview.tsx
  21. 33 1
      web/app/components/workflow/run/index.tsx
  22. 24 0
      web/app/components/workflow/run/node.tsx
  23. 28 0
      web/app/components/workflow/run/result-panel.tsx
  24. 46 0
      web/app/components/workflow/run/retry-result-panel.tsx
  25. 4 0
      web/app/components/workflow/run/tracing-panel.tsx
  26. 4 0
      web/app/components/workflow/types.ts
  27. 14 0
      web/app/components/workflow/utils.ts
  28. 14 0
      web/i18n/en-US/workflow.ts
  29. 14 0
      web/i18n/zh-Hans/workflow.ts
  30. 8 1
      web/service/base.ts
  31. 3 0
      web/types/workflow.ts

+ 7 - 0
web/app/components/base/chat/chat/answer/workflow-process.tsx

@@ -64,6 +64,12 @@ const WorkflowProcessItem = ({
     setShowMessageLogModal(true)
   }, [item, setCurrentLogItem, setCurrentLogModalActiveTab, setShowMessageLogModal])
 
+  const showRetryDetail = useCallback(() => {
+    setCurrentLogItem(item)
+    setCurrentLogModalActiveTab('TRACING')
+    setShowMessageLogModal(true)
+  }, [item, setCurrentLogItem, setCurrentLogModalActiveTab, setShowMessageLogModal])
+
   return (
     <div
       className={cn(
@@ -105,6 +111,7 @@ const WorkflowProcessItem = ({
               <TracingPanel
                 list={data.tracing}
                 onShowIterationDetail={showIterationDetail}
+                onShowRetryDetail={showRetryDetail}
                 hideNodeInfo={hideInfo}
                 hideNodeProcessDetail={hideProcessDetail}
               />

+ 9 - 0
web/app/components/base/input/index.tsx

@@ -28,6 +28,7 @@ export type InputProps = {
   destructive?: boolean
   wrapperClassName?: string
   styleCss?: CSSProperties
+  unit?: string
 } & React.InputHTMLAttributes<HTMLInputElement> & VariantProps<typeof inputVariants>
 
 const Input = ({
@@ -43,6 +44,7 @@ const Input = ({
   value,
   placeholder,
   onChange,
+  unit,
   ...props
 }: InputProps) => {
   const { t } = useTranslation()
@@ -80,6 +82,13 @@ const Input = ({
       {destructive && (
         <RiErrorWarningLine className='absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-text-destructive-secondary' />
       )}
+      {
+        unit && (
+          <div className='absolute right-2 top-1/2 -translate-y-1/2 system-sm-regular text-text-tertiary'>
+            {unit}
+          </div>
+        )
+      }
     </div>
   )
 }

+ 2 - 0
web/app/components/workflow/constants.ts

@@ -506,3 +506,5 @@ export const WORKFLOW_DATA_UPDATE = 'WORKFLOW_DATA_UPDATE'
 export const CUSTOM_NODE = 'custom'
 export const CUSTOM_EDGE = 'custom'
 export const DSL_EXPORT_CHECK = 'DSL_EXPORT_CHECK'
+export const DEFAULT_RETRY_MAX = 3
+export const DEFAULT_RETRY_INTERVAL = 100

+ 41 - 1
web/app/components/workflow/hooks/use-workflow-run.ts

@@ -28,6 +28,7 @@ import {
   getFilesInLogs,
 } from '@/app/components/base/file-uploader/utils'
 import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
+import type { NodeTracing } from '@/types/workflow'
 
 export const useWorkflowRun = () => {
   const store = useStoreApi()
@@ -114,6 +115,7 @@ export const useWorkflowRun = () => {
       onIterationStart,
       onIterationNext,
       onIterationFinish,
+      onNodeRetry,
       onError,
       ...restCallback
     } = callback || {}
@@ -440,10 +442,13 @@ export const useWorkflowRun = () => {
               })
               if (currentIndex > -1 && draft.tracing) {
                 draft.tracing[currentIndex] = {
+                  ...data,
                   ...(draft.tracing[currentIndex].extras
                     ? { extras: draft.tracing[currentIndex].extras }
                     : {}),
-                  ...data,
+                  ...(draft.tracing[currentIndex].retryDetail
+                    ? { retryDetail: draft.tracing[currentIndex].retryDetail }
+                    : {}),
                 } as any
               }
             }))
@@ -616,6 +621,41 @@ export const useWorkflowRun = () => {
           if (onIterationFinish)
             onIterationFinish(params)
         },
+        onNodeRetry: (params) => {
+          const { data } = params
+          const {
+            workflowRunningData,
+            setWorkflowRunningData,
+          } = workflowStore.getState()
+          const {
+            getNodes,
+            setNodes,
+          } = store.getState()
+
+          const nodes = getNodes()
+          setWorkflowRunningData(produce(workflowRunningData!, (draft) => {
+            const tracing = draft.tracing!
+            const currentRetryNodeIndex = tracing.findIndex(trace => trace.node_id === data.node_id)
+
+            if (currentRetryNodeIndex > -1) {
+              const currentRetryNode = tracing[currentRetryNodeIndex]
+              if (currentRetryNode.retryDetail)
+                draft.tracing![currentRetryNodeIndex].retryDetail!.push(data as NodeTracing)
+
+              else
+                draft.tracing![currentRetryNodeIndex].retryDetail = [data as NodeTracing]
+            }
+          }))
+          const newNodes = produce(nodes, (draft) => {
+            const currentNode = draft.find(node => node.id === data.node_id)!
+
+            currentNode.data._retryIndex = data.retry_index
+          })
+          setNodes(newNodes)
+
+          if (onNodeRetry)
+            onNodeRetry(params)
+        },
         onParallelBranchStarted: (params) => {
           // console.log(params, 'parallel start')
         },

+ 70 - 37
web/app/components/workflow/nodes/_base/components/before-run-form/index.tsx

@@ -17,17 +17,25 @@ import ResultPanel from '@/app/components/workflow/run/result-panel'
 import Toast from '@/app/components/base/toast'
 import { TransferMethod } from '@/types/app'
 import { getProcessedFiles } from '@/app/components/base/file-uploader/utils'
+import type { NodeTracing } from '@/types/workflow'
+import RetryResultPanel from '@/app/components/workflow/run/retry-result-panel'
+import type { BlockEnum } from '@/app/components/workflow/types'
+import type { Emoji } from '@/app/components/tools/types'
 
 const i18nPrefix = 'workflow.singleRun'
 
 type BeforeRunFormProps = {
   nodeName: string
+  nodeType?: BlockEnum
+  toolIcon?: string | Emoji
   onHide: () => void
   onRun: (submitData: Record<string, any>) => void
   onStop: () => void
   runningStatus: NodeRunningStatus
   result?: JSX.Element
   forms: FormProps[]
+  retryDetails?: NodeTracing[]
+  onRetryDetailBack?: any
 }
 
 function formatValue(value: string | any, type: InputVarType) {
@@ -50,12 +58,16 @@ function formatValue(value: string | any, type: InputVarType) {
 }
 const BeforeRunForm: FC<BeforeRunFormProps> = ({
   nodeName,
+  nodeType,
+  toolIcon,
   onHide,
   onRun,
   onStop,
   runningStatus,
   result,
   forms,
+  retryDetails,
+  onRetryDetailBack = () => { },
 }) => {
   const { t } = useTranslation()
 
@@ -122,48 +134,69 @@ const BeforeRunForm: FC<BeforeRunFormProps> = ({
           <div className='text-base font-semibold text-gray-900 truncate'>
             {t(`${i18nPrefix}.testRun`)} {nodeName}
           </div>
-          <div className='ml-2 shrink-0 p-1 cursor-pointer' onClick={onHide}>
+          <div className='ml-2 shrink-0 p-1 cursor-pointer' onClick={() => {
+            onHide()
+          }}>
             <RiCloseLine className='w-4 h-4 text-gray-500 ' />
           </div>
         </div>
-
-        <div className='h-0 grow overflow-y-auto pb-4'>
-          <div className='mt-3 px-4 space-y-4'>
-            {forms.map((form, index) => (
-              <div key={index}>
-                <Form
-                  key={index}
-                  className={cn(index < forms.length - 1 && 'mb-4')}
-                  {...form}
-                />
-                {index < forms.length - 1 && <Split />}
+        {
+          retryDetails?.length && (
+            <div className='h-0 grow overflow-y-auto pb-4'>
+              <RetryResultPanel
+                list={retryDetails.map((item, index) => ({
+                  ...item,
+                  title: `${t('workflow.nodes.common.retry.retry')} ${index + 1}`,
+                  node_type: nodeType!,
+                  extras: {
+                    icon: toolIcon!,
+                  },
+                }))}
+                onBack={onRetryDetailBack}
+              />
+            </div>
+          )
+        }
+        {
+          !retryDetails?.length && (
+            <div className='h-0 grow overflow-y-auto pb-4'>
+              <div className='mt-3 px-4 space-y-4'>
+                {forms.map((form, index) => (
+                  <div key={index}>
+                    <Form
+                      key={index}
+                      className={cn(index < forms.length - 1 && 'mb-4')}
+                      {...form}
+                    />
+                    {index < forms.length - 1 && <Split />}
+                  </div>
+                ))}
               </div>
-            ))}
-          </div>
-
-          <div className='mt-4 flex justify-between space-x-2 px-4' >
-            {isRunning && (
-              <div
-                className='p-2 rounded-lg border border-gray-200 bg-white shadow-xs cursor-pointer'
-                onClick={onStop}
-              >
-                <StopCircle className='w-4 h-4 text-gray-500' />
+              <div className='mt-4 flex justify-between space-x-2 px-4' >
+                {isRunning && (
+                  <div
+                    className='p-2 rounded-lg border border-gray-200 bg-white shadow-xs cursor-pointer'
+                    onClick={onStop}
+                  >
+                    <StopCircle className='w-4 h-4 text-gray-500' />
+                  </div>
+                )}
+                <Button disabled={!isFileLoaded || isRunning} variant='primary' className='w-0 grow space-x-2' onClick={handleRun}>
+                  {isRunning && <RiLoader2Line className='animate-spin w-4 h-4 text-white' />}
+                  <div>{t(`${i18nPrefix}.${isRunning ? 'running' : 'startRun'}`)}</div>
+                </Button>
               </div>
-            )}
-            <Button disabled={!isFileLoaded || isRunning} variant='primary' className='w-0 grow space-x-2' onClick={handleRun}>
-              {isRunning && <RiLoader2Line className='animate-spin w-4 h-4 text-white' />}
-              <div>{t(`${i18nPrefix}.${isRunning ? 'running' : 'startRun'}`)}</div>
-            </Button>
-          </div>
-          {isRunning && (
-            <ResultPanel status='running' showSteps={false} />
-          )}
-          {isFinished && (
-            <>
-              {result}
-            </>
-          )}
-        </div>
+              {isRunning && (
+                <ResultPanel status='running' showSteps={false} />
+              )}
+              {isFinished && (
+                <>
+                  {result}
+                </>
+              )}
+            </div>
+          )
+        }
       </div>
     </div>
   )

+ 0 - 2
web/app/components/workflow/nodes/_base/components/error-handle/error-handle-on-panel.tsx

@@ -14,7 +14,6 @@ import type {
   CommonNodeType,
   Node,
 } from '@/app/components/workflow/types'
-import Split from '@/app/components/workflow/nodes/_base/components/split'
 import Tooltip from '@/app/components/base/tooltip'
 
 type ErrorHandleProps = Pick<Node, 'id' | 'data'>
@@ -45,7 +44,6 @@ const ErrorHandle = ({
 
   return (
     <>
-      <Split />
       <div className='py-4'>
         <Collapse
           disabled={!error_strategy}

+ 41 - 0
web/app/components/workflow/nodes/_base/components/retry/hooks.ts

@@ -0,0 +1,41 @@
+import {
+  useCallback,
+  useState,
+} from 'react'
+import type { WorkflowRetryConfig } from './types'
+import {
+  useNodeDataUpdate,
+} from '@/app/components/workflow/hooks'
+import type { NodeTracing } from '@/types/workflow'
+
+export const useRetryConfig = (
+  id: string,
+) => {
+  const { handleNodeDataUpdateWithSyncDraft } = useNodeDataUpdate()
+
+  const handleRetryConfigChange = useCallback((value?: WorkflowRetryConfig) => {
+    handleNodeDataUpdateWithSyncDraft({
+      id,
+      data: {
+        retry_config: value,
+      },
+    })
+  }, [id, handleNodeDataUpdateWithSyncDraft])
+
+  return {
+    handleRetryConfigChange,
+  }
+}
+
+export const useRetryDetailShowInSingleRun = () => {
+  const [retryDetails, setRetryDetails] = useState<NodeTracing[] | undefined>()
+
+  const handleRetryDetailsChange = useCallback((details: NodeTracing[] | undefined) => {
+    setRetryDetails(details)
+  }, [])
+
+  return {
+    retryDetails,
+    handleRetryDetailsChange,
+  }
+}

+ 88 - 0
web/app/components/workflow/nodes/_base/components/retry/retry-on-node.tsx

@@ -0,0 +1,88 @@
+import { useMemo } from 'react'
+import { useTranslation } from 'react-i18next'
+import {
+  RiAlertFill,
+  RiCheckboxCircleFill,
+  RiLoader2Line,
+} from '@remixicon/react'
+import type { Node } from '@/app/components/workflow/types'
+import { NodeRunningStatus } from '@/app/components/workflow/types'
+import cn from '@/utils/classnames'
+
+type RetryOnNodeProps = Pick<Node, 'id' | 'data'>
+const RetryOnNode = ({
+  data,
+}: RetryOnNodeProps) => {
+  const { t } = useTranslation()
+  const { retry_config } = data
+  const showSelectedBorder = data.selected || data._isBundled || data._isEntering
+  const {
+    isRunning,
+    isSuccessful,
+    isException,
+    isFailed,
+  } = useMemo(() => {
+    return {
+      isRunning: data._runningStatus === NodeRunningStatus.Running && !showSelectedBorder,
+      isSuccessful: data._runningStatus === NodeRunningStatus.Succeeded && !showSelectedBorder,
+      isFailed: data._runningStatus === NodeRunningStatus.Failed && !showSelectedBorder,
+      isException: data._runningStatus === NodeRunningStatus.Exception && !showSelectedBorder,
+    }
+  }, [data._runningStatus, showSelectedBorder])
+  const showDefault = !isRunning && !isSuccessful && !isException && !isFailed
+
+  if (!retry_config)
+    return null
+
+  return (
+    <div className='px-3'>
+      <div className={cn(
+        'flex items-center justify-between px-[5px] py-1 bg-workflow-block-parma-bg border-[0.5px] border-transparent rounded-md system-xs-medium-uppercase text-text-tertiary',
+        isRunning && 'bg-state-accent-hover border-state-accent-active text-text-accent',
+        isSuccessful && 'bg-state-success-hover border-state-success-active text-text-success',
+        (isException || isFailed) && 'bg-state-warning-hover border-state-warning-active text-text-warning',
+      )}>
+        <div className='flex items-center'>
+          {
+            showDefault && (
+              t('workflow.nodes.common.retry.retryTimes', { times: retry_config.max_retries })
+            )
+          }
+          {
+            isRunning && (
+              <>
+                <RiLoader2Line className='animate-spin mr-1 w-3.5 h-3.5' />
+                {t('workflow.nodes.common.retry.retrying')}
+              </>
+            )
+          }
+          {
+            isSuccessful && (
+              <>
+                <RiCheckboxCircleFill className='mr-1 w-3.5 h-3.5' />
+                {t('workflow.nodes.common.retry.retrySuccessful')}
+              </>
+            )
+          }
+          {
+            (isFailed || isException) && (
+              <>
+                <RiAlertFill className='mr-1 w-3.5 h-3.5' />
+                {t('workflow.nodes.common.retry.retryFailed')}
+              </>
+            )
+          }
+        </div>
+        {
+          !showDefault && (
+            <div>
+              {data._retryIndex}/{data.retry_config?.max_retries}
+            </div>
+          )
+        }
+      </div>
+    </div>
+  )
+}
+
+export default RetryOnNode

+ 117 - 0
web/app/components/workflow/nodes/_base/components/retry/retry-on-panel.tsx

@@ -0,0 +1,117 @@
+import { useTranslation } from 'react-i18next'
+import { useRetryConfig } from './hooks'
+import s from './style.module.css'
+import Switch from '@/app/components/base/switch'
+import Slider from '@/app/components/base/slider'
+import Input from '@/app/components/base/input'
+import type {
+  Node,
+} from '@/app/components/workflow/types'
+import Split from '@/app/components/workflow/nodes/_base/components/split'
+
+type RetryOnPanelProps = Pick<Node, 'id' | 'data'>
+const RetryOnPanel = ({
+  id,
+  data,
+}: RetryOnPanelProps) => {
+  const { t } = useTranslation()
+  const { handleRetryConfigChange } = useRetryConfig(id)
+  const { retry_config } = data
+
+  const handleRetryEnabledChange = (value: boolean) => {
+    handleRetryConfigChange({
+      retry_enabled: value,
+      max_retries: retry_config?.max_retries || 3,
+      retry_interval: retry_config?.retry_interval || 1000,
+    })
+  }
+
+  const handleMaxRetriesChange = (value: number) => {
+    if (value > 10)
+      value = 10
+    else if (value < 1)
+      value = 1
+    handleRetryConfigChange({
+      retry_enabled: true,
+      max_retries: value,
+      retry_interval: retry_config?.retry_interval || 1000,
+    })
+  }
+
+  const handleRetryIntervalChange = (value: number) => {
+    if (value > 5000)
+      value = 5000
+    else if (value < 100)
+      value = 100
+    handleRetryConfigChange({
+      retry_enabled: true,
+      max_retries: retry_config?.max_retries || 3,
+      retry_interval: value,
+    })
+  }
+
+  return (
+    <>
+      <div className='pt-2'>
+        <div className='flex items-center justify-between px-4 py-2 h-10'>
+          <div className='flex items-center'>
+            <div className='mr-0.5 system-sm-semibold-uppercase text-text-secondary'>{t('workflow.nodes.common.retry.retryOnFailure')}</div>
+          </div>
+          <Switch
+            defaultValue={retry_config?.retry_enabled}
+            onChange={v => handleRetryEnabledChange(v)}
+          />
+        </div>
+        {
+          retry_config?.retry_enabled && (
+            <div className='px-4 pb-2'>
+              <div className='flex items-center mb-1 w-full'>
+                <div className='grow mr-2 system-xs-medium-uppercase'>{t('workflow.nodes.common.retry.maxRetries')}</div>
+                <Slider
+                  className='mr-3 w-[108px]'
+                  value={retry_config?.max_retries || 3}
+                  onChange={handleMaxRetriesChange}
+                  min={1}
+                  max={10}
+                />
+                <Input
+                  type='number'
+                  wrapperClassName='w-[80px]'
+                  value={retry_config?.max_retries || 3}
+                  onChange={e => handleMaxRetriesChange(e.target.value as any)}
+                  min={1}
+                  max={10}
+                  unit={t('workflow.nodes.common.retry.times') || ''}
+                  className={s.input}
+                />
+              </div>
+              <div className='flex items-center'>
+                <div className='grow mr-2 system-xs-medium-uppercase'>{t('workflow.nodes.common.retry.retryInterval')}</div>
+                <Slider
+                  className='mr-3 w-[108px]'
+                  value={retry_config?.retry_interval || 1000}
+                  onChange={handleRetryIntervalChange}
+                  min={100}
+                  max={5000}
+                />
+                <Input
+                  type='number'
+                  wrapperClassName='w-[80px]'
+                  value={retry_config?.retry_interval || 1000}
+                  onChange={e => handleRetryIntervalChange(e.target.value as any)}
+                  min={100}
+                  max={5000}
+                  unit={t('workflow.nodes.common.retry.ms') || ''}
+                  className={s.input}
+                />
+              </div>
+            </div>
+          )
+        }
+      </div>
+      <Split className='mx-4 mt-2' />
+    </>
+  )
+}
+
+export default RetryOnPanel

+ 5 - 0
web/app/components/workflow/nodes/_base/components/retry/style.module.css

@@ -0,0 +1,5 @@
+.input::-webkit-inner-spin-button,
+.input::-webkit-outer-spin-button {
+  -webkit-appearance: none;
+  margin: 0;
+}

+ 5 - 0
web/app/components/workflow/nodes/_base/components/retry/types.ts

@@ -0,0 +1,5 @@
+export type WorkflowRetryConfig = {
+  max_retries: number
+  retry_interval: number
+  retry_enabled: boolean
+}

+ 0 - 0
web/app/components/workflow/nodes/_base/components/retry/utils.ts


+ 13 - 1
web/app/components/workflow/nodes/_base/node.tsx

@@ -25,7 +25,10 @@ import {
   useNodesReadOnly,
   useToolIcon,
 } from '../../hooks'
-import { hasErrorHandleNode } from '../../utils'
+import {
+  hasErrorHandleNode,
+  hasRetryNode,
+} from '../../utils'
 import { useNodeIterationInteractions } from '../iteration/use-interactions'
 import type { IterationNodeType } from '../iteration/types'
 import {
@@ -35,6 +38,7 @@ import {
 import NodeResizer from './components/node-resizer'
 import NodeControl from './components/node-control'
 import ErrorHandleOnNode from './components/error-handle/error-handle-on-node'
+import RetryOnNode from './components/retry/retry-on-node'
 import AddVariablePopupWithPosition from './components/add-variable-popup-with-position'
 import cn from '@/utils/classnames'
 import BlockIcon from '@/app/components/workflow/block-icon'
@@ -237,6 +241,14 @@ const BaseNode: FC<BaseNodeProps> = ({
             </div>
           )
         }
+        {
+          hasRetryNode(data.type) && (
+            <RetryOnNode
+              id={id}
+              data={data}
+            />
+          )
+        }
         {
           hasErrorHandleNode(data.type) && (
             <ErrorHandleOnNode

+ 12 - 0
web/app/components/workflow/nodes/_base/panel.tsx

@@ -21,9 +21,11 @@ import {
   TitleInput,
 } from './components/title-description-input'
 import ErrorHandleOnPanel from './components/error-handle/error-handle-on-panel'
+import RetryOnPanel from './components/retry/retry-on-panel'
 import { useResizePanel } from './hooks/use-resize-panel'
 import cn from '@/utils/classnames'
 import BlockIcon from '@/app/components/workflow/block-icon'
+import Split from '@/app/components/workflow/nodes/_base/components/split'
 import {
   WorkflowHistoryEvent,
   useAvailableBlocks,
@@ -38,6 +40,7 @@ import {
 import {
   canRunBySingle,
   hasErrorHandleNode,
+  hasRetryNode,
 } from '@/app/components/workflow/utils'
 import Tooltip from '@/app/components/base/tooltip'
 import type { Node } from '@/app/components/workflow/types'
@@ -168,6 +171,15 @@ const BasePanel: FC<BasePanelProps> = ({
         <div>
           {cloneElement(children, { id, data })}
         </div>
+        <Split />
+        {
+          hasRetryNode(data.type) && (
+            <RetryOnPanel
+              id={id}
+              data={data}
+            />
+          )
+        }
         {
           hasErrorHandleNode(data.type) && (
             <ErrorHandleOnPanel

+ 9 - 1
web/app/components/workflow/nodes/http/default.ts

@@ -2,7 +2,10 @@ import { BlockEnum } from '../../types'
 import type { NodeDefault } from '../../types'
 import { AuthorizationType, BodyType, Method } from './types'
 import type { BodyPayload, HttpNodeType } from './types'
-import { ALL_CHAT_AVAILABLE_BLOCKS, ALL_COMPLETION_AVAILABLE_BLOCKS } from '@/app/components/workflow/constants'
+import {
+  ALL_CHAT_AVAILABLE_BLOCKS,
+  ALL_COMPLETION_AVAILABLE_BLOCKS,
+} from '@/app/components/workflow/constants'
 
 const nodeDefault: NodeDefault<HttpNodeType> = {
   defaultValue: {
@@ -24,6 +27,11 @@ const nodeDefault: NodeDefault<HttpNodeType> = {
       max_read_timeout: 0,
       max_write_timeout: 0,
     },
+    retry_config: {
+      retry_enabled: true,
+      max_retries: 3,
+      retry_interval: 100,
+    },
   },
   getAvailablePrevNodes(isChatMode: boolean) {
     const nodes = isChatMode

+ 11 - 3
web/app/components/workflow/nodes/http/panel.tsx

@@ -1,5 +1,5 @@
 import type { FC } from 'react'
-import React from 'react'
+import { memo } from 'react'
 import { useTranslation } from 'react-i18next'
 import useConfig from './use-config'
 import ApiInput from './components/api-input'
@@ -18,6 +18,7 @@ import { FileArrow01 } from '@/app/components/base/icons/src/vender/line/files'
 import type { NodePanelProps } from '@/app/components/workflow/types'
 import BeforeRunForm from '@/app/components/workflow/nodes/_base/components/before-run-form'
 import ResultPanel from '@/app/components/workflow/run/result-panel'
+import { useRetryDetailShowInSingleRun } from '@/app/components/workflow/nodes/_base/components/retry/hooks'
 
 const i18nPrefix = 'workflow.nodes.http'
 
@@ -60,6 +61,10 @@ const Panel: FC<NodePanelProps<HttpNodeType>> = ({
     hideCurlPanel,
     handleCurlImport,
   } = useConfig(id, data)
+  const {
+    retryDetails,
+    handleRetryDetailsChange,
+  } = useRetryDetailShowInSingleRun()
   // To prevent prompt editor in body not update data.
   if (!isDataReady)
     return null
@@ -181,6 +186,7 @@ const Panel: FC<NodePanelProps<HttpNodeType>> = ({
       {isShowSingleRun && (
         <BeforeRunForm
           nodeName={inputs.title}
+          nodeType={inputs.type}
           onHide={hideSingleRun}
           forms={[
             {
@@ -192,7 +198,9 @@ const Panel: FC<NodePanelProps<HttpNodeType>> = ({
           runningStatus={runningStatus}
           onRun={handleRun}
           onStop={handleStop}
-          result={<ResultPanel {...runResult} showSteps={false} />}
+          retryDetails={retryDetails}
+          onRetryDetailBack={handleRetryDetailsChange}
+          result={<ResultPanel {...runResult} showSteps={false} onShowRetryDetail={handleRetryDetailsChange} />}
         />
       )}
       {(isShowCurlPanel && !readOnly) && (
@@ -207,4 +215,4 @@ const Panel: FC<NodePanelProps<HttpNodeType>> = ({
   )
 }
 
-export default React.memo(Panel)
+export default memo(Panel)

+ 9 - 1
web/app/components/workflow/nodes/llm/panel.tsx

@@ -19,6 +19,7 @@ import type { Props as FormProps } from '@/app/components/workflow/nodes/_base/c
 import ResultPanel from '@/app/components/workflow/run/result-panel'
 import Tooltip from '@/app/components/base/tooltip'
 import Editor from '@/app/components/workflow/nodes/_base/components/prompt/editor'
+import { useRetryDetailShowInSingleRun } from '@/app/components/workflow/nodes/_base/components/retry/hooks'
 
 const i18nPrefix = 'workflow.nodes.llm'
 
@@ -69,6 +70,10 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
     runResult,
     filterJinjia2InputVar,
   } = useConfig(id, data)
+  const {
+    retryDetails,
+    handleRetryDetailsChange,
+  } = useRetryDetailShowInSingleRun()
 
   const model = inputs.model
 
@@ -282,12 +287,15 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
       {isShowSingleRun && (
         <BeforeRunForm
           nodeName={inputs.title}
+          nodeType={inputs.type}
           onHide={hideSingleRun}
           forms={singleRunForms}
           runningStatus={runningStatus}
           onRun={handleRun}
           onStop={handleStop}
-          result={<ResultPanel {...runResult} showSteps={false} />}
+          retryDetails={retryDetails}
+          onRetryDetailBack={handleRetryDetailsChange}
+          result={<ResultPanel {...runResult} showSteps={false} onShowRetryDetail={handleRetryDetailsChange} />}
         />
       )}
     </div>

+ 12 - 1
web/app/components/workflow/nodes/tool/panel.tsx

@@ -14,6 +14,8 @@ import Loading from '@/app/components/base/loading'
 import BeforeRunForm from '@/app/components/workflow/nodes/_base/components/before-run-form'
 import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/components/output-vars'
 import ResultPanel from '@/app/components/workflow/run/result-panel'
+import { useRetryDetailShowInSingleRun } from '@/app/components/workflow/nodes/_base/components/retry/hooks'
+import { useToolIcon } from '@/app/components/workflow/hooks'
 
 const i18nPrefix = 'workflow.nodes.tool'
 
@@ -48,6 +50,11 @@ const Panel: FC<NodePanelProps<ToolNodeType>> = ({
     handleStop,
     runResult,
   } = useConfig(id, data)
+  const toolIcon = useToolIcon(data)
+  const {
+    retryDetails,
+    handleRetryDetailsChange,
+  } = useRetryDetailShowInSingleRun()
 
   if (isLoading) {
     return <div className='flex h-[200px] items-center justify-center'>
@@ -143,12 +150,16 @@ const Panel: FC<NodePanelProps<ToolNodeType>> = ({
       {isShowSingleRun && (
         <BeforeRunForm
           nodeName={inputs.title}
+          nodeType={inputs.type}
+          toolIcon={toolIcon}
           onHide={hideSingleRun}
           forms={singleRunForms}
           runningStatus={runningStatus}
           onRun={handleRun}
           onStop={handleStop}
-          result={<ResultPanel {...runResult} showSteps={false} />}
+          retryDetails={retryDetails}
+          onRetryDetailBack={handleRetryDetailsChange}
+          result={<ResultPanel {...runResult} showSteps={false} onShowRetryDetail={handleRetryDetailsChange} />}
         />
       )}
     </div>

+ 26 - 0
web/app/components/workflow/panel/debug-and-preview/hooks.ts

@@ -27,6 +27,7 @@ import {
   getProcessedFilesFromResponse,
 } from '@/app/components/base/file-uploader/utils'
 import type { FileEntity } from '@/app/components/base/file-uploader/types'
+import type { NodeTracing } from '@/types/workflow'
 
 type GetAbortController = (abortController: AbortController) => void
 type SendCallback = {
@@ -381,6 +382,28 @@ export const useChat = (
             }
           }))
         },
+        onNodeRetry: ({ data }) => {
+          if (data.iteration_id)
+            return
+
+          const currentIndex = responseItem.workflowProcess!.tracing!.findIndex((item) => {
+            if (!item.execution_metadata?.parallel_id)
+              return item.node_id === data.node_id
+            return item.node_id === data.node_id && (item.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || item.parallel_id === data.execution_metadata?.parallel_id)
+          })
+          if (responseItem.workflowProcess!.tracing[currentIndex].retryDetail)
+            responseItem.workflowProcess!.tracing[currentIndex].retryDetail?.push(data as NodeTracing)
+          else
+            responseItem.workflowProcess!.tracing[currentIndex].retryDetail = [data as NodeTracing]
+
+          handleUpdateChatList(produce(chatListRef.current, (draft) => {
+            const currentIndex = draft.findIndex(item => item.id === responseItem.id)
+            draft[currentIndex] = {
+              ...draft[currentIndex],
+              ...responseItem,
+            }
+          }))
+        },
         onNodeFinished: ({ data }) => {
           if (data.iteration_id)
             return
@@ -394,6 +417,9 @@ export const useChat = (
             ...(responseItem.workflowProcess!.tracing[currentIndex]?.extras
               ? { extras: responseItem.workflowProcess!.tracing[currentIndex].extras }
               : {}),
+            ...(responseItem.workflowProcess!.tracing[currentIndex]?.retryDetail
+              ? { retryDetail: responseItem.workflowProcess!.tracing[currentIndex].retryDetail }
+              : {}),
             ...data,
           } as any
           handleUpdateChatList(produce(chatListRef.current, (draft) => {

+ 21 - 2
web/app/components/workflow/panel/workflow-preview.tsx

@@ -25,6 +25,7 @@ import {
 import { SimpleBtn } from '../../app/text-generate/item'
 import Toast from '../../base/toast'
 import IterationResultPanel from '../run/iteration-result-panel'
+import RetryResultPanel from '../run/retry-result-panel'
 import InputsPanel from './inputs-panel'
 import cn from '@/utils/classnames'
 import Loading from '@/app/components/base/loading'
@@ -53,11 +54,16 @@ const WorkflowPreview = () => {
   }, [workflowRunningData])
 
   const [iterationRunResult, setIterationRunResult] = useState<NodeTracing[][]>([])
+  const [retryRunResult, setRetryRunResult] = useState<NodeTracing[]>([])
   const [iterDurationMap, setIterDurationMap] = useState<IterationDurationMap>({})
   const [isShowIterationDetail, {
     setTrue: doShowIterationDetail,
     setFalse: doHideIterationDetail,
   }] = useBoolean(false)
+  const [isShowRetryDetail, {
+    setTrue: doShowRetryDetail,
+    setFalse: doHideRetryDetail,
+  }] = useBoolean(false)
 
   const handleShowIterationDetail = useCallback((detail: NodeTracing[][], iterationDurationMap: IterationDurationMap) => {
     setIterDurationMap(iterationDurationMap)
@@ -65,6 +71,11 @@ const WorkflowPreview = () => {
     doShowIterationDetail()
   }, [doShowIterationDetail])
 
+  const handleRetryDetail = useCallback((detail: NodeTracing[]) => {
+    setRetryRunResult(detail)
+    doShowRetryDetail()
+  }, [doShowRetryDetail])
+
   if (isShowIterationDetail) {
     return (
       <div className={`
@@ -201,11 +212,12 @@ const WorkflowPreview = () => {
                     <Loading />
                   </div>
                 )}
-                {currentTab === 'TRACING' && (
+                {currentTab === 'TRACING' && !isShowRetryDetail && (
                   <TracingPanel
                     className='bg-background-section-burn'
                     list={workflowRunningData?.tracing || []}
                     onShowIterationDetail={handleShowIterationDetail}
+                    onShowRetryDetail={handleRetryDetail}
                   />
                 )}
                 {currentTab === 'TRACING' && !workflowRunningData?.tracing?.length && (
@@ -213,7 +225,14 @@ const WorkflowPreview = () => {
                     <Loading />
                   </div>
                 )}
-
+                {
+                  currentTab === 'TRACING' && isShowRetryDetail && (
+                    <RetryResultPanel
+                      list={retryRunResult}
+                      onBack={doHideRetryDetail}
+                    />
+                  )
+                }
               </div>
             </>
           )}

+ 33 - 1
web/app/components/workflow/run/index.tsx

@@ -9,6 +9,7 @@ import OutputPanel from './output-panel'
 import ResultPanel from './result-panel'
 import TracingPanel from './tracing-panel'
 import IterationResultPanel from './iteration-result-panel'
+import RetryResultPanel from './retry-result-panel'
 import cn from '@/utils/classnames'
 import { ToastContext } from '@/app/components/base/toast'
 import Loading from '@/app/components/base/loading'
@@ -107,6 +108,18 @@ const RunPanel: FC<RunProps> = ({ hideResult, activeTab = 'RESULT', runID, getRe
     const processNonIterationNode = (item: NodeTracing) => {
       const { execution_metadata } = item
       if (!execution_metadata?.iteration_id) {
+        if (item.status === 'retry') {
+          const retryNode = result.find(node => node.node_id === item.node_id)
+
+          if (retryNode) {
+            if (retryNode?.retryDetail)
+              retryNode.retryDetail.push(item)
+            else
+              retryNode.retryDetail = [item]
+          }
+
+          return
+        }
         result.push(item)
         return
       }
@@ -181,10 +194,15 @@ const RunPanel: FC<RunProps> = ({ hideResult, activeTab = 'RESULT', runID, getRe
 
   const [iterationRunResult, setIterationRunResult] = useState<NodeTracing[][]>([])
   const [iterDurationMap, setIterDurationMap] = useState<IterationDurationMap>({})
+  const [retryRunResult, setRetryRunResult] = useState<NodeTracing[]>([])
   const [isShowIterationDetail, {
     setTrue: doShowIterationDetail,
     setFalse: doHideIterationDetail,
   }] = useBoolean(false)
+  const [isShowRetryDetail, {
+    setTrue: doShowRetryDetail,
+    setFalse: doHideRetryDetail,
+  }] = useBoolean(false)
 
   const handleShowIterationDetail = useCallback((detail: NodeTracing[][], iterDurationMap: IterationDurationMap) => {
     setIterationRunResult(detail)
@@ -192,6 +210,11 @@ const RunPanel: FC<RunProps> = ({ hideResult, activeTab = 'RESULT', runID, getRe
     setIterDurationMap(iterDurationMap)
   }, [doShowIterationDetail, setIterationRunResult, setIterDurationMap])
 
+  const handleShowRetryDetail = useCallback((detail: NodeTracing[]) => {
+    setRetryRunResult(detail)
+    doShowRetryDetail()
+  }, [doShowRetryDetail, setRetryRunResult])
+
   if (isShowIterationDetail) {
     return (
       <div className='grow relative flex flex-col'>
@@ -261,13 +284,22 @@ const RunPanel: FC<RunProps> = ({ hideResult, activeTab = 'RESULT', runID, getRe
             exceptionCounts={runDetail.exceptions_count}
           />
         )}
-        {!loading && currentTab === 'TRACING' && (
+        {!loading && currentTab === 'TRACING' && !isShowRetryDetail && (
           <TracingPanel
             className='bg-background-section-burn'
             list={list}
             onShowIterationDetail={handleShowIterationDetail}
+            onShowRetryDetail={handleShowRetryDetail}
           />
         )}
+        {
+          !loading && currentTab === 'TRACING' && isShowRetryDetail && (
+            <RetryResultPanel
+              list={retryRunResult}
+              onBack={doHideRetryDetail}
+            />
+          )
+        }
       </div>
     </div>
   )

+ 24 - 0
web/app/components/workflow/run/node.tsx

@@ -8,6 +8,7 @@ import {
   RiCheckboxCircleFill,
   RiErrorWarningLine,
   RiLoader2Line,
+  RiRestartFill,
 } from '@remixicon/react'
 import BlockIcon from '../block-icon'
 import { BlockEnum } from '../types'
@@ -20,6 +21,7 @@ import Button from '@/app/components/base/button'
 import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
 import type { IterationDurationMap, NodeTracing } from '@/types/workflow'
 import ErrorHandleTip from '@/app/components/workflow/nodes/_base/components/error-handle/error-handle-tip'
+import { hasRetryNode } from '@/app/components/workflow/utils'
 
 type Props = {
   className?: string
@@ -28,8 +30,10 @@ type Props = {
   hideInfo?: boolean
   hideProcessDetail?: boolean
   onShowIterationDetail?: (detail: NodeTracing[][], iterDurationMap: IterationDurationMap) => void
+  onShowRetryDetail?: (detail: NodeTracing[]) => void
   notShowIterationNav?: boolean
   justShowIterationNavArrow?: boolean
+  justShowRetryNavArrow?: boolean
 }
 
 const NodePanel: FC<Props> = ({
@@ -39,6 +43,7 @@ const NodePanel: FC<Props> = ({
   hideInfo = false,
   hideProcessDetail,
   onShowIterationDetail,
+  onShowRetryDetail,
   notShowIterationNav,
   justShowIterationNavArrow,
 }) => {
@@ -88,11 +93,17 @@ const NodePanel: FC<Props> = ({
   }, [nodeInfo.expand, setCollapseState])
 
   const isIterationNode = nodeInfo.node_type === BlockEnum.Iteration
+  const isRetryNode = hasRetryNode(nodeInfo.node_type) && nodeInfo.retryDetail
   const handleOnShowIterationDetail = (e: React.MouseEvent<HTMLButtonElement>) => {
     e.stopPropagation()
     e.nativeEvent.stopImmediatePropagation()
     onShowIterationDetail?.(nodeInfo.details || [], nodeInfo?.iterDurationMap || nodeInfo.execution_metadata?.iteration_duration_map || {})
   }
+  const handleOnShowRetryDetail = (e: React.MouseEvent<HTMLButtonElement>) => {
+    e.stopPropagation()
+    e.nativeEvent.stopImmediatePropagation()
+    onShowRetryDetail?.(nodeInfo.retryDetail || [])
+  }
   return (
     <div className={cn('px-2 py-1', className)}>
       <div className='group transition-all bg-background-default border border-components-panel-border rounded-[10px] shadow-xs hover:shadow-md'>
@@ -169,6 +180,19 @@ const NodePanel: FC<Props> = ({
                 <Split className='mt-2' />
               </div>
             )}
+            {isRetryNode && (
+              <Button
+                className='flex items-center justify-between mb-1 w-full'
+                variant='tertiary'
+                onClick={handleOnShowRetryDetail}
+              >
+                <div className='flex items-center'>
+                  <RiRestartFill className='mr-0.5 w-4 h-4 text-components-button-tertiary-text flex-shrink-0' />
+                  {t('workflow.nodes.common.retry.retries', { num: nodeInfo.retryDetail?.length })}
+                </div>
+                <RiArrowRightSLine className='w-4 h-4 text-components-button-tertiary-text flex-shrink-0' />
+              </Button>
+            )}
             <div className={cn('mb-1', hideInfo && '!px-2 !py-0.5')}>
               {(nodeInfo.status === 'stopped') && (
                 <StatusContainer status='stopped'>

+ 28 - 0
web/app/components/workflow/run/result-panel.tsx

@@ -1,11 +1,17 @@
 'use client'
 import type { FC } from 'react'
 import { useTranslation } from 'react-i18next'
+import {
+  RiArrowRightSLine,
+  RiRestartFill,
+} from '@remixicon/react'
 import StatusPanel from './status'
 import MetaData from './meta'
 import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
 import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
 import ErrorHandleTip from '@/app/components/workflow/nodes/_base/components/error-handle/error-handle-tip'
+import type { NodeTracing } from '@/types/workflow'
+import Button from '@/app/components/base/button'
 
 type ResultPanelProps = {
   inputs?: string
@@ -22,6 +28,8 @@ type ResultPanelProps = {
   showSteps?: boolean
   exceptionCounts?: number
   execution_metadata?: any
+  retry_events?: NodeTracing[]
+  onShowRetryDetail?: (retries: NodeTracing[]) => void
 }
 
 const ResultPanel: FC<ResultPanelProps> = ({
@@ -38,8 +46,11 @@ const ResultPanel: FC<ResultPanelProps> = ({
   showSteps,
   exceptionCounts,
   execution_metadata,
+  retry_events,
+  onShowRetryDetail,
 }) => {
   const { t } = useTranslation()
+
   return (
     <div className='bg-components-panel-bg py-2'>
       <div className='px-4 py-2'>
@@ -51,6 +62,23 @@ const ResultPanel: FC<ResultPanelProps> = ({
           exceptionCounts={exceptionCounts}
         />
       </div>
+      {
+        retry_events?.length && onShowRetryDetail && (
+          <div className='px-4'>
+            <Button
+              className='flex items-center justify-between w-full'
+              variant='tertiary'
+              onClick={() => onShowRetryDetail(retry_events)}
+            >
+              <div className='flex items-center'>
+                <RiRestartFill className='mr-0.5 w-4 h-4 text-components-button-tertiary-text flex-shrink-0' />
+                {t('workflow.nodes.common.retry.retries', { num: retry_events?.length })}
+              </div>
+              <RiArrowRightSLine className='w-4 h-4 text-components-button-tertiary-text flex-shrink-0' />
+            </Button>
+          </div>
+        )
+      }
       <div className='px-4 py-2 flex flex-col gap-2'>
         <CodeEditor
           readOnly

+ 46 - 0
web/app/components/workflow/run/retry-result-panel.tsx

@@ -0,0 +1,46 @@
+'use client'
+
+import type { FC } from 'react'
+import { memo } from 'react'
+import { useTranslation } from 'react-i18next'
+import {
+  RiArrowLeftLine,
+} from '@remixicon/react'
+import TracingPanel from './tracing-panel'
+import type { NodeTracing } from '@/types/workflow'
+
+type Props = {
+  list: NodeTracing[]
+  onBack: () => void
+}
+
+const RetryResultPanel: FC<Props> = ({
+  list,
+  onBack,
+}) => {
+  const { t } = useTranslation()
+
+  return (
+    <div>
+      <div
+        className='flex items-center px-4 h-8 text-text-accent-secondary bg-components-panel-bg system-sm-medium cursor-pointer'
+        onClick={(e) => {
+          e.stopPropagation()
+          e.nativeEvent.stopImmediatePropagation()
+          onBack()
+        }}
+      >
+        <RiArrowLeftLine className='mr-1 w-4 h-4' />
+        {t('workflow.singleRun.back')}
+      </div>
+      <TracingPanel
+        list={list.map((item, index) => ({
+          ...item,
+          title: `${t('workflow.nodes.common.retry.retry')} ${index + 1}`,
+        }))}
+        className='bg-background-section-burn'
+      />
+    </div >
+  )
+}
+export default memo(RetryResultPanel)

+ 4 - 0
web/app/components/workflow/run/tracing-panel.tsx

@@ -21,6 +21,7 @@ import type { IterationDurationMap, NodeTracing } from '@/types/workflow'
 type TracingPanelProps = {
   list: NodeTracing[]
   onShowIterationDetail?: (detail: NodeTracing[][], iterDurationMap: IterationDurationMap) => void
+  onShowRetryDetail?: (detail: NodeTracing[]) => void
   className?: string
   hideNodeInfo?: boolean
   hideNodeProcessDetail?: boolean
@@ -160,6 +161,7 @@ function buildLogTree(nodes: NodeTracing[], t: (key: string) => string): Tracing
 const TracingPanel: FC<TracingPanelProps> = ({
   list,
   onShowIterationDetail,
+  onShowRetryDetail,
   className,
   hideNodeInfo = false,
   hideNodeProcessDetail = false,
@@ -251,7 +253,9 @@ const TracingPanel: FC<TracingPanelProps> = ({
           <NodePanel
             nodeInfo={node.data!}
             onShowIterationDetail={onShowIterationDetail}
+            onShowRetryDetail={onShowRetryDetail}
             justShowIterationNavArrow={true}
+            justShowRetryNavArrow={true}
             hideInfo={hideNodeInfo}
             hideProcessDetail={hideNodeProcessDetail}
           />

+ 4 - 0
web/app/components/workflow/types.ts

@@ -13,6 +13,7 @@ import type {
   DefaultValueForm,
   ErrorHandleTypeEnum,
 } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
+import type { WorkflowRetryConfig } from '@/app/components/workflow/nodes/_base/components/retry/types'
 
 export enum BlockEnum {
   Start = 'start',
@@ -68,6 +69,7 @@ export type CommonNodeType<T = {}> = {
   _iterationIndex?: number
   _inParallelHovering?: boolean
   _waitingRun?: boolean
+  _retryIndex?: number
   isInIteration?: boolean
   iteration_id?: string
   selected?: boolean
@@ -77,6 +79,7 @@ export type CommonNodeType<T = {}> = {
   width?: number
   height?: number
   error_strategy?: ErrorHandleTypeEnum
+  retry_config?: WorkflowRetryConfig
   default_value?: DefaultValueForm[]
 } & T & Partial<Pick<ToolDefaultValue, 'provider_id' | 'provider_type' | 'provider_name' | 'tool_name'>>
 
@@ -293,6 +296,7 @@ export enum NodeRunningStatus {
   Succeeded = 'succeeded',
   Failed = 'failed',
   Exception = 'exception',
+  Retry = 'retry',
 }
 
 export type OnNodeAdd = (

+ 14 - 0
web/app/components/workflow/utils.ts

@@ -26,6 +26,8 @@ import {
 } from './types'
 import {
   CUSTOM_NODE,
+  DEFAULT_RETRY_INTERVAL,
+  DEFAULT_RETRY_MAX,
   ITERATION_CHILDREN_Z_INDEX,
   ITERATION_NODE_Z_INDEX,
   NODE_WIDTH_X_OFFSET,
@@ -279,6 +281,14 @@ export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => {
       iterationNodeData.error_handle_mode = iterationNodeData.error_handle_mode || ErrorHandleMode.Terminated
     }
 
+    if (node.data.type === BlockEnum.HttpRequest && !node.data.retry_config) {
+      node.data.retry_config = {
+        retry_enabled: true,
+        max_retries: DEFAULT_RETRY_MAX,
+        retry_interval: DEFAULT_RETRY_INTERVAL,
+      }
+    }
+
     return node
   })
 }
@@ -797,3 +807,7 @@ export const isExceptionVariable = (variable: string, nodeType?: BlockEnum) => {
 
   return false
 }
+
+export const hasRetryNode = (nodeType?: BlockEnum) => {
+  return nodeType === BlockEnum.LLM || nodeType === BlockEnum.Tool || nodeType === BlockEnum.HttpRequest || nodeType === BlockEnum.Code
+}

+ 14 - 0
web/i18n/en-US/workflow.ts

@@ -329,6 +329,20 @@ const translation = {
           tip: 'There are {{num}} nodes in the process running abnormally, please go to tracing to check the logs.',
         },
       },
+      retry: {
+        retry: 'Retry',
+        retryOnFailure: 'retry on failure',
+        maxRetries: 'max retries',
+        retryInterval: 'retry interval',
+        retryTimes: 'Retry {{times}} times on failure',
+        retrying: 'Retrying...',
+        retrySuccessful: 'Retry successful',
+        retryFailed: 'Retry failed',
+        retryFailedTimes: '{{times}} retries failed',
+        times: 'times',
+        ms: 'ms',
+        retries: '{{num}} Retries',
+      },
     },
     start: {
       required: 'required',

+ 14 - 0
web/i18n/zh-Hans/workflow.ts

@@ -329,6 +329,20 @@ const translation = {
           tip: '流程中有 {{num}} 个节点运行异常,请前往追踪查看日志。',
         },
       },
+      retry: {
+        retry: '重试',
+        retryOnFailure: '失败时重试',
+        maxRetries: '最大重试次数',
+        retryInterval: '重试间隔',
+        retryTimes: '失败时重试 {{times}} 次',
+        retrying: '重试中...',
+        retrySuccessful: '重试成功',
+        retryFailed: '重试失败',
+        retryFailedTimes: '{{times}} 次重试失败',
+        times: '次',
+        ms: '毫秒',
+        retries: '{{num}} 重试次数',
+      },
     },
     start: {
       required: '必填',

+ 8 - 1
web/service/base.ts

@@ -62,6 +62,7 @@ export type IOnNodeStarted = (nodeStarted: NodeStartedResponse) => void
 export type IOnNodeFinished = (nodeFinished: NodeFinishedResponse) => void
 export type IOnIterationStarted = (workflowStarted: IterationStartedResponse) => void
 export type IOnIterationNext = (workflowStarted: IterationNextResponse) => void
+export type IOnNodeRetry = (nodeFinished: NodeFinishedResponse) => void
 export type IOnIterationFinished = (workflowFinished: IterationFinishedResponse) => void
 export type IOnParallelBranchStarted = (parallelBranchStarted: ParallelBranchStartedResponse) => void
 export type IOnParallelBranchFinished = (parallelBranchFinished: ParallelBranchFinishedResponse) => void
@@ -92,6 +93,7 @@ export type IOtherOptions = {
   onIterationStart?: IOnIterationStarted
   onIterationNext?: IOnIterationNext
   onIterationFinish?: IOnIterationFinished
+  onNodeRetry?: IOnNodeRetry
   onParallelBranchStarted?: IOnParallelBranchStarted
   onParallelBranchFinished?: IOnParallelBranchFinished
   onTextChunk?: IOnTextChunk
@@ -165,6 +167,7 @@ const handleStream = (
   onIterationStart?: IOnIterationStarted,
   onIterationNext?: IOnIterationNext,
   onIterationFinish?: IOnIterationFinished,
+  onNodeRetry?: IOnNodeRetry,
   onParallelBranchStarted?: IOnParallelBranchStarted,
   onParallelBranchFinished?: IOnParallelBranchFinished,
   onTextChunk?: IOnTextChunk,
@@ -256,6 +259,9 @@ const handleStream = (
             else if (bufferObj.event === 'iteration_completed') {
               onIterationFinish?.(bufferObj as IterationFinishedResponse)
             }
+            else if (bufferObj.event === 'node_retry') {
+              onNodeRetry?.(bufferObj as NodeFinishedResponse)
+            }
             else if (bufferObj.event === 'parallel_branch_started') {
               onParallelBranchStarted?.(bufferObj as ParallelBranchStartedResponse)
             }
@@ -462,6 +468,7 @@ export const ssePost = (
     onIterationStart,
     onIterationNext,
     onIterationFinish,
+    onNodeRetry,
     onParallelBranchStarted,
     onParallelBranchFinished,
     onTextChunk,
@@ -533,7 +540,7 @@ export const ssePost = (
           return
         }
         onData?.(str, isFirstMessage, moreInfo)
-      }, onCompleted, onThought, onMessageEnd, onMessageReplace, onFile, onWorkflowStarted, onWorkflowFinished, onNodeStarted, onNodeFinished, onIterationStart, onIterationNext, onIterationFinish, onParallelBranchStarted, onParallelBranchFinished, onTextChunk, onTTSChunk, onTTSEnd, onTextReplace)
+      }, onCompleted, onThought, onMessageEnd, onMessageReplace, onFile, onWorkflowStarted, onWorkflowFinished, onNodeStarted, onNodeFinished, onIterationStart, onIterationNext, onIterationFinish, onNodeRetry, onParallelBranchStarted, onParallelBranchFinished, onTextChunk, onTTSChunk, onTTSEnd, onTextReplace)
     }).catch((e) => {
       if (e.toString() !== 'AbortError: The user aborted a request.' && !e.toString().errorMessage.includes('TypeError: Cannot assign to read only property'))
         Toast.notify({ type: 'error', message: e })

+ 3 - 0
web/types/workflow.ts

@@ -52,10 +52,12 @@ export type NodeTracing = {
   extras?: any
   expand?: boolean // for UI
   details?: NodeTracing[][] // iteration detail
+  retryDetail?: NodeTracing[] // retry detail
   parallel_id?: string
   parallel_start_node_id?: string
   parent_parallel_id?: string
   parent_parallel_start_node_id?: string
+  retry_index?: number
 }
 
 export type FetchWorkflowDraftResponse = {
@@ -178,6 +180,7 @@ export type NodeFinishedResponse = {
     }
     created_at: number
     files?: FileResponse[]
+    retry_index?: number
   }
 }