feat: last run frontend (#21369)

The frontend of feat: Persist Variables for Enhanced Debugging Workflow (#20699).

Co-authored-by: jZonG <jzongcode@gmail.com>
This commit is contained in:
Joel
2025-06-24 09:10:30 +08:00
committed by GitHub
parent 10b738a296
commit 1a1bfd4048
122 changed files with 5888 additions and 2061 deletions

View File

@@ -95,6 +95,7 @@ const FormItem: FC<Props> = ({
const isArrayLikeType = [InputVarType.contexts, InputVarType.iterator].includes(type)
const isContext = type === InputVarType.contexts
const isIterator = type === InputVarType.iterator
const isIteratorItemFile = isIterator && payload.isFileItem
const singleFileValue = useMemo(() => {
if (payload.variable === '#files#')
return value?.[0] || []
@@ -202,12 +203,12 @@ const FormItem: FC<Props> = ({
}}
/>
)}
{(type === InputVarType.multiFiles) && (
{(type === InputVarType.multiFiles || isIteratorItemFile) && (
<FileUploaderInAttachmentWrapper
value={value}
onChange={files => onChange(files)}
fileConfig={{
allowed_file_types: inStepRun
allowed_file_types: (inStepRun || isIteratorItemFile)
? [
SupportUploadFileTypes.image,
SupportUploadFileTypes.document,
@@ -215,7 +216,7 @@ const FormItem: FC<Props> = ({
SupportUploadFileTypes.video,
]
: payload.allowed_file_types,
allowed_file_extensions: inStepRun
allowed_file_extensions: (inStepRun || isIteratorItemFile)
? [
...FILE_EXTS[SupportUploadFileTypes.image],
...FILE_EXTS[SupportUploadFileTypes.document],
@@ -223,8 +224,8 @@ const FormItem: FC<Props> = ({
...FILE_EXTS[SupportUploadFileTypes.video],
]
: payload.allowed_file_extensions,
allowed_file_upload_methods: inStepRun ? [TransferMethod.local_file, TransferMethod.remote_url] : payload.allowed_file_upload_methods,
number_limits: inStepRun ? 5 : payload.max_length,
allowed_file_upload_methods: (inStepRun || isIteratorItemFile) ? [TransferMethod.local_file, TransferMethod.remote_url] : payload.allowed_file_upload_methods,
number_limits: (inStepRun || isIteratorItemFile) ? 5 : payload.max_length,
fileUploadConfig: fileSettings?.fileUploadConfig,
}}
/>
@@ -272,7 +273,7 @@ const FormItem: FC<Props> = ({
}
{
isIterator && (
(isIterator && !isIteratorItemFile) && (
<div className='space-y-2'>
{(value || []).map((item: any, index: number) => (
<TextEditor

View File

@@ -61,10 +61,14 @@ const Form: FC<Props> = ({
}
}, [valuesRef, onChange, mapKeysWithSameValueSelector])
const isArrayLikeType = [InputVarType.contexts, InputVarType.iterator].includes(inputs[0]?.type)
const isIteratorItemFile = inputs[0]?.type === InputVarType.iterator && inputs[0]?.isFileItem
const isContext = inputs[0]?.type === InputVarType.contexts
const handleAddContext = useCallback(() => {
const newValues = produce(values, (draft: any) => {
const key = inputs[0].variable
if (!draft[key])
draft[key] = []
draft[key].push(isContext ? RETRIEVAL_OUTPUT_STRUCT : '')
})
onChange(newValues)
@@ -75,7 +79,7 @@ const Form: FC<Props> = ({
{label && (
<div className='mb-1 flex items-center justify-between'>
<div className='system-xs-medium-uppercase flex h-6 items-center text-text-tertiary'>{label}</div>
{isArrayLikeType && (
{isArrayLikeType && !isIteratorItemFile && (
<AddButton onClick={handleAddContext} />
)}
</div>

View File

@@ -1,30 +1,23 @@
'use client'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import React, { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiCloseLine,
RiLoader2Line,
} from '@remixicon/react'
import type { Props as FormProps } from './form'
import Form from './form'
import cn from '@/utils/classnames'
import Button from '@/app/components/base/button'
import { StopCircle } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
import Split from '@/app/components/workflow/nodes/_base/components/split'
import { InputVarType, NodeRunningStatus } from '@/app/components/workflow/types'
import ResultPanel from '@/app/components/workflow/run/result-panel'
import { InputVarType } from '@/app/components/workflow/types'
import Toast from '@/app/components/base/toast'
import { TransferMethod } from '@/types/app'
import { getProcessedFiles } from '@/app/components/base/file-uploader/utils'
import type { BlockEnum } from '@/app/components/workflow/types'
import type { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types'
import type { Emoji } from '@/app/components/tools/types'
import type { SpecialResultPanelProps } from '@/app/components/workflow/run/special-result-panel'
import SpecialResultPanel from '@/app/components/workflow/run/special-result-panel'
import PanelWrap from './panel-wrap'
const i18nPrefix = 'workflow.singleRun'
type BeforeRunFormProps = {
export type BeforeRunFormProps = {
nodeName: string
nodeType?: BlockEnum
toolIcon?: string | Emoji
@@ -32,12 +25,15 @@ type BeforeRunFormProps = {
onRun: (submitData: Record<string, any>) => void
onStop: () => void
runningStatus: NodeRunningStatus
result?: React.JSX.Element
forms: FormProps[]
showSpecialResultPanel?: boolean
existVarValuesInForms: Record<string, any>[]
filteredExistVarForms: FormProps[]
} & Partial<SpecialResultPanelProps>
function formatValue(value: string | any, type: InputVarType) {
if(value === undefined || value === null)
return value
if (type === InputVarType.number)
return Number.parseFloat(value)
if (type === InputVarType.json)
@@ -53,6 +49,8 @@ function formatValue(value: string | any, type: InputVarType) {
if (type === InputVarType.singleFile) {
if (Array.isArray(value))
return getProcessedFiles(value)
if (!value)
return undefined
return getProcessedFiles([value])[0]
}
@@ -60,22 +58,17 @@ function formatValue(value: string | any, type: InputVarType) {
}
const BeforeRunForm: FC<BeforeRunFormProps> = ({
nodeName,
nodeType,
toolIcon,
onHide,
onRun,
onStop,
runningStatus,
result,
forms,
showSpecialResultPanel,
...restResultPanelParams
filteredExistVarForms,
existVarValuesInForms,
}) => {
const { t } = useTranslation()
const isFinished = runningStatus === NodeRunningStatus.Succeeded || runningStatus === NodeRunningStatus.Failed || runningStatus === NodeRunningStatus.Exception
const isRunning = runningStatus === NodeRunningStatus.Running
const isFileLoaded = (() => {
if (!forms || forms.length === 0)
return true
// system files
const filesForm = forms.find(item => !!item.values['#files#'])
if (!filesForm)
@@ -87,12 +80,14 @@ const BeforeRunForm: FC<BeforeRunFormProps> = ({
return true
})()
const handleRun = useCallback(() => {
const handleRun = () => {
let errMsg = ''
forms.forEach((form) => {
forms.forEach((form, i) => {
const existVarValuesInForm = existVarValuesInForms[i]
form.inputs.forEach((input) => {
const value = form.values[input.variable] as any
if (!errMsg && input.required && (value === '' || value === undefined || value === null || (input.type === InputVarType.files && value.length === 0)))
if (!errMsg && input.required && !(input.variable in existVarValuesInForm) && (value === '' || value === undefined || value === null || (input.type === InputVarType.files && value.length === 0)))
errMsg = t('workflow.errorMsg.fieldRequired', { field: typeof input.label === 'object' ? input.label.variable : input.label })
if (!errMsg && (input.type === InputVarType.singleFile || input.type === InputVarType.multiFiles) && value) {
@@ -137,69 +132,45 @@ const BeforeRunForm: FC<BeforeRunFormProps> = ({
}
onRun(submitData)
}, [forms, onRun, t])
}
const hasRun = useRef(false)
useEffect(() => {
// React 18 run twice in dev mode
if(hasRun.current)
return
hasRun.current = true
if(filteredExistVarForms.length === 0)
onRun({})
}, [filteredExistVarForms, onRun])
if(filteredExistVarForms.length === 0)
return null
return (
<div className='absolute inset-0 z-10 rounded-2xl bg-background-overlay-alt pt-10'>
<div className='flex h-full flex-col rounded-2xl bg-components-panel-bg'>
<div className='flex h-8 shrink-0 items-center justify-between pl-4 pr-3 pt-3'>
<div className='truncate text-base font-semibold text-text-primary'>
{t(`${i18nPrefix}.testRun`)} {nodeName}
</div>
<div className='ml-2 shrink-0 cursor-pointer p-1' onClick={() => {
onHide()
}}>
<RiCloseLine className='h-4 w-4 text-text-tertiary ' />
</div>
<PanelWrap
nodeName={nodeName}
onHide={onHide}
>
<div className='h-0 grow overflow-y-auto pb-4'>
<div className='mt-3 space-y-4 px-4'>
{filteredExistVarForms.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 className='mt-4 flex justify-between space-x-2 px-4' >
<Button disabled={!isFileLoaded} variant='primary' className='w-0 grow space-x-2' onClick={handleRun}>
<div>{t(`${i18nPrefix}.startRun`)}</div>
</Button>
</div>
{
showSpecialResultPanel && (
<div className='h-0 grow overflow-y-auto pb-4'>
<SpecialResultPanel {...restResultPanelParams} />
</div>
)
}
{
!showSpecialResultPanel && (
<div className='h-0 grow overflow-y-auto pb-4'>
<div className='mt-3 space-y-4 px-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 className='mt-4 flex justify-between space-x-2 px-4' >
{isRunning && (
<div
className='cursor-pointer rounded-lg border border-divider-regular bg-components-button-secondary-bg p-2 shadow-xs'
onClick={onStop}
>
<StopCircle className='h-4 w-4 text-text-tertiary' />
</div>
)}
<Button disabled={!isFileLoaded || isRunning} variant='primary' className='w-0 grow space-x-2' onClick={handleRun}>
{isRunning && <RiLoader2Line className='h-4 w-4 animate-spin' />}
<div>{t(`${i18nPrefix}.${isRunning ? 'running' : 'startRun'}`)}</div>
</Button>
</div>
{isRunning && (
<ResultPanel status='running' showSteps={false} />
)}
{isFinished && (
<>
{result}
</>
)}
</div>
)
}
</div>
</div>
</PanelWrap>
)
}
export default React.memo(BeforeRunForm)

View File

@@ -0,0 +1,41 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import {
RiCloseLine,
} from '@remixicon/react'
const i18nPrefix = 'workflow.singleRun'
export type Props = {
nodeName: string
onHide: () => void
children: React.ReactNode
}
const PanelWrap: FC<Props> = ({
nodeName,
onHide,
children,
}) => {
const { t } = useTranslation()
return (
<div className='absolute inset-0 z-10 rounded-2xl bg-background-overlay-alt'>
<div className='flex h-full flex-col rounded-2xl bg-components-panel-bg'>
<div className='flex h-8 shrink-0 items-center justify-between pl-4 pr-3 pt-3'>
<div className='truncate text-base font-semibold text-text-primary'>
{t(`${i18nPrefix}.testRun`)} {nodeName}
</div>
<div className='ml-2 shrink-0 cursor-pointer p-1' onClick={() => {
onHide()
}}>
<RiCloseLine className='h-4 w-4 text-text-tertiary ' />
</div>
</div>
{children}
</div>
</div>
)
}
export default React.memo(PanelWrap)

View File

@@ -13,7 +13,7 @@ import {
useNodesInteractions,
useNodesSyncDraft,
} from '../../../hooks'
import type { Node } from '../../../types'
import { type Node, NodeRunningStatus } from '../../../types'
import { canRunBySingle } from '../../../utils'
import PanelOperator from './panel-operator'
import {
@@ -31,11 +31,12 @@ const NodeControl: FC<NodeControlProps> = ({
const { handleNodeDataUpdate } = useNodeDataUpdate()
const { handleNodeSelect } = useNodesInteractions()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const isSingleRunning = data._singleRunningStatus === NodeRunningStatus.Running
const handleOpenChange = useCallback((newOpen: boolean) => {
setOpen(newOpen)
}, [])
const isChildNode = !!(data.isInIteration || data.isInLoop)
return (
<div
className={`
@@ -49,23 +50,25 @@ const NodeControl: FC<NodeControlProps> = ({
onClick={e => e.stopPropagation()}
>
{
canRunBySingle(data.type) && (
canRunBySingle(data.type, isChildNode) && (
<div
className='flex h-5 w-5 cursor-pointer items-center justify-center rounded-md hover:bg-state-base-hover'
onClick={() => {
const nextData: Record<string, any> = {
_isSingleRun: !isSingleRunning,
}
if(isSingleRunning)
nextData._singleRunningStatus = undefined
handleNodeDataUpdate({
id,
data: {
_isSingleRun: !data._isSingleRun,
},
data: nextData,
})
handleNodeSelect(id)
if (!data._isSingleRun)
handleSyncWorkflowDraft(true)
}}
>
{
data._isSingleRun
isSingleRunning
? <Stop className='h-3 w-3' />
: (
<Tooltip

View File

@@ -83,14 +83,16 @@ const PanelOperatorPopup = ({
const link = useNodeHelpLink(data.type)
const isChildNode = !!(data.isInIteration || data.isInLoop)
return (
<div className='w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl'>
{
(showChangeBlock || canRunBySingle(data.type)) && (
(showChangeBlock || canRunBySingle(data.type, isChildNode)) && (
<>
<div className='p-1'>
{
canRunBySingle(data.type) && (
canRunBySingle(data.type, isChildNode) && (
<div
className={`
flex h-8 cursor-pointer items-center rounded-lg px-3 text-sm text-text-secondary

View File

@@ -0,0 +1,429 @@
import type {
FC,
ReactNode,
} from 'react'
import {
cloneElement,
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import {
RiCloseLine,
RiPlayLargeLine,
} from '@remixicon/react'
import { useShallow } from 'zustand/react/shallow'
import { useTranslation } from 'react-i18next'
import NextStep from '../next-step'
import PanelOperator from '../panel-operator'
import NodePosition from '@/app/components/workflow/nodes/_base/components/node-position'
import HelpLink from '../help-link'
import {
DescriptionInput,
TitleInput,
} from '../title-description-input'
import ErrorHandleOnPanel from '../error-handle/error-handle-on-panel'
import RetryOnPanel from '../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,
useNodeDataUpdate,
useNodesInteractions,
useNodesReadOnly,
useToolIcon,
useWorkflowHistory,
} from '@/app/components/workflow/hooks'
import {
canRunBySingle,
hasErrorHandleNode,
hasRetryNode,
} from '@/app/components/workflow/utils'
import Tooltip from '@/app/components/base/tooltip'
import { BlockEnum, type Node, NodeRunningStatus } from '@/app/components/workflow/types'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useStore } from '@/app/components/workflow/store'
import Tab, { TabType } from './tab'
import LastRun from './last-run'
import useLastRun from './last-run/use-last-run'
import BeforeRunForm from '../before-run-form'
import { debounce } from 'lodash-es'
import { NODES_EXTRA_DATA } from '@/app/components/workflow/constants'
import { useLogs } from '@/app/components/workflow/run/hooks'
import PanelWrap from '../before-run-form/panel-wrap'
import SpecialResultPanel from '@/app/components/workflow/run/special-result-panel'
import { Stop } from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
type BasePanelProps = {
children: ReactNode
} & Node
const BasePanel: FC<BasePanelProps> = ({
id,
data,
children,
position,
width,
height,
}) => {
const { t } = useTranslation()
const { showMessageLogModal } = useAppStore(useShallow(state => ({
showMessageLogModal: state.showMessageLogModal,
})))
const isSingleRunning = data._singleRunningStatus === NodeRunningStatus.Running
const showSingleRunPanel = useStore(s => s.showSingleRunPanel)
const workflowCanvasWidth = useStore(s => s.workflowCanvasWidth)
const nodePanelWidth = useStore(s => s.nodePanelWidth)
const otherPanelWidth = useStore(s => s.otherPanelWidth)
const setNodePanelWidth = useStore(s => s.setNodePanelWidth)
const maxNodePanelWidth = useMemo(() => {
if (!workflowCanvasWidth)
return 720
if (!otherPanelWidth)
return workflowCanvasWidth - 400
return workflowCanvasWidth - otherPanelWidth - 400
}, [workflowCanvasWidth, otherPanelWidth])
const updateNodePanelWidth = useCallback((width: number) => {
// Ensure the width is within the min and max range
const newValue = Math.min(Math.max(width, 400), maxNodePanelWidth)
localStorage.setItem('workflow-node-panel-width', `${newValue}`)
setNodePanelWidth(newValue)
}, [maxNodePanelWidth, setNodePanelWidth])
const handleResize = useCallback((width: number) => {
updateNodePanelWidth(width)
}, [updateNodePanelWidth])
const {
triggerRef,
containerRef,
} = useResizePanel({
direction: 'horizontal',
triggerDirection: 'left',
minWidth: 400,
maxWidth: maxNodePanelWidth,
onResize: debounce(handleResize),
})
const debounceUpdate = debounce(updateNodePanelWidth)
useEffect(() => {
if (!workflowCanvasWidth)
return
if (workflowCanvasWidth - 400 <= nodePanelWidth + otherPanelWidth)
debounceUpdate(workflowCanvasWidth - 400 - otherPanelWidth)
}, [nodePanelWidth, otherPanelWidth, workflowCanvasWidth, updateNodePanelWidth])
const { handleNodeSelect } = useNodesInteractions()
const { nodesReadOnly } = useNodesReadOnly()
const { availableNextBlocks } = useAvailableBlocks(data.type, data.isInIteration, data.isInLoop)
const toolIcon = useToolIcon(data)
const { saveStateToHistory } = useWorkflowHistory()
const {
handleNodeDataUpdate,
handleNodeDataUpdateWithSyncDraft,
} = useNodeDataUpdate()
const handleTitleBlur = useCallback((title: string) => {
handleNodeDataUpdateWithSyncDraft({ id, data: { title } })
saveStateToHistory(WorkflowHistoryEvent.NodeTitleChange)
}, [handleNodeDataUpdateWithSyncDraft, id, saveStateToHistory])
const handleDescriptionChange = useCallback((desc: string) => {
handleNodeDataUpdateWithSyncDraft({ id, data: { desc } })
saveStateToHistory(WorkflowHistoryEvent.NodeDescriptionChange)
}, [handleNodeDataUpdateWithSyncDraft, id, saveStateToHistory])
const isChildNode = !!(data.isInIteration || data.isInLoop)
const isSupportSingleRun = canRunBySingle(data.type, isChildNode)
const appDetail = useAppStore(state => state.appDetail)
const hasClickRunning = useRef(false)
const [isPaused, setIsPaused] = useState(false)
useEffect(() => {
if(data._singleRunningStatus === NodeRunningStatus.Running) {
hasClickRunning.current = true
setIsPaused(false)
}
else if(data._isSingleRun && data._singleRunningStatus === undefined && hasClickRunning) {
setIsPaused(true)
hasClickRunning.current = false
}
}, [data])
const updateNodeRunningStatus = useCallback((status: NodeRunningStatus) => {
handleNodeDataUpdate({
id,
data: {
...data,
_singleRunningStatus: status,
},
})
}, [handleNodeDataUpdate, id, data])
useEffect(() => {
// console.log(`id changed: ${id}, hasClickRunning: ${hasClickRunning.current}`)
hasClickRunning.current = false
}, [id])
const {
isShowSingleRun,
hideSingleRun,
runningStatus,
handleStop,
runInputData,
runInputDataRef,
runResult,
getInputVars,
toVarInputs,
tabType,
isRunAfterSingleRun,
setTabType,
singleRunParams,
nodeInfo,
setRunInputData,
handleSingleRun,
handleRunWithParams,
getExistVarValuesInForms,
getFilteredExistVarForms,
} = useLastRun<typeof data>({
id,
data,
defaultRunInputData: NODES_EXTRA_DATA[data.type]?.defaultRunInputData || {},
isPaused,
})
useEffect(() => {
setIsPaused(false)
}, [tabType])
const logParams = useLogs()
const passedLogParams = (() => {
if ([BlockEnum.Tool, BlockEnum.Agent, BlockEnum.Iteration, BlockEnum.Loop].includes(data.type))
return logParams
return {}
})()
if(logParams.showSpecialResultPanel) {
return (
<div className={cn(
'relative mr-1 h-full',
)}>
<div
ref={containerRef}
className={cn('flex h-full flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg', showSingleRunPanel ? 'overflow-hidden' : 'overflow-y-auto')}
style={{
width: `${nodePanelWidth}px`,
}}
>
<PanelWrap
nodeName={data.title}
onHide={hideSingleRun}
>
<div className='h-0 grow overflow-y-auto pb-4'>
<SpecialResultPanel {...passedLogParams} />
</div>
</PanelWrap>
</div>
</div>
)
}
if (isShowSingleRun) {
return (
<div className={cn(
'relative mr-1 h-full',
)}>
<div
ref={containerRef}
className={cn('flex h-full flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg', showSingleRunPanel ? 'overflow-hidden' : 'overflow-y-auto')}
style={{
width: `${nodePanelWidth}px`,
}}
>
<BeforeRunForm
nodeName={data.title}
nodeType={data.type}
onHide={hideSingleRun}
onRun={handleRunWithParams}
{...singleRunParams!}
{...passedLogParams}
existVarValuesInForms={getExistVarValuesInForms(singleRunParams?.forms as any)}
filteredExistVarForms={getFilteredExistVarForms(singleRunParams?.forms as any)}
/>
</div>
</div>
)
}
return (
<div className={cn(
'relative mr-1 h-full',
showMessageLogModal && '!absolute -top-[5px] right-[416px] z-0 !mr-0 w-[384px] overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border shadow-lg transition-all',
)}>
<div
ref={triggerRef}
className='absolute -left-1 top-0 flex h-full w-1 cursor-col-resize resize-x items-center justify-center'>
<div className='h-10 w-0.5 rounded-sm bg-state-base-handle hover:h-full hover:bg-state-accent-solid active:h-full active:bg-state-accent-solid'></div>
</div>
<div
ref={containerRef}
className={cn('flex h-full flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg', showSingleRunPanel ? 'overflow-hidden' : 'overflow-y-auto')}
style={{
width: `${nodePanelWidth}px`,
}}
>
<div className='sticky top-0 z-10 shrink-0 border-b-[0.5px] border-divider-regular bg-components-panel-bg'>
<div className='flex items-center px-4 pb-1 pt-4'>
<BlockIcon
className='mr-1 shrink-0'
type={data.type}
toolIcon={toolIcon}
size='md'
/>
<TitleInput
value={data.title || ''}
onBlur={handleTitleBlur}
/>
<div className='flex shrink-0 items-center text-text-tertiary'>
{
isSupportSingleRun && !nodesReadOnly && (
<Tooltip
popupContent={t('workflow.panel.runThisStep')}
popupClassName='mr-1'
disabled={isSingleRunning}
>
<div
className='mr-1 flex h-6 w-6 cursor-pointer items-center justify-center rounded-md hover:bg-state-base-hover'
onClick={() => {
if(isSingleRunning) {
handleNodeDataUpdate({
id,
data: {
_isSingleRun: false,
_singleRunningStatus: undefined,
},
})
}
else {
handleSingleRun()
}
}}
>
{
isSingleRunning ? <Stop className='h-4 w-4 text-text-tertiary' />
: <RiPlayLargeLine className='h-4 w-4 text-text-tertiary' />
}
</div>
</Tooltip>
)
}
<NodePosition nodePosition={position} nodeWidth={width} nodeHeight={height}></NodePosition>
<HelpLink nodeType={data.type} />
<PanelOperator id={id} data={data} showHelpLink={false} />
<div className='mx-3 h-3.5 w-[1px] bg-divider-regular' />
<div
className='flex h-6 w-6 cursor-pointer items-center justify-center'
onClick={() => handleNodeSelect(id, true)}
>
<RiCloseLine className='h-4 w-4 text-text-tertiary' />
</div>
</div>
</div>
<div className='p-2'>
<DescriptionInput
value={data.desc || ''}
onChange={handleDescriptionChange}
/>
</div>
<div className='pl-4'>
<Tab
value={tabType}
onChange={setTabType}
/>
</div>
<Split />
</div>
{tabType === TabType.settings && (
<>
<div>
{cloneElement(children as any, {
id,
data,
panelProps: {
getInputVars,
toVarInputs,
runInputData,
setRunInputData,
runResult,
runInputDataRef,
},
})}
</div>
<Split />
{
hasRetryNode(data.type) && (
<RetryOnPanel
id={id}
data={data}
/>
)
}
{
hasErrorHandleNode(data.type) && (
<ErrorHandleOnPanel
id={id}
data={data}
/>
)
}
{
!!availableNextBlocks.length && (
<div className='border-t-[0.5px] border-divider-regular p-4'>
<div className='system-sm-semibold-uppercase mb-1 flex items-center text-text-secondary'>
{t('workflow.panel.nextStep').toLocaleUpperCase()}
</div>
<div className='system-xs-regular mb-2 text-text-tertiary'>
{t('workflow.panel.addNextStep')}
</div>
<NextStep selectedNode={{ id, data } as Node} />
</div>
)
}
</>
)}
{tabType === TabType.lastRun && (
<LastRun
appId={appDetail?.id || ''}
nodeId={id}
canSingleRun={isSupportSingleRun}
runningStatus={runningStatus}
isRunAfterSingleRun={isRunAfterSingleRun}
updateNodeRunningStatus={updateNodeRunningStatus}
onSingleRunClicked={handleSingleRun}
nodeInfo={nodeInfo}
singleRunResult={runResult!}
isPaused={isPaused}
{...passedLogParams}
/>
)}
</div>
</div>
)
}
export default memo(BasePanel)

View File

@@ -0,0 +1,126 @@
'use client'
import type { ResultPanelProps } from '@/app/components/workflow/run/result-panel'
import ResultPanel from '@/app/components/workflow/run/result-panel'
import { NodeRunningStatus } from '@/app/components/workflow/types'
import type { FC } from 'react'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import NoData from './no-data'
import { useLastRun } from '@/service/use-workflow'
import { RiLoader2Line } from '@remixicon/react'
import type { NodeTracing } from '@/types/workflow'
type Props = {
appId: string
nodeId: string
canSingleRun: boolean
isRunAfterSingleRun: boolean
updateNodeRunningStatus: (status: NodeRunningStatus) => void
nodeInfo?: NodeTracing
runningStatus?: NodeRunningStatus
onSingleRunClicked: () => void
singleRunResult?: NodeTracing
isPaused?: boolean
} & Partial<ResultPanelProps>
const LastRun: FC<Props> = ({
appId,
nodeId,
canSingleRun,
isRunAfterSingleRun,
updateNodeRunningStatus,
nodeInfo,
runningStatus: oneStepRunRunningStatus,
onSingleRunClicked,
singleRunResult,
isPaused,
...otherResultPanelProps
}) => {
const isOneStepRunSucceed = oneStepRunRunningStatus === NodeRunningStatus.Succeeded
const isOneStepRunFailed = oneStepRunRunningStatus === NodeRunningStatus.Failed
// hide page and return to page would lost the oneStepRunRunningStatus
const [hidePageOneStepFinishedStatus, setHidePageOneStepFinishedStatus] = React.useState<NodeRunningStatus | null>(null)
const [pageHasHide, setPageHasHide] = useState(false)
const [pageShowed, setPageShowed] = useState(false)
const hidePageOneStepRunFinished = [NodeRunningStatus.Succeeded, NodeRunningStatus.Failed].includes(hidePageOneStepFinishedStatus!)
const canRunLastRun = !isRunAfterSingleRun || isOneStepRunSucceed || isOneStepRunFailed || (pageHasHide && hidePageOneStepRunFinished)
const { data: lastRunResult, isFetching, error } = useLastRun(appId, nodeId, canRunLastRun)
const isRunning = useMemo(() => {
if(isPaused)
return false
if(!isRunAfterSingleRun)
return isFetching
return [NodeRunningStatus.Running, NodeRunningStatus.NotStart].includes(oneStepRunRunningStatus!)
}, [isFetching, isPaused, isRunAfterSingleRun, oneStepRunRunningStatus])
const noLastRun = (error as any)?.status === 404
const runResult = (canRunLastRun ? lastRunResult : singleRunResult) || lastRunResult || {}
const resetHidePageStatus = useCallback(() => {
setPageHasHide(false)
setPageShowed(false)
setHidePageOneStepFinishedStatus(null)
}, [])
useEffect(() => {
if (pageShowed && hidePageOneStepFinishedStatus && (!oneStepRunRunningStatus || oneStepRunRunningStatus === NodeRunningStatus.NotStart)) {
updateNodeRunningStatus(hidePageOneStepFinishedStatus)
resetHidePageStatus()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOneStepRunSucceed, isOneStepRunFailed, oneStepRunRunningStatus])
useEffect(() => {
if([NodeRunningStatus.Succeeded, NodeRunningStatus.Failed].includes(oneStepRunRunningStatus!))
setHidePageOneStepFinishedStatus(oneStepRunRunningStatus!)
}, [oneStepRunRunningStatus])
useEffect(() => {
resetHidePageStatus()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nodeId])
const handlePageVisibilityChange = useCallback(() => {
if (document.visibilityState === 'hidden')
setPageHasHide(true)
else
setPageShowed(true)
}, [])
useEffect(() => {
document.addEventListener('visibilitychange', handlePageVisibilityChange)
return () => {
document.removeEventListener('visibilitychange', handlePageVisibilityChange)
}
}, [handlePageVisibilityChange])
if (isFetching && !isRunAfterSingleRun) {
return (
<div className='flex h-0 grow flex-col items-center justify-center'>
<RiLoader2Line className='size-4 animate-spin text-text-tertiary' />
</div>)
}
if (isRunning)
return <ResultPanel status='running' showSteps={false} />
if (!isPaused && (noLastRun || !runResult)) {
return (
<NoData canSingleRun={canSingleRun} onSingleRun={onSingleRunClicked} />
)
}
return (
<div>
<ResultPanel
{...runResult as any}
{...otherResultPanelProps}
status={isPaused ? NodeRunningStatus.Stopped : ((runResult as any).status || otherResultPanelProps.status)}
total_tokens={(runResult as any)?.execution_metadata?.total_tokens || otherResultPanelProps?.total_tokens}
created_by={(runResult as any)?.created_by_account?.created_by || otherResultPanelProps?.created_by}
nodeInfo={nodeInfo}
showSteps={false}
/>
</div>
)
}
export default React.memo(LastRun)

View File

@@ -0,0 +1,36 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { ClockPlay } from '@/app/components/base/icons/src/vender/line/time'
import Button from '@/app/components/base/button'
import { RiPlayLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
type Props = {
canSingleRun: boolean
onSingleRun: () => void
}
const NoData: FC<Props> = ({
canSingleRun,
onSingleRun,
}) => {
const { t } = useTranslation()
return (
<div className='flex h-0 grow flex-col items-center justify-center'>
<ClockPlay className='h-8 w-8 text-text-quaternary' />
<div className='system-xs-regular my-2 text-text-tertiary'>{t('workflow.debug.noData.description')}</div>
{canSingleRun && (
<Button
className='flex'
size='small'
onClick={onSingleRun}
>
<RiPlayLine className='mr-1 h-3.5 w-3.5' />
<div>{t('workflow.debug.noData.runThisNode')}</div>
</Button>
)}
</div>
)
}
export default React.memo(NoData)

View File

@@ -0,0 +1,330 @@
import useOneStepRun from '@/app/components/workflow/nodes/_base/hooks/use-one-step-run'
import type { Params as OneStepRunParams } from '@/app/components/workflow/nodes/_base/hooks/use-one-step-run'
import { useCallback, useEffect, useState } from 'react'
import { TabType } from '../tab'
import type { Props as FormProps } from '@/app/components/workflow/nodes/_base/components/before-run-form/form'
import useStartSingleRunFormParams from '@/app/components/workflow/nodes/start/use-single-run-form-params'
import useLLMSingleRunFormParams from '@/app/components/workflow/nodes/llm/use-single-run-form-params'
import useKnowledgeRetrievalSingleRunFormParams from '@/app/components/workflow/nodes/knowledge-retrieval/use-single-run-form-params'
import useCodeSingleRunFormParams from '@/app/components/workflow/nodes/code/use-single-run-form-params'
import useTemplateTransformSingleRunFormParams from '@/app/components/workflow/nodes/template-transform/use-single-run-form-params'
import useQuestionClassifierSingleRunFormParams from '@/app/components/workflow/nodes/question-classifier/use-single-run-form-params'
import useParameterExtractorSingleRunFormParams from '@/app/components/workflow/nodes/parameter-extractor/use-single-run-form-params'
import useHttpRequestSingleRunFormParams from '@/app/components/workflow/nodes/http/use-single-run-form-params'
import useToolSingleRunFormParams from '@/app/components/workflow/nodes/tool/use-single-run-form-params'
import useIterationSingleRunFormParams from '@/app/components/workflow/nodes/iteration/use-single-run-form-params'
import useAgentSingleRunFormParams from '@/app/components/workflow/nodes/agent/use-single-run-form-params'
import useDocExtractorSingleRunFormParams from '@/app/components/workflow/nodes/document-extractor/use-single-run-form-params'
import useLoopSingleRunFormParams from '@/app/components/workflow/nodes/loop/use-single-run-form-params'
import useIfElseSingleRunFormParams from '@/app/components/workflow/nodes/if-else/use-single-run-form-params'
import useVariableAggregatorSingleRunFormParams from '@/app/components/workflow/nodes/variable-assigner/use-single-run-form-params'
import useVariableAssignerSingleRunFormParams from '@/app/components/workflow/nodes/assigner/use-single-run-form-params'
import useToolGetDataForCheckMore from '@/app/components/workflow/nodes/tool/use-get-data-for-check-more'
import { VALUE_SELECTOR_DELIMITER as DELIMITER } from '@/config'
// import
import type { CommonNodeType, ValueSelector } from '@/app/components/workflow/types'
import { BlockEnum } from '@/app/components/workflow/types'
import {
useNodesSyncDraft,
} from '@/app/components/workflow/hooks'
import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud'
import { useInvalidLastRun } from '@/service/use-workflow'
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
const singleRunFormParamsHooks: Record<BlockEnum, any> = {
[BlockEnum.LLM]: useLLMSingleRunFormParams,
[BlockEnum.KnowledgeRetrieval]: useKnowledgeRetrievalSingleRunFormParams,
[BlockEnum.Code]: useCodeSingleRunFormParams,
[BlockEnum.TemplateTransform]: useTemplateTransformSingleRunFormParams,
[BlockEnum.QuestionClassifier]: useQuestionClassifierSingleRunFormParams,
[BlockEnum.HttpRequest]: useHttpRequestSingleRunFormParams,
[BlockEnum.Tool]: useToolSingleRunFormParams,
[BlockEnum.ParameterExtractor]: useParameterExtractorSingleRunFormParams,
[BlockEnum.Iteration]: useIterationSingleRunFormParams,
[BlockEnum.Agent]: useAgentSingleRunFormParams,
[BlockEnum.DocExtractor]: useDocExtractorSingleRunFormParams,
[BlockEnum.Loop]: useLoopSingleRunFormParams,
[BlockEnum.Start]: useStartSingleRunFormParams,
[BlockEnum.IfElse]: useIfElseSingleRunFormParams,
[BlockEnum.VariableAggregator]: useVariableAggregatorSingleRunFormParams,
[BlockEnum.Assigner]: useVariableAssignerSingleRunFormParams,
[BlockEnum.VariableAssigner]: undefined,
[BlockEnum.End]: undefined,
[BlockEnum.Answer]: undefined,
[BlockEnum.ListFilter]: undefined,
[BlockEnum.IterationStart]: undefined,
[BlockEnum.LoopStart]: undefined,
[BlockEnum.LoopEnd]: undefined,
}
const useSingleRunFormParamsHooks = (nodeType: BlockEnum) => {
return (params: any) => {
return singleRunFormParamsHooks[nodeType]?.(params) || {}
}
}
const getDataForCheckMoreHooks: Record<BlockEnum, any> = {
[BlockEnum.Tool]: useToolGetDataForCheckMore,
[BlockEnum.LLM]: undefined,
[BlockEnum.KnowledgeRetrieval]: undefined,
[BlockEnum.Code]: undefined,
[BlockEnum.TemplateTransform]: undefined,
[BlockEnum.QuestionClassifier]: undefined,
[BlockEnum.HttpRequest]: undefined,
[BlockEnum.ParameterExtractor]: undefined,
[BlockEnum.Iteration]: undefined,
[BlockEnum.Agent]: undefined,
[BlockEnum.DocExtractor]: undefined,
[BlockEnum.Loop]: undefined,
[BlockEnum.Start]: undefined,
[BlockEnum.IfElse]: undefined,
[BlockEnum.VariableAggregator]: undefined,
[BlockEnum.End]: undefined,
[BlockEnum.Answer]: undefined,
[BlockEnum.VariableAssigner]: undefined,
[BlockEnum.ListFilter]: undefined,
[BlockEnum.IterationStart]: undefined,
[BlockEnum.Assigner]: undefined,
[BlockEnum.LoopStart]: undefined,
[BlockEnum.LoopEnd]: undefined,
}
const useGetDataForCheckMoreHooks = <T>(nodeType: BlockEnum) => {
return (id: string, payload: CommonNodeType<T>) => {
return getDataForCheckMoreHooks[nodeType]?.({ id, payload }) || {
getData: () => {
return {}
},
}
}
}
type Params<T> = Omit<OneStepRunParams<T>, 'isRunAfterSingleRun'>
const useLastRun = <T>({
...oneStepRunParams
}: Params<T>) => {
const { conversationVars, systemVars, hasSetInspectVar } = useInspectVarsCrud()
const blockType = oneStepRunParams.data.type
const isStartNode = blockType === BlockEnum.Start
const isIterationNode = blockType === BlockEnum.Iteration
const isLoopNode = blockType === BlockEnum.Loop
const isAggregatorNode = blockType === BlockEnum.VariableAggregator
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const {
getData: getDataForCheckMore,
} = useGetDataForCheckMoreHooks<T>(blockType)(oneStepRunParams.id, oneStepRunParams.data)
const [isRunAfterSingleRun, setIsRunAfterSingleRun] = useState(false)
const {
id,
data,
} = oneStepRunParams
const oneStepRunRes = useOneStepRun({
...oneStepRunParams,
iteratorInputKey: blockType === BlockEnum.Iteration ? `${id}.input_selector` : '',
moreDataForCheckValid: getDataForCheckMore(),
isRunAfterSingleRun,
})
const {
appId,
hideSingleRun,
handleRun: doCallRunApi,
getInputVars,
toVarInputs,
varSelectorsToVarInputs,
runInputData,
runInputDataRef,
setRunInputData,
showSingleRun,
runResult,
iterationRunResult,
loopRunResult,
setNodeRunning,
checkValid,
} = oneStepRunRes
const {
nodeInfo,
...singleRunParams
} = useSingleRunFormParamsHooks(blockType)({
id,
payload: data,
runInputData,
runInputDataRef,
getInputVars,
setRunInputData,
toVarInputs,
varSelectorsToVarInputs,
runResult,
iterationRunResult,
loopRunResult,
})
const toSubmitData = useCallback((data: Record<string, any>) => {
if(!isIterationNode && !isLoopNode)
return data
const allVarObject = singleRunParams?.allVarObject || {}
const formattedData: Record<string, any> = {}
Object.keys(allVarObject).forEach((key) => {
const [varSectorStr, nodeId] = key.split(DELIMITER)
formattedData[`${nodeId}.${allVarObject[key].inSingleRunPassedKey}`] = data[varSectorStr]
})
if(isIterationNode) {
const iteratorInputKey = `${id}.input_selector`
formattedData[iteratorInputKey] = data[iteratorInputKey]
}
return formattedData
}, [isIterationNode, isLoopNode, singleRunParams?.allVarObject, id])
const callRunApi = (data: Record<string, any>, cb?: () => void) => {
handleSyncWorkflowDraft(true, true, {
onSuccess() {
doCallRunApi(toSubmitData(data))
cb?.()
},
})
}
const workflowStore = useWorkflowStore()
const { setInitShowLastRunTab } = workflowStore.getState()
const initShowLastRunTab = useStore(s => s.initShowLastRunTab)
const [tabType, setTabType] = useState<TabType>(initShowLastRunTab ? TabType.lastRun : TabType.settings)
useEffect(() => {
if(initShowLastRunTab)
setTabType(TabType.lastRun)
setInitShowLastRunTab(false)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initShowLastRunTab])
const invalidLastRun = useInvalidLastRun(appId!, id)
const handleRunWithParams = async (data: Record<string, any>) => {
const { isValid } = checkValid()
if(!isValid)
return
setNodeRunning()
setIsRunAfterSingleRun(true)
setTabType(TabType.lastRun)
callRunApi(data, () => {
invalidLastRun()
})
hideSingleRun()
}
const handleTabClicked = useCallback((type: TabType) => {
setIsRunAfterSingleRun(false)
setTabType(type)
}, [])
const getExistVarValuesInForms = (forms: FormProps[]) => {
if (!forms || forms.length === 0)
return []
const valuesArr = forms.map((form) => {
const values: Record<string, boolean> = {}
form.inputs.forEach(({ variable, getVarValueFromDependent }) => {
const isGetValueFromDependent = getVarValueFromDependent || !variable.includes('.')
if(isGetValueFromDependent && !singleRunParams?.getDependentVar)
return
const selector = isGetValueFromDependent ? (singleRunParams?.getDependentVar(variable) || []) : variable.slice(1, -1).split('.')
if(!selector || selector.length === 0)
return
const [nodeId, varName] = selector.slice(0, 2)
if(!isStartNode && nodeId === id) { // inner vars like loop vars
values[variable] = true
return
}
const inspectVarValue = hasSetInspectVar(nodeId, varName, systemVars, conversationVars) // also detect system var , env and conversation var
if (inspectVarValue)
values[variable] = true
})
return values
})
return valuesArr
}
const isAllVarsHasValue = (vars?: ValueSelector[]) => {
if(!vars || vars.length === 0)
return true
return vars.every((varItem) => {
const [nodeId, varName] = varItem.slice(0, 2)
const inspectVarValue = hasSetInspectVar(nodeId, varName, systemVars, conversationVars) // also detect system var , env and conversation var
return inspectVarValue
})
}
const isSomeVarsHasValue = (vars?: ValueSelector[]) => {
if(!vars || vars.length === 0)
return true
return vars.some((varItem) => {
const [nodeId, varName] = varItem.slice(0, 2)
const inspectVarValue = hasSetInspectVar(nodeId, varName, systemVars, conversationVars) // also detect system var , env and conversation var
return inspectVarValue
})
}
const getFilteredExistVarForms = (forms: FormProps[]) => {
if (!forms || forms.length === 0)
return []
const existVarValuesInForms = getExistVarValuesInForms(forms)
const res = forms.map((form, i) => {
const existVarValuesInForm = existVarValuesInForms[i]
const newForm = { ...form }
const inputs = form.inputs.filter((input) => {
return !(input.variable in existVarValuesInForm)
})
newForm.inputs = inputs
return newForm
}).filter(form => form.inputs.length > 0)
return res
}
const checkAggregatorVarsSet = (vars: ValueSelector[][]) => {
if(!vars || vars.length === 0)
return true
// in each group, at last one set is ok
return vars.every((varItem) => {
return isSomeVarsHasValue(varItem)
})
}
const handleSingleRun = () => {
const { isValid } = checkValid()
if(!isValid)
return
const vars = singleRunParams?.getDependentVars?.()
// no need to input params
if (isAggregatorNode ? checkAggregatorVarsSet(vars) : isAllVarsHasValue(vars)) {
callRunApi({}, async () => {
setIsRunAfterSingleRun(true)
setNodeRunning()
invalidLastRun()
setTabType(TabType.lastRun)
})
}
else {
showSingleRun()
}
}
return {
...oneStepRunRes,
tabType,
isRunAfterSingleRun,
setTabType: handleTabClicked,
singleRunParams,
nodeInfo,
setRunInputData,
handleSingleRun,
handleRunWithParams,
getExistVarValuesInForms,
getFilteredExistVarForms,
}
}
export default useLastRun

View File

@@ -0,0 +1,34 @@
'use client'
import TabHeader from '@/app/components/base/tab-header'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
export enum TabType {
settings = 'settings',
lastRun = 'lastRun',
}
type Props = {
value: TabType,
onChange: (value: TabType) => void
}
const Tab: FC<Props> = ({
value,
onChange,
}) => {
const { t } = useTranslation()
return (
<TabHeader
items={[
{ id: TabType.settings, name: t('workflow.debug.settingsTab').toLocaleUpperCase() },
{ id: TabType.lastRun, name: t('workflow.debug.lastRunTab').toLocaleUpperCase() },
]}
itemClassName='ml-0'
value={value}
onChange={onChange as any}
/>
)
}
export default React.memo(Tab)