feat: introduce trigger functionality (#27644)
Signed-off-by: lyzno1 <yuanyouhuilyz@gmail.com> Co-authored-by: Stream <Stream_2@qq.com> Co-authored-by: lyzno1 <92089059+lyzno1@users.noreply.github.com> Co-authored-by: zhsama <torvalds@linux.do> Co-authored-by: Harry <xh001x@hotmail.com> Co-authored-by: lyzno1 <yuanyouhuilyz@gmail.com> Co-authored-by: yessenia <yessenia.contact@gmail.com> Co-authored-by: hjlarry <hjlarry@163.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: WTW0313 <twwu@dify.ai> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -12,7 +12,7 @@ import SearchInput from '@/app/components/base/search-input'
|
||||
import Tools from '../../../block-selector/tools'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStrategyProviders } from '@/service/use-strategy'
|
||||
import { PluginType, type StrategyPluginDetail } from '@/app/components/plugins/types'
|
||||
import { PluginCategoryEnum, type StrategyPluginDetail } from '@/app/components/plugins/types'
|
||||
import type { ToolWithProvider } from '../../../types'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon'
|
||||
@@ -140,7 +140,7 @@ export const AgentStrategySelector = memo((props: AgentStrategySelectorProps) =>
|
||||
if (query) {
|
||||
fetchPlugins({
|
||||
query,
|
||||
category: PluginType.agent,
|
||||
category: PluginCategoryEnum.agent,
|
||||
})
|
||||
}
|
||||
}, [query])
|
||||
|
||||
@@ -22,6 +22,7 @@ import type { Node } from 'reactflow'
|
||||
import type { PluginMeta } from '@/app/components/plugins/types'
|
||||
import { noop } from 'lodash-es'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
export type Strategy = {
|
||||
agent_strategy_provider_name: string
|
||||
@@ -99,7 +100,7 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => {
|
||||
modelConfig={
|
||||
defaultModel.data
|
||||
? {
|
||||
mode: 'chat',
|
||||
mode: AppModeEnum.CHAT,
|
||||
name: defaultModel.data.model,
|
||||
provider: defaultModel.data.provider.provider,
|
||||
completion_params: {},
|
||||
|
||||
@@ -6,7 +6,7 @@ import cn from 'classnames'
|
||||
import type { CodeLanguage } from '../../code/types'
|
||||
import { Generator } from '@/app/components/base/icons/src/vender/other'
|
||||
import { ActionButton } from '@/app/components/base/action-button'
|
||||
import { AppType } from '@/types/app'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import type { GenRes } from '@/service/debug'
|
||||
import { GetCodeGeneratorResModal } from '@/app/components/app/configuration/config/code-generator/get-code-generator-res'
|
||||
import { useHooksStore } from '../../../hooks-store'
|
||||
@@ -42,7 +42,7 @@ const CodeGenerateBtn: FC<Props> = ({
|
||||
</ActionButton>
|
||||
{showAutomatic && (
|
||||
<GetCodeGeneratorResModal
|
||||
mode={AppType.chat}
|
||||
mode={AppModeEnum.CHAT}
|
||||
isShow={showAutomatic}
|
||||
codeLanguages={codeLanguages}
|
||||
onClose={showAutomaticFalse}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export enum StartNodeTypeEnum {
|
||||
Start = 'start',
|
||||
Trigger = 'trigger',
|
||||
}
|
||||
|
||||
type EntryNodeContainerProps = {
|
||||
children: ReactNode
|
||||
customLabel?: string
|
||||
nodeType?: StartNodeTypeEnum
|
||||
}
|
||||
|
||||
const EntryNodeContainer: FC<EntryNodeContainerProps> = ({
|
||||
children,
|
||||
customLabel,
|
||||
nodeType = StartNodeTypeEnum.Trigger,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const label = useMemo(() => {
|
||||
const translationKey = nodeType === StartNodeTypeEnum.Start ? 'entryNodeStatus' : 'triggerStatus'
|
||||
return customLabel || t(`workflow.${translationKey}.enabled`)
|
||||
}, [customLabel, nodeType, t])
|
||||
|
||||
return (
|
||||
<div className="w-fit min-w-[242px] rounded-2xl bg-workflow-block-wrapper-bg-1 px-0 pb-0 pt-0.5">
|
||||
<div className="mb-0.5 flex items-center px-1.5 pt-0.5">
|
||||
<span className="text-2xs font-semibold uppercase text-text-tertiary">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EntryNodeContainer
|
||||
@@ -1,38 +1,50 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { ToolVarInputs } from '@/app/components/workflow/nodes/tool/types'
|
||||
import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { type ResourceVarInputs, VarKindType } from '../types'
|
||||
import type { CredentialFormSchema, FormOption } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types'
|
||||
import { VarType } from '@/app/components/workflow/types'
|
||||
import { useFetchDynamicOptions } from '@/service/use-plugins'
|
||||
import { useTriggerPluginDynamicOptions } from '@/service/use-triggers'
|
||||
|
||||
import type { ToolWithProvider, ValueSelector, Var } from '@/app/components/workflow/types'
|
||||
import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
|
||||
import type { Tool } from '@/app/components/tools/types'
|
||||
import FormInputTypeSwitch from './form-input-type-switch'
|
||||
import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { SimpleSelect } from '@/app/components/base/select'
|
||||
import MixedVariableTextInput from '@/app/components/workflow/nodes/tool/components/mixed-variable-text-input'
|
||||
import FormInputBoolean from './form-input-boolean'
|
||||
import AppSelector from '@/app/components/plugins/plugin-detail-panel/app-selector'
|
||||
import ModelParameterModal from '@/app/components/plugins/plugin-detail-panel/model-selector'
|
||||
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
|
||||
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
|
||||
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { Tool } from '@/app/components/tools/types'
|
||||
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react'
|
||||
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
||||
import { RiCheckLine, RiLoader4Line } from '@remixicon/react'
|
||||
import type { Event } from '@/app/components/tools/types'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import CheckboxList from '@/app/components/base/checkbox-list'
|
||||
import FormInputBoolean from './form-input-boolean'
|
||||
|
||||
type Props = {
|
||||
readOnly: boolean
|
||||
nodeId: string
|
||||
schema: CredentialFormSchema
|
||||
value: ToolVarInputs
|
||||
value: ResourceVarInputs
|
||||
onChange: (value: any) => void
|
||||
inPanel?: boolean
|
||||
currentTool?: Tool
|
||||
currentProvider?: ToolWithProvider
|
||||
currentTool?: Tool | Event
|
||||
currentProvider?: ToolWithProvider | TriggerWithProvider
|
||||
showManageInputField?: boolean
|
||||
onManageInputField?: () => void
|
||||
extraParams?: Record<string, any>
|
||||
providerType?: string
|
||||
disableVariableInsertion?: boolean
|
||||
}
|
||||
|
||||
const FormInputItem: FC<Props> = ({
|
||||
@@ -46,15 +58,22 @@ const FormInputItem: FC<Props> = ({
|
||||
currentProvider,
|
||||
showManageInputField,
|
||||
onManageInputField,
|
||||
extraParams,
|
||||
providerType,
|
||||
disableVariableInsertion = false,
|
||||
}) => {
|
||||
const language = useLanguage()
|
||||
const [toolsOptions, setToolsOptions] = useState<FormOption[] | null>(null)
|
||||
const [isLoadingToolsOptions, setIsLoadingToolsOptions] = useState(false)
|
||||
|
||||
const {
|
||||
placeholder,
|
||||
variable,
|
||||
type,
|
||||
_type,
|
||||
default: defaultValue,
|
||||
options,
|
||||
multiple,
|
||||
scope,
|
||||
} = schema as any
|
||||
const varInput = value[variable]
|
||||
@@ -64,13 +83,16 @@ const FormInputItem: FC<Props> = ({
|
||||
const isArray = type === FormTypeEnum.array
|
||||
const isShowJSONEditor = isObject || isArray
|
||||
const isFile = type === FormTypeEnum.file || type === FormTypeEnum.files
|
||||
const isBoolean = type === FormTypeEnum.boolean
|
||||
const isSelect = type === FormTypeEnum.select || type === FormTypeEnum.dynamicSelect
|
||||
const isBoolean = _type === FormTypeEnum.boolean
|
||||
const isCheckbox = _type === FormTypeEnum.checkbox
|
||||
const isSelect = type === FormTypeEnum.select
|
||||
const isDynamicSelect = type === FormTypeEnum.dynamicSelect
|
||||
const isAppSelector = type === FormTypeEnum.appSelector
|
||||
const isModelSelector = type === FormTypeEnum.modelSelector
|
||||
const showTypeSwitch = isNumber || isBoolean || isObject || isArray || isSelect
|
||||
const isConstant = varInput?.type === VarKindType.constant || !varInput?.type
|
||||
const showVariableSelector = isFile || varInput?.type === VarKindType.variable
|
||||
const isMultipleSelect = multiple && (isSelect || isDynamicSelect)
|
||||
|
||||
const { availableVars, availableNodesWithParent } = useAvailableVarList(nodeId, {
|
||||
onlyLeafNodeVar: false,
|
||||
@@ -123,12 +145,71 @@ const FormInputItem: FC<Props> = ({
|
||||
const getVarKindType = () => {
|
||||
if (isFile)
|
||||
return VarKindType.variable
|
||||
if (isSelect || isBoolean || isNumber || isArray || isObject)
|
||||
if (isSelect || isDynamicSelect || isBoolean || isNumber || isArray || isObject)
|
||||
return VarKindType.constant
|
||||
if (isString)
|
||||
return VarKindType.mixed
|
||||
}
|
||||
|
||||
// Fetch dynamic options hook for tools
|
||||
const { mutateAsync: fetchDynamicOptions } = useFetchDynamicOptions(
|
||||
currentProvider?.plugin_id || '',
|
||||
currentProvider?.name || '',
|
||||
currentTool?.name || '',
|
||||
variable || '',
|
||||
providerType,
|
||||
extraParams,
|
||||
)
|
||||
|
||||
// Fetch dynamic options hook for triggers
|
||||
const { data: triggerDynamicOptions, isLoading: isTriggerOptionsLoading } = useTriggerPluginDynamicOptions({
|
||||
plugin_id: currentProvider?.plugin_id || '',
|
||||
provider: currentProvider?.name || '',
|
||||
action: currentTool?.name || '',
|
||||
parameter: variable || '',
|
||||
extra: extraParams,
|
||||
credential_id: currentProvider?.credential_id || '',
|
||||
}, isDynamicSelect && providerType === PluginCategoryEnum.trigger && !!currentTool && !!currentProvider)
|
||||
|
||||
// Computed values for dynamic options (unified for triggers and tools)
|
||||
const triggerOptions = triggerDynamicOptions?.options
|
||||
const dynamicOptions = providerType === PluginCategoryEnum.trigger
|
||||
? triggerOptions ?? toolsOptions
|
||||
: toolsOptions
|
||||
const isLoadingOptions = providerType === PluginCategoryEnum.trigger
|
||||
? (isTriggerOptionsLoading || isLoadingToolsOptions)
|
||||
: isLoadingToolsOptions
|
||||
|
||||
// Fetch dynamic options for tools only (triggers use hook directly)
|
||||
useEffect(() => {
|
||||
const fetchPanelDynamicOptions = async () => {
|
||||
if (isDynamicSelect && currentTool && currentProvider && (providerType === PluginCategoryEnum.tool || providerType === PluginCategoryEnum.trigger)) {
|
||||
setIsLoadingToolsOptions(true)
|
||||
try {
|
||||
const data = await fetchDynamicOptions()
|
||||
setToolsOptions(data?.options || [])
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to fetch dynamic options:', error)
|
||||
setToolsOptions([])
|
||||
}
|
||||
finally {
|
||||
setIsLoadingToolsOptions(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetchPanelDynamicOptions()
|
||||
}, [
|
||||
isDynamicSelect,
|
||||
currentTool?.name,
|
||||
currentProvider?.name,
|
||||
variable,
|
||||
extraParams,
|
||||
providerType,
|
||||
fetchDynamicOptions,
|
||||
])
|
||||
|
||||
const handleTypeChange = (newType: string) => {
|
||||
if (newType === VarKindType.variable) {
|
||||
onChange({
|
||||
@@ -163,6 +244,24 @@ const FormInputItem: FC<Props> = ({
|
||||
})
|
||||
}
|
||||
|
||||
const getSelectedLabels = (selectedValues: any[]) => {
|
||||
if (!selectedValues || selectedValues.length === 0)
|
||||
return ''
|
||||
|
||||
const optionsList = isDynamicSelect ? (dynamicOptions || options || []) : (options || [])
|
||||
const selectedOptions = optionsList.filter((opt: any) =>
|
||||
selectedValues.includes(opt.value),
|
||||
)
|
||||
|
||||
if (selectedOptions.length <= 2) {
|
||||
return selectedOptions
|
||||
.map((opt: any) => opt.label?.[language] || opt.label?.en_US || opt.value)
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
return `${selectedOptions.length} selected`
|
||||
}
|
||||
|
||||
const handleAppOrModelSelect = (newValue: any) => {
|
||||
onChange({
|
||||
...value,
|
||||
@@ -184,6 +283,45 @@ const FormInputItem: FC<Props> = ({
|
||||
})
|
||||
}
|
||||
|
||||
const availableCheckboxOptions = useMemo(() => (
|
||||
(options || []).filter((option: { show_on?: Array<{ variable: string; value: any }> }) => {
|
||||
if (option.show_on?.length)
|
||||
return option.show_on.every(showOnItem => value[showOnItem.variable]?.value === showOnItem.value || value[showOnItem.variable] === showOnItem.value)
|
||||
return true
|
||||
})
|
||||
), [options, value])
|
||||
|
||||
const checkboxListOptions = useMemo(() => (
|
||||
availableCheckboxOptions.map((option: { value: string; label: Record<string, string> }) => ({
|
||||
value: option.value,
|
||||
label: option.label?.[language] || option.label?.en_US || option.value,
|
||||
}))
|
||||
), [availableCheckboxOptions, language])
|
||||
|
||||
const checkboxListValue = useMemo(() => {
|
||||
let current: string[] = []
|
||||
if (Array.isArray(varInput?.value))
|
||||
current = varInput.value as string[]
|
||||
else if (typeof varInput?.value === 'string')
|
||||
current = [varInput.value as string]
|
||||
else if (Array.isArray(defaultValue))
|
||||
current = defaultValue as string[]
|
||||
|
||||
const allowedValues = new Set(availableCheckboxOptions.map((option: { value: string }) => option.value))
|
||||
return current.filter(item => allowedValues.has(item))
|
||||
}, [varInput?.value, defaultValue, availableCheckboxOptions])
|
||||
|
||||
const handleCheckboxListChange = (selected: string[]) => {
|
||||
onChange({
|
||||
...value,
|
||||
[variable]: {
|
||||
...varInput,
|
||||
type: VarKindType.constant,
|
||||
value: selected,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('gap-1', !(isShowJSONEditor && isConstant) && 'flex')}>
|
||||
{showTypeSwitch && (
|
||||
@@ -198,6 +336,7 @@ const FormInputItem: FC<Props> = ({
|
||||
availableNodes={availableNodesWithParent}
|
||||
showManageInputField={showManageInputField}
|
||||
onManageInputField={onManageInputField}
|
||||
disableVariableInsertion={disableVariableInsertion}
|
||||
/>
|
||||
)}
|
||||
{isNumber && isConstant && (
|
||||
@@ -209,13 +348,23 @@ const FormInputItem: FC<Props> = ({
|
||||
placeholder={placeholder?.[language] || placeholder?.en_US}
|
||||
/>
|
||||
)}
|
||||
{isCheckbox && isConstant && (
|
||||
<CheckboxList
|
||||
title={schema.label?.[language] || schema.label?.en_US || variable}
|
||||
value={checkboxListValue}
|
||||
onChange={handleCheckboxListChange}
|
||||
options={checkboxListOptions}
|
||||
disabled={readOnly}
|
||||
maxHeight='200px'
|
||||
/>
|
||||
)}
|
||||
{isBoolean && isConstant && (
|
||||
<FormInputBoolean
|
||||
value={varInput?.value as boolean}
|
||||
onChange={handleValueChange}
|
||||
/>
|
||||
)}
|
||||
{isSelect && isConstant && (
|
||||
{isSelect && isConstant && !isMultipleSelect && (
|
||||
<SimpleSelect
|
||||
wrapperClassName='h-8 grow'
|
||||
disabled={readOnly}
|
||||
@@ -225,11 +374,175 @@ const FormInputItem: FC<Props> = ({
|
||||
return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value)
|
||||
|
||||
return true
|
||||
}).map((option: { value: any; label: { [x: string]: any; en_US: any } }) => ({ value: option.value, name: option.label[language] || option.label.en_US }))}
|
||||
}).map((option: { value: any; label: { [x: string]: any; en_US: any }; icon?: string }) => ({
|
||||
value: option.value,
|
||||
name: option.label[language] || option.label.en_US,
|
||||
icon: option.icon,
|
||||
}))}
|
||||
onSelect={item => handleValueChange(item.value as string)}
|
||||
placeholder={placeholder?.[language] || placeholder?.en_US}
|
||||
renderOption={options.some((opt: any) => opt.icon) ? ({ item }) => (
|
||||
<div className="flex items-center">
|
||||
{item.icon && (
|
||||
<img src={item.icon} alt="" className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
<span>{item.name}</span>
|
||||
</div>
|
||||
) : undefined}
|
||||
/>
|
||||
)}
|
||||
{isSelect && isConstant && isMultipleSelect && (
|
||||
<Listbox
|
||||
multiple
|
||||
value={varInput?.value || []}
|
||||
onChange={handleValueChange}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<div className="group/simple-select relative h-8 grow">
|
||||
<ListboxButton className="flex h-full w-full cursor-pointer items-center rounded-lg border-0 bg-components-input-bg-normal pl-3 pr-10 focus-visible:bg-state-base-hover-alt focus-visible:outline-none group-hover/simple-select:bg-state-base-hover-alt sm:text-sm sm:leading-6">
|
||||
<span className={cn('system-sm-regular block truncate text-left',
|
||||
varInput?.value?.length > 0 ? 'text-components-input-text-filled' : 'text-components-input-text-placeholder',
|
||||
)}>
|
||||
{getSelectedLabels(varInput?.value) || placeholder?.[language] || placeholder?.en_US || 'Select options'}
|
||||
</span>
|
||||
<span className="absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronDownIcon
|
||||
className="h-4 w-4 text-text-quaternary group-hover/simple-select:text-text-secondary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</ListboxButton>
|
||||
<ListboxOptions className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-1 py-1 text-base shadow-lg backdrop-blur-sm focus:outline-none sm:text-sm">
|
||||
{options.filter((option: { show_on: any[] }) => {
|
||||
if (option.show_on?.length)
|
||||
return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value)
|
||||
return true
|
||||
}).map((option: { value: any; label: { [x: string]: any; en_US: any }; icon?: string }) => (
|
||||
<ListboxOption
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={({ focus }) =>
|
||||
cn('relative cursor-pointer select-none rounded-lg py-2 pl-3 pr-9 text-text-secondary hover:bg-state-base-hover',
|
||||
focus && 'bg-state-base-hover',
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
{option.icon && (
|
||||
<img src={option.icon} alt="" className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
<span className={cn('block truncate', selected && 'font-normal')}>
|
||||
{option.label[language] || option.label.en_US}
|
||||
</span>
|
||||
</div>
|
||||
{selected && (
|
||||
<span className="absolute inset-y-0 right-0 flex items-center pr-2 text-text-accent">
|
||||
<RiCheckLine className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</div>
|
||||
</Listbox>
|
||||
)}
|
||||
{isDynamicSelect && !isMultipleSelect && (
|
||||
<SimpleSelect
|
||||
wrapperClassName='h-8 grow'
|
||||
disabled={readOnly || isLoadingOptions}
|
||||
defaultValue={varInput?.value}
|
||||
items={(dynamicOptions || options || []).filter((option: { show_on?: any[] }) => {
|
||||
if (option.show_on?.length)
|
||||
return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value)
|
||||
|
||||
return true
|
||||
}).map((option: { value: any; label: { [x: string]: any; en_US: any }; icon?: string }) => ({
|
||||
value: option.value,
|
||||
name: option.label[language] || option.label.en_US,
|
||||
icon: option.icon,
|
||||
}))}
|
||||
onSelect={item => handleValueChange(item.value as string)}
|
||||
placeholder={isLoadingOptions ? 'Loading...' : (placeholder?.[language] || placeholder?.en_US)}
|
||||
renderOption={({ item }) => (
|
||||
<div className="flex items-center">
|
||||
{item.icon && (
|
||||
<img src={item.icon} alt="" className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
<span>{item.name}</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{isDynamicSelect && isMultipleSelect && (
|
||||
<Listbox
|
||||
multiple
|
||||
value={varInput?.value || []}
|
||||
onChange={handleValueChange}
|
||||
disabled={readOnly || isLoadingOptions}
|
||||
>
|
||||
<div className="group/simple-select relative h-8 grow">
|
||||
<ListboxButton className="flex h-full w-full cursor-pointer items-center rounded-lg border-0 bg-components-input-bg-normal pl-3 pr-10 focus-visible:bg-state-base-hover-alt focus-visible:outline-none group-hover/simple-select:bg-state-base-hover-alt sm:text-sm sm:leading-6">
|
||||
<span className={cn('system-sm-regular block truncate text-left',
|
||||
isLoadingOptions ? 'text-components-input-text-placeholder'
|
||||
: varInput?.value?.length > 0 ? 'text-components-input-text-filled' : 'text-components-input-text-placeholder',
|
||||
)}>
|
||||
{isLoadingOptions
|
||||
? 'Loading...'
|
||||
: getSelectedLabels(varInput?.value) || placeholder?.[language] || placeholder?.en_US || 'Select options'}
|
||||
</span>
|
||||
<span className="absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
{isLoadingOptions ? (
|
||||
<RiLoader4Line className='h-3.5 w-3.5 animate-spin text-text-secondary' />
|
||||
) : (
|
||||
<ChevronDownIcon
|
||||
className="h-4 w-4 text-text-quaternary group-hover/simple-select:text-text-secondary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</ListboxButton>
|
||||
<ListboxOptions className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-1 py-1 text-base shadow-lg backdrop-blur-sm focus:outline-none sm:text-sm">
|
||||
{(dynamicOptions || options || []).filter((option: { show_on?: any[] }) => {
|
||||
if (option.show_on?.length)
|
||||
return option.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value)
|
||||
return true
|
||||
}).map((option: { value: any; label: { [x: string]: any; en_US: any }; icon?: string }) => (
|
||||
<ListboxOption
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={({ focus }) =>
|
||||
cn('relative cursor-pointer select-none rounded-lg py-2 pl-3 pr-9 text-text-secondary hover:bg-state-base-hover',
|
||||
focus && 'bg-state-base-hover',
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
{option.icon && (
|
||||
<img src={option.icon} alt="" className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
<span className={cn('block truncate', selected && 'font-normal')}>
|
||||
{option.label[language] || option.label.en_US}
|
||||
</span>
|
||||
</div>
|
||||
{selected && (
|
||||
<span className="absolute inset-y-0 right-0 flex items-center pr-2 text-text-accent">
|
||||
<RiCheckLine className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</div>
|
||||
</Listbox>
|
||||
)}
|
||||
{isShowJSONEditor && isConstant && (
|
||||
<div className='mt-1 w-full'>
|
||||
<CodeEditor
|
||||
|
||||
@@ -1,37 +1,96 @@
|
||||
import Button from '@/app/components/base/button'
|
||||
import { RiInstallLine, RiLoader2Line } from '@remixicon/react'
|
||||
import type { ComponentProps, MouseEventHandler } from 'react'
|
||||
import { useState } from 'react'
|
||||
import classNames from '@/utils/classnames'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import checkTaskStatus from '@/app/components/plugins/install-plugin/base/check-task-status'
|
||||
import { TaskStatus } from '@/app/components/plugins/types'
|
||||
import { useCheckInstalled, useInstallPackageFromMarketPlace } from '@/service/use-plugins'
|
||||
|
||||
type InstallPluginButtonProps = Omit<ComponentProps<typeof Button>, 'children' | 'loading'> & {
|
||||
uniqueIdentifier: string
|
||||
extraIdentifiers?: string[]
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
export const InstallPluginButton = (props: InstallPluginButtonProps) => {
|
||||
const { className, uniqueIdentifier, onSuccess, ...rest } = props
|
||||
const {
|
||||
className,
|
||||
uniqueIdentifier,
|
||||
extraIdentifiers = [],
|
||||
onSuccess,
|
||||
...rest
|
||||
} = props
|
||||
const { t } = useTranslation()
|
||||
const identifiers = Array.from(new Set(
|
||||
[uniqueIdentifier, ...extraIdentifiers].filter((item): item is string => Boolean(item)),
|
||||
))
|
||||
const manifest = useCheckInstalled({
|
||||
pluginIds: [uniqueIdentifier],
|
||||
enabled: !!uniqueIdentifier,
|
||||
pluginIds: identifiers,
|
||||
enabled: identifiers.length > 0,
|
||||
})
|
||||
const install = useInstallPackageFromMarketPlace()
|
||||
const isLoading = manifest.isLoading || install.isPending
|
||||
// await for refetch to get the new installed plugin, when manifest refetch, this component will unmount
|
||||
|| install.isSuccess
|
||||
const [isTracking, setIsTracking] = useState(false)
|
||||
const isLoading = manifest.isLoading || install.isPending || isTracking
|
||||
const handleInstall: MouseEventHandler = (e) => {
|
||||
e.stopPropagation()
|
||||
if (isLoading)
|
||||
return
|
||||
setIsTracking(true)
|
||||
install.mutate(uniqueIdentifier, {
|
||||
onSuccess: async () => {
|
||||
await manifest.refetch()
|
||||
onSuccess?.()
|
||||
onSuccess: async (response) => {
|
||||
const finish = async () => {
|
||||
await manifest.refetch()
|
||||
onSuccess?.()
|
||||
setIsTracking(false)
|
||||
install.reset()
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
await finish()
|
||||
return
|
||||
}
|
||||
|
||||
if (response.all_installed) {
|
||||
await finish()
|
||||
return
|
||||
}
|
||||
|
||||
const { check } = checkTaskStatus()
|
||||
try {
|
||||
const { status } = await check({
|
||||
taskId: response.task_id,
|
||||
pluginUniqueIdentifier: uniqueIdentifier,
|
||||
})
|
||||
|
||||
if (status === TaskStatus.failed) {
|
||||
setIsTracking(false)
|
||||
install.reset()
|
||||
return
|
||||
}
|
||||
|
||||
await finish()
|
||||
}
|
||||
catch {
|
||||
setIsTracking(false)
|
||||
install.reset()
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
setIsTracking(false)
|
||||
install.reset()
|
||||
},
|
||||
})
|
||||
}
|
||||
if (!manifest.data) return null
|
||||
if (manifest.data.plugins.some(plugin => plugin.id === uniqueIdentifier)) return null
|
||||
const identifierSet = new Set(identifiers)
|
||||
const isInstalled = manifest.data.plugins.some(plugin => (
|
||||
identifierSet.has(plugin.id)
|
||||
|| (plugin.plugin_unique_identifier && identifierSet.has(plugin.plugin_unique_identifier))
|
||||
|| (plugin.plugin_id && identifierSet.has(plugin.plugin_id))
|
||||
))
|
||||
if (isInstalled) return null
|
||||
return <Button
|
||||
variant={'secondary'}
|
||||
disabled={isLoading}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
memo,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import PromptEditor from '@/app/components/base/prompt-editor'
|
||||
import Placeholder from './placeholder'
|
||||
import type {
|
||||
Node,
|
||||
NodeOutPutVar,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type MixedVariableTextInputProps = {
|
||||
readOnly?: boolean
|
||||
nodesOutputVars?: NodeOutPutVar[]
|
||||
availableNodes?: Node[]
|
||||
value?: string
|
||||
onChange?: (text: string) => void
|
||||
}
|
||||
const MixedVariableTextInput = ({
|
||||
readOnly = false,
|
||||
nodesOutputVars,
|
||||
availableNodes = [],
|
||||
value = '',
|
||||
onChange,
|
||||
}: MixedVariableTextInputProps) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<PromptEditor
|
||||
wrapperClassName={cn(
|
||||
'w-full rounded-lg border border-transparent bg-components-input-bg-normal px-2 py-1',
|
||||
'hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
|
||||
'focus-within:border-components-input-border-active focus-within:bg-components-input-bg-active focus-within:shadow-xs',
|
||||
)}
|
||||
className='caret:text-text-accent'
|
||||
editable={!readOnly}
|
||||
value={value}
|
||||
workflowVariableBlock={{
|
||||
show: true,
|
||||
variables: nodesOutputVars || [],
|
||||
workflowNodesMap: availableNodes.reduce((acc, node) => {
|
||||
acc[node.id] = {
|
||||
title: node.data.title,
|
||||
type: node.data.type,
|
||||
}
|
||||
if (node.data.type === BlockEnum.Start) {
|
||||
acc.sys = {
|
||||
title: t('workflow.blocks.start'),
|
||||
type: BlockEnum.Start,
|
||||
}
|
||||
}
|
||||
return acc
|
||||
}, {} as any),
|
||||
}}
|
||||
placeholder={<Placeholder />}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(MixedVariableTextInput)
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { FOCUS_COMMAND } from 'lexical'
|
||||
import { $insertNodes } from 'lexical'
|
||||
import { CustomTextNode } from '@/app/components/base/prompt-editor/plugins/custom-text/node'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
|
||||
const Placeholder = () => {
|
||||
const { t } = useTranslation()
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
const handleInsert = useCallback((text: string) => {
|
||||
editor.update(() => {
|
||||
const textNode = new CustomTextNode(text)
|
||||
$insertNodes([textNode])
|
||||
})
|
||||
editor.dispatchCommand(FOCUS_COMMAND, undefined as any)
|
||||
}, [editor])
|
||||
|
||||
return (
|
||||
<div
|
||||
className='pointer-events-auto flex h-full w-full cursor-text items-center px-2'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleInsert('')
|
||||
}}
|
||||
>
|
||||
<div className='flex grow items-center'>
|
||||
{t('workflow.nodes.tool.insertPlaceholder1')}
|
||||
<div className='system-kbd mx-0.5 flex h-4 w-4 items-center justify-center rounded bg-components-kbd-bg-gray text-text-placeholder'>/</div>
|
||||
<div
|
||||
className='system-sm-regular cursor-pointer text-components-input-text-placeholder underline decoration-dotted decoration-auto underline-offset-auto hover:text-text-tertiary'
|
||||
onMouseDown={((e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleInsert('/')
|
||||
})}
|
||||
>
|
||||
{t('workflow.nodes.tool.insertPlaceholder2')}
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
className='shrink-0'
|
||||
text='String'
|
||||
uppercase={false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Placeholder
|
||||
@@ -39,11 +39,11 @@ const Add = ({
|
||||
const { nodesReadOnly } = useNodesReadOnly()
|
||||
const { availableNextBlocks } = useAvailableBlocks(nodeData.type, nodeData.isInIteration || nodeData.isInLoop)
|
||||
|
||||
const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => {
|
||||
const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => {
|
||||
handleNodeAdd(
|
||||
{
|
||||
nodeType: type,
|
||||
toolDefaultValue,
|
||||
pluginDefaultValue,
|
||||
},
|
||||
{
|
||||
prevNodeId: nodeId,
|
||||
|
||||
@@ -38,8 +38,8 @@ const ChangeItem = ({
|
||||
availableNextBlocks,
|
||||
} = useAvailableBlocks(data.type, data.isInIteration || data.isInLoop)
|
||||
|
||||
const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => {
|
||||
handleNodeChange(nodeId, type, sourceHandle, toolDefaultValue)
|
||||
const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => {
|
||||
handleNodeChange(nodeId, type, sourceHandle, pluginDefaultValue)
|
||||
}, [nodeId, sourceHandle, handleNodeChange])
|
||||
|
||||
const renderTrigger = useCallback(() => {
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
RiPlayLargeLine,
|
||||
} from '@remixicon/react'
|
||||
import {
|
||||
useNodeDataUpdate,
|
||||
useNodesInteractions,
|
||||
} from '../../../hooks'
|
||||
import { type Node, NodeRunningStatus } from '../../../types'
|
||||
@@ -19,6 +18,9 @@ import {
|
||||
Stop,
|
||||
} from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { useWorkflowRunValidation } from '@/app/components/workflow/hooks/use-checklist'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
|
||||
type NodeControlProps = Pick<Node, 'id' | 'data'>
|
||||
const NodeControl: FC<NodeControlProps> = ({
|
||||
@@ -27,9 +29,11 @@ const NodeControl: FC<NodeControlProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const { handleNodeDataUpdate } = useNodeDataUpdate()
|
||||
const { handleNodeSelect } = useNodesInteractions()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const isSingleRunning = data._singleRunningStatus === NodeRunningStatus.Running
|
||||
const { warningNodes } = useWorkflowRunValidation()
|
||||
const warningForNode = warningNodes.find(item => item.id === id)
|
||||
const handleOpenChange = useCallback((newOpen: boolean) => {
|
||||
setOpen(newOpen)
|
||||
}, [])
|
||||
@@ -38,7 +42,8 @@ const NodeControl: FC<NodeControlProps> = ({
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
absolute -top-7 right-0 hidden h-7 pb-1 group-hover:flex
|
||||
absolute -top-7 right-0 hidden h-7 pb-1
|
||||
${!data._pluginInstallLocked && 'group-hover:flex'}
|
||||
${data.selected && '!flex'}
|
||||
${open && '!flex'}
|
||||
`}
|
||||
@@ -50,17 +55,20 @@ const NodeControl: FC<NodeControlProps> = ({
|
||||
{
|
||||
canRunBySingle(data.type, isChildNode) && (
|
||||
<div
|
||||
className='flex h-5 w-5 cursor-pointer items-center justify-center rounded-md hover:bg-state-base-hover'
|
||||
className={`flex h-5 w-5 items-center justify-center rounded-md ${isSingleRunning ? 'cursor-pointer hover:bg-state-base-hover' : warningForNode ? 'cursor-not-allowed text-text-disabled' : 'cursor-pointer hover:bg-state-base-hover'}`}
|
||||
onClick={() => {
|
||||
const nextData: Record<string, any> = {
|
||||
_isSingleRun: !isSingleRunning,
|
||||
const action = isSingleRunning ? 'stop' : 'run'
|
||||
if (!isSingleRunning && warningForNode) {
|
||||
const message = warningForNode.errorMessage || t('workflow.panel.checklistTip')
|
||||
Toast.notify({ type: 'error', message })
|
||||
return
|
||||
}
|
||||
if(isSingleRunning)
|
||||
nextData._singleRunningStatus = undefined
|
||||
|
||||
handleNodeDataUpdate({
|
||||
id,
|
||||
data: nextData,
|
||||
const store = workflowStore.getState()
|
||||
store.setInitShowLastRunTab(true)
|
||||
store.setPendingSingleRun({
|
||||
nodeId: id,
|
||||
action,
|
||||
})
|
||||
handleNodeSelect(id)
|
||||
}}
|
||||
@@ -70,7 +78,7 @@ const NodeControl: FC<NodeControlProps> = ({
|
||||
? <Stop className='h-3 w-3' />
|
||||
: (
|
||||
<Tooltip
|
||||
popupContent={t('workflow.panel.runThisStep')}
|
||||
popupContent={warningForNode ? warningForNode.errorMessage || t('workflow.panel.checklistTip') : t('workflow.panel.runThisStep')}
|
||||
asChild={false}
|
||||
>
|
||||
<RiPlayLargeLine className='h-3 w-3' />
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from '../../../types'
|
||||
import type { Node } from '../../../types'
|
||||
import BlockSelector from '../../../block-selector'
|
||||
import type { DataSourceDefaultValue, ToolDefaultValue } from '../../../block-selector/types'
|
||||
import type { PluginDefaultValue } from '../../../block-selector/types'
|
||||
import {
|
||||
useAvailableBlocks,
|
||||
useIsChatMode,
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from '../../../hooks'
|
||||
import {
|
||||
useStore,
|
||||
useWorkflowStore,
|
||||
} from '../../../store'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
@@ -57,11 +58,11 @@ export const NodeTargetHandle = memo(({
|
||||
if (!connected)
|
||||
setOpen(v => !v)
|
||||
}, [connected])
|
||||
const handleSelect = useCallback((type: BlockEnum, toolDefaultValue?: ToolDefaultValue | DataSourceDefaultValue) => {
|
||||
const handleSelect = useCallback((type: BlockEnum, pluginDefaultValue?: PluginDefaultValue) => {
|
||||
handleNodeAdd(
|
||||
{
|
||||
nodeType: type,
|
||||
toolDefaultValue,
|
||||
pluginDefaultValue,
|
||||
},
|
||||
{
|
||||
nextNodeId: id,
|
||||
@@ -84,7 +85,10 @@ export const NodeTargetHandle = memo(({
|
||||
data._runningStatus === NodeRunningStatus.Failed && 'after:bg-workflow-link-line-error-handle',
|
||||
data._runningStatus === NodeRunningStatus.Exception && 'after:bg-workflow-link-line-failure-handle',
|
||||
!connected && 'after:opacity-0',
|
||||
data.type === BlockEnum.Start && 'opacity-0',
|
||||
(data.type === BlockEnum.Start
|
||||
|| data.type === BlockEnum.TriggerWebhook
|
||||
|| data.type === BlockEnum.TriggerSchedule
|
||||
|| data.type === BlockEnum.TriggerPlugin) && 'opacity-0',
|
||||
handleClassName,
|
||||
)}
|
||||
isConnectable={isConnectable}
|
||||
@@ -124,7 +128,10 @@ export const NodeSourceHandle = memo(({
|
||||
showExceptionStatus,
|
||||
}: NodeHandleProps) => {
|
||||
const { t } = useTranslation()
|
||||
const notInitialWorkflow = useStore(s => s.notInitialWorkflow)
|
||||
const shouldAutoOpenStartNodeSelector = useStore(s => s.shouldAutoOpenStartNodeSelector)
|
||||
const setShouldAutoOpenStartNodeSelector = useStore(s => s.setShouldAutoOpenStartNodeSelector)
|
||||
const setHasSelectedStartNode = useStore(s => s.setHasSelectedStartNode)
|
||||
const workflowStoreApi = useWorkflowStore()
|
||||
const [open, setOpen] = useState(false)
|
||||
const { handleNodeAdd } = useNodesInteractions()
|
||||
const { getNodesReadOnly } = useNodesReadOnly()
|
||||
@@ -140,11 +147,11 @@ export const NodeSourceHandle = memo(({
|
||||
e.stopPropagation()
|
||||
setOpen(v => !v)
|
||||
}, [])
|
||||
const handleSelect = useCallback((type: BlockEnum, toolDefaultValue?: ToolDefaultValue | DataSourceDefaultValue) => {
|
||||
const handleSelect = useCallback((type: BlockEnum, pluginDefaultValue?: PluginDefaultValue) => {
|
||||
handleNodeAdd(
|
||||
{
|
||||
nodeType: type,
|
||||
toolDefaultValue,
|
||||
pluginDefaultValue,
|
||||
},
|
||||
{
|
||||
prevNodeId: id,
|
||||
@@ -154,9 +161,27 @@ export const NodeSourceHandle = memo(({
|
||||
}, [handleNodeAdd, id, handleId])
|
||||
|
||||
useEffect(() => {
|
||||
if (notInitialWorkflow && data.type === BlockEnum.Start && !isChatMode)
|
||||
if (!shouldAutoOpenStartNodeSelector)
|
||||
return
|
||||
|
||||
if (isChatMode) {
|
||||
setShouldAutoOpenStartNodeSelector?.(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.type === BlockEnum.Start || data.type === BlockEnum.TriggerSchedule || data.type === BlockEnum.TriggerWebhook || data.type === BlockEnum.TriggerPlugin) {
|
||||
setOpen(true)
|
||||
}, [notInitialWorkflow, data.type, isChatMode])
|
||||
if (setShouldAutoOpenStartNodeSelector)
|
||||
setShouldAutoOpenStartNodeSelector(false)
|
||||
else
|
||||
workflowStoreApi?.setState?.({ shouldAutoOpenStartNodeSelector: false })
|
||||
|
||||
if (setHasSelectedStartNode)
|
||||
setHasSelectedStartNode(false)
|
||||
else
|
||||
workflowStoreApi?.setState?.({ hasSelectedStartNode: false })
|
||||
}
|
||||
}, [shouldAutoOpenStartNodeSelector, data.type, isChatMode, setShouldAutoOpenStartNodeSelector, setHasSelectedStartNode, workflowStoreApi])
|
||||
|
||||
return (
|
||||
<Handle
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { RiCrosshairLine } from '@remixicon/react'
|
||||
import { useReactFlow, useStore } from 'reactflow'
|
||||
import TooltipPlus from '@/app/components/base/tooltip'
|
||||
import { useNodesSyncDraft } from '@/app/components/workflow-app/hooks'
|
||||
|
||||
type NodePositionProps = {
|
||||
nodeId: string
|
||||
}
|
||||
const NodePosition = ({
|
||||
nodeId,
|
||||
}: NodePositionProps) => {
|
||||
const { t } = useTranslation()
|
||||
const reactflow = useReactFlow()
|
||||
const { doSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const {
|
||||
nodePosition,
|
||||
nodeWidth,
|
||||
nodeHeight,
|
||||
} = useStore(useShallow((s) => {
|
||||
const nodes = s.getNodes()
|
||||
const currentNode = nodes.find(node => node.id === nodeId)!
|
||||
|
||||
return {
|
||||
nodePosition: currentNode.position,
|
||||
nodeWidth: currentNode.width,
|
||||
nodeHeight: currentNode.height,
|
||||
}
|
||||
}))
|
||||
const transform = useStore(s => s.transform)
|
||||
|
||||
if (!nodePosition || !nodeWidth || !nodeHeight) return null
|
||||
|
||||
const workflowContainer = document.getElementById('workflow-container')
|
||||
const zoom = transform[2]
|
||||
|
||||
const { clientWidth, clientHeight } = workflowContainer!
|
||||
const { setViewport } = reactflow
|
||||
|
||||
return (
|
||||
<TooltipPlus
|
||||
popupContent={t('workflow.panel.moveToThisNode')}
|
||||
>
|
||||
<div
|
||||
className='mr-1 flex h-6 w-6 cursor-pointer items-center justify-center rounded-md hover:bg-state-base-hover'
|
||||
onClick={() => {
|
||||
setViewport({
|
||||
x: (clientWidth - 400 - nodeWidth * zoom) / 2 - nodePosition.x * zoom,
|
||||
y: (clientHeight - nodeHeight * zoom) / 2 - nodePosition.y * zoom,
|
||||
zoom: transform[2],
|
||||
})
|
||||
doSyncWorkflowDraft()
|
||||
}}
|
||||
>
|
||||
<RiCrosshairLine className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</TooltipPlus>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(NodePosition)
|
||||
@@ -8,12 +8,17 @@ import { intersection } from 'lodash-es'
|
||||
import BlockSelector from '@/app/components/workflow/block-selector'
|
||||
import {
|
||||
useAvailableBlocks,
|
||||
useIsChatMode,
|
||||
useNodesInteractions,
|
||||
} from '@/app/components/workflow/hooks'
|
||||
import { useHooksStore } from '@/app/components/workflow/hooks-store'
|
||||
import type {
|
||||
Node,
|
||||
OnSelectBlock,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { BlockEnum, isTriggerNode } from '@/app/components/workflow/types'
|
||||
|
||||
import { FlowType } from '@/types/common'
|
||||
|
||||
type ChangeBlockProps = {
|
||||
nodeId: string
|
||||
@@ -31,6 +36,14 @@ const ChangeBlock = ({
|
||||
availablePrevBlocks,
|
||||
availableNextBlocks,
|
||||
} = useAvailableBlocks(nodeData.type, nodeData.isInIteration || nodeData.isInLoop)
|
||||
const isChatMode = useIsChatMode()
|
||||
const flowType = useHooksStore(s => s.configsMap?.flowType)
|
||||
const showStartTab = flowType !== FlowType.ragPipeline && !isChatMode
|
||||
const ignoreNodeIds = useMemo(() => {
|
||||
if (isTriggerNode(nodeData.type as BlockEnum))
|
||||
return [nodeId]
|
||||
return undefined
|
||||
}, [nodeData.type, nodeId])
|
||||
|
||||
const availableNodes = useMemo(() => {
|
||||
if (availablePrevBlocks.length && availableNextBlocks.length)
|
||||
@@ -41,8 +54,8 @@ const ChangeBlock = ({
|
||||
return availableNextBlocks
|
||||
}, [availablePrevBlocks, availableNextBlocks])
|
||||
|
||||
const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => {
|
||||
handleNodeChange(nodeId, type, sourceHandle, toolDefaultValue)
|
||||
const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => {
|
||||
handleNodeChange(nodeId, type, sourceHandle, pluginDefaultValue)
|
||||
}, [handleNodeChange, nodeId, sourceHandle])
|
||||
|
||||
const renderTrigger = useCallback(() => {
|
||||
@@ -64,6 +77,9 @@ const ChangeBlock = ({
|
||||
trigger={renderTrigger}
|
||||
popupClassName='min-w-[240px]'
|
||||
availableBlocksTypes={availableNodes}
|
||||
showStartTab={showStartTab}
|
||||
ignoreNodeIds={ignoreNodeIds}
|
||||
forceEnableStartTab={nodeData.type === BlockEnum.Start}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
VarType,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { getNodeInfoById, isConversationVar, isENV, isRagVariableVar, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils'
|
||||
import { getNodeInfoById, isConversationVar, isENV, isGlobalVar, isRagVariableVar, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils'
|
||||
import { isExceptionVariable } from '@/app/components/workflow/utils'
|
||||
import {
|
||||
VariableLabelInSelect,
|
||||
@@ -39,7 +39,8 @@ const VariableTag = ({
|
||||
|
||||
const isEnv = isENV(valueSelector)
|
||||
const isChatVar = isConversationVar(valueSelector)
|
||||
const isValid = Boolean(node) || isEnv || isChatVar || isRagVar
|
||||
const isGlobal = isGlobalVar(valueSelector)
|
||||
const isValid = Boolean(node) || isEnv || isChatVar || isRagVar || isGlobal
|
||||
|
||||
const variableName = isSystemVar(valueSelector) ? valueSelector.slice(0).join('.') : valueSelector.slice(1).join('.')
|
||||
const isException = isExceptionVariable(variableName, node?.data.type)
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
'use client'
|
||||
import cn from '@/utils/classnames'
|
||||
import { RiArrowDropDownLine } from '@remixicon/react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { Field as FieldType } from '../../../../../llm/types'
|
||||
import { Type } from '../../../../../llm/types'
|
||||
import { getFieldType } from '../../../../../llm/utils'
|
||||
import type { Field as FieldType } from '../../../../../llm/types'
|
||||
import cn from '@/utils/classnames'
|
||||
import TreeIndentLine from '../tree-indent-line'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { RiArrowDropDownLine } from '@remixicon/react'
|
||||
|
||||
type Props = {
|
||||
name: string,
|
||||
@@ -28,6 +28,7 @@ const Field: FC<Props> = ({
|
||||
const { t } = useTranslation()
|
||||
const isRoot = depth === 1
|
||||
const hasChildren = payload.type === Type.object && payload.properties
|
||||
const hasEnum = payload.enum && payload.enum.length > 0
|
||||
const [fold, {
|
||||
toggle: toggleFold,
|
||||
}] = useBoolean(false)
|
||||
@@ -44,7 +45,10 @@ const Field: FC<Props> = ({
|
||||
/>
|
||||
)}
|
||||
<div className={cn('system-sm-medium ml-[7px] h-6 truncate leading-6 text-text-secondary', isRoot && rootClassName)}>{name}</div>
|
||||
<div className='system-xs-regular ml-3 shrink-0 leading-6 text-text-tertiary'>{getFieldType(payload)}{(payload.schemaType && payload.schemaType !== 'file' && ` (${payload.schemaType})`)}</div>
|
||||
<div className='system-xs-regular ml-3 shrink-0 leading-6 text-text-tertiary'>
|
||||
{getFieldType(payload)}
|
||||
{(payload.schemaType && payload.schemaType !== 'file' && ` (${payload.schemaType})`)}
|
||||
</div>
|
||||
{required && <div className='system-2xs-medium-uppercase ml-3 leading-6 text-text-warning'>{t('app.structOutput.required')}</div>}
|
||||
</div>
|
||||
{payload.description && (
|
||||
@@ -52,6 +56,18 @@ const Field: FC<Props> = ({
|
||||
<div className='system-xs-regular w-0 grow truncate text-text-tertiary'>{payload.description}</div>
|
||||
</div>
|
||||
)}
|
||||
{hasEnum && (
|
||||
<div className='ml-[7px] flex'>
|
||||
<div className='system-xs-regular w-0 grow text-text-quaternary'>
|
||||
{payload.enum!.map((value, index) => (
|
||||
<span key={index}>
|
||||
{typeof value === 'string' ? `"${value}"` : value}
|
||||
{index < payload.enum!.length - 1 && ' | '}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -39,6 +39,9 @@ import type {
|
||||
import type { VariableAssignerNodeType } from '@/app/components/workflow/nodes/variable-assigner/types'
|
||||
import type { Field as StructField } from '@/app/components/workflow/nodes/llm/types'
|
||||
import type { RAGPipelineVariable } from '@/models/pipeline'
|
||||
import type { WebhookTriggerNodeType } from '@/app/components/workflow/nodes/trigger-webhook/types'
|
||||
import type { PluginTriggerNodeType } from '@/app/components/workflow/nodes/trigger-plugin/types'
|
||||
import PluginTriggerNodeDefault from '@/app/components/workflow/nodes/trigger-plugin/default'
|
||||
|
||||
import {
|
||||
AGENT_OUTPUT_STRUCT,
|
||||
@@ -51,6 +54,7 @@ import {
|
||||
SUPPORT_OUTPUT_VARS_NODE,
|
||||
TEMPLATE_TRANSFORM_OUTPUT_STRUCT,
|
||||
TOOL_OUTPUT_STRUCT,
|
||||
getGlobalVars,
|
||||
} from '@/app/components/workflow/constants'
|
||||
import ToolNodeDefault from '@/app/components/workflow/nodes/tool/default'
|
||||
import DataSourceNodeDefault from '@/app/components/workflow/nodes/data-source/default'
|
||||
@@ -59,11 +63,21 @@ import type { PromptItem } from '@/models/debug'
|
||||
import { VAR_REGEX } from '@/config'
|
||||
import type { AgentNodeType } from '../../../agent/types'
|
||||
import type { SchemaTypeDefinition } from '@/service/use-common'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
export const isSystemVar = (valueSelector: ValueSelector) => {
|
||||
return valueSelector[0] === 'sys' || valueSelector[1] === 'sys'
|
||||
}
|
||||
|
||||
export const isGlobalVar = (valueSelector: ValueSelector) => {
|
||||
if(!isSystemVar(valueSelector)) return false
|
||||
const second = valueSelector[1]
|
||||
|
||||
if(['query', 'files'].includes(second))
|
||||
return false
|
||||
return true
|
||||
}
|
||||
|
||||
export const isENV = (valueSelector: ValueSelector) => {
|
||||
return valueSelector[0] === 'env'
|
||||
}
|
||||
@@ -348,34 +362,29 @@ const formatItem = (
|
||||
variable: 'sys.query',
|
||||
type: VarType.string,
|
||||
})
|
||||
res.vars.push({
|
||||
variable: 'sys.dialogue_count',
|
||||
type: VarType.number,
|
||||
})
|
||||
res.vars.push({
|
||||
variable: 'sys.conversation_id',
|
||||
type: VarType.string,
|
||||
})
|
||||
}
|
||||
res.vars.push({
|
||||
variable: 'sys.user_id',
|
||||
type: VarType.string,
|
||||
})
|
||||
res.vars.push({
|
||||
variable: 'sys.files',
|
||||
type: VarType.arrayFile,
|
||||
})
|
||||
res.vars.push({
|
||||
variable: 'sys.app_id',
|
||||
type: VarType.string,
|
||||
})
|
||||
res.vars.push({
|
||||
variable: 'sys.workflow_id',
|
||||
type: VarType.string,
|
||||
})
|
||||
res.vars.push({
|
||||
variable: 'sys.workflow_run_id',
|
||||
type: VarType.string,
|
||||
break
|
||||
}
|
||||
|
||||
case BlockEnum.TriggerWebhook: {
|
||||
const {
|
||||
variables = [],
|
||||
} = data as WebhookTriggerNodeType
|
||||
res.vars = variables.map((v) => {
|
||||
const type = v.value_type || VarType.string
|
||||
const varRes: Var = {
|
||||
variable: v.variable,
|
||||
type,
|
||||
isParagraph: false,
|
||||
isSelect: false,
|
||||
options: v.options,
|
||||
required: v.required,
|
||||
}
|
||||
return varRes
|
||||
})
|
||||
|
||||
break
|
||||
@@ -612,6 +621,17 @@ const formatItem = (
|
||||
break
|
||||
}
|
||||
|
||||
case BlockEnum.TriggerPlugin: {
|
||||
const outputSchema = PluginTriggerNodeDefault.getOutputVars?.(
|
||||
data as PluginTriggerNodeType,
|
||||
allPluginInfoList,
|
||||
[],
|
||||
{ schemaTypeDefinitions },
|
||||
) || []
|
||||
res.vars = outputSchema
|
||||
break
|
||||
}
|
||||
|
||||
case 'env': {
|
||||
res.vars = data.envList.map((env: EnvironmentVariable) => {
|
||||
return {
|
||||
@@ -634,6 +654,11 @@ const formatItem = (
|
||||
break
|
||||
}
|
||||
|
||||
case 'global': {
|
||||
res.vars = data.globalVarList
|
||||
break
|
||||
}
|
||||
|
||||
case 'rag': {
|
||||
res.vars = data.ragVariables.map((ragVar: RAGPipelineVariable) => {
|
||||
return {
|
||||
@@ -774,6 +799,15 @@ export const toNodeOutputVars = (
|
||||
chatVarList: conversationVariables,
|
||||
},
|
||||
}
|
||||
// GLOBAL_VAR_NODE data format
|
||||
const GLOBAL_VAR_NODE = {
|
||||
id: 'global',
|
||||
data: {
|
||||
title: 'SYSTEM',
|
||||
type: 'global',
|
||||
globalVarList: getGlobalVars(isChatMode),
|
||||
},
|
||||
}
|
||||
// RAG_PIPELINE_NODE data format
|
||||
const RAG_PIPELINE_NODE = {
|
||||
id: 'rag',
|
||||
@@ -793,6 +827,8 @@ export const toNodeOutputVars = (
|
||||
if (b.data.type === 'env') return -1
|
||||
if (a.data.type === 'conversation') return 1
|
||||
if (b.data.type === 'conversation') return -1
|
||||
if (a.data.type === 'global') return 1
|
||||
if (b.data.type === 'global') return -1
|
||||
// sort nodes by x position
|
||||
return (b.position?.x || 0) - (a.position?.x || 0)
|
||||
})
|
||||
@@ -803,6 +839,7 @@ export const toNodeOutputVars = (
|
||||
),
|
||||
...(environmentVariables.length > 0 ? [ENV_NODE] : []),
|
||||
...(isChatMode && conversationVariables.length > 0 ? [CHAT_VAR_NODE] : []),
|
||||
GLOBAL_VAR_NODE,
|
||||
...(RAG_PIPELINE_NODE.data.ragVariables.length > 0
|
||||
? [RAG_PIPELINE_NODE]
|
||||
: []),
|
||||
@@ -1026,7 +1063,8 @@ export const getVarType = ({
|
||||
if (valueSelector[1] === 'index') return VarType.number
|
||||
}
|
||||
|
||||
const isSystem = isSystemVar(valueSelector)
|
||||
const isGlobal = isGlobalVar(valueSelector)
|
||||
const isInStartNodeSysVar = isSystemVar(valueSelector) && !isGlobal
|
||||
const isEnv = isENV(valueSelector)
|
||||
const isChatVar = isConversationVar(valueSelector)
|
||||
const isSharedRagVariable
|
||||
@@ -1039,7 +1077,8 @@ export const getVarType = ({
|
||||
})
|
||||
|
||||
const targetVarNodeId = (() => {
|
||||
if (isSystem) return startNode?.id
|
||||
if (isInStartNodeSysVar) return startNode?.id
|
||||
if (isGlobal) return 'global'
|
||||
if (isInNodeRagVariable) return valueSelector[1]
|
||||
return valueSelector[0]
|
||||
})()
|
||||
@@ -1052,7 +1091,7 @@ export const getVarType = ({
|
||||
let type: VarType = VarType.string
|
||||
let curr: any = targetVar.vars
|
||||
|
||||
if (isSystem || isEnv || isChatVar || isSharedRagVariable) {
|
||||
if (isInStartNodeSysVar || isEnv || isChatVar || isSharedRagVariable || isGlobal) {
|
||||
return curr.find(
|
||||
(v: any) => v.variable === (valueSelector as ValueSelector).join('.'),
|
||||
)?.type
|
||||
@@ -1242,7 +1281,7 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => {
|
||||
}
|
||||
case BlockEnum.LLM: {
|
||||
const payload = data as LLMNodeType
|
||||
const isChatModel = payload.model?.mode === 'chat'
|
||||
const isChatModel = payload.model?.mode === AppModeEnum.CHAT
|
||||
let prompts: string[] = []
|
||||
if (isChatModel) {
|
||||
prompts
|
||||
@@ -1545,7 +1584,7 @@ export const updateNodeVars = (
|
||||
}
|
||||
case BlockEnum.LLM: {
|
||||
const payload = data as LLMNodeType
|
||||
const isChatModel = payload.model?.mode === 'chat'
|
||||
const isChatModel = payload.model?.mode === AppModeEnum.CHAT
|
||||
if (isChatModel) {
|
||||
payload.prompt_template = (
|
||||
payload.prompt_template as PromptItem[]
|
||||
|
||||
@@ -18,10 +18,11 @@ import {
|
||||
import RemoveButton from '../remove-button'
|
||||
import useAvailableVarList from '../../hooks/use-available-var-list'
|
||||
import VarReferencePopup from './var-reference-popup'
|
||||
import { getNodeInfoById, isConversationVar, isENV, isRagVariableVar, isSystemVar, removeFileVars, varTypeToStructType } from './utils'
|
||||
import { getNodeInfoById, isConversationVar, isENV, isGlobalVar, isRagVariableVar, isSystemVar, removeFileVars, varTypeToStructType } from './utils'
|
||||
import ConstantField from './constant-field'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { CommonNodeType, Node, NodeOutPutVar, ToolWithProvider, ValueSelector, Var } from '@/app/components/workflow/types'
|
||||
import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
|
||||
import type { CredentialFormSchemaSelect } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { type CredentialFormSchema, type FormOption, FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
useWorkflowVariables,
|
||||
} from '@/app/components/workflow/hooks'
|
||||
import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types'
|
||||
// import type { BaseResource, BaseResourceProvider } from '@/app/components/workflow/nodes/_base/types'
|
||||
import TypeSelector from '@/app/components/workflow/nodes/_base/components/selector'
|
||||
import AddButton from '@/app/components/base/button/add-button'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
@@ -45,9 +47,10 @@ import Tooltip from '@/app/components/base/tooltip'
|
||||
import { isExceptionVariable } from '@/app/components/workflow/utils'
|
||||
import VarFullPathPanel from './var-full-path-panel'
|
||||
import { noop } from 'lodash-es'
|
||||
import { useFetchDynamicOptions } from '@/service/use-plugins'
|
||||
import type { Tool } from '@/app/components/tools/types'
|
||||
import { useFetchDynamicOptions } from '@/service/use-plugins'
|
||||
import { VariableIconWithColor } from '@/app/components/workflow/nodes/_base/components/variable/variable-label'
|
||||
import { VAR_SHOW_NAME_MAP } from '@/app/components/workflow/constants'
|
||||
|
||||
const TRIGGER_DEFAULT_WIDTH = 227
|
||||
|
||||
@@ -78,7 +81,7 @@ type Props = {
|
||||
popupFor?: 'assigned' | 'toAssigned'
|
||||
zIndex?: number
|
||||
currentTool?: Tool
|
||||
currentProvider?: ToolWithProvider
|
||||
currentProvider?: ToolWithProvider | TriggerWithProvider
|
||||
preferSchemaType?: boolean
|
||||
}
|
||||
|
||||
@@ -203,6 +206,9 @@ const VarReferencePicker: FC<Props> = ({
|
||||
const varName = useMemo(() => {
|
||||
if (!hasValue)
|
||||
return ''
|
||||
const showName = VAR_SHOW_NAME_MAP[(value as ValueSelector).join('.')]
|
||||
if(showName)
|
||||
return showName
|
||||
|
||||
const isSystem = isSystemVar(value as ValueSelector)
|
||||
const varName = Array.isArray(value) ? value[(value as ValueSelector).length - 1] : ''
|
||||
@@ -291,15 +297,17 @@ const VarReferencePicker: FC<Props> = ({
|
||||
preferSchemaType,
|
||||
})
|
||||
|
||||
const { isEnv, isChatVar, isRagVar, isValidVar, isException } = useMemo(() => {
|
||||
const { isEnv, isChatVar, isGlobal, isRagVar, isValidVar, isException } = useMemo(() => {
|
||||
const isEnv = isENV(value as ValueSelector)
|
||||
const isChatVar = isConversationVar(value as ValueSelector)
|
||||
const isGlobal = isGlobalVar(value as ValueSelector)
|
||||
const isRagVar = isRagVariableVar(value as ValueSelector)
|
||||
const isValidVar = Boolean(outputVarNode) || isEnv || isChatVar || isRagVar
|
||||
const isValidVar = Boolean(outputVarNode) || isEnv || isChatVar || isGlobal || isRagVar
|
||||
const isException = isExceptionVariable(varName, outputVarNode?.type)
|
||||
return {
|
||||
isEnv,
|
||||
isChatVar,
|
||||
isGlobal,
|
||||
isRagVar,
|
||||
isValidVar,
|
||||
isException,
|
||||
@@ -392,10 +400,11 @@ const VarReferencePicker: FC<Props> = ({
|
||||
const variableCategory = useMemo(() => {
|
||||
if (isEnv) return 'environment'
|
||||
if (isChatVar) return 'conversation'
|
||||
if (isGlobal) return 'global'
|
||||
if (isLoopVar) return 'loop'
|
||||
if (isRagVar) return 'rag'
|
||||
return 'system'
|
||||
}, [isEnv, isChatVar, isLoopVar, isRagVar])
|
||||
}, [isEnv, isChatVar, isGlobal, isLoopVar, isRagVar])
|
||||
|
||||
return (
|
||||
<div className={cn(className, !readonly && 'cursor-pointer')}>
|
||||
@@ -473,7 +482,7 @@ const VarReferencePicker: FC<Props> = ({
|
||||
{hasValue
|
||||
? (
|
||||
<>
|
||||
{isShowNodeName && !isEnv && !isChatVar && !isRagVar && (
|
||||
{isShowNodeName && !isEnv && !isChatVar && !isGlobal && !isRagVar && (
|
||||
<div className='flex items-center' onClick={(e) => {
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
e.stopPropagation()
|
||||
@@ -501,10 +510,11 @@ const VarReferencePicker: FC<Props> = ({
|
||||
<div className='flex items-center text-text-accent'>
|
||||
{isLoading && <RiLoader4Line className='h-3.5 w-3.5 animate-spin text-text-secondary' />}
|
||||
<VariableIconWithColor
|
||||
variables={value as ValueSelector}
|
||||
variableCategory={variableCategory}
|
||||
isExceptionVariable={isException}
|
||||
/>
|
||||
<div className={cn('ml-0.5 truncate text-xs font-medium', isEnv && '!text-text-secondary', isChatVar && 'text-util-colors-teal-teal-700', isException && 'text-text-warning')} title={varName} style={{
|
||||
<div className={cn('ml-0.5 truncate text-xs font-medium', isEnv && '!text-text-secondary', isChatVar && 'text-util-colors-teal-teal-700', isException && 'text-text-warning', isGlobal && 'text-util-colors-orange-orange-600')} title={varName} style={{
|
||||
maxWidth: maxVarNameWidth,
|
||||
}}>{varName}</div>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { CodeAssistant, MagicEdit } from '@/app/components/base/icons/src/vender
|
||||
import ManageInputField from './manage-input-field'
|
||||
import { VariableIconWithColor } from '@/app/components/workflow/nodes/_base/components/variable/variable-label'
|
||||
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
|
||||
import { VAR_SHOW_NAME_MAP } from '@/app/components/workflow/constants'
|
||||
|
||||
type ItemProps = {
|
||||
nodeId: string
|
||||
@@ -82,10 +83,14 @@ const Item: FC<ItemProps> = ({
|
||||
}, [isFlat, isInCodeGeneratorInstructionEditor, itemData.variable])
|
||||
|
||||
const varName = useMemo(() => {
|
||||
if(VAR_SHOW_NAME_MAP[itemData.variable])
|
||||
return VAR_SHOW_NAME_MAP[itemData.variable]
|
||||
|
||||
if (!isFlat)
|
||||
return itemData.variable
|
||||
if (itemData.variable === 'current')
|
||||
return isInCodeGeneratorInstructionEditor ? 'current_code' : 'current_prompt'
|
||||
|
||||
return itemData.variable
|
||||
}, [isFlat, isInCodeGeneratorInstructionEditor, itemData.variable])
|
||||
|
||||
@@ -182,6 +187,7 @@ const Item: FC<ItemProps> = ({
|
||||
>
|
||||
<div className='flex w-0 grow items-center'>
|
||||
{!isFlat && <VariableIconWithColor
|
||||
variables={itemData.variable.split('.')}
|
||||
variableCategory={variableCategory}
|
||||
isExceptionVariable={isException}
|
||||
/>}
|
||||
|
||||
@@ -11,6 +11,7 @@ import VariableIcon from './variable-icon'
|
||||
import VariableName from './variable-name'
|
||||
import cn from '@/utils/classnames'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { isConversationVar, isENV, isGlobalVar, isRagVariableVar } from '../../utils'
|
||||
|
||||
const VariableLabel = ({
|
||||
nodeType,
|
||||
@@ -26,6 +27,7 @@ const VariableLabel = ({
|
||||
rightSlot,
|
||||
}: VariablePayload) => {
|
||||
const varColorClassName = useVarColor(variables, isExceptionVariable)
|
||||
const isHideNodeLabel = !(isENV(variables) || isConversationVar(variables) || isGlobalVar(variables) || isRagVariableVar(variables))
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -35,10 +37,12 @@ const VariableLabel = ({
|
||||
onClick={onClick}
|
||||
ref={ref}
|
||||
>
|
||||
<VariableNodeLabel
|
||||
nodeType={nodeType}
|
||||
nodeTitle={nodeTitle}
|
||||
/>
|
||||
{ isHideNodeLabel && (
|
||||
<VariableNodeLabel
|
||||
nodeType={nodeType}
|
||||
nodeTitle={nodeTitle}
|
||||
/>
|
||||
)}
|
||||
{
|
||||
notShowFullPath && (
|
||||
<>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
|
||||
import { BubbleX, Env } from '@/app/components/base/icons/src/vender/line/others'
|
||||
import { BubbleX, Env, GlobalVariable } from '@/app/components/base/icons/src/vender/line/others'
|
||||
import { Loop } from '@/app/components/base/icons/src/vender/workflow'
|
||||
import { InputField } from '@/app/components/base/icons/src/vender/pipeline'
|
||||
import {
|
||||
isConversationVar,
|
||||
isENV,
|
||||
isGlobalVar,
|
||||
isRagVariableVar,
|
||||
isSystemVar,
|
||||
} from '../utils'
|
||||
import { VarInInspectType } from '@/types/workflow'
|
||||
import { VAR_SHOW_NAME_MAP } from '@/app/components/workflow/constants'
|
||||
|
||||
export const useVarIcon = (variables: string[], variableCategory?: VarInInspectType | string) => {
|
||||
if (variableCategory === 'loop')
|
||||
@@ -24,6 +26,9 @@ export const useVarIcon = (variables: string[], variableCategory?: VarInInspectT
|
||||
if (isConversationVar(variables) || variableCategory === VarInInspectType.conversation || variableCategory === 'conversation')
|
||||
return BubbleX
|
||||
|
||||
if (isGlobalVar(variables) || variableCategory === VarInInspectType.system)
|
||||
return GlobalVariable
|
||||
|
||||
return Variable02
|
||||
}
|
||||
|
||||
@@ -41,13 +46,22 @@ export const useVarColor = (variables: string[], isExceptionVariable?: boolean,
|
||||
if (isConversationVar(variables) || variableCategory === VarInInspectType.conversation || variableCategory === 'conversation')
|
||||
return 'text-util-colors-teal-teal-700'
|
||||
|
||||
if (isGlobalVar(variables) || variableCategory === VarInInspectType.system)
|
||||
return 'text-util-colors-orange-orange-600'
|
||||
|
||||
return 'text-text-accent'
|
||||
}, [variables, isExceptionVariable, variableCategory])
|
||||
}
|
||||
|
||||
export const useVarName = (variables: string[], notShowFullPath?: boolean) => {
|
||||
const showName = VAR_SHOW_NAME_MAP[variables.join('.')]
|
||||
let variableFullPathName = variables.slice(1).join('.')
|
||||
|
||||
if (isRagVariableVar(variables))
|
||||
variableFullPathName = variables.slice(2).join('.')
|
||||
|
||||
const varName = useMemo(() => {
|
||||
let variableFullPathName = variables.slice(1).join('.')
|
||||
variableFullPathName = variables.slice(1).join('.')
|
||||
|
||||
if (isRagVariableVar(variables))
|
||||
variableFullPathName = variables.slice(2).join('.')
|
||||
@@ -58,6 +72,8 @@ export const useVarName = (variables: string[], notShowFullPath?: boolean) => {
|
||||
return `${isSystem ? 'sys.' : ''}${varName}`
|
||||
}, [variables, notShowFullPath])
|
||||
|
||||
if (showName)
|
||||
return showName
|
||||
return varName
|
||||
}
|
||||
|
||||
|
||||
@@ -1,36 +1,18 @@
|
||||
import type {
|
||||
FC,
|
||||
ReactNode,
|
||||
} from 'react'
|
||||
import React, {
|
||||
cloneElement,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { Stop } from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
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'
|
||||
AuthCategory,
|
||||
AuthorizedInDataSourceNode,
|
||||
AuthorizedInNode,
|
||||
PluginAuth,
|
||||
PluginAuthInDataSourceNode,
|
||||
} from '@/app/components/plugins/plugin-auth'
|
||||
import { usePluginStore } from '@/app/components/plugins/plugin-detail-panel/store'
|
||||
import type { SimpleSubscription } from '@/app/components/plugins/plugin-detail-panel/subscription-list'
|
||||
import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance'
|
||||
import BlockIcon from '@/app/components/workflow/block-icon'
|
||||
import Split from '@/app/components/workflow/nodes/_base/components/split'
|
||||
import {
|
||||
WorkflowHistoryEvent,
|
||||
useAvailableBlocks,
|
||||
@@ -41,41 +23,59 @@ import {
|
||||
useToolIcon,
|
||||
useWorkflowHistory,
|
||||
} from '@/app/components/workflow/hooks'
|
||||
import { useHooksStore } from '@/app/components/workflow/hooks-store'
|
||||
import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud'
|
||||
import Split from '@/app/components/workflow/nodes/_base/components/split'
|
||||
import DataSourceBeforeRunForm from '@/app/components/workflow/nodes/data-source/before-run-form'
|
||||
import type { CustomRunFormProps } from '@/app/components/workflow/nodes/data-source/types'
|
||||
import { DataSourceClassification } from '@/app/components/workflow/nodes/data-source/types'
|
||||
import { useLogs } from '@/app/components/workflow/run/hooks'
|
||||
import SpecialResultPanel from '@/app/components/workflow/run/special-result-panel'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import { BlockEnum, type Node, NodeRunningStatus } from '@/app/components/workflow/types'
|
||||
import {
|
||||
canRunBySingle,
|
||||
hasErrorHandleNode,
|
||||
hasRetryNode,
|
||||
isSupportCustomRunForm,
|
||||
} 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 { useModalContext } from '@/context/modal-context'
|
||||
import { useAllBuiltInTools } from '@/service/use-tools'
|
||||
import { useAllTriggerPlugins } from '@/service/use-triggers'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { canFindTool } from '@/utils'
|
||||
import cn from '@/utils/classnames'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
import {
|
||||
RiCloseLine,
|
||||
RiPlayLargeLine,
|
||||
} from '@remixicon/react'
|
||||
import { debounce } from 'lodash-es'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import React, {
|
||||
cloneElement,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useResizePanel } from '../../hooks/use-resize-panel'
|
||||
import BeforeRunForm from '../before-run-form'
|
||||
import PanelWrap from '../before-run-form/panel-wrap'
|
||||
import ErrorHandleOnPanel from '../error-handle/error-handle-on-panel'
|
||||
import HelpLink from '../help-link'
|
||||
import NextStep from '../next-step'
|
||||
import PanelOperator from '../panel-operator'
|
||||
import RetryOnPanel from '../retry/retry-on-panel'
|
||||
import { DescriptionInput, TitleInput } from '../title-description-input'
|
||||
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 { 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'
|
||||
import { useHooksStore } from '@/app/components/workflow/hooks-store'
|
||||
import { FlowType } from '@/types/common'
|
||||
import {
|
||||
AuthorizedInDataSourceNode,
|
||||
AuthorizedInNode,
|
||||
PluginAuth,
|
||||
PluginAuthInDataSourceNode,
|
||||
} from '@/app/components/plugins/plugin-auth'
|
||||
import { AuthCategory } from '@/app/components/plugins/plugin-auth'
|
||||
import { canFindTool } from '@/utils'
|
||||
import type { CustomRunFormProps } from '@/app/components/workflow/nodes/data-source/types'
|
||||
import { DataSourceClassification } from '@/app/components/workflow/nodes/data-source/types'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import DataSourceBeforeRunForm from '@/app/components/workflow/nodes/data-source/before-run-form'
|
||||
import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud'
|
||||
import { useAllBuiltInTools } from '@/service/use-tools'
|
||||
import Tab, { TabType } from './tab'
|
||||
import { TriggerSubscription } from './trigger-subscription'
|
||||
|
||||
const getCustomRunForm = (params: CustomRunFormProps): React.JSX.Element => {
|
||||
const nodeType = params.payload.type
|
||||
@@ -86,6 +86,7 @@ const getCustomRunForm = (params: CustomRunFormProps): React.JSX.Element => {
|
||||
return <div>Custom Run Form: {nodeType} not found</div>
|
||||
}
|
||||
}
|
||||
|
||||
type BasePanelProps = {
|
||||
children: ReactNode
|
||||
id: Node['id']
|
||||
@@ -98,6 +99,7 @@ const BasePanel: FC<BasePanelProps> = ({
|
||||
children,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const language = useLanguage()
|
||||
const { showMessageLogModal } = useAppStore(useShallow(state => ({
|
||||
showMessageLogModal: state.showMessageLogModal,
|
||||
})))
|
||||
@@ -108,6 +110,13 @@ const BasePanel: FC<BasePanelProps> = ({
|
||||
const nodePanelWidth = useStore(s => s.nodePanelWidth)
|
||||
const otherPanelWidth = useStore(s => s.otherPanelWidth)
|
||||
const setNodePanelWidth = useStore(s => s.setNodePanelWidth)
|
||||
const {
|
||||
pendingSingleRun,
|
||||
setPendingSingleRun,
|
||||
} = useStore(s => ({
|
||||
pendingSingleRun: s.pendingSingleRun,
|
||||
setPendingSingleRun: s.setPendingSingleRun,
|
||||
}))
|
||||
|
||||
const reservedCanvasWidth = 400 // Reserve the minimum visible width for the canvas
|
||||
|
||||
@@ -212,6 +221,7 @@ const BasePanel: FC<BasePanelProps> = ({
|
||||
useEffect(() => {
|
||||
hasClickRunning.current = false
|
||||
}, [id])
|
||||
|
||||
const {
|
||||
nodesMap,
|
||||
} = useNodesMetaData()
|
||||
@@ -235,6 +245,7 @@ const BasePanel: FC<BasePanelProps> = ({
|
||||
singleRunParams,
|
||||
nodeInfo,
|
||||
setRunInputData,
|
||||
handleStop,
|
||||
handleSingleRun,
|
||||
handleRunWithParams,
|
||||
getExistVarValuesInForms,
|
||||
@@ -252,26 +263,65 @@ const BasePanel: FC<BasePanelProps> = ({
|
||||
setIsPaused(false)
|
||||
}, [tabType])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingSingleRun || pendingSingleRun.nodeId !== id)
|
||||
return
|
||||
|
||||
if (pendingSingleRun.action === 'run')
|
||||
handleSingleRun()
|
||||
else
|
||||
handleStop()
|
||||
|
||||
setPendingSingleRun(undefined)
|
||||
}, [pendingSingleRun, id, handleSingleRun, handleStop, setPendingSingleRun])
|
||||
|
||||
const logParams = useLogs()
|
||||
const passedLogParams = (() => {
|
||||
if ([BlockEnum.Tool, BlockEnum.Agent, BlockEnum.Iteration, BlockEnum.Loop].includes(data.type))
|
||||
return logParams
|
||||
|
||||
return {}
|
||||
})()
|
||||
const passedLogParams = useMemo(() => [BlockEnum.Tool, BlockEnum.Agent, BlockEnum.Iteration, BlockEnum.Loop].includes(data.type) ? logParams : {}, [data.type, logParams])
|
||||
|
||||
const storeBuildInTools = useStore(s => s.buildInTools)
|
||||
const { data: buildInTools } = useAllBuiltInTools()
|
||||
const currCollection = useMemo(() => {
|
||||
return buildInTools?.find(item => canFindTool(item.id, data.provider_id))
|
||||
}, [buildInTools, data.provider_id])
|
||||
const showPluginAuth = useMemo(() => {
|
||||
return data.type === BlockEnum.Tool && currCollection?.allow_delete
|
||||
}, [currCollection, data.type])
|
||||
const currToolCollection = useMemo(() => {
|
||||
const candidates = buildInTools ?? storeBuildInTools
|
||||
return candidates?.find(item => canFindTool(item.id, data.provider_id))
|
||||
}, [buildInTools, storeBuildInTools, data.provider_id])
|
||||
const needsToolAuth = useMemo(() => {
|
||||
return data.type === BlockEnum.Tool && currToolCollection?.allow_delete
|
||||
}, [data.type, currToolCollection?.allow_delete])
|
||||
|
||||
// only fetch trigger plugins when the node is a trigger plugin
|
||||
const { data: triggerPlugins = [] } = useAllTriggerPlugins(data.type === BlockEnum.TriggerPlugin)
|
||||
const currentTriggerPlugin = useMemo(() => {
|
||||
if (data.type !== BlockEnum.TriggerPlugin || !data.plugin_id || !triggerPlugins?.length)
|
||||
return undefined
|
||||
return triggerPlugins?.find(p => p.plugin_id === data.plugin_id)
|
||||
}, [data.type, data.plugin_id, triggerPlugins])
|
||||
const { setDetail } = usePluginStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (currentTriggerPlugin?.subscription_constructor) {
|
||||
setDetail({
|
||||
name: currentTriggerPlugin.label[language],
|
||||
plugin_id: currentTriggerPlugin.plugin_id || '',
|
||||
plugin_unique_identifier: currentTriggerPlugin.plugin_unique_identifier || '',
|
||||
id: currentTriggerPlugin.id,
|
||||
provider: currentTriggerPlugin.name,
|
||||
declaration: {
|
||||
trigger: {
|
||||
subscription_schema: currentTriggerPlugin.subscription_schema || [],
|
||||
subscription_constructor: currentTriggerPlugin.subscription_constructor,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [currentTriggerPlugin, language, setDetail])
|
||||
|
||||
const dataSourceList = useStore(s => s.dataSourceList)
|
||||
|
||||
const currentDataSource = useMemo(() => {
|
||||
if (data.type === BlockEnum.DataSource && data.provider_type !== DataSourceClassification.localFile)
|
||||
return dataSourceList?.find(item => item.plugin_id === data.plugin_id)
|
||||
}, [dataSourceList, data.plugin_id, data.type, data.provider_type])
|
||||
}, [dataSourceList, data.provider_id, data.type, data.provider_type])
|
||||
|
||||
const handleAuthorizationItemClick = useCallback((credential_id: string) => {
|
||||
handleNodeDataUpdateWithSyncDraft({
|
||||
id,
|
||||
@@ -280,15 +330,46 @@ const BasePanel: FC<BasePanelProps> = ({
|
||||
},
|
||||
})
|
||||
}, [handleNodeDataUpdateWithSyncDraft, id])
|
||||
|
||||
const { setShowAccountSettingModal } = useModalContext()
|
||||
|
||||
const handleJumpToDataSourcePage = useCallback(() => {
|
||||
setShowAccountSettingModal({ payload: 'data-source' })
|
||||
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.DATA_SOURCE })
|
||||
}, [setShowAccountSettingModal])
|
||||
|
||||
const {
|
||||
appendNodeInspectVars,
|
||||
} = useInspectVarsCrud()
|
||||
|
||||
const handleSubscriptionChange = useCallback((v: SimpleSubscription, callback?: () => void) => {
|
||||
handleNodeDataUpdateWithSyncDraft(
|
||||
{ id, data: { subscription_id: v.id } },
|
||||
{
|
||||
sync: true,
|
||||
callback: { onSettled: callback },
|
||||
},
|
||||
)
|
||||
}, [handleNodeDataUpdateWithSyncDraft, id])
|
||||
|
||||
const readmeEntranceComponent = useMemo(() => {
|
||||
let pluginDetail
|
||||
switch (data.type) {
|
||||
case BlockEnum.Tool:
|
||||
pluginDetail = currToolCollection
|
||||
break
|
||||
case BlockEnum.DataSource:
|
||||
pluginDetail = currentDataSource
|
||||
break
|
||||
case BlockEnum.TriggerPlugin:
|
||||
pluginDetail = currentTriggerPlugin
|
||||
break
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
return !pluginDetail ? null : <ReadmeEntrance pluginDetail={pluginDetail as any} className='mt-auto' />
|
||||
}, [data.type, currToolCollection, currentDataSource, currentTriggerPlugin])
|
||||
|
||||
if (logParams.showSpecialResultPanel) {
|
||||
return (
|
||||
<div className={cn(
|
||||
@@ -405,18 +486,10 @@ const BasePanel: FC<BasePanelProps> = ({
|
||||
<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 {
|
||||
if (isSingleRunning)
|
||||
handleStop()
|
||||
else
|
||||
handleSingleRun()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{
|
||||
@@ -427,7 +500,6 @@ const BasePanel: FC<BasePanelProps> = ({
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
<NodePosition nodeId={id}></NodePosition>
|
||||
<HelpLink nodeType={data.type} />
|
||||
<PanelOperator id={id} data={data} showHelpLink={false} />
|
||||
<div className='mx-3 h-3.5 w-[1px] bg-divider-regular' />
|
||||
@@ -446,13 +518,14 @@ const BasePanel: FC<BasePanelProps> = ({
|
||||
/>
|
||||
</div>
|
||||
{
|
||||
showPluginAuth && (
|
||||
needsToolAuth && (
|
||||
<PluginAuth
|
||||
className='px-4 pb-2'
|
||||
pluginPayload={{
|
||||
provider: currCollection?.name || '',
|
||||
providerType: currCollection?.type || '',
|
||||
provider: currToolCollection?.name || '',
|
||||
providerType: currToolCollection?.type || '',
|
||||
category: AuthCategory.tool,
|
||||
detail: currToolCollection as any,
|
||||
}}
|
||||
>
|
||||
<div className='flex items-center justify-between pl-4 pr-3'>
|
||||
@@ -462,9 +535,10 @@ const BasePanel: FC<BasePanelProps> = ({
|
||||
/>
|
||||
<AuthorizedInNode
|
||||
pluginPayload={{
|
||||
provider: currCollection?.name || '',
|
||||
providerType: currCollection?.type || '',
|
||||
provider: currToolCollection?.name || '',
|
||||
providerType: currToolCollection?.type || '',
|
||||
category: AuthCategory.tool,
|
||||
detail: currToolCollection as any,
|
||||
}}
|
||||
onAuthorizationItemClick={handleAuthorizationItemClick}
|
||||
credentialId={data.credential_id}
|
||||
@@ -493,7 +567,20 @@ const BasePanel: FC<BasePanelProps> = ({
|
||||
)
|
||||
}
|
||||
{
|
||||
!showPluginAuth && !currentDataSource && (
|
||||
currentTriggerPlugin && (
|
||||
<TriggerSubscription
|
||||
subscriptionIdSelected={data.subscription_id}
|
||||
onSubscriptionChange={handleSubscriptionChange}
|
||||
>
|
||||
<Tab
|
||||
value={tabType}
|
||||
onChange={setTabType}
|
||||
/>
|
||||
</TriggerSubscription>
|
||||
)
|
||||
}
|
||||
{
|
||||
!needsToolAuth && !currentDataSource && !currentTriggerPlugin && (
|
||||
<div className='flex items-center justify-between pl-4 pr-3'>
|
||||
<Tab
|
||||
value={tabType}
|
||||
@@ -505,7 +592,7 @@ const BasePanel: FC<BasePanelProps> = ({
|
||||
<Split />
|
||||
</div>
|
||||
{tabType === TabType.settings && (
|
||||
<div className='flex-1 overflow-y-auto'>
|
||||
<div className='flex flex-1 flex-col overflow-y-auto'>
|
||||
<div>
|
||||
{cloneElement(children as any, {
|
||||
id,
|
||||
@@ -550,6 +637,7 @@ const BasePanel: FC<BasePanelProps> = ({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{readmeEntranceComponent}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -568,6 +656,7 @@ const BasePanel: FC<BasePanelProps> = ({
|
||||
{...passedLogParams}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -60,6 +60,19 @@ const LastRun: FC<Props> = ({
|
||||
const noLastRun = (error as any)?.status === 404
|
||||
const runResult = (canRunLastRun ? lastRunResult : singleRunResult) || lastRunResult || {}
|
||||
|
||||
const resolvedStatus = useMemo(() => {
|
||||
if (isPaused)
|
||||
return NodeRunningStatus.Stopped
|
||||
|
||||
if (oneStepRunRunningStatus === NodeRunningStatus.Stopped)
|
||||
return NodeRunningStatus.Stopped
|
||||
|
||||
if (oneStepRunRunningStatus === NodeRunningStatus.Listening)
|
||||
return NodeRunningStatus.Listening
|
||||
|
||||
return (runResult as any).status || otherResultPanelProps.status
|
||||
}, [isPaused, oneStepRunRunningStatus, runResult, otherResultPanelProps.status])
|
||||
|
||||
const resetHidePageStatus = useCallback(() => {
|
||||
setPageHasHide(false)
|
||||
setPageShowed(false)
|
||||
@@ -104,18 +117,18 @@ const LastRun: FC<Props> = ({
|
||||
|
||||
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)}
|
||||
status={resolvedStatus}
|
||||
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={runResult as NodeTracing}
|
||||
|
||||
@@ -22,6 +22,7 @@ import useVariableAssignerSingleRunFormParams from '@/app/components/workflow/no
|
||||
import useKnowledgeBaseSingleRunFormParams from '@/app/components/workflow/nodes/knowledge-base/use-single-run-form-params'
|
||||
|
||||
import useToolGetDataForCheckMore from '@/app/components/workflow/nodes/tool/use-get-data-for-check-more'
|
||||
import useTriggerPluginGetDataForCheckMore from '@/app/components/workflow/nodes/trigger-plugin/use-check-params'
|
||||
import { VALUE_SELECTOR_DELIMITER as DELIMITER } from '@/config'
|
||||
|
||||
// import
|
||||
@@ -30,10 +31,12 @@ import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import {
|
||||
useNodesSyncDraft,
|
||||
} from '@/app/components/workflow/hooks'
|
||||
import { useWorkflowRunValidation } from '@/app/components/workflow/hooks/use-checklist'
|
||||
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'
|
||||
import { isSupportCustomRunForm } from '@/app/components/workflow/utils'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
|
||||
const singleRunFormParamsHooks: Record<BlockEnum, any> = {
|
||||
[BlockEnum.LLM]: useLLMSingleRunFormParams,
|
||||
@@ -62,6 +65,9 @@ const singleRunFormParamsHooks: Record<BlockEnum, any> = {
|
||||
[BlockEnum.LoopEnd]: undefined,
|
||||
[BlockEnum.DataSource]: undefined,
|
||||
[BlockEnum.DataSourceEmpty]: undefined,
|
||||
[BlockEnum.TriggerWebhook]: undefined,
|
||||
[BlockEnum.TriggerSchedule]: undefined,
|
||||
[BlockEnum.TriggerPlugin]: undefined,
|
||||
}
|
||||
|
||||
const useSingleRunFormParamsHooks = (nodeType: BlockEnum) => {
|
||||
@@ -97,6 +103,9 @@ const getDataForCheckMoreHooks: Record<BlockEnum, any> = {
|
||||
[BlockEnum.DataSource]: undefined,
|
||||
[BlockEnum.DataSourceEmpty]: undefined,
|
||||
[BlockEnum.KnowledgeBase]: undefined,
|
||||
[BlockEnum.TriggerWebhook]: undefined,
|
||||
[BlockEnum.TriggerSchedule]: undefined,
|
||||
[BlockEnum.TriggerPlugin]: useTriggerPluginGetDataForCheckMore,
|
||||
}
|
||||
|
||||
const useGetDataForCheckMoreHooks = <T>(nodeType: BlockEnum) => {
|
||||
@@ -139,6 +148,17 @@ const useLastRun = <T>({
|
||||
isRunAfterSingleRun,
|
||||
})
|
||||
|
||||
const { warningNodes } = useWorkflowRunValidation()
|
||||
const blockIfChecklistFailed = useCallback(() => {
|
||||
const warningForNode = warningNodes.find(item => item.id === id)
|
||||
if (!warningForNode)
|
||||
return false
|
||||
|
||||
const message = warningForNode.errorMessage || 'This node has unresolved checklist issues'
|
||||
Toast.notify({ type: 'error', message })
|
||||
return true
|
||||
}, [warningNodes, id])
|
||||
|
||||
const {
|
||||
hideSingleRun,
|
||||
handleRun: doCallRunApi,
|
||||
@@ -199,7 +219,7 @@ const useLastRun = <T>({
|
||||
})
|
||||
}
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { setInitShowLastRunTab } = workflowStore.getState()
|
||||
const { setInitShowLastRunTab, setShowVariableInspectPanel } = workflowStore.getState()
|
||||
const initShowLastRunTab = useStore(s => s.initShowLastRunTab)
|
||||
const [tabType, setTabType] = useState<TabType>(initShowLastRunTab ? TabType.lastRun : TabType.settings)
|
||||
useEffect(() => {
|
||||
@@ -211,6 +231,8 @@ const useLastRun = <T>({
|
||||
const invalidLastRun = useInvalidLastRun(flowType, flowId, id)
|
||||
|
||||
const handleRunWithParams = async (data: Record<string, any>) => {
|
||||
if (blockIfChecklistFailed())
|
||||
return
|
||||
const { isValid } = checkValid()
|
||||
if (!isValid)
|
||||
return
|
||||
@@ -309,9 +331,13 @@ const useLastRun = <T>({
|
||||
}
|
||||
|
||||
const handleSingleRun = () => {
|
||||
if (blockIfChecklistFailed())
|
||||
return
|
||||
const { isValid } = checkValid()
|
||||
if (!isValid)
|
||||
return
|
||||
if (blockType === BlockEnum.TriggerWebhook || blockType === BlockEnum.TriggerPlugin || blockType === BlockEnum.TriggerSchedule)
|
||||
setShowVariableInspectPanel(true)
|
||||
if (isCustomRunNode) {
|
||||
showSingleRun()
|
||||
return
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { SimpleSubscription } from '@/app/components/plugins/plugin-detail-panel/subscription-list'
|
||||
import { CreateButtonType, CreateSubscriptionButton } from '@/app/components/plugins/plugin-detail-panel/subscription-list/create'
|
||||
import { SubscriptionSelectorEntry } from '@/app/components/plugins/plugin-detail-panel/subscription-list/selector-entry'
|
||||
import { useSubscriptionList } from '@/app/components/plugins/plugin-detail-panel/subscription-list/use-subscription-list'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { FC } from 'react'
|
||||
|
||||
type TriggerSubscriptionProps = {
|
||||
subscriptionIdSelected?: string
|
||||
onSubscriptionChange: (v: SimpleSubscription, callback?: () => void) => void
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export const TriggerSubscription: FC<TriggerSubscriptionProps> = ({ subscriptionIdSelected, onSubscriptionChange, children }) => {
|
||||
const { subscriptions } = useSubscriptionList()
|
||||
const subscriptionCount = subscriptions?.length || 0
|
||||
|
||||
return <div className={cn('px-4', subscriptionCount > 0 && 'flex items-center justify-between pr-3')}>
|
||||
{!subscriptionCount && <CreateSubscriptionButton buttonType={CreateButtonType.FULL_BUTTON} />}
|
||||
{children}
|
||||
{subscriptionCount > 0 && <SubscriptionSelectorEntry
|
||||
selectedId={subscriptionIdSelected}
|
||||
onSelect={onSubscriptionChange}
|
||||
/>}
|
||||
</div>
|
||||
}
|
||||
@@ -10,7 +10,15 @@ import {
|
||||
import { getNodeInfoById, isConversationVar, isENV, isSystemVar, toNodeOutputVars } from '@/app/components/workflow/nodes/_base/components/variable/utils'
|
||||
|
||||
import type { CommonNodeType, InputVar, ValueSelector, Var, Variable } from '@/app/components/workflow/types'
|
||||
import { BlockEnum, InputVarType, NodeRunningStatus, VarType } from '@/app/components/workflow/types'
|
||||
import {
|
||||
BlockEnum,
|
||||
InputVarType,
|
||||
NodeRunningStatus,
|
||||
VarType,
|
||||
WorkflowRunningStatus,
|
||||
} from '@/app/components/workflow/types'
|
||||
import type { TriggerNodeType } from '@/app/components/workflow/types'
|
||||
import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types'
|
||||
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { fetchNodeInspectVars, getIterationSingleNodeRunUrl, getLoopSingleNodeRunUrl, singleNodeRun } from '@/service/workflow'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
@@ -28,7 +36,7 @@ import ParameterExtractorDefault from '@/app/components/workflow/nodes/parameter
|
||||
import IterationDefault from '@/app/components/workflow/nodes/iteration/default'
|
||||
import DocumentExtractorDefault from '@/app/components/workflow/nodes/document-extractor/default'
|
||||
import LoopDefault from '@/app/components/workflow/nodes/loop/default'
|
||||
import { ssePost } from '@/service/base'
|
||||
import { post, ssePost } from '@/service/base'
|
||||
import { noop } from 'lodash-es'
|
||||
import { getInputVars as doGetInputVars } from '@/app/components/base/prompt-editor/constants'
|
||||
import type { NodeRunResult, NodeTracing } from '@/types/workflow'
|
||||
@@ -50,9 +58,10 @@ import {
|
||||
useStoreApi,
|
||||
} from 'reactflow'
|
||||
import { useInvalidLastRun } from '@/service/use-workflow'
|
||||
import useInspectVarsCrud from '../../../hooks/use-inspect-vars-crud'
|
||||
import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud'
|
||||
import type { FlowType } from '@/types/common'
|
||||
import useMatchSchemaType from '../components/variable/use-match-schema-type'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import {
|
||||
useAllBuiltInTools,
|
||||
useAllCustomTools,
|
||||
@@ -61,7 +70,7 @@ import {
|
||||
} from '@/service/use-tools'
|
||||
|
||||
// eslint-disable-next-line ts/no-unsafe-function-type
|
||||
const checkValidFns: Record<BlockEnum, Function> = {
|
||||
const checkValidFns: Partial<Record<BlockEnum, Function>> = {
|
||||
[BlockEnum.LLM]: checkLLMValid,
|
||||
[BlockEnum.KnowledgeRetrieval]: checkKnowledgeRetrievalValid,
|
||||
[BlockEnum.IfElse]: checkIfElseValid,
|
||||
@@ -76,7 +85,12 @@ const checkValidFns: Record<BlockEnum, Function> = {
|
||||
[BlockEnum.Iteration]: checkIterationValid,
|
||||
[BlockEnum.DocExtractor]: checkDocumentExtractorValid,
|
||||
[BlockEnum.Loop]: checkLoopValid,
|
||||
} as any
|
||||
}
|
||||
|
||||
type RequestError = {
|
||||
message: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export type Params<T> = {
|
||||
id: string
|
||||
@@ -198,7 +212,52 @@ const useOneStepRun = <T>({
|
||||
const store = useStoreApi()
|
||||
const {
|
||||
setShowSingleRunPanel,
|
||||
setIsListening,
|
||||
setListeningTriggerType,
|
||||
setListeningTriggerNodeId,
|
||||
setListeningTriggerNodeIds,
|
||||
setListeningTriggerIsAll,
|
||||
setShowVariableInspectPanel,
|
||||
} = workflowStore.getState()
|
||||
const updateNodeInspectRunningState = useCallback((nodeId: string, isRunning: boolean) => {
|
||||
const {
|
||||
nodesWithInspectVars,
|
||||
setNodesWithInspectVars,
|
||||
} = workflowStore.getState()
|
||||
|
||||
let hasChanges = false
|
||||
const nodes = produce(nodesWithInspectVars, (draft) => {
|
||||
const index = draft.findIndex(node => node.nodeId === nodeId)
|
||||
if (index !== -1) {
|
||||
const targetNode = draft[index]
|
||||
if (targetNode.isSingRunRunning !== isRunning) {
|
||||
targetNode.isSingRunRunning = isRunning
|
||||
if (isRunning)
|
||||
targetNode.isValueFetched = false
|
||||
hasChanges = true
|
||||
}
|
||||
}
|
||||
else if (isRunning) {
|
||||
const { getNodes } = store.getState()
|
||||
const target = getNodes().find(node => node.id === nodeId)
|
||||
if (target) {
|
||||
draft.unshift({
|
||||
nodeId,
|
||||
nodeType: target.data.type,
|
||||
title: target.data.title,
|
||||
vars: [],
|
||||
nodePayload: target.data,
|
||||
isSingRunRunning: true,
|
||||
isValueFetched: false,
|
||||
})
|
||||
hasChanges = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (hasChanges)
|
||||
setNodesWithInspectVars(nodes)
|
||||
}, [workflowStore, store])
|
||||
const invalidLastRun = useInvalidLastRun(flowType, flowId!, id)
|
||||
const [runResult, doSetRunResult] = useState<NodeRunResult | null>(null)
|
||||
const {
|
||||
@@ -207,10 +266,26 @@ const useOneStepRun = <T>({
|
||||
invalidateConversationVarValues,
|
||||
} = useInspectVarsCrud()
|
||||
const runningStatus = data._singleRunningStatus || NodeRunningStatus.NotStart
|
||||
const webhookSingleRunActiveRef = useRef(false)
|
||||
const webhookSingleRunAbortRef = useRef<AbortController | null>(null)
|
||||
const webhookSingleRunTimeoutRef = useRef<number | undefined>(undefined)
|
||||
const webhookSingleRunTokenRef = useRef(0)
|
||||
const webhookSingleRunDelayResolveRef = useRef<(() => void) | null>(null)
|
||||
const pluginSingleRunActiveRef = useRef(false)
|
||||
const pluginSingleRunAbortRef = useRef<AbortController | null>(null)
|
||||
const pluginSingleRunTimeoutRef = useRef<number | undefined>(undefined)
|
||||
const pluginSingleRunTokenRef = useRef(0)
|
||||
const pluginSingleRunDelayResolveRef = useRef<(() => void) | null>(null)
|
||||
const isPausedRef = useRef(isPaused)
|
||||
useEffect(() => {
|
||||
isPausedRef.current = isPaused
|
||||
}, [isPaused])
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
|
||||
const isScheduleTriggerNode = data.type === BlockEnum.TriggerSchedule
|
||||
const isWebhookTriggerNode = data.type === BlockEnum.TriggerWebhook
|
||||
const isPluginTriggerNode = data.type === BlockEnum.TriggerPlugin
|
||||
const isTriggerNode = isWebhookTriggerNode || isPluginTriggerNode || isScheduleTriggerNode
|
||||
|
||||
const setRunResult = useCallback(async (data: NodeRunResult | null) => {
|
||||
const isPaused = isPausedRef.current
|
||||
@@ -230,13 +305,27 @@ const useOneStepRun = <T>({
|
||||
const { getNodes } = store.getState()
|
||||
const nodes = getNodes()
|
||||
appendNodeInspectVars(id, vars, nodes)
|
||||
updateNodeInspectRunningState(id, false)
|
||||
if (data?.status === NodeRunningStatus.Succeeded) {
|
||||
invalidLastRun()
|
||||
if (isStartNode)
|
||||
if (isStartNode || isTriggerNode)
|
||||
invalidateSysVarValues()
|
||||
invalidateConversationVarValues() // loop, iteration, variable assigner node can update the conversation variables, but to simple the logic(some nodes may also can update in the future), all nodes refresh.
|
||||
}
|
||||
}, [isRunAfterSingleRun, runningStatus, flowId, id, store, appendNodeInspectVars, invalidLastRun, isStartNode, invalidateSysVarValues, invalidateConversationVarValues])
|
||||
}, [
|
||||
isRunAfterSingleRun,
|
||||
runningStatus,
|
||||
flowId,
|
||||
id,
|
||||
store,
|
||||
appendNodeInspectVars,
|
||||
updateNodeInspectRunningState,
|
||||
invalidLastRun,
|
||||
isStartNode,
|
||||
isTriggerNode,
|
||||
invalidateSysVarValues,
|
||||
invalidateConversationVarValues,
|
||||
])
|
||||
|
||||
const { handleNodeDataUpdate }: { handleNodeDataUpdate: (data: any) => void } = useNodeDataUpdate()
|
||||
const setNodeRunning = () => {
|
||||
@@ -248,6 +337,299 @@ const useOneStepRun = <T>({
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const cancelWebhookSingleRun = useCallback(() => {
|
||||
webhookSingleRunActiveRef.current = false
|
||||
webhookSingleRunTokenRef.current += 1
|
||||
if (webhookSingleRunAbortRef.current)
|
||||
webhookSingleRunAbortRef.current.abort()
|
||||
webhookSingleRunAbortRef.current = null
|
||||
if (webhookSingleRunTimeoutRef.current !== undefined) {
|
||||
window.clearTimeout(webhookSingleRunTimeoutRef.current)
|
||||
webhookSingleRunTimeoutRef.current = undefined
|
||||
}
|
||||
if (webhookSingleRunDelayResolveRef.current) {
|
||||
webhookSingleRunDelayResolveRef.current()
|
||||
webhookSingleRunDelayResolveRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const cancelPluginSingleRun = useCallback(() => {
|
||||
pluginSingleRunActiveRef.current = false
|
||||
pluginSingleRunTokenRef.current += 1
|
||||
if (pluginSingleRunAbortRef.current)
|
||||
pluginSingleRunAbortRef.current.abort()
|
||||
pluginSingleRunAbortRef.current = null
|
||||
if (pluginSingleRunTimeoutRef.current !== undefined) {
|
||||
window.clearTimeout(pluginSingleRunTimeoutRef.current)
|
||||
pluginSingleRunTimeoutRef.current = undefined
|
||||
}
|
||||
if (pluginSingleRunDelayResolveRef.current) {
|
||||
pluginSingleRunDelayResolveRef.current()
|
||||
pluginSingleRunDelayResolveRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startTriggerListening = useCallback(() => {
|
||||
if (!isTriggerNode)
|
||||
return
|
||||
|
||||
setIsListening(true)
|
||||
setShowVariableInspectPanel(true)
|
||||
setListeningTriggerType(data.type as TriggerNodeType)
|
||||
setListeningTriggerNodeId(id)
|
||||
setListeningTriggerNodeIds([id])
|
||||
setListeningTriggerIsAll(false)
|
||||
}, [
|
||||
isTriggerNode,
|
||||
setIsListening,
|
||||
setShowVariableInspectPanel,
|
||||
setListeningTriggerType,
|
||||
data.type,
|
||||
setListeningTriggerNodeId,
|
||||
id,
|
||||
setListeningTriggerNodeIds,
|
||||
setListeningTriggerIsAll,
|
||||
])
|
||||
|
||||
const stopTriggerListening = useCallback(() => {
|
||||
if (!isTriggerNode)
|
||||
return
|
||||
|
||||
setIsListening(false)
|
||||
setListeningTriggerType(null)
|
||||
setListeningTriggerNodeId(null)
|
||||
setListeningTriggerNodeIds([])
|
||||
setListeningTriggerIsAll(false)
|
||||
}, [
|
||||
isTriggerNode,
|
||||
setIsListening,
|
||||
setListeningTriggerType,
|
||||
setListeningTriggerNodeId,
|
||||
setListeningTriggerNodeIds,
|
||||
setListeningTriggerIsAll,
|
||||
])
|
||||
|
||||
const runScheduleSingleRun = useCallback(async (): Promise<NodeRunResult | null> => {
|
||||
const urlPath = `/apps/${flowId}/workflows/draft/nodes/${id}/trigger/run`
|
||||
|
||||
try {
|
||||
const response: any = await post(urlPath, {
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
|
||||
if (!response) {
|
||||
const message = 'Schedule trigger run failed'
|
||||
Toast.notify({ type: 'error', message })
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
if (response?.status === 'error') {
|
||||
const message = response?.message || 'Schedule trigger run failed'
|
||||
Toast.notify({ type: 'error', message })
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
handleNodeDataUpdate({
|
||||
id,
|
||||
data: {
|
||||
...data,
|
||||
_isSingleRun: false,
|
||||
_singleRunningStatus: NodeRunningStatus.Succeeded,
|
||||
},
|
||||
})
|
||||
|
||||
return response as NodeRunResult
|
||||
}
|
||||
catch (error) {
|
||||
console.error('handleRun: schedule trigger single run error', error)
|
||||
handleNodeDataUpdate({
|
||||
id,
|
||||
data: {
|
||||
...data,
|
||||
_isSingleRun: false,
|
||||
_singleRunningStatus: NodeRunningStatus.Failed,
|
||||
},
|
||||
})
|
||||
Toast.notify({ type: 'error', message: 'Schedule trigger run failed' })
|
||||
throw error
|
||||
}
|
||||
}, [flowId, id, handleNodeDataUpdate, data])
|
||||
|
||||
const runWebhookSingleRun = useCallback(async (): Promise<any | null> => {
|
||||
const urlPath = `/apps/${flowId}/workflows/draft/nodes/${id}/trigger/run`
|
||||
|
||||
webhookSingleRunActiveRef.current = true
|
||||
const token = ++webhookSingleRunTokenRef.current
|
||||
|
||||
while (webhookSingleRunActiveRef.current && token === webhookSingleRunTokenRef.current) {
|
||||
const controller = new AbortController()
|
||||
webhookSingleRunAbortRef.current = controller
|
||||
|
||||
try {
|
||||
const response: any = await post(urlPath, {
|
||||
body: JSON.stringify({}),
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
if (!webhookSingleRunActiveRef.current || token !== webhookSingleRunTokenRef.current)
|
||||
return null
|
||||
|
||||
if (!response) {
|
||||
const message = response?.message || 'Webhook debug failed'
|
||||
Toast.notify({ type: 'error', message })
|
||||
cancelWebhookSingleRun()
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
if (response?.status === 'waiting') {
|
||||
const delay = Number(response.retry_in) || 2000
|
||||
webhookSingleRunAbortRef.current = null
|
||||
if (!webhookSingleRunActiveRef.current || token !== webhookSingleRunTokenRef.current)
|
||||
return null
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeoutId = window.setTimeout(resolve, delay)
|
||||
webhookSingleRunTimeoutRef.current = timeoutId
|
||||
webhookSingleRunDelayResolveRef.current = resolve
|
||||
controller.signal.addEventListener('abort', () => {
|
||||
window.clearTimeout(timeoutId)
|
||||
resolve()
|
||||
}, { once: true })
|
||||
})
|
||||
|
||||
webhookSingleRunTimeoutRef.current = undefined
|
||||
webhookSingleRunDelayResolveRef.current = null
|
||||
continue
|
||||
}
|
||||
|
||||
if (response?.status === 'error') {
|
||||
const message = response.message || 'Webhook debug failed'
|
||||
Toast.notify({ type: 'error', message })
|
||||
cancelWebhookSingleRun()
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
handleNodeDataUpdate({
|
||||
id,
|
||||
data: {
|
||||
...data,
|
||||
_isSingleRun: false,
|
||||
_singleRunningStatus: NodeRunningStatus.Listening,
|
||||
},
|
||||
})
|
||||
|
||||
cancelWebhookSingleRun()
|
||||
return response
|
||||
}
|
||||
catch (error) {
|
||||
if (controller.signal.aborted && (!webhookSingleRunActiveRef.current || token !== webhookSingleRunTokenRef.current))
|
||||
return null
|
||||
if (controller.signal.aborted)
|
||||
return null
|
||||
|
||||
Toast.notify({ type: 'error', message: 'Webhook debug request failed' })
|
||||
cancelWebhookSingleRun()
|
||||
if (error instanceof Error)
|
||||
throw error
|
||||
throw new Error(String(error))
|
||||
}
|
||||
finally {
|
||||
webhookSingleRunAbortRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}, [flowId, id, data, handleNodeDataUpdate, cancelWebhookSingleRun])
|
||||
|
||||
const runPluginSingleRun = useCallback(async (): Promise<any | null> => {
|
||||
const urlPath = `/apps/${flowId}/workflows/draft/nodes/${id}/trigger/run`
|
||||
|
||||
pluginSingleRunActiveRef.current = true
|
||||
const token = ++pluginSingleRunTokenRef.current
|
||||
|
||||
while (pluginSingleRunActiveRef.current && token === pluginSingleRunTokenRef.current) {
|
||||
const controller = new AbortController()
|
||||
pluginSingleRunAbortRef.current = controller
|
||||
|
||||
let requestError: RequestError | undefined
|
||||
const response: any = await post(urlPath, {
|
||||
body: JSON.stringify({}),
|
||||
signal: controller.signal,
|
||||
}).catch(async (error: Response) => {
|
||||
const data = await error.clone().json() as Record<string, any>
|
||||
const { error: respError, status } = data || {}
|
||||
requestError = {
|
||||
message: respError,
|
||||
status,
|
||||
}
|
||||
return null
|
||||
}).finally(() => {
|
||||
pluginSingleRunAbortRef.current = null
|
||||
})
|
||||
|
||||
if (!pluginSingleRunActiveRef.current || token !== pluginSingleRunTokenRef.current)
|
||||
return null
|
||||
|
||||
if (requestError) {
|
||||
if (controller.signal.aborted)
|
||||
return null
|
||||
|
||||
Toast.notify({ type: 'error', message: requestError.message })
|
||||
cancelPluginSingleRun()
|
||||
throw requestError
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
const message = 'Plugin debug failed'
|
||||
Toast.notify({ type: 'error', message })
|
||||
cancelPluginSingleRun()
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
if (response?.status === 'waiting') {
|
||||
const delay = Number(response.retry_in) || 2000
|
||||
if (!pluginSingleRunActiveRef.current || token !== pluginSingleRunTokenRef.current)
|
||||
return null
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeoutId = window.setTimeout(resolve, delay)
|
||||
pluginSingleRunTimeoutRef.current = timeoutId
|
||||
pluginSingleRunDelayResolveRef.current = resolve
|
||||
controller.signal.addEventListener('abort', () => {
|
||||
window.clearTimeout(timeoutId)
|
||||
resolve()
|
||||
}, { once: true })
|
||||
})
|
||||
|
||||
pluginSingleRunTimeoutRef.current = undefined
|
||||
pluginSingleRunDelayResolveRef.current = null
|
||||
continue
|
||||
}
|
||||
|
||||
if (response?.status === 'error') {
|
||||
const message = response.message || 'Plugin debug failed'
|
||||
Toast.notify({ type: 'error', message })
|
||||
cancelPluginSingleRun()
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
handleNodeDataUpdate({
|
||||
id,
|
||||
data: {
|
||||
...data,
|
||||
_isSingleRun: false,
|
||||
_singleRunningStatus: NodeRunningStatus.Listening,
|
||||
},
|
||||
})
|
||||
|
||||
cancelPluginSingleRun()
|
||||
return response
|
||||
}
|
||||
|
||||
return null
|
||||
}, [flowId, id, data, handleNodeDataUpdate, cancelPluginSingleRun])
|
||||
|
||||
const checkValidWrap = () => {
|
||||
if (!checkValid)
|
||||
return { isValid: true, errorMessage: '' }
|
||||
@@ -262,7 +644,7 @@ const useOneStepRun = <T>({
|
||||
})
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: res.errorMessage,
|
||||
message: res.errorMessage || '',
|
||||
})
|
||||
}
|
||||
return res
|
||||
@@ -309,33 +691,84 @@ const useOneStepRun = <T>({
|
||||
const isCompleted = runningStatus === NodeRunningStatus.Succeeded || runningStatus === NodeRunningStatus.Failed
|
||||
|
||||
const handleRun = async (submitData: Record<string, any>) => {
|
||||
if (isWebhookTriggerNode)
|
||||
cancelWebhookSingleRun()
|
||||
if (isPluginTriggerNode)
|
||||
cancelPluginSingleRun()
|
||||
|
||||
updateNodeInspectRunningState(id, true)
|
||||
|
||||
if (isTriggerNode)
|
||||
startTriggerListening()
|
||||
else
|
||||
stopTriggerListening()
|
||||
|
||||
handleNodeDataUpdate({
|
||||
id,
|
||||
data: {
|
||||
...data,
|
||||
_isSingleRun: false,
|
||||
_singleRunningStatus: NodeRunningStatus.Running,
|
||||
_singleRunningStatus: isTriggerNode
|
||||
? NodeRunningStatus.Listening
|
||||
: NodeRunningStatus.Running,
|
||||
},
|
||||
})
|
||||
let res: any
|
||||
let hasError = false
|
||||
try {
|
||||
if (!isIteration && !isLoop) {
|
||||
const isStartNode = data.type === BlockEnum.Start
|
||||
const postData: Record<string, any> = {}
|
||||
if (isStartNode) {
|
||||
const { '#sys.query#': query, '#sys.files#': files, ...inputs } = submitData
|
||||
if (isChatMode)
|
||||
postData.conversation_id = ''
|
||||
|
||||
postData.inputs = inputs
|
||||
postData.query = query
|
||||
postData.files = files || []
|
||||
if (isScheduleTriggerNode) {
|
||||
res = await runScheduleSingleRun()
|
||||
}
|
||||
else if (isWebhookTriggerNode) {
|
||||
res = await runWebhookSingleRun()
|
||||
if (!res) {
|
||||
if (webhookSingleRunActiveRef.current) {
|
||||
handleNodeDataUpdate({
|
||||
id,
|
||||
data: {
|
||||
...data,
|
||||
_isSingleRun: false,
|
||||
_singleRunningStatus: NodeRunningStatus.Stopped,
|
||||
},
|
||||
})
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
else if (isPluginTriggerNode) {
|
||||
res = await runPluginSingleRun()
|
||||
if (!res) {
|
||||
if (pluginSingleRunActiveRef.current) {
|
||||
handleNodeDataUpdate({
|
||||
id,
|
||||
data: {
|
||||
...data,
|
||||
_isSingleRun: false,
|
||||
_singleRunningStatus: NodeRunningStatus.Stopped,
|
||||
},
|
||||
})
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
else {
|
||||
postData.inputs = submitData
|
||||
const isStartNode = data.type === BlockEnum.Start
|
||||
const postData: Record<string, any> = {}
|
||||
if (isStartNode) {
|
||||
const { '#sys.query#': query, '#sys.files#': files, ...inputs } = submitData
|
||||
if (isChatMode)
|
||||
postData.conversation_id = ''
|
||||
|
||||
postData.inputs = inputs
|
||||
postData.query = query
|
||||
postData.files = files || []
|
||||
}
|
||||
else {
|
||||
postData.inputs = submitData
|
||||
}
|
||||
res = await singleNodeRun(flowType, flowId!, id, postData) as any
|
||||
}
|
||||
res = await singleNodeRun(flowType, flowId!, id, postData) as any
|
||||
}
|
||||
else if (isIteration) {
|
||||
setIterationRunResult([])
|
||||
@@ -566,6 +999,14 @@ const useOneStepRun = <T>({
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if (isWebhookTriggerNode)
|
||||
cancelWebhookSingleRun()
|
||||
if (isPluginTriggerNode)
|
||||
cancelPluginSingleRun()
|
||||
if (isTriggerNode)
|
||||
stopTriggerListening()
|
||||
if (!isIteration && !isLoop)
|
||||
updateNodeInspectRunningState(id, false)
|
||||
if (!isPausedRef.current && !isIteration && !isLoop && res) {
|
||||
setRunResult({
|
||||
...res,
|
||||
@@ -591,15 +1032,55 @@ const useOneStepRun = <T>({
|
||||
}
|
||||
}
|
||||
|
||||
const handleStop = () => {
|
||||
const handleStop = useCallback(() => {
|
||||
if (isTriggerNode) {
|
||||
const isTriggerActive = runningStatus === NodeRunningStatus.Listening
|
||||
|| webhookSingleRunActiveRef.current
|
||||
|| pluginSingleRunActiveRef.current
|
||||
if (!isTriggerActive)
|
||||
return
|
||||
}
|
||||
else if (runningStatus !== NodeRunningStatus.Running) {
|
||||
return
|
||||
}
|
||||
|
||||
cancelWebhookSingleRun()
|
||||
cancelPluginSingleRun()
|
||||
handleNodeDataUpdate({
|
||||
id,
|
||||
data: {
|
||||
...data,
|
||||
_singleRunningStatus: NodeRunningStatus.NotStart,
|
||||
_isSingleRun: false,
|
||||
_singleRunningStatus: NodeRunningStatus.Stopped,
|
||||
},
|
||||
})
|
||||
}
|
||||
stopTriggerListening()
|
||||
updateNodeInspectRunningState(id, false)
|
||||
const {
|
||||
workflowRunningData,
|
||||
setWorkflowRunningData,
|
||||
nodesWithInspectVars,
|
||||
deleteNodeInspectVars,
|
||||
} = workflowStore.getState()
|
||||
if (workflowRunningData) {
|
||||
setWorkflowRunningData(produce(workflowRunningData, (draft) => {
|
||||
draft.result.status = WorkflowRunningStatus.Stopped
|
||||
}))
|
||||
}
|
||||
|
||||
const inspectNode = nodesWithInspectVars.find(node => node.nodeId === id)
|
||||
if (inspectNode && !inspectNode.isValueFetched && (!inspectNode.vars || inspectNode.vars.length === 0))
|
||||
deleteNodeInspectVars(id)
|
||||
}, [
|
||||
isTriggerNode,
|
||||
runningStatus,
|
||||
cancelWebhookSingleRun,
|
||||
cancelPluginSingleRun,
|
||||
handleNodeDataUpdate,
|
||||
id,
|
||||
stopTriggerListening,
|
||||
updateNodeInspectRunningState,
|
||||
workflowStore,
|
||||
])
|
||||
|
||||
const toVarInputs = (variables: Variable[]): InputVar[] => {
|
||||
if (!variables)
|
||||
@@ -662,6 +1143,11 @@ const useOneStepRun = <T>({
|
||||
})
|
||||
}
|
||||
|
||||
eventEmitter?.useSubscription((v: any) => {
|
||||
if (v.type === EVENT_WORKFLOW_STOP)
|
||||
handleStop()
|
||||
})
|
||||
|
||||
return {
|
||||
isShowSingleRun,
|
||||
hideSingleRun,
|
||||
|
||||
@@ -16,23 +16,18 @@ import {
|
||||
RiLoader2Line,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { NodeProps } from '../../types'
|
||||
import type { NodeProps } from '@/app/components/workflow/types'
|
||||
import {
|
||||
BlockEnum,
|
||||
NodeRunningStatus,
|
||||
} from '../../types'
|
||||
import {
|
||||
useNodesReadOnly,
|
||||
useToolIcon,
|
||||
} from '../../hooks'
|
||||
import {
|
||||
hasErrorHandleNode,
|
||||
hasRetryNode,
|
||||
} from '../../utils'
|
||||
import { useNodeIterationInteractions } from '../iteration/use-interactions'
|
||||
import { useNodeLoopInteractions } from '../loop/use-interactions'
|
||||
import type { IterationNodeType } from '../iteration/types'
|
||||
import CopyID from '../tool/components/copy-id'
|
||||
isTriggerNode,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { useNodesReadOnly, useToolIcon } from '@/app/components/workflow/hooks'
|
||||
import { hasErrorHandleNode, hasRetryNode } from '@/app/components/workflow/utils'
|
||||
import { useNodeIterationInteractions } from '@/app/components/workflow/nodes/iteration/use-interactions'
|
||||
import { useNodeLoopInteractions } from '@/app/components/workflow/nodes/loop/use-interactions'
|
||||
import type { IterationNodeType } from '@/app/components/workflow/nodes/iteration/types'
|
||||
import CopyID from '@/app/components/workflow/nodes/tool/components/copy-id'
|
||||
import {
|
||||
NodeSourceHandle,
|
||||
NodeTargetHandle,
|
||||
@@ -42,11 +37,12 @@ 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 EntryNodeContainer, { StartNodeTypeEnum } from './components/entry-node-container'
|
||||
import cn from '@/utils/classnames'
|
||||
import BlockIcon from '@/app/components/workflow/block-icon'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import useInspectVarsCrud from '../../hooks/use-inspect-vars-crud'
|
||||
import { ToolTypeEnum } from '../../block-selector/types'
|
||||
import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud'
|
||||
import { ToolTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
|
||||
type NodeChildProps = {
|
||||
id: string
|
||||
@@ -67,6 +63,7 @@ const BaseNode: FC<BaseNodeProps> = ({
|
||||
const { t } = useTranslation()
|
||||
const nodeRef = useRef<HTMLDivElement>(null)
|
||||
const { nodesReadOnly } = useNodesReadOnly()
|
||||
|
||||
const { handleNodeIterationChildSizeChange } = useNodeIterationInteractions()
|
||||
const { handleNodeLoopChildSizeChange } = useNodeLoopInteractions()
|
||||
const toolIcon = useToolIcon(data)
|
||||
@@ -141,13 +138,13 @@ const BaseNode: FC<BaseNodeProps> = ({
|
||||
return null
|
||||
}, [data._loopIndex, data._runningStatus, t])
|
||||
|
||||
return (
|
||||
const nodeContent = (
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex rounded-2xl border',
|
||||
showSelectedBorder ? 'border-components-option-card-option-selected-border' : 'border-transparent',
|
||||
data._waitingRun && 'opacity-70',
|
||||
data._dimmed && 'opacity-30',
|
||||
data._pluginInstallLocked && 'cursor-not-allowed',
|
||||
)}
|
||||
ref={nodeRef}
|
||||
style={{
|
||||
@@ -155,6 +152,17 @@ const BaseNode: FC<BaseNodeProps> = ({
|
||||
height: (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) ? data.height : 'auto',
|
||||
}}
|
||||
>
|
||||
{(data._dimmed || data._pluginInstallLocked) && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 rounded-2xl transition-opacity',
|
||||
data._pluginInstallLocked
|
||||
? 'pointer-events-auto z-30 bg-workflow-block-parma-bg opacity-80 backdrop-blur-[2px]'
|
||||
: 'pointer-events-none z-20 bg-workflow-block-parma-bg opacity-50',
|
||||
)}
|
||||
data-testid='workflow-node-install-overlay'
|
||||
/>
|
||||
)}
|
||||
{
|
||||
data.type === BlockEnum.DataSource && (
|
||||
<div className='absolute inset-[-2px] top-[-22px] z-[-1] rounded-[18px] bg-node-data-source-bg p-0.5 backdrop-blur-[6px]'>
|
||||
@@ -297,13 +305,13 @@ const BaseNode: FC<BaseNodeProps> = ({
|
||||
</div>
|
||||
{
|
||||
data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop && (
|
||||
cloneElement(children, { id, data })
|
||||
cloneElement(children, { id, data } as any)
|
||||
)
|
||||
}
|
||||
{
|
||||
(data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) && (
|
||||
<div className='grow pb-1 pl-1 pr-1'>
|
||||
{cloneElement(children, { id, data })}
|
||||
{cloneElement(children, { id, data } as any)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -338,6 +346,17 @@ const BaseNode: FC<BaseNodeProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const isStartNode = data.type === BlockEnum.Start
|
||||
const isEntryNode = isTriggerNode(data.type as any) || isStartNode
|
||||
|
||||
return isEntryNode ? (
|
||||
<EntryNodeContainer
|
||||
nodeType={isStartNode ? StartNodeTypeEnum.Start : StartNodeTypeEnum.Trigger}
|
||||
>
|
||||
{nodeContent}
|
||||
</EntryNodeContainer>
|
||||
) : nodeContent
|
||||
}
|
||||
|
||||
export default memo(BaseNode)
|
||||
|
||||
27
web/app/components/workflow/nodes/_base/types.ts
Normal file
27
web/app/components/workflow/nodes/_base/types.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { ValueSelector } from '@/app/components/workflow/types'
|
||||
|
||||
// Generic variable types for all resource forms
|
||||
export enum VarKindType {
|
||||
variable = 'variable',
|
||||
constant = 'constant',
|
||||
mixed = 'mixed',
|
||||
}
|
||||
|
||||
// Generic resource variable inputs
|
||||
export type ResourceVarInputs = Record<string, {
|
||||
type: VarKindType
|
||||
value?: string | ValueSelector | any
|
||||
}>
|
||||
|
||||
// Base resource interface
|
||||
export type BaseResource = {
|
||||
name: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
// Base resource provider interface
|
||||
export type BaseResourceProvider = {
|
||||
plugin_id?: string
|
||||
name: string
|
||||
[key: string]: any
|
||||
}
|
||||
@@ -68,7 +68,7 @@ const VarList: FC<Props> = ({
|
||||
draft[index].value = '' // Clear value when operation changes
|
||||
if (item.value === WriteMode.set || item.value === WriteMode.increment || item.value === WriteMode.decrement
|
||||
|| item.value === WriteMode.multiply || item.value === WriteMode.divide) {
|
||||
if(varType === VarType.boolean)
|
||||
if (varType === VarType.boolean)
|
||||
draft[index].value = false
|
||||
draft[index].input_type = AssignerNodeInputType.constant
|
||||
}
|
||||
|
||||
@@ -42,6 +42,12 @@ import DataSourceNode from './data-source/node'
|
||||
import DataSourcePanel from './data-source/panel'
|
||||
import KnowledgeBaseNode from './knowledge-base/node'
|
||||
import KnowledgeBasePanel from './knowledge-base/panel'
|
||||
import TriggerScheduleNode from './trigger-schedule/node'
|
||||
import TriggerSchedulePanel from './trigger-schedule/panel'
|
||||
import TriggerWebhookNode from './trigger-webhook/node'
|
||||
import TriggerWebhookPanel from './trigger-webhook/panel'
|
||||
import TriggerPluginNode from './trigger-plugin/node'
|
||||
import TriggerPluginPanel from './trigger-plugin/panel'
|
||||
|
||||
export const NodeComponentMap: Record<string, ComponentType<any>> = {
|
||||
[BlockEnum.Start]: StartNode,
|
||||
@@ -66,6 +72,9 @@ export const NodeComponentMap: Record<string, ComponentType<any>> = {
|
||||
[BlockEnum.Agent]: AgentNode,
|
||||
[BlockEnum.DataSource]: DataSourceNode,
|
||||
[BlockEnum.KnowledgeBase]: KnowledgeBaseNode,
|
||||
[BlockEnum.TriggerSchedule]: TriggerScheduleNode,
|
||||
[BlockEnum.TriggerWebhook]: TriggerWebhookNode,
|
||||
[BlockEnum.TriggerPlugin]: TriggerPluginNode,
|
||||
}
|
||||
|
||||
export const PanelComponentMap: Record<string, ComponentType<any>> = {
|
||||
@@ -91,4 +100,7 @@ export const PanelComponentMap: Record<string, ComponentType<any>> = {
|
||||
[BlockEnum.Agent]: AgentPanel,
|
||||
[BlockEnum.DataSource]: DataSourcePanel,
|
||||
[BlockEnum.KnowledgeBase]: KnowledgeBasePanel,
|
||||
[BlockEnum.TriggerSchedule]: TriggerSchedulePanel,
|
||||
[BlockEnum.TriggerWebhook]: TriggerWebhookPanel,
|
||||
[BlockEnum.TriggerPlugin]: TriggerPluginPanel,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { TransferMethod } from '@/types/app'
|
||||
|
||||
export const CUSTOM_NODE_TYPE = 'custom'
|
||||
|
||||
export const FILE_TYPE_OPTIONS = [
|
||||
{ value: 'image', i18nKey: 'image' },
|
||||
{ value: 'document', i18nKey: 'doc' },
|
||||
|
||||
@@ -11,7 +11,7 @@ export const useReplaceDataSourceNode = (id: string) => {
|
||||
|
||||
const handleReplaceNode = useCallback<OnSelectBlock>((
|
||||
type,
|
||||
toolDefaultValue,
|
||||
pluginDefaultValue,
|
||||
) => {
|
||||
const {
|
||||
getNodes,
|
||||
@@ -28,7 +28,7 @@ export const useReplaceDataSourceNode = (id: string) => {
|
||||
const { newNode } = generateNewNode({
|
||||
data: {
|
||||
...(defaultValue as any),
|
||||
...toolDefaultValue,
|
||||
...pluginDefaultValue,
|
||||
},
|
||||
position: {
|
||||
x: emptyNode.position.x,
|
||||
|
||||
@@ -1,10 +1,57 @@
|
||||
import type { FC } from 'react'
|
||||
import { memo } from 'react'
|
||||
import type { DataSourceNodeType } from './types'
|
||||
import { memo, useEffect } from 'react'
|
||||
import type { NodeProps } from '@/app/components/workflow/types'
|
||||
const Node: FC<NodeProps<DataSourceNodeType>> = () => {
|
||||
import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button'
|
||||
import { useNodePluginInstallation } from '@/app/components/workflow/hooks/use-node-plugin-installation'
|
||||
import { useNodeDataUpdate } from '@/app/components/workflow/hooks/use-node-data-update'
|
||||
import type { DataSourceNodeType } from './types'
|
||||
|
||||
const Node: FC<NodeProps<DataSourceNodeType>> = ({
|
||||
id,
|
||||
data,
|
||||
}) => {
|
||||
const {
|
||||
isChecking,
|
||||
isMissing,
|
||||
uniqueIdentifier,
|
||||
canInstall,
|
||||
onInstallSuccess,
|
||||
shouldDim,
|
||||
} = useNodePluginInstallation(data)
|
||||
const { handleNodeDataUpdate } = useNodeDataUpdate()
|
||||
const shouldLock = !isChecking && isMissing && canInstall && Boolean(uniqueIdentifier)
|
||||
|
||||
useEffect(() => {
|
||||
if (data._pluginInstallLocked === shouldLock && data._dimmed === shouldDim)
|
||||
return
|
||||
handleNodeDataUpdate({
|
||||
id,
|
||||
data: {
|
||||
_pluginInstallLocked: shouldLock,
|
||||
_dimmed: shouldDim,
|
||||
},
|
||||
})
|
||||
}, [data._pluginInstallLocked, data._dimmed, handleNodeDataUpdate, id, shouldDim, shouldLock])
|
||||
|
||||
const showInstallButton = !isChecking && isMissing && canInstall && uniqueIdentifier
|
||||
|
||||
if (!showInstallButton)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='relative mb-1 px-3 py-1'>
|
||||
<div className='pointer-events-auto absolute right-3 top-[-32px] z-40'>
|
||||
<InstallPluginButton
|
||||
size='small'
|
||||
extraIdentifiers={[
|
||||
data.plugin_id,
|
||||
data.provider_name,
|
||||
].filter(Boolean) as string[]}
|
||||
className='!font-medium !text-text-accent'
|
||||
uniqueIdentifier={uniqueIdentifier!}
|
||||
onSuccess={onInstallSuccess}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import type { CommonNodeType, Node, ValueSelector } from '@/app/components/workflow/types'
|
||||
import type { CommonNodeType, Node } from '@/app/components/workflow/types'
|
||||
import type { FlowType } from '@/types/common'
|
||||
import type { NodeRunResult, VarInInspect } from '@/types/workflow'
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
|
||||
export enum VarType {
|
||||
variable = 'variable',
|
||||
constant = 'constant',
|
||||
mixed = 'mixed',
|
||||
}
|
||||
import type { ResourceVarInputs } from '../_base/types'
|
||||
export { VarKindType as VarType } from '../_base/types'
|
||||
|
||||
export enum DataSourceClassification {
|
||||
localFile = 'local_file',
|
||||
@@ -16,10 +12,7 @@ export enum DataSourceClassification {
|
||||
onlineDrive = 'online_drive',
|
||||
}
|
||||
|
||||
export type ToolVarInputs = Record<string, {
|
||||
type: VarType
|
||||
value?: string | ValueSelector | any
|
||||
}>
|
||||
export type ToolVarInputs = ResourceVarInputs
|
||||
|
||||
export type DataSourceNodeType = CommonNodeType & {
|
||||
fileExtensions?: string[]
|
||||
@@ -30,6 +23,7 @@ export type DataSourceNodeType = CommonNodeType & {
|
||||
datasource_label: string
|
||||
datasource_parameters: ToolVarInputs
|
||||
datasource_configurations: Record<string, any>
|
||||
plugin_unique_identifier?: string
|
||||
}
|
||||
|
||||
export type CustomRunFormProps = {
|
||||
|
||||
@@ -6,17 +6,34 @@ import { BlockEnum } from '@/app/components/workflow/types'
|
||||
const metaData = genNodeMetaData({
|
||||
sort: 2.1,
|
||||
type: BlockEnum.End,
|
||||
isRequired: true,
|
||||
isRequired: false,
|
||||
})
|
||||
const nodeDefault: NodeDefault<EndNodeType> = {
|
||||
metaData,
|
||||
defaultValue: {
|
||||
outputs: [],
|
||||
},
|
||||
checkValid() {
|
||||
checkValid(payload: EndNodeType, t: any) {
|
||||
const outputs = payload.outputs || []
|
||||
|
||||
let errorMessage = ''
|
||||
if (!outputs.length) {
|
||||
errorMessage = t('workflow.errorMsg.fieldRequired', { field: t('workflow.nodes.end.output.variable') })
|
||||
}
|
||||
else {
|
||||
const invalidOutput = outputs.find((output) => {
|
||||
const variableName = output.variable?.trim()
|
||||
const hasSelector = Array.isArray(output.value_selector) && output.value_selector.length > 0
|
||||
return !variableName || !hasSelector
|
||||
})
|
||||
|
||||
if (invalidOutput)
|
||||
errorMessage = t('workflow.errorMsg.fieldRequired', { field: t('workflow.nodes.end.output.variable') })
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
errorMessage: '',
|
||||
isValid: !errorMessage,
|
||||
errorMessage,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ const Panel: FC<NodePanelProps<EndNodeType>> = ({
|
||||
|
||||
<Field
|
||||
title={t(`${i18nPrefix}.output.variable`)}
|
||||
required
|
||||
operations={
|
||||
!readOnly ? <AddButton onClick={handleAddVariable} /> : undefined
|
||||
}
|
||||
|
||||
@@ -33,11 +33,11 @@ const AddBlock = ({
|
||||
const { handleNodeAdd } = useNodesInteractions()
|
||||
const { availableNextBlocks } = useAvailableBlocks(BlockEnum.Start, true)
|
||||
|
||||
const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => {
|
||||
const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => {
|
||||
handleNodeAdd(
|
||||
{
|
||||
nodeType: type,
|
||||
toolDefaultValue,
|
||||
pluginDefaultValue,
|
||||
},
|
||||
{
|
||||
prevNodeId: iterationNodeData.start_node_id,
|
||||
|
||||
@@ -84,7 +84,6 @@ const MetadataFilter = ({
|
||||
popupClassName='!w-[387px]'
|
||||
isInWorkflow
|
||||
isAdvancedMode={true}
|
||||
mode={metadataModelConfig?.mode || 'chat'}
|
||||
provider={metadataModelConfig?.provider || ''}
|
||||
completionParams={metadataModelConfig?.completion_params || { temperature: 0.7 }}
|
||||
modelId={metadataModelConfig?.name || ''}
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
getMultipleRetrievalConfig,
|
||||
getSelectedDatasetsMode,
|
||||
} from './utils'
|
||||
import { RETRIEVE_TYPE } from '@/types/app'
|
||||
import { AppModeEnum, RETRIEVE_TYPE } from '@/types/app'
|
||||
import { DATASET_DEFAULT } from '@/config'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { fetchDatasets } from '@/service/datasets'
|
||||
@@ -344,7 +344,7 @@ const useConfig = (id: string, payload: KnowledgeRetrievalNodeType) => {
|
||||
draft.metadata_model_config = {
|
||||
provider: model.provider,
|
||||
name: model.modelId,
|
||||
mode: model.mode || 'chat',
|
||||
mode: model.mode || AppModeEnum.CHAT,
|
||||
completion_params: draft.metadata_model_config?.completion_params || { temperature: 0.7 },
|
||||
}
|
||||
})
|
||||
|
||||
@@ -65,7 +65,6 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
||||
portalToFollowElemContentClassName='z-[1000]'
|
||||
isAdvancedMode={true}
|
||||
provider={model.provider}
|
||||
mode={model.mode}
|
||||
completionParams={model.completion_params}
|
||||
modelId={model.name}
|
||||
setModel={onModelChange}
|
||||
|
||||
@@ -6,7 +6,7 @@ import cn from 'classnames'
|
||||
import { Generator } from '@/app/components/base/icons/src/vender/other'
|
||||
import { ActionButton } from '@/app/components/base/action-button'
|
||||
import GetAutomaticResModal from '@/app/components/app/configuration/config/automatic/get-automatic-res'
|
||||
import { AppType } from '@/types/app'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import type { GenRes } from '@/service/debug'
|
||||
import type { ModelConfig } from '@/app/components/workflow/types'
|
||||
import { useHooksStore } from '../../../hooks-store'
|
||||
@@ -42,7 +42,7 @@ const PromptGeneratorBtn: FC<Props> = ({
|
||||
</ActionButton>
|
||||
{showAutomatic && (
|
||||
<GetAutomaticResModal
|
||||
mode={AppType.chat}
|
||||
mode={AppModeEnum.CHAT}
|
||||
isShow={showAutomatic}
|
||||
onClose={showAutomaticFalse}
|
||||
onFinished={handleAutomaticRes}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// import { RETRIEVAL_OUTPUT_STRUCT } from '../../constants'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { BlockEnum, EditionType } from '../../types'
|
||||
import { type NodeDefault, type PromptItem, PromptRole } from '../../types'
|
||||
import type { LLMNodeType } from './types'
|
||||
@@ -36,7 +37,7 @@ const nodeDefault: NodeDefault<LLMNodeType> = {
|
||||
model: {
|
||||
provider: '',
|
||||
name: '',
|
||||
mode: 'chat',
|
||||
mode: AppModeEnum.CHAT,
|
||||
completion_params: {
|
||||
temperature: 0.7,
|
||||
},
|
||||
@@ -63,7 +64,7 @@ const nodeDefault: NodeDefault<LLMNodeType> = {
|
||||
errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t(`${i18nPrefix}.fields.model`) })
|
||||
|
||||
if (!errorMessages && !payload.memory) {
|
||||
const isChatModel = payload.model.mode === 'chat'
|
||||
const isChatModel = payload.model.mode === AppModeEnum.CHAT
|
||||
const isPromptEmpty = isChatModel
|
||||
? !(payload.prompt_template as PromptItem[]).some((t) => {
|
||||
if (t.edition_type === EditionType.jinja2)
|
||||
@@ -77,14 +78,14 @@ const nodeDefault: NodeDefault<LLMNodeType> = {
|
||||
}
|
||||
|
||||
if (!errorMessages && !!payload.memory) {
|
||||
const isChatModel = payload.model.mode === 'chat'
|
||||
const isChatModel = payload.model.mode === AppModeEnum.CHAT
|
||||
// payload.memory.query_prompt_template not pass is default: {{#sys.query#}}
|
||||
if (isChatModel && !!payload.memory.query_prompt_template && !payload.memory.query_prompt_template.includes('{{#sys.query#}}'))
|
||||
errorMessages = t('workflow.nodes.llm.sysQueryInUser')
|
||||
}
|
||||
|
||||
if (!errorMessages) {
|
||||
const isChatModel = payload.model.mode === 'chat'
|
||||
const isChatModel = payload.model.mode === AppModeEnum.CHAT
|
||||
const isShowVars = (() => {
|
||||
if (isChatModel)
|
||||
return (payload.prompt_template as PromptItem[]).some(item => item.edition_type === EditionType.jinja2)
|
||||
|
||||
@@ -94,7 +94,6 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
|
||||
}
|
||||
})()
|
||||
}, [inputs.model.completion_params])
|
||||
|
||||
return (
|
||||
<div className='mt-2'>
|
||||
<div className='space-y-4 px-4 pb-4'>
|
||||
@@ -106,7 +105,6 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
|
||||
popupClassName='!w-[387px]'
|
||||
isInWorkflow
|
||||
isAdvancedMode={true}
|
||||
mode={model?.mode}
|
||||
provider={model?.provider}
|
||||
completionParams={model?.completion_params}
|
||||
modelId={model?.name}
|
||||
|
||||
@@ -30,6 +30,7 @@ export enum Type {
|
||||
arrayNumber = 'array[number]',
|
||||
arrayObject = 'array[object]',
|
||||
file = 'file',
|
||||
enumType = 'enum',
|
||||
}
|
||||
|
||||
export enum ArrayType {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
|
||||
import { checkHasContextBlock, checkHasHistoryBlock, checkHasQueryBlock } from '@/app/components/base/prompt-editor/constants'
|
||||
import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
const useConfig = (id: string, payload: LLMNodeType) => {
|
||||
const { nodesReadOnly: readOnly } = useNodesReadOnly()
|
||||
@@ -49,7 +50,7 @@ const useConfig = (id: string, payload: LLMNodeType) => {
|
||||
// model
|
||||
const model = inputs.model
|
||||
const modelMode = inputs.model?.mode
|
||||
const isChatModel = modelMode === 'chat'
|
||||
const isChatModel = modelMode === AppModeEnum.CHAT
|
||||
|
||||
const isCompletionModel = !isChatModel
|
||||
|
||||
@@ -134,7 +135,7 @@ const useConfig = (id: string, payload: LLMNodeType) => {
|
||||
draft.model.mode = model.mode!
|
||||
const isModeChange = model.mode !== inputRef.current.model.mode
|
||||
if (isModeChange && defaultConfig && Object.keys(defaultConfig).length > 0)
|
||||
appendDefaultPromptConfig(draft, defaultConfig, model.mode === 'chat')
|
||||
appendDefaultPromptConfig(draft, defaultConfig, model.mode === AppModeEnum.CHAT)
|
||||
})
|
||||
setInputs(newInputs)
|
||||
setModelChanged(true)
|
||||
|
||||
@@ -12,6 +12,7 @@ import useConfigVision from '../../hooks/use-config-vision'
|
||||
import { noop } from 'lodash-es'
|
||||
import { findVariableWhenOnLLMVision } from '../utils'
|
||||
import useAvailableVarList from '../_base/hooks/use-available-var-list'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
const i18nPrefix = 'workflow.nodes.llm'
|
||||
type Params = {
|
||||
@@ -56,7 +57,7 @@ const useSingleRunFormParams = ({
|
||||
// model
|
||||
const model = inputs.model
|
||||
const modelMode = inputs.model?.mode
|
||||
const isChatModel = modelMode === 'chat'
|
||||
const isChatModel = modelMode === AppModeEnum.CHAT
|
||||
const {
|
||||
isVisionModel,
|
||||
} = useConfigVision(model, {
|
||||
|
||||
@@ -9,8 +9,9 @@ export const checkNodeValid = (_payload: LLMNodeType) => {
|
||||
}
|
||||
|
||||
export const getFieldType = (field: Field) => {
|
||||
const { type, items } = field
|
||||
if(field.schemaType === 'file') return Type.file
|
||||
const { type, items, enum: enums } = field
|
||||
if (field.schemaType === 'file') return Type.file
|
||||
if (enums && enums.length > 0) return Type.enumType
|
||||
if (type !== Type.array || !items)
|
||||
return type
|
||||
|
||||
|
||||
@@ -34,11 +34,11 @@ const AddBlock = ({
|
||||
const { handleNodeAdd } = useNodesInteractions()
|
||||
const { availableNextBlocks } = useAvailableBlocks(BlockEnum.Start, true)
|
||||
|
||||
const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => {
|
||||
const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => {
|
||||
handleNodeAdd(
|
||||
{
|
||||
nodeType: type,
|
||||
toolDefaultValue,
|
||||
pluginDefaultValue,
|
||||
},
|
||||
{
|
||||
prevNodeId: loopNodeData.start_node_id,
|
||||
|
||||
@@ -25,11 +25,11 @@ const InsertBlock = ({
|
||||
const handleOpenChange = useCallback((v: boolean) => {
|
||||
setOpen(v)
|
||||
}, [])
|
||||
const handleInsert = useCallback<OnSelectBlock>((nodeType, toolDefaultValue) => {
|
||||
const handleInsert = useCallback<OnSelectBlock>((nodeType, pluginDefaultValue) => {
|
||||
handleNodeAdd(
|
||||
{
|
||||
nodeType,
|
||||
toolDefaultValue,
|
||||
pluginDefaultValue,
|
||||
},
|
||||
{
|
||||
nextNodeId: startNodeId,
|
||||
|
||||
@@ -9,7 +9,7 @@ import BlockSelector from '../../../../block-selector'
|
||||
import type { Param, ParamType } from '../../types'
|
||||
import cn from '@/utils/classnames'
|
||||
import type {
|
||||
DataSourceDefaultValue,
|
||||
PluginDefaultValue,
|
||||
ToolDefaultValue,
|
||||
} from '@/app/components/workflow/block-selector/types'
|
||||
import type { ToolParameter } from '@/app/components/tools/types'
|
||||
@@ -50,11 +50,11 @@ const ImportFromTool: FC<Props> = ({
|
||||
const { data: customTools } = useAllCustomTools()
|
||||
const { data: workflowTools } = useAllWorkflowTools()
|
||||
|
||||
const handleSelectTool = useCallback((_type: BlockEnum, toolInfo?: ToolDefaultValue | DataSourceDefaultValue) => {
|
||||
if (!toolInfo || 'datasource_name' in toolInfo)
|
||||
const handleSelectTool = useCallback((_type: BlockEnum, toolInfo?: PluginDefaultValue) => {
|
||||
if (!toolInfo || 'datasource_name' in toolInfo || !('tool_name' in toolInfo))
|
||||
return
|
||||
|
||||
const { provider_id, provider_type, tool_name } = toolInfo
|
||||
const { provider_id, provider_type, tool_name } = toolInfo as ToolDefaultValue
|
||||
const currentTools = (() => {
|
||||
switch (provider_type) {
|
||||
case CollectionType.builtIn:
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type ParameterExtractorNodeType, ReasoningModeType } from './types'
|
||||
import { genNodeMetaData } from '@/app/components/workflow/utils'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
const i18nPrefix = 'workflow'
|
||||
|
||||
const metaData = genNodeMetaData({
|
||||
@@ -17,7 +18,7 @@ const nodeDefault: NodeDefault<ParameterExtractorNodeType> = {
|
||||
model: {
|
||||
provider: '',
|
||||
name: '',
|
||||
mode: 'chat',
|
||||
mode: AppModeEnum.CHAT,
|
||||
completion_params: {
|
||||
temperature: 0.7,
|
||||
},
|
||||
|
||||
@@ -67,7 +67,6 @@ const Panel: FC<NodePanelProps<ParameterExtractorNodeType>> = ({
|
||||
popupClassName='!w-[387px]'
|
||||
isInWorkflow
|
||||
isAdvancedMode={true}
|
||||
mode={model?.mode}
|
||||
provider={model?.provider}
|
||||
completionParams={model?.completion_params}
|
||||
modelId={model?.name}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { checkHasQueryBlock } from '@/app/components/base/prompt-editor/constant
|
||||
import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list'
|
||||
import { supportFunctionCall } from '@/utils/tool-call'
|
||||
import useInspectVarsCrud from '../../hooks/use-inspect-vars-crud'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
const useConfig = (id: string, payload: ParameterExtractorNodeType) => {
|
||||
const {
|
||||
@@ -86,13 +87,13 @@ const useConfig = (id: string, payload: ParameterExtractorNodeType) => {
|
||||
const model = inputs.model || {
|
||||
provider: '',
|
||||
name: '',
|
||||
mode: 'chat',
|
||||
mode: AppModeEnum.CHAT,
|
||||
completion_params: {
|
||||
temperature: 0.7,
|
||||
},
|
||||
}
|
||||
const modelMode = inputs.model?.mode
|
||||
const isChatModel = modelMode === 'chat'
|
||||
const isChatModel = modelMode === AppModeEnum.CHAT
|
||||
const isCompletionModel = !isChatModel
|
||||
|
||||
const {
|
||||
@@ -133,7 +134,7 @@ const useConfig = (id: string, payload: ParameterExtractorNodeType) => {
|
||||
draft.model.mode = model.mode!
|
||||
const isModeChange = model.mode !== inputRef.current.model?.mode
|
||||
if (isModeChange && defaultConfig && Object.keys(defaultConfig).length > 0)
|
||||
appendDefaultPromptConfig(draft, defaultConfig, model.mode === 'chat')
|
||||
appendDefaultPromptConfig(draft, defaultConfig, model.mode === AppModeEnum.CHAT)
|
||||
})
|
||||
setInputs(newInputs)
|
||||
setModelChanged(true)
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { QuestionClassifierNodeType } from './types'
|
||||
import { genNodeMetaData } from '@/app/components/workflow/utils'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
const i18nPrefix = 'workflow'
|
||||
|
||||
@@ -18,7 +19,7 @@ const nodeDefault: NodeDefault<QuestionClassifierNodeType> = {
|
||||
model: {
|
||||
provider: '',
|
||||
name: '',
|
||||
mode: 'chat',
|
||||
mode: AppModeEnum.CHAT,
|
||||
completion_params: {
|
||||
temperature: 0.7,
|
||||
},
|
||||
|
||||
@@ -56,7 +56,6 @@ const Panel: FC<NodePanelProps<QuestionClassifierNodeType>> = ({
|
||||
popupClassName='!w-[387px]'
|
||||
isInWorkflow
|
||||
isAdvancedMode={true}
|
||||
mode={model?.mode}
|
||||
provider={model?.provider}
|
||||
completionParams={model.completion_params}
|
||||
modelId={model.name}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/com
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { checkHasQueryBlock } from '@/app/components/base/prompt-editor/constants'
|
||||
import { useUpdateNodeInternals } from 'reactflow'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
const useConfig = (id: string, payload: QuestionClassifierNodeType) => {
|
||||
const updateNodeInternals = useUpdateNodeInternals()
|
||||
@@ -38,7 +39,7 @@ const useConfig = (id: string, payload: QuestionClassifierNodeType) => {
|
||||
|
||||
const model = inputs.model
|
||||
const modelMode = inputs.model?.mode
|
||||
const isChatModel = modelMode === 'chat'
|
||||
const isChatModel = modelMode === AppModeEnum.CHAT
|
||||
|
||||
const {
|
||||
isVisionModel,
|
||||
|
||||
@@ -7,10 +7,10 @@ const metaData = genNodeMetaData({
|
||||
sort: 0.1,
|
||||
type: BlockEnum.Start,
|
||||
isStart: true,
|
||||
isRequired: true,
|
||||
isUndeletable: true,
|
||||
isRequired: false,
|
||||
isSingleton: true,
|
||||
isTypeFixed: true,
|
||||
isTypeFixed: false, // support node type change for start node(user input)
|
||||
helpLinkUri: 'user-input',
|
||||
})
|
||||
const nodeDefault: NodeDefault<StartNodeType> = {
|
||||
metaData,
|
||||
|
||||
@@ -62,7 +62,7 @@ const Panel: FC<NodePanelProps<StartNodeType>> = ({
|
||||
<VarItem
|
||||
readonly
|
||||
payload={{
|
||||
variable: 'sys.query',
|
||||
variable: 'userinput.query',
|
||||
} as any}
|
||||
rightContent={
|
||||
<div className='text-xs font-normal text-text-tertiary'>
|
||||
@@ -76,7 +76,7 @@ const Panel: FC<NodePanelProps<StartNodeType>> = ({
|
||||
readonly
|
||||
showLegacyBadge={!isChatMode}
|
||||
payload={{
|
||||
variable: 'sys.files',
|
||||
variable: 'userinput.files',
|
||||
} as any}
|
||||
rightContent={
|
||||
<div className='text-xs font-normal text-text-tertiary'>
|
||||
@@ -84,80 +84,7 @@ const Panel: FC<NodePanelProps<StartNodeType>> = ({
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{
|
||||
isChatMode && (
|
||||
<>
|
||||
<VarItem
|
||||
readonly
|
||||
payload={{
|
||||
variable: 'sys.dialogue_count',
|
||||
} as any}
|
||||
rightContent={
|
||||
<div className='text-xs font-normal text-text-tertiary'>
|
||||
Number
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<VarItem
|
||||
readonly
|
||||
payload={{
|
||||
variable: 'sys.conversation_id',
|
||||
} as any}
|
||||
rightContent={
|
||||
<div className='text-xs font-normal text-text-tertiary'>
|
||||
String
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
<VarItem
|
||||
readonly
|
||||
payload={{
|
||||
variable: 'sys.user_id',
|
||||
} as any}
|
||||
rightContent={
|
||||
<div className='text-xs font-normal text-text-tertiary'>
|
||||
String
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<VarItem
|
||||
readonly
|
||||
payload={{
|
||||
variable: 'sys.app_id',
|
||||
} as any}
|
||||
rightContent={
|
||||
<div className='text-xs font-normal text-text-tertiary'>
|
||||
String
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<VarItem
|
||||
readonly
|
||||
payload={{
|
||||
variable: 'sys.workflow_id',
|
||||
} as any}
|
||||
rightContent={
|
||||
<div className='text-xs font-normal text-text-tertiary'>
|
||||
String
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<VarItem
|
||||
readonly
|
||||
payload={{
|
||||
variable: 'sys.workflow_run_id',
|
||||
} as any}
|
||||
rightContent={
|
||||
<div className='text-xs font-normal text-text-tertiary'>
|
||||
String
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ type MixedVariableTextInputProps = {
|
||||
onChange?: (text: string) => void
|
||||
showManageInputField?: boolean
|
||||
onManageInputField?: () => void
|
||||
disableVariableInsertion?: boolean
|
||||
}
|
||||
const MixedVariableTextInput = ({
|
||||
readOnly = false,
|
||||
@@ -29,6 +30,7 @@ const MixedVariableTextInput = ({
|
||||
onChange,
|
||||
showManageInputField,
|
||||
onManageInputField,
|
||||
disableVariableInsertion = false,
|
||||
}: MixedVariableTextInputProps) => {
|
||||
const { t } = useTranslation()
|
||||
const controlPromptEditorRerenderKey = useStore(s => s.controlPromptEditorRerenderKey)
|
||||
@@ -37,7 +39,7 @@ const MixedVariableTextInput = ({
|
||||
<PromptEditor
|
||||
key={controlPromptEditorRerenderKey}
|
||||
wrapperClassName={cn(
|
||||
'w-full rounded-lg border border-transparent bg-components-input-bg-normal px-2 py-1',
|
||||
'min-h-8 w-full rounded-lg border border-transparent bg-components-input-bg-normal px-2 py-1',
|
||||
'hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
|
||||
'focus-within:border-components-input-border-active focus-within:bg-components-input-bg-active focus-within:shadow-xs',
|
||||
)}
|
||||
@@ -45,7 +47,7 @@ const MixedVariableTextInput = ({
|
||||
editable={!readOnly}
|
||||
value={value}
|
||||
workflowVariableBlock={{
|
||||
show: true,
|
||||
show: !disableVariableInsertion,
|
||||
variables: nodesOutputVars || [],
|
||||
workflowNodesMap: availableNodes.reduce((acc, node) => {
|
||||
acc[node.id] = {
|
||||
@@ -63,7 +65,7 @@ const MixedVariableTextInput = ({
|
||||
showManageInputField,
|
||||
onManageInputField,
|
||||
}}
|
||||
placeholder={<Placeholder />}
|
||||
placeholder={<Placeholder disableVariableInsertion={disableVariableInsertion} />}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -6,7 +6,11 @@ import { $insertNodes } from 'lexical'
|
||||
import { CustomTextNode } from '@/app/components/base/prompt-editor/plugins/custom-text/node'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
|
||||
const Placeholder = () => {
|
||||
type PlaceholderProps = {
|
||||
disableVariableInsertion?: boolean
|
||||
}
|
||||
|
||||
const Placeholder = ({ disableVariableInsertion = false }: PlaceholderProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
@@ -28,17 +32,21 @@ const Placeholder = () => {
|
||||
>
|
||||
<div className='flex grow items-center'>
|
||||
{t('workflow.nodes.tool.insertPlaceholder1')}
|
||||
<div className='system-kbd mx-0.5 flex h-4 w-4 items-center justify-center rounded bg-components-kbd-bg-gray text-text-placeholder'>/</div>
|
||||
<div
|
||||
className='system-sm-regular cursor-pointer text-components-input-text-placeholder underline decoration-dotted decoration-auto underline-offset-auto hover:text-text-tertiary'
|
||||
onMouseDown={((e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleInsert('/')
|
||||
})}
|
||||
>
|
||||
{t('workflow.nodes.tool.insertPlaceholder2')}
|
||||
</div>
|
||||
{(!disableVariableInsertion) && (
|
||||
<>
|
||||
<div className='system-kbd mx-0.5 flex h-4 w-4 items-center justify-center rounded bg-components-kbd-bg-gray text-text-placeholder'>/</div>
|
||||
<div
|
||||
className='system-sm-regular cursor-pointer text-components-input-text-placeholder underline decoration-dotted decoration-auto underline-offset-auto hover:text-text-tertiary'
|
||||
onMouseDown={((e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleInsert('/')
|
||||
})}
|
||||
>
|
||||
{t('workflow.nodes.tool.insertPlaceholder2')}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Badge
|
||||
className='shrink-0'
|
||||
|
||||
@@ -18,6 +18,7 @@ type Props = {
|
||||
currentProvider?: ToolWithProvider
|
||||
showManageInputField?: boolean
|
||||
onManageInputField?: () => void
|
||||
extraParams?: Record<string, any>
|
||||
}
|
||||
|
||||
const ToolForm: FC<Props> = ({
|
||||
@@ -31,6 +32,7 @@ const ToolForm: FC<Props> = ({
|
||||
currentProvider,
|
||||
showManageInputField,
|
||||
onManageInputField,
|
||||
extraParams,
|
||||
}) => {
|
||||
return (
|
||||
<div className='space-y-1'>
|
||||
@@ -48,6 +50,8 @@ const ToolForm: FC<Props> = ({
|
||||
currentProvider={currentProvider}
|
||||
showManageInputField={showManageInputField}
|
||||
onManageInputField={onManageInputField}
|
||||
extraParams={extraParams}
|
||||
providerType='tool'
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ type Props = {
|
||||
currentProvider?: ToolWithProvider
|
||||
showManageInputField?: boolean
|
||||
onManageInputField?: () => void
|
||||
extraParams?: Record<string, any>
|
||||
providerType?: 'tool' | 'trigger'
|
||||
}
|
||||
|
||||
const ToolFormItem: FC<Props> = ({
|
||||
@@ -39,6 +41,8 @@ const ToolFormItem: FC<Props> = ({
|
||||
currentProvider,
|
||||
showManageInputField,
|
||||
onManageInputField,
|
||||
extraParams,
|
||||
providerType = 'tool',
|
||||
}) => {
|
||||
const language = useLanguage()
|
||||
const { name, label, type, required, tooltip, input_schema } = schema
|
||||
@@ -95,6 +99,8 @@ const ToolFormItem: FC<Props> = ({
|
||||
currentProvider={currentProvider}
|
||||
showManageInputField={showManageInputField}
|
||||
onManageInputField={onManageInputField}
|
||||
extraParams={extraParams}
|
||||
providerType={providerType}
|
||||
/>
|
||||
|
||||
{isShowSchema && (
|
||||
|
||||
@@ -1,46 +1,90 @@
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import type { ToolNodeType } from './types'
|
||||
import React, { useEffect } from 'react'
|
||||
import type { NodeProps } from '@/app/components/workflow/types'
|
||||
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button'
|
||||
import { useNodePluginInstallation } from '@/app/components/workflow/hooks/use-node-plugin-installation'
|
||||
import { useNodeDataUpdate } from '@/app/components/workflow/hooks/use-node-data-update'
|
||||
import type { ToolNodeType } from './types'
|
||||
|
||||
const Node: FC<NodeProps<ToolNodeType>> = ({
|
||||
id,
|
||||
data,
|
||||
}) => {
|
||||
const { tool_configurations, paramSchemas } = data
|
||||
const toolConfigs = Object.keys(tool_configurations || {})
|
||||
const {
|
||||
isChecking,
|
||||
isMissing,
|
||||
uniqueIdentifier,
|
||||
canInstall,
|
||||
onInstallSuccess,
|
||||
shouldDim,
|
||||
} = useNodePluginInstallation(data)
|
||||
const showInstallButton = !isChecking && isMissing && canInstall && uniqueIdentifier
|
||||
const { handleNodeDataUpdate } = useNodeDataUpdate()
|
||||
const shouldLock = !isChecking && isMissing && canInstall && Boolean(uniqueIdentifier)
|
||||
|
||||
if (!toolConfigs.length)
|
||||
useEffect(() => {
|
||||
if (data._pluginInstallLocked === shouldLock && data._dimmed === shouldDim)
|
||||
return
|
||||
handleNodeDataUpdate({
|
||||
id,
|
||||
data: {
|
||||
_pluginInstallLocked: shouldLock,
|
||||
_dimmed: shouldDim,
|
||||
},
|
||||
})
|
||||
}, [data._pluginInstallLocked, data._dimmed, handleNodeDataUpdate, id, shouldDim, shouldLock])
|
||||
|
||||
const hasConfigs = toolConfigs.length > 0
|
||||
|
||||
if (!showInstallButton && !hasConfigs)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className='mb-1 px-3 py-1'>
|
||||
<div className='space-y-0.5'>
|
||||
{toolConfigs.map((key, index) => (
|
||||
<div key={index} className='flex h-6 items-center justify-between space-x-1 rounded-md bg-workflow-block-parma-bg px-1 text-xs font-normal text-text-secondary'>
|
||||
<div title={key} className='max-w-[100px] shrink-0 truncate text-xs font-medium uppercase text-text-tertiary'>
|
||||
{key}
|
||||
<div className='relative mb-1 px-3 py-1'>
|
||||
{showInstallButton && (
|
||||
<div className='pointer-events-auto absolute right-3 top-[-32px] z-40'>
|
||||
<InstallPluginButton
|
||||
size='small'
|
||||
className='!font-medium !text-text-accent'
|
||||
extraIdentifiers={[
|
||||
data.plugin_id,
|
||||
data.provider_id,
|
||||
data.provider_name,
|
||||
].filter(Boolean) as string[]}
|
||||
uniqueIdentifier={uniqueIdentifier!}
|
||||
onSuccess={onInstallSuccess}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasConfigs && (
|
||||
<div className='space-y-0.5' aria-disabled={shouldDim}>
|
||||
{toolConfigs.map((key, index) => (
|
||||
<div key={index} className='flex h-6 items-center justify-between space-x-1 rounded-md bg-workflow-block-parma-bg px-1 text-xs font-normal text-text-secondary'>
|
||||
<div title={key} className='max-w-[100px] shrink-0 truncate text-xs font-medium uppercase text-text-tertiary'>
|
||||
{key}
|
||||
</div>
|
||||
{typeof tool_configurations[key].value === 'string' && (
|
||||
<div title={tool_configurations[key].value} className='w-0 shrink-0 grow truncate text-right text-xs font-normal text-text-secondary'>
|
||||
{paramSchemas?.find(i => i.name === key)?.type === FormTypeEnum.secretInput ? '********' : tool_configurations[key].value}
|
||||
</div>
|
||||
)}
|
||||
{typeof tool_configurations[key].value === 'number' && (
|
||||
<div title={Number.isNaN(tool_configurations[key].value) ? '' : tool_configurations[key].value} className='w-0 shrink-0 grow truncate text-right text-xs font-normal text-text-secondary'>
|
||||
{Number.isNaN(tool_configurations[key].value) ? '' : tool_configurations[key].value}
|
||||
</div>
|
||||
)}
|
||||
{typeof tool_configurations[key] !== 'string' && tool_configurations[key]?.type === FormTypeEnum.modelSelector && (
|
||||
<div title={tool_configurations[key].model} className='w-0 shrink-0 grow truncate text-right text-xs font-normal text-text-secondary'>
|
||||
{tool_configurations[key].model}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{typeof tool_configurations[key].value === 'string' && (
|
||||
<div title={tool_configurations[key].value} className='w-0 shrink-0 grow truncate text-right text-xs font-normal text-text-secondary'>
|
||||
{paramSchemas?.find(i => i.name === key)?.type === FormTypeEnum.secretInput ? '********' : tool_configurations[key].value}
|
||||
</div>
|
||||
)}
|
||||
{typeof tool_configurations[key].value === 'number' && (
|
||||
<div title={Number.isNaN(tool_configurations[key].value) ? '' : tool_configurations[key].value} className='w-0 shrink-0 grow truncate text-right text-xs font-normal text-text-secondary'>
|
||||
{Number.isNaN(tool_configurations[key].value) ? '' : tool_configurations[key].value}
|
||||
</div>
|
||||
)}
|
||||
{typeof tool_configurations[key] !== 'string' && tool_configurations[key]?.type === FormTypeEnum.modelSelector && (
|
||||
<div title={tool_configurations[key].model} className='w-0 shrink-0 grow truncate text-right text-xs font-normal text-text-secondary'>
|
||||
{tool_configurations[key].model}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
))}
|
||||
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import type { CollectionType } from '@/app/components/tools/types'
|
||||
import type { CommonNodeType, ValueSelector } from '@/app/components/workflow/types'
|
||||
import type { Collection, CollectionType } from '@/app/components/tools/types'
|
||||
import type { CommonNodeType } from '@/app/components/workflow/types'
|
||||
import type { ResourceVarInputs } from '../_base/types'
|
||||
|
||||
export enum VarType {
|
||||
variable = 'variable',
|
||||
constant = 'constant',
|
||||
mixed = 'mixed',
|
||||
}
|
||||
|
||||
export type ToolVarInputs = Record<string, {
|
||||
type: VarType
|
||||
value?: string | ValueSelector | any
|
||||
}>
|
||||
// Use base types directly
|
||||
export { VarKindType as VarType } from '../_base/types'
|
||||
export type ToolVarInputs = ResourceVarInputs
|
||||
|
||||
export type ToolNodeType = CommonNodeType & {
|
||||
provider_id: string
|
||||
@@ -26,4 +20,7 @@ export type ToolNodeType = CommonNodeType & {
|
||||
tool_description?: string
|
||||
is_team_authorization?: boolean
|
||||
params?: Record<string, any>
|
||||
plugin_id?: string
|
||||
provider_icon?: Collection['icon']
|
||||
plugin_unique_identifier?: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client'
|
||||
import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { Event } from '@/app/components/tools/types'
|
||||
import type { FC } from 'react'
|
||||
import type { PluginTriggerVarInputs } from '@/app/components/workflow/nodes/trigger-plugin/types'
|
||||
import TriggerFormItem from './item'
|
||||
import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
|
||||
|
||||
type Props = {
|
||||
readOnly: boolean
|
||||
nodeId: string
|
||||
schema: CredentialFormSchema[]
|
||||
value: PluginTriggerVarInputs
|
||||
onChange: (value: PluginTriggerVarInputs) => void
|
||||
onOpen?: (index: number) => void
|
||||
inPanel?: boolean
|
||||
currentEvent?: Event
|
||||
currentProvider?: TriggerWithProvider
|
||||
extraParams?: Record<string, any>
|
||||
disableVariableInsertion?: boolean
|
||||
}
|
||||
|
||||
const TriggerForm: FC<Props> = ({
|
||||
readOnly,
|
||||
nodeId,
|
||||
schema,
|
||||
value,
|
||||
onChange,
|
||||
inPanel,
|
||||
currentEvent,
|
||||
currentProvider,
|
||||
extraParams,
|
||||
disableVariableInsertion = false,
|
||||
}) => {
|
||||
return (
|
||||
<div className='space-y-1'>
|
||||
{
|
||||
schema.map((schema, index) => (
|
||||
<TriggerFormItem
|
||||
key={index}
|
||||
readOnly={readOnly}
|
||||
nodeId={nodeId}
|
||||
schema={schema}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
inPanel={inPanel}
|
||||
currentEvent={currentEvent}
|
||||
currentProvider={currentProvider}
|
||||
extraParams={extraParams}
|
||||
disableVariableInsertion={disableVariableInsertion}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default TriggerForm
|
||||
@@ -0,0 +1,112 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import {
|
||||
RiBracesLine,
|
||||
} from '@remixicon/react'
|
||||
import type { PluginTriggerVarInputs } from '@/app/components/workflow/nodes/trigger-plugin/types'
|
||||
import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import FormInputItem from '@/app/components/workflow/nodes/_base/components/form-input-item'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import SchemaModal from '@/app/components/plugins/plugin-detail-panel/tool-selector/schema-modal'
|
||||
import type { Event } from '@/app/components/tools/types'
|
||||
import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
|
||||
|
||||
type Props = {
|
||||
readOnly: boolean
|
||||
nodeId: string
|
||||
schema: CredentialFormSchema
|
||||
value: PluginTriggerVarInputs
|
||||
onChange: (value: PluginTriggerVarInputs) => void
|
||||
inPanel?: boolean
|
||||
currentEvent?: Event
|
||||
currentProvider?: TriggerWithProvider
|
||||
extraParams?: Record<string, any>
|
||||
disableVariableInsertion?: boolean
|
||||
}
|
||||
|
||||
const TriggerFormItem: FC<Props> = ({
|
||||
readOnly,
|
||||
nodeId,
|
||||
schema,
|
||||
value,
|
||||
onChange,
|
||||
inPanel,
|
||||
currentEvent,
|
||||
currentProvider,
|
||||
extraParams,
|
||||
disableVariableInsertion = false,
|
||||
}) => {
|
||||
const language = useLanguage()
|
||||
const { name, label, type, required, tooltip, input_schema } = schema
|
||||
const showSchemaButton = type === FormTypeEnum.object || type === FormTypeEnum.array
|
||||
const showDescription = type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput
|
||||
const [isShowSchema, {
|
||||
setTrue: showSchema,
|
||||
setFalse: hideSchema,
|
||||
}] = useBoolean(false)
|
||||
return (
|
||||
<div className='space-y-0.5 py-1'>
|
||||
<div>
|
||||
<div className='flex h-6 items-center'>
|
||||
<div className='system-sm-medium text-text-secondary'>{label[language] || label.en_US}</div>
|
||||
{required && (
|
||||
<div className='system-xs-regular ml-1 text-text-destructive-secondary'>*</div>
|
||||
)}
|
||||
{!showDescription && tooltip && (
|
||||
<Tooltip
|
||||
popupContent={<div className='w-[200px]'>
|
||||
{tooltip[language] || tooltip.en_US}
|
||||
</div>}
|
||||
triggerClassName='ml-1 w-4 h-4'
|
||||
asChild={false}
|
||||
/>
|
||||
)}
|
||||
{showSchemaButton && (
|
||||
<>
|
||||
<div className='system-xs-regular ml-1 mr-0.5 text-text-quaternary'>·</div>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='small'
|
||||
onClick={showSchema}
|
||||
className='system-xs-regular px-1 text-text-tertiary'
|
||||
>
|
||||
<RiBracesLine className='mr-1 size-3.5' />
|
||||
<span>JSON Schema</span>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{showDescription && tooltip && (
|
||||
<div className='body-xs-regular pb-0.5 text-text-tertiary'>{tooltip[language] || tooltip.en_US}</div>
|
||||
)}
|
||||
</div>
|
||||
<FormInputItem
|
||||
readOnly={readOnly}
|
||||
nodeId={nodeId}
|
||||
schema={schema}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
inPanel={inPanel}
|
||||
currentTool={currentEvent}
|
||||
currentProvider={currentProvider}
|
||||
providerType='trigger'
|
||||
extraParams={extraParams}
|
||||
disableVariableInsertion={disableVariableInsertion}
|
||||
/>
|
||||
|
||||
{isShowSchema && (
|
||||
<SchemaModal
|
||||
isShow
|
||||
onClose={hideSchema}
|
||||
rootName={name}
|
||||
schema={input_schema!}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default TriggerFormItem
|
||||
297
web/app/components/workflow/nodes/trigger-plugin/default.ts
Normal file
297
web/app/components/workflow/nodes/trigger-plugin/default.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import type { SchemaTypeDefinition } from '@/service/use-common'
|
||||
import type { NodeDefault, Var } from '../../types'
|
||||
import { BlockEnum, VarType } from '../../types'
|
||||
import { genNodeMetaData } from '../../utils'
|
||||
import { VarKindType } from '../_base/types'
|
||||
import { type Field, type StructuredOutput, Type } from '../llm/types'
|
||||
import type { PluginTriggerNodeType } from './types'
|
||||
|
||||
const normalizeJsonSchemaType = (schema: any): string | undefined => {
|
||||
if (!schema) return undefined
|
||||
const { type, properties, items, oneOf, anyOf, allOf } = schema
|
||||
|
||||
if (Array.isArray(type))
|
||||
return type.find((item: string | null) => item && item !== 'null') || type[0]
|
||||
|
||||
if (typeof type === 'string')
|
||||
return type
|
||||
|
||||
const compositeCandidates = [oneOf, anyOf, allOf]
|
||||
.filter((entry): entry is any[] => Array.isArray(entry))
|
||||
.flat()
|
||||
|
||||
for (const candidate of compositeCandidates) {
|
||||
const normalized = normalizeJsonSchemaType(candidate)
|
||||
if (normalized)
|
||||
return normalized
|
||||
}
|
||||
|
||||
if (properties)
|
||||
return 'object'
|
||||
|
||||
if (items)
|
||||
return 'array'
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const pickItemSchema = (schema: any) => {
|
||||
if (!schema || !schema.items)
|
||||
return undefined
|
||||
return Array.isArray(schema.items) ? schema.items[0] : schema.items
|
||||
}
|
||||
|
||||
const extractSchemaType = (schema: any, _schemaTypeDefinitions?: SchemaTypeDefinition[]): string | undefined => {
|
||||
if (!schema)
|
||||
return undefined
|
||||
|
||||
const schemaTypeFromSchema = schema.schema_type || schema.schemaType
|
||||
if (typeof schemaTypeFromSchema === 'string' && schemaTypeFromSchema.trim().length > 0)
|
||||
return schemaTypeFromSchema
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const resolveVarType = (
|
||||
schema: any,
|
||||
schemaTypeDefinitions?: SchemaTypeDefinition[],
|
||||
): { type: VarType; schemaType?: string } => {
|
||||
const schemaType = extractSchemaType(schema, schemaTypeDefinitions)
|
||||
const normalizedType = normalizeJsonSchemaType(schema)
|
||||
|
||||
switch (normalizedType) {
|
||||
case 'string':
|
||||
return { type: VarType.string, schemaType }
|
||||
case 'number':
|
||||
return { type: VarType.number, schemaType }
|
||||
case 'integer':
|
||||
return { type: VarType.integer, schemaType }
|
||||
case 'boolean':
|
||||
return { type: VarType.boolean, schemaType }
|
||||
case 'object':
|
||||
return { type: VarType.object, schemaType }
|
||||
case 'array': {
|
||||
const itemSchema = pickItemSchema(schema)
|
||||
if (!itemSchema)
|
||||
return { type: VarType.array, schemaType }
|
||||
|
||||
const { type: itemType, schemaType: itemSchemaType } = resolveVarType(itemSchema, schemaTypeDefinitions)
|
||||
const resolvedSchemaType = schemaType || itemSchemaType
|
||||
|
||||
if (itemSchemaType === 'file')
|
||||
return { type: VarType.arrayFile, schemaType: resolvedSchemaType }
|
||||
|
||||
switch (itemType) {
|
||||
case VarType.string:
|
||||
return { type: VarType.arrayString, schemaType: resolvedSchemaType }
|
||||
case VarType.number:
|
||||
case VarType.integer:
|
||||
return { type: VarType.arrayNumber, schemaType: resolvedSchemaType }
|
||||
case VarType.boolean:
|
||||
return { type: VarType.arrayBoolean, schemaType: resolvedSchemaType }
|
||||
case VarType.object:
|
||||
return { type: VarType.arrayObject, schemaType: resolvedSchemaType }
|
||||
case VarType.file:
|
||||
return { type: VarType.arrayFile, schemaType: resolvedSchemaType }
|
||||
default:
|
||||
return { type: VarType.array, schemaType: resolvedSchemaType }
|
||||
}
|
||||
}
|
||||
default:
|
||||
return { type: VarType.any, schemaType }
|
||||
}
|
||||
}
|
||||
|
||||
const toFieldType = (normalizedType: string | undefined, schemaType?: string): Type => {
|
||||
if (schemaType === 'file')
|
||||
return normalizedType === 'array' ? Type.array : Type.file
|
||||
|
||||
switch (normalizedType) {
|
||||
case 'number':
|
||||
case 'integer':
|
||||
return Type.number
|
||||
case 'boolean':
|
||||
return Type.boolean
|
||||
case 'object':
|
||||
return Type.object
|
||||
case 'array':
|
||||
return Type.array
|
||||
case 'string':
|
||||
default:
|
||||
return Type.string
|
||||
}
|
||||
}
|
||||
|
||||
const toArrayItemType = (type: Type): Exclude<Type, Type.array> => {
|
||||
if (type === Type.array)
|
||||
return Type.object
|
||||
return type as Exclude<Type, Type.array>
|
||||
}
|
||||
|
||||
const convertJsonSchemaToField = (schema: any, schemaTypeDefinitions?: SchemaTypeDefinition[]): Field => {
|
||||
const schemaType = extractSchemaType(schema, schemaTypeDefinitions)
|
||||
const normalizedType = normalizeJsonSchemaType(schema)
|
||||
const fieldType = toFieldType(normalizedType, schemaType)
|
||||
|
||||
const field: Field = {
|
||||
type: fieldType,
|
||||
}
|
||||
|
||||
if (schema?.description)
|
||||
field.description = schema.description
|
||||
|
||||
if (schemaType)
|
||||
field.schemaType = schemaType
|
||||
|
||||
if (Array.isArray(schema?.enum))
|
||||
field.enum = schema.enum
|
||||
|
||||
if (fieldType === Type.object) {
|
||||
const properties = schema?.properties || {}
|
||||
field.properties = Object.entries(properties).reduce((acc, [key, value]) => {
|
||||
acc[key] = convertJsonSchemaToField(value, schemaTypeDefinitions)
|
||||
return acc
|
||||
}, {} as Record<string, Field>)
|
||||
|
||||
const required = Array.isArray(schema?.required) ? schema.required.filter(Boolean) : undefined
|
||||
field.required = required && required.length > 0 ? required : undefined
|
||||
field.additionalProperties = false
|
||||
}
|
||||
|
||||
if (fieldType === Type.array) {
|
||||
const itemSchema = pickItemSchema(schema)
|
||||
if (itemSchema) {
|
||||
const itemField = convertJsonSchemaToField(itemSchema, schemaTypeDefinitions)
|
||||
const { type, ...rest } = itemField
|
||||
field.items = {
|
||||
...rest,
|
||||
type: toArrayItemType(type),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return field
|
||||
}
|
||||
|
||||
const buildOutputVars = (schema: Record<string, any>, schemaTypeDefinitions?: SchemaTypeDefinition[]): Var[] => {
|
||||
if (!schema || typeof schema !== 'object')
|
||||
return []
|
||||
|
||||
const properties = schema.properties as Record<string, any> | undefined
|
||||
if (!properties)
|
||||
return []
|
||||
|
||||
return Object.entries(properties).map(([name, propertySchema]) => {
|
||||
const { type, schemaType } = resolveVarType(propertySchema, schemaTypeDefinitions)
|
||||
const normalizedType = normalizeJsonSchemaType(propertySchema)
|
||||
|
||||
const varItem: Var = {
|
||||
variable: name,
|
||||
type,
|
||||
des: propertySchema?.description,
|
||||
...(schemaType ? { schemaType } : {}),
|
||||
}
|
||||
|
||||
if (normalizedType === 'object') {
|
||||
const childProperties = propertySchema?.properties
|
||||
? Object.entries(propertySchema.properties).reduce((acc, [key, value]) => {
|
||||
acc[key] = convertJsonSchemaToField(value, schemaTypeDefinitions)
|
||||
return acc
|
||||
}, {} as Record<string, Field>)
|
||||
: {}
|
||||
|
||||
const required = Array.isArray(propertySchema?.required) ? propertySchema.required.filter(Boolean) : undefined
|
||||
|
||||
varItem.children = {
|
||||
schema: {
|
||||
type: Type.object,
|
||||
properties: childProperties,
|
||||
required: required && required.length > 0 ? required : undefined,
|
||||
additionalProperties: false,
|
||||
},
|
||||
} as StructuredOutput
|
||||
}
|
||||
|
||||
return varItem
|
||||
})
|
||||
}
|
||||
|
||||
const metaData = genNodeMetaData({
|
||||
sort: 1,
|
||||
type: BlockEnum.TriggerPlugin,
|
||||
helpLinkUri: 'plugin-trigger',
|
||||
isStart: true,
|
||||
})
|
||||
|
||||
const nodeDefault: NodeDefault<PluginTriggerNodeType> = {
|
||||
metaData,
|
||||
defaultValue: {
|
||||
plugin_id: '',
|
||||
event_name: '',
|
||||
event_parameters: {},
|
||||
// event_type: '',
|
||||
config: {},
|
||||
},
|
||||
checkValid(payload: PluginTriggerNodeType, t: any, moreDataForCheckValid: {
|
||||
triggerInputsSchema?: Array<{
|
||||
variable: string
|
||||
label: string
|
||||
required?: boolean
|
||||
}>
|
||||
isReadyForCheckValid?: boolean
|
||||
} = {}) {
|
||||
let errorMessage = ''
|
||||
|
||||
if (!payload.subscription_id)
|
||||
errorMessage = t('workflow.nodes.triggerPlugin.subscriptionRequired')
|
||||
|
||||
const {
|
||||
triggerInputsSchema = [],
|
||||
isReadyForCheckValid = true,
|
||||
} = moreDataForCheckValid || {}
|
||||
|
||||
if (!errorMessage && isReadyForCheckValid) {
|
||||
triggerInputsSchema.filter(field => field.required).forEach((field) => {
|
||||
if (errorMessage)
|
||||
return
|
||||
|
||||
const rawParam = payload.event_parameters?.[field.variable]
|
||||
?? (payload.config as Record<string, any> | undefined)?.[field.variable]
|
||||
if (!rawParam) {
|
||||
errorMessage = t('workflow.errorMsg.fieldRequired', { field: field.label })
|
||||
return
|
||||
}
|
||||
|
||||
const targetParam = typeof rawParam === 'object' && rawParam !== null && 'type' in rawParam
|
||||
? rawParam as { type: VarKindType; value: any }
|
||||
: { type: VarKindType.constant, value: rawParam }
|
||||
|
||||
const { type, value } = targetParam
|
||||
if (type === VarKindType.variable) {
|
||||
if (!value || (Array.isArray(value) && value.length === 0))
|
||||
errorMessage = t('workflow.errorMsg.fieldRequired', { field: field.label })
|
||||
}
|
||||
else {
|
||||
if (
|
||||
value === undefined
|
||||
|| value === null
|
||||
|| value === ''
|
||||
|| (Array.isArray(value) && value.length === 0)
|
||||
)
|
||||
errorMessage = t('workflow.errorMsg.fieldRequired', { field: field.label })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: !errorMessage,
|
||||
errorMessage,
|
||||
}
|
||||
},
|
||||
getOutputVars(payload, _allPluginInfoList, _ragVars, { schemaTypeDefinitions } = { schemaTypeDefinitions: [] }) {
|
||||
const schema = payload.output_schema || {}
|
||||
return buildOutputVars(schema, schemaTypeDefinitions)
|
||||
},
|
||||
}
|
||||
|
||||
export default nodeDefault
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import {
|
||||
useBuildTriggerSubscription,
|
||||
useCreateTriggerSubscriptionBuilder,
|
||||
useUpdateTriggerSubscriptionBuilder,
|
||||
useVerifyTriggerSubscriptionBuilder,
|
||||
} from '@/service/use-triggers'
|
||||
import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
|
||||
|
||||
// Helper function to serialize complex values to strings for backend encryption
|
||||
const serializeFormValues = (values: Record<string, any>): Record<string, string> => {
|
||||
const result: Record<string, string> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value === null || value === undefined)
|
||||
result[key] = ''
|
||||
else if (typeof value === 'object')
|
||||
result[key] = JSON.stringify(value)
|
||||
else
|
||||
result[key] = String(value)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export type AuthFlowStep = 'auth' | 'params' | 'complete'
|
||||
|
||||
export type AuthFlowState = {
|
||||
step: AuthFlowStep
|
||||
builderId: string
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export type AuthFlowActions = {
|
||||
startAuth: () => Promise<void>
|
||||
verifyAuth: (credentials: Record<string, any>) => Promise<void>
|
||||
completeConfig: (parameters: Record<string, any>, properties?: Record<string, any>, name?: string) => Promise<void>
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export const useTriggerAuthFlow = (provider: TriggerWithProvider): AuthFlowState & AuthFlowActions => {
|
||||
const [step, setStep] = useState<AuthFlowStep>('auth')
|
||||
const [builderId, setBuilderId] = useState<string>('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const createBuilder = useCreateTriggerSubscriptionBuilder()
|
||||
const updateBuilder = useUpdateTriggerSubscriptionBuilder()
|
||||
const verifyBuilder = useVerifyTriggerSubscriptionBuilder()
|
||||
const buildSubscription = useBuildTriggerSubscription()
|
||||
|
||||
const startAuth = useCallback(async () => {
|
||||
if (builderId) return // Prevent multiple calls if already started
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await createBuilder.mutateAsync({
|
||||
provider: provider.name,
|
||||
})
|
||||
setBuilderId(response.subscription_builder.id)
|
||||
setStep('auth')
|
||||
}
|
||||
catch (err: any) {
|
||||
setError(err.message || 'Failed to start authentication flow')
|
||||
throw err
|
||||
}
|
||||
finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [provider.name, createBuilder, builderId])
|
||||
|
||||
const verifyAuth = useCallback(async (credentials: Record<string, any>) => {
|
||||
if (!builderId) {
|
||||
setError('No builder ID available')
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await updateBuilder.mutateAsync({
|
||||
provider: provider.name,
|
||||
subscriptionBuilderId: builderId,
|
||||
credentials: serializeFormValues(credentials),
|
||||
})
|
||||
|
||||
await verifyBuilder.mutateAsync({
|
||||
provider: provider.name,
|
||||
subscriptionBuilderId: builderId,
|
||||
})
|
||||
|
||||
setStep('params')
|
||||
}
|
||||
catch (err: any) {
|
||||
setError(err.message || 'Authentication verification failed')
|
||||
throw err
|
||||
}
|
||||
finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [provider.name, builderId, updateBuilder, verifyBuilder])
|
||||
|
||||
const completeConfig = useCallback(async (
|
||||
parameters: Record<string, any>,
|
||||
properties: Record<string, any> = {},
|
||||
name?: string,
|
||||
) => {
|
||||
if (!builderId) {
|
||||
setError('No builder ID available')
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await updateBuilder.mutateAsync({
|
||||
provider: provider.name,
|
||||
subscriptionBuilderId: builderId,
|
||||
parameters: serializeFormValues(parameters),
|
||||
properties: serializeFormValues(properties),
|
||||
name,
|
||||
})
|
||||
|
||||
await buildSubscription.mutateAsync({
|
||||
provider: provider.name,
|
||||
subscriptionBuilderId: builderId,
|
||||
})
|
||||
|
||||
setStep('complete')
|
||||
}
|
||||
catch (err: any) {
|
||||
setError(err.message || 'Configuration failed')
|
||||
throw err
|
||||
}
|
||||
finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [provider.name, builderId, updateBuilder, buildSubscription])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setStep('auth')
|
||||
setBuilderId('')
|
||||
setIsLoading(false)
|
||||
setError(null)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
step,
|
||||
builderId,
|
||||
isLoading,
|
||||
error,
|
||||
startAuth,
|
||||
verifyAuth,
|
||||
completeConfig,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
126
web/app/components/workflow/nodes/trigger-plugin/node.tsx
Normal file
126
web/app/components/workflow/nodes/trigger-plugin/node.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import NodeStatus, { NodeStatusEnum } from '@/app/components/base/node-status'
|
||||
import type { NodeProps } from '@/app/components/workflow/types'
|
||||
import type { FC } from 'react'
|
||||
import React, { useEffect, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button'
|
||||
import { useNodePluginInstallation } from '@/app/components/workflow/hooks/use-node-plugin-installation'
|
||||
import { useNodeDataUpdate } from '@/app/components/workflow/hooks/use-node-data-update'
|
||||
import type { PluginTriggerNodeType } from './types'
|
||||
import useConfig from './use-config'
|
||||
|
||||
const formatConfigValue = (rawValue: any): string => {
|
||||
if (rawValue === null || rawValue === undefined)
|
||||
return ''
|
||||
|
||||
if (typeof rawValue === 'string' || typeof rawValue === 'number' || typeof rawValue === 'boolean')
|
||||
return String(rawValue)
|
||||
|
||||
if (Array.isArray(rawValue))
|
||||
return rawValue.join('.')
|
||||
|
||||
if (typeof rawValue === 'object') {
|
||||
const { value } = rawValue as { value?: any }
|
||||
if (value === null || value === undefined)
|
||||
return ''
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean')
|
||||
return String(value)
|
||||
if (Array.isArray(value))
|
||||
return value.join('.')
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const Node: FC<NodeProps<PluginTriggerNodeType>> = ({
|
||||
id,
|
||||
data,
|
||||
}) => {
|
||||
const { subscriptions } = useConfig(id, data)
|
||||
const { config = {}, subscription_id } = data
|
||||
const configKeys = Object.keys(config)
|
||||
const {
|
||||
isChecking,
|
||||
isMissing,
|
||||
uniqueIdentifier,
|
||||
canInstall,
|
||||
onInstallSuccess,
|
||||
shouldDim,
|
||||
} = useNodePluginInstallation(data)
|
||||
const { handleNodeDataUpdate } = useNodeDataUpdate()
|
||||
const showInstallButton = !isChecking && isMissing && canInstall && uniqueIdentifier
|
||||
const shouldLock = !isChecking && isMissing && canInstall && Boolean(uniqueIdentifier)
|
||||
|
||||
useEffect(() => {
|
||||
if (data._pluginInstallLocked === shouldLock && data._dimmed === shouldDim)
|
||||
return
|
||||
handleNodeDataUpdate({
|
||||
id,
|
||||
data: {
|
||||
_pluginInstallLocked: shouldLock,
|
||||
_dimmed: shouldDim,
|
||||
},
|
||||
})
|
||||
}, [data._pluginInstallLocked, data._dimmed, handleNodeDataUpdate, id, shouldDim, shouldLock])
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
const isValidSubscription = useMemo(() => {
|
||||
return subscription_id && subscriptions?.some(sub => sub.id === subscription_id)
|
||||
}, [subscription_id, subscriptions])
|
||||
|
||||
return (
|
||||
<div className="relative mb-1 px-3 py-1">
|
||||
{showInstallButton && (
|
||||
<div className="pointer-events-auto absolute right-3 top-[-32px] z-40">
|
||||
<InstallPluginButton
|
||||
size="small"
|
||||
extraIdentifiers={[
|
||||
data.plugin_id,
|
||||
data.provider_id,
|
||||
data.provider_name,
|
||||
].filter(Boolean) as string[]}
|
||||
className="!font-medium !text-text-accent"
|
||||
uniqueIdentifier={uniqueIdentifier!}
|
||||
onSuccess={onInstallSuccess}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-0.5" aria-disabled={shouldDim}>
|
||||
{!isValidSubscription && <NodeStatus status={NodeStatusEnum.warning} message={t('pluginTrigger.node.status.warning')} />}
|
||||
{isValidSubscription && configKeys.map((key, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex h-6 items-center justify-between space-x-1 rounded-md bg-workflow-block-parma-bg px-1 text-xs font-normal text-text-secondary"
|
||||
>
|
||||
<div
|
||||
title={key}
|
||||
className="max-w-[100px] shrink-0 truncate text-xs font-medium uppercase text-text-tertiary"
|
||||
>
|
||||
{key}
|
||||
</div>
|
||||
<div
|
||||
title={formatConfigValue(config[key])}
|
||||
className="w-0 shrink-0 grow truncate text-right text-xs font-normal text-text-secondary"
|
||||
>
|
||||
{(() => {
|
||||
const displayValue = formatConfigValue(config[key])
|
||||
if (displayValue.includes('secret'))
|
||||
return '********'
|
||||
return displayValue
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Node)
|
||||
94
web/app/components/workflow/nodes/trigger-plugin/panel.tsx
Normal file
94
web/app/components/workflow/nodes/trigger-plugin/panel.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import type { PluginTriggerNodeType } from './types'
|
||||
import Split from '@/app/components/workflow/nodes/_base/components/split'
|
||||
import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/components/output-vars'
|
||||
import type { NodePanelProps } from '@/app/components/workflow/types'
|
||||
import useConfig from './use-config'
|
||||
import TriggerForm from './components/trigger-form'
|
||||
import StructureOutputItem from '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show'
|
||||
import { Type } from '../llm/types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
|
||||
const Panel: FC<NodePanelProps<PluginTriggerNodeType>> = ({
|
||||
id,
|
||||
data,
|
||||
}) => {
|
||||
const {
|
||||
readOnly,
|
||||
triggerParameterSchema,
|
||||
triggerParameterValue,
|
||||
setTriggerParameterValue,
|
||||
outputSchema,
|
||||
hasObjectOutput,
|
||||
currentProvider,
|
||||
currentEvent,
|
||||
subscriptionSelected,
|
||||
} = useConfig(id, data)
|
||||
const disableVariableInsertion = data.type === BlockEnum.TriggerPlugin
|
||||
|
||||
// Convert output schema to VarItem format
|
||||
const outputVars = Object.entries(outputSchema.properties || {}).map(([name, schema]: [string, any]) => ({
|
||||
name,
|
||||
type: schema.type || 'string',
|
||||
description: schema.description || '',
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className='mt-2'>
|
||||
{/* Dynamic Parameters Form - Only show when authenticated */}
|
||||
{triggerParameterSchema.length > 0 && subscriptionSelected && (
|
||||
<>
|
||||
<div className='px-4 pb-4'>
|
||||
<TriggerForm
|
||||
readOnly={readOnly}
|
||||
nodeId={id}
|
||||
schema={triggerParameterSchema as any}
|
||||
value={triggerParameterValue}
|
||||
onChange={setTriggerParameterValue}
|
||||
currentProvider={currentProvider}
|
||||
currentEvent={currentEvent}
|
||||
disableVariableInsertion={disableVariableInsertion}
|
||||
/>
|
||||
</div>
|
||||
<Split />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Output Variables - Always show */}
|
||||
<OutputVars>
|
||||
<>
|
||||
{outputVars.map(varItem => (
|
||||
<VarItem
|
||||
key={varItem.name}
|
||||
name={varItem.name}
|
||||
type={varItem.type}
|
||||
description={varItem.description}
|
||||
isIndent={hasObjectOutput}
|
||||
/>
|
||||
))}
|
||||
{Object.entries(outputSchema.properties || {}).map(([name, schema]: [string, any]) => (
|
||||
<div key={name}>
|
||||
{schema.type === 'object' ? (
|
||||
<StructureOutputItem
|
||||
rootClassName='code-sm-semibold text-text-secondary'
|
||||
payload={{
|
||||
schema: {
|
||||
type: Type.object,
|
||||
properties: {
|
||||
[name]: schema,
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
</OutputVars>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Panel)
|
||||
24
web/app/components/workflow/nodes/trigger-plugin/types.ts
Normal file
24
web/app/components/workflow/nodes/trigger-plugin/types.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { CommonNodeType } from '@/app/components/workflow/types'
|
||||
import type { CollectionType } from '@/app/components/tools/types'
|
||||
import type { ResourceVarInputs } from '../_base/types'
|
||||
|
||||
export type PluginTriggerNodeType = CommonNodeType & {
|
||||
provider_id: string
|
||||
provider_type: CollectionType
|
||||
provider_name: string
|
||||
event_name: string
|
||||
event_label: string
|
||||
event_parameters: PluginTriggerVarInputs
|
||||
event_configurations: Record<string, any>
|
||||
output_schema: Record<string, any>
|
||||
parameters_schema?: Record<string, any>[]
|
||||
version?: string
|
||||
event_node_version?: string
|
||||
plugin_id?: string
|
||||
config?: Record<string, any>
|
||||
plugin_unique_identifier?: string
|
||||
}
|
||||
|
||||
// Use base types directly
|
||||
export { VarKindType as PluginTriggerVarType } from '../_base/types'
|
||||
export type PluginTriggerVarInputs = ResourceVarInputs
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useCallback } from 'react'
|
||||
import type { PluginTriggerNodeType } from './types'
|
||||
import { useAllTriggerPlugins } from '@/service/use-triggers'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import { getTriggerCheckParams } from '@/app/components/workflow/utils/trigger'
|
||||
|
||||
type Params = {
|
||||
id: string
|
||||
payload: PluginTriggerNodeType
|
||||
}
|
||||
|
||||
const useGetDataForCheckMore = ({
|
||||
payload,
|
||||
}: Params) => {
|
||||
const { data: triggerPlugins } = useAllTriggerPlugins()
|
||||
const language = useGetLanguage()
|
||||
|
||||
const getData = useCallback(() => {
|
||||
return getTriggerCheckParams(payload, triggerPlugins, language)
|
||||
}, [payload, triggerPlugins, language])
|
||||
|
||||
return {
|
||||
getData,
|
||||
}
|
||||
}
|
||||
|
||||
export default useGetDataForCheckMore
|
||||
233
web/app/components/workflow/nodes/trigger-plugin/use-config.ts
Normal file
233
web/app/components/workflow/nodes/trigger-plugin/use-config.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import { produce } from 'immer'
|
||||
import type { PluginTriggerNodeType } from './types'
|
||||
import type { PluginTriggerVarInputs } from './types'
|
||||
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
|
||||
import { useNodesReadOnly } from '@/app/components/workflow/hooks'
|
||||
import {
|
||||
useAllTriggerPlugins,
|
||||
useTriggerSubscriptions,
|
||||
} from '@/service/use-triggers'
|
||||
import {
|
||||
getConfiguredValue,
|
||||
toolParametersToFormSchemas,
|
||||
} from '@/app/components/tools/utils/to-form-schema'
|
||||
import type { InputVar } from '@/app/components/workflow/types'
|
||||
import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
|
||||
import type { Event } from '@/app/components/tools/types'
|
||||
import { VarKindType } from '../_base/types'
|
||||
|
||||
const normalizeEventParameters = (
|
||||
params: PluginTriggerVarInputs | Record<string, unknown> | null | undefined,
|
||||
{ allowScalars = false }: { allowScalars?: boolean } = {},
|
||||
): PluginTriggerVarInputs => {
|
||||
if (!params || typeof params !== 'object' || Array.isArray(params))
|
||||
return {} as PluginTriggerVarInputs
|
||||
|
||||
return Object.entries(params).reduce((acc, [key, entry]) => {
|
||||
if (!entry && entry !== 0 && entry !== false)
|
||||
return acc
|
||||
|
||||
if (
|
||||
typeof entry === 'object'
|
||||
&& !Array.isArray(entry)
|
||||
&& 'type' in entry
|
||||
&& 'value' in entry
|
||||
) {
|
||||
const normalizedEntry = { ...(entry as PluginTriggerVarInputs[string]) }
|
||||
if (normalizedEntry.type === VarKindType.mixed)
|
||||
normalizedEntry.type = VarKindType.constant
|
||||
acc[key] = normalizedEntry
|
||||
return acc
|
||||
}
|
||||
|
||||
if (!allowScalars)
|
||||
return acc
|
||||
|
||||
if (typeof entry === 'string') {
|
||||
acc[key] = {
|
||||
type: VarKindType.constant,
|
||||
value: entry,
|
||||
}
|
||||
return acc
|
||||
}
|
||||
|
||||
if (typeof entry === 'number' || typeof entry === 'boolean') {
|
||||
acc[key] = {
|
||||
type: VarKindType.constant,
|
||||
value: entry,
|
||||
}
|
||||
return acc
|
||||
}
|
||||
|
||||
if (Array.isArray(entry) && entry.every(item => typeof item === 'string')) {
|
||||
acc[key] = {
|
||||
type: VarKindType.variable,
|
||||
value: entry,
|
||||
}
|
||||
}
|
||||
|
||||
return acc
|
||||
}, {} as PluginTriggerVarInputs)
|
||||
}
|
||||
|
||||
const useConfig = (id: string, payload: PluginTriggerNodeType) => {
|
||||
const { nodesReadOnly: readOnly } = useNodesReadOnly()
|
||||
const { data: triggerPlugins = [] } = useAllTriggerPlugins()
|
||||
|
||||
const { inputs, setInputs: doSetInputs } = useNodeCrud<PluginTriggerNodeType>(
|
||||
id,
|
||||
payload,
|
||||
)
|
||||
|
||||
const {
|
||||
provider_id,
|
||||
provider_name,
|
||||
event_name,
|
||||
config = {},
|
||||
event_parameters: rawEventParameters = {},
|
||||
subscription_id,
|
||||
} = inputs
|
||||
|
||||
const event_parameters = useMemo(
|
||||
() => normalizeEventParameters(rawEventParameters as PluginTriggerVarInputs),
|
||||
[rawEventParameters],
|
||||
)
|
||||
const legacy_config_parameters = useMemo(
|
||||
() => normalizeEventParameters(config as PluginTriggerVarInputs, { allowScalars: true }),
|
||||
[config],
|
||||
)
|
||||
|
||||
const currentProvider = useMemo<TriggerWithProvider | undefined>(() => {
|
||||
return triggerPlugins.find(
|
||||
provider =>
|
||||
provider.name === provider_name
|
||||
|| provider.id === provider_id
|
||||
|| (provider_id && provider.plugin_id === provider_id),
|
||||
)
|
||||
}, [triggerPlugins, provider_name, provider_id])
|
||||
|
||||
const { data: subscriptions = [] } = useTriggerSubscriptions(provider_id || '')
|
||||
|
||||
const subscriptionSelected = useMemo(() => {
|
||||
return subscriptions?.find(s => s.id === subscription_id)
|
||||
}, [subscriptions, subscription_id])
|
||||
|
||||
const currentEvent = useMemo<Event | undefined>(() => {
|
||||
return currentProvider?.events.find(
|
||||
event => event.name === event_name,
|
||||
)
|
||||
}, [currentProvider, event_name])
|
||||
|
||||
// Dynamic trigger parameters (from specific trigger.parameters)
|
||||
const triggerSpecificParameterSchema = useMemo(() => {
|
||||
if (!currentEvent) return []
|
||||
return toolParametersToFormSchemas(currentEvent.parameters)
|
||||
}, [currentEvent])
|
||||
|
||||
// Combined parameter schema (subscription + trigger specific)
|
||||
const triggerParameterSchema = useMemo(() => {
|
||||
const schemaMap = new Map()
|
||||
|
||||
triggerSpecificParameterSchema.forEach((schema) => {
|
||||
schemaMap.set(schema.variable || schema.name, schema)
|
||||
})
|
||||
|
||||
return Array.from(schemaMap.values())
|
||||
}, [triggerSpecificParameterSchema])
|
||||
|
||||
const triggerParameterValue = useMemo(() => {
|
||||
if (!triggerParameterSchema.length)
|
||||
return {} as PluginTriggerVarInputs
|
||||
|
||||
const hasStoredParameters = event_parameters && Object.keys(event_parameters).length > 0
|
||||
const baseValue = hasStoredParameters ? event_parameters : legacy_config_parameters
|
||||
|
||||
const configuredValue = getConfiguredValue(baseValue, triggerParameterSchema) as PluginTriggerVarInputs
|
||||
return normalizeEventParameters(configuredValue)
|
||||
}, [triggerParameterSchema, event_parameters, legacy_config_parameters])
|
||||
|
||||
useEffect(() => {
|
||||
if (!triggerParameterSchema.length)
|
||||
return
|
||||
|
||||
if (event_parameters && Object.keys(event_parameters).length > 0)
|
||||
return
|
||||
|
||||
if (!triggerParameterValue || Object.keys(triggerParameterValue).length === 0)
|
||||
return
|
||||
|
||||
const newInputs = produce(inputs, (draft) => {
|
||||
draft.event_parameters = triggerParameterValue
|
||||
draft.config = triggerParameterValue
|
||||
})
|
||||
doSetInputs(newInputs)
|
||||
}, [
|
||||
doSetInputs,
|
||||
event_parameters,
|
||||
inputs,
|
||||
triggerParameterSchema,
|
||||
triggerParameterValue,
|
||||
])
|
||||
|
||||
const setTriggerParameterValue = useCallback(
|
||||
(value: PluginTriggerVarInputs) => {
|
||||
const sanitizedValue = normalizeEventParameters(value)
|
||||
const newInputs = produce(inputs, (draft) => {
|
||||
draft.event_parameters = sanitizedValue
|
||||
draft.config = sanitizedValue
|
||||
})
|
||||
doSetInputs(newInputs)
|
||||
},
|
||||
[inputs, doSetInputs],
|
||||
)
|
||||
|
||||
const setInputVar = useCallback(
|
||||
(variable: InputVar, varDetail: InputVar) => {
|
||||
const newInputs = produce(inputs, (draft) => {
|
||||
const nextEventParameters = normalizeEventParameters({
|
||||
...draft.event_parameters,
|
||||
[variable.variable]: {
|
||||
type: VarKindType.variable,
|
||||
value: varDetail.variable,
|
||||
},
|
||||
} as PluginTriggerVarInputs)
|
||||
|
||||
draft.event_parameters = nextEventParameters
|
||||
draft.config = nextEventParameters
|
||||
})
|
||||
doSetInputs(newInputs)
|
||||
},
|
||||
[inputs, doSetInputs],
|
||||
)
|
||||
|
||||
// Get output schema
|
||||
const outputSchema = useMemo(() => {
|
||||
return currentEvent?.output_schema || {}
|
||||
}, [currentEvent])
|
||||
|
||||
// Check if trigger has complex output structure
|
||||
const hasObjectOutput = useMemo(() => {
|
||||
const properties = outputSchema.properties || {}
|
||||
return Object.values(properties).some(
|
||||
(prop: any) => prop.type === 'object',
|
||||
)
|
||||
}, [outputSchema])
|
||||
|
||||
return {
|
||||
readOnly,
|
||||
inputs,
|
||||
currentProvider,
|
||||
currentEvent,
|
||||
triggerParameterSchema,
|
||||
triggerParameterValue,
|
||||
setTriggerParameterValue,
|
||||
setInputVar,
|
||||
outputSchema,
|
||||
hasObjectOutput,
|
||||
subscriptions,
|
||||
subscriptionSelected,
|
||||
}
|
||||
}
|
||||
|
||||
export default useConfig
|
||||
@@ -0,0 +1,308 @@
|
||||
import { deepSanitizeFormValues, findMissingRequiredField, sanitizeFormValues } from '../form-helpers'
|
||||
|
||||
describe('Form Helpers', () => {
|
||||
describe('sanitizeFormValues', () => {
|
||||
it('should convert null values to empty strings', () => {
|
||||
const input = { field1: null, field2: 'value', field3: undefined }
|
||||
const result = sanitizeFormValues(input)
|
||||
|
||||
expect(result).toEqual({
|
||||
field1: '',
|
||||
field2: 'value',
|
||||
field3: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('should convert undefined values to empty strings', () => {
|
||||
const input = { field1: undefined, field2: 'test' }
|
||||
const result = sanitizeFormValues(input)
|
||||
|
||||
expect(result).toEqual({
|
||||
field1: '',
|
||||
field2: 'test',
|
||||
})
|
||||
})
|
||||
|
||||
it('should convert non-string values to strings', () => {
|
||||
const input = { number: 123, boolean: true, string: 'test' }
|
||||
const result = sanitizeFormValues(input)
|
||||
|
||||
expect(result).toEqual({
|
||||
number: '123',
|
||||
boolean: 'true',
|
||||
string: 'test',
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle empty objects', () => {
|
||||
const result = sanitizeFormValues({})
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('should handle objects with mixed value types', () => {
|
||||
const input = {
|
||||
null_field: null,
|
||||
undefined_field: undefined,
|
||||
zero: 0,
|
||||
false_field: false,
|
||||
empty_string: '',
|
||||
valid_string: 'test',
|
||||
}
|
||||
const result = sanitizeFormValues(input)
|
||||
|
||||
expect(result).toEqual({
|
||||
null_field: '',
|
||||
undefined_field: '',
|
||||
zero: '0',
|
||||
false_field: 'false',
|
||||
empty_string: '',
|
||||
valid_string: 'test',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('deepSanitizeFormValues', () => {
|
||||
it('should handle nested objects', () => {
|
||||
const input = {
|
||||
level1: {
|
||||
field1: null,
|
||||
field2: 'value',
|
||||
level2: {
|
||||
field3: undefined,
|
||||
field4: 'nested',
|
||||
},
|
||||
},
|
||||
simple: 'test',
|
||||
}
|
||||
const result = deepSanitizeFormValues(input)
|
||||
|
||||
expect(result).toEqual({
|
||||
level1: {
|
||||
field1: '',
|
||||
field2: 'value',
|
||||
level2: {
|
||||
field3: '',
|
||||
field4: 'nested',
|
||||
},
|
||||
},
|
||||
simple: 'test',
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle arrays correctly', () => {
|
||||
const input = {
|
||||
array: [1, 2, 3],
|
||||
nested: {
|
||||
array: ['a', null, 'c'],
|
||||
},
|
||||
}
|
||||
const result = deepSanitizeFormValues(input)
|
||||
|
||||
expect(result).toEqual({
|
||||
array: [1, 2, 3],
|
||||
nested: {
|
||||
array: ['a', null, 'c'],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle null and undefined at root level', () => {
|
||||
const input = {
|
||||
null_field: null,
|
||||
undefined_field: undefined,
|
||||
nested: {
|
||||
null_nested: null,
|
||||
},
|
||||
}
|
||||
const result = deepSanitizeFormValues(input)
|
||||
|
||||
expect(result).toEqual({
|
||||
null_field: '',
|
||||
undefined_field: '',
|
||||
nested: {
|
||||
null_nested: '',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle deeply nested structures', () => {
|
||||
const input = {
|
||||
level1: {
|
||||
level2: {
|
||||
level3: {
|
||||
field: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
const result = deepSanitizeFormValues(input)
|
||||
|
||||
expect(result).toEqual({
|
||||
level1: {
|
||||
level2: {
|
||||
level3: {
|
||||
field: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should preserve non-null values in nested structures', () => {
|
||||
const input = {
|
||||
config: {
|
||||
client_id: 'valid_id',
|
||||
client_secret: null,
|
||||
options: {
|
||||
timeout: 5000,
|
||||
enabled: true,
|
||||
message: undefined,
|
||||
},
|
||||
},
|
||||
}
|
||||
const result = deepSanitizeFormValues(input)
|
||||
|
||||
expect(result).toEqual({
|
||||
config: {
|
||||
client_id: 'valid_id',
|
||||
client_secret: '',
|
||||
options: {
|
||||
timeout: 5000,
|
||||
enabled: true,
|
||||
message: '',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('findMissingRequiredField', () => {
|
||||
const requiredFields = [
|
||||
{ name: 'client_id', label: 'Client ID' },
|
||||
{ name: 'client_secret', label: 'Client Secret' },
|
||||
{ name: 'scope', label: 'Scope' },
|
||||
]
|
||||
|
||||
it('should return null when all required fields are present', () => {
|
||||
const formData = {
|
||||
client_id: 'test_id',
|
||||
client_secret: 'test_secret',
|
||||
scope: 'read',
|
||||
optional_field: 'optional',
|
||||
}
|
||||
|
||||
const result = findMissingRequiredField(formData, requiredFields)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should return the first missing field', () => {
|
||||
const formData = {
|
||||
client_id: 'test_id',
|
||||
scope: 'read',
|
||||
}
|
||||
|
||||
const result = findMissingRequiredField(formData, requiredFields)
|
||||
expect(result).toEqual({ name: 'client_secret', label: 'Client Secret' })
|
||||
})
|
||||
|
||||
it('should treat empty strings as missing fields', () => {
|
||||
const formData = {
|
||||
client_id: '',
|
||||
client_secret: 'test_secret',
|
||||
scope: 'read',
|
||||
}
|
||||
|
||||
const result = findMissingRequiredField(formData, requiredFields)
|
||||
expect(result).toEqual({ name: 'client_id', label: 'Client ID' })
|
||||
})
|
||||
|
||||
it('should treat null values as missing fields', () => {
|
||||
const formData = {
|
||||
client_id: 'test_id',
|
||||
client_secret: null,
|
||||
scope: 'read',
|
||||
}
|
||||
|
||||
const result = findMissingRequiredField(formData, requiredFields)
|
||||
expect(result).toEqual({ name: 'client_secret', label: 'Client Secret' })
|
||||
})
|
||||
|
||||
it('should treat undefined values as missing fields', () => {
|
||||
const formData = {
|
||||
client_id: 'test_id',
|
||||
client_secret: 'test_secret',
|
||||
scope: undefined,
|
||||
}
|
||||
|
||||
const result = findMissingRequiredField(formData, requiredFields)
|
||||
expect(result).toEqual({ name: 'scope', label: 'Scope' })
|
||||
})
|
||||
|
||||
it('should handle empty required fields array', () => {
|
||||
const formData = {
|
||||
client_id: 'test_id',
|
||||
}
|
||||
|
||||
const result = findMissingRequiredField(formData, [])
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle empty form data', () => {
|
||||
const result = findMissingRequiredField({}, requiredFields)
|
||||
expect(result).toEqual({ name: 'client_id', label: 'Client ID' })
|
||||
})
|
||||
|
||||
it('should handle multilingual labels', () => {
|
||||
const multilingualFields = [
|
||||
{ name: 'field1', label: { en_US: 'Field 1 EN', zh_Hans: 'Field 1 CN' } },
|
||||
]
|
||||
const formData = {}
|
||||
|
||||
const result = findMissingRequiredField(formData, multilingualFields)
|
||||
expect(result).toEqual({
|
||||
name: 'field1',
|
||||
label: { en_US: 'Field 1 EN', zh_Hans: 'Field 1 CN' },
|
||||
})
|
||||
})
|
||||
|
||||
it('should return null for form data with extra fields', () => {
|
||||
const formData = {
|
||||
client_id: 'test_id',
|
||||
client_secret: 'test_secret',
|
||||
scope: 'read',
|
||||
extra_field1: 'extra1',
|
||||
extra_field2: 'extra2',
|
||||
}
|
||||
|
||||
const result = findMissingRequiredField(formData, requiredFields)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle objects with non-string keys', () => {
|
||||
const input = { [Symbol('test')]: 'value', regular: 'field' } as any
|
||||
const result = sanitizeFormValues(input)
|
||||
|
||||
expect(result.regular).toBe('field')
|
||||
})
|
||||
|
||||
it('should handle objects with getter properties', () => {
|
||||
const obj = {}
|
||||
Object.defineProperty(obj, 'getter', {
|
||||
get: () => 'computed_value',
|
||||
enumerable: true,
|
||||
})
|
||||
|
||||
const result = sanitizeFormValues(obj)
|
||||
expect(result.getter).toBe('computed_value')
|
||||
})
|
||||
|
||||
it('should handle circular references in deepSanitizeFormValues gracefully', () => {
|
||||
const obj: any = { field: 'value' }
|
||||
obj.circular = obj
|
||||
|
||||
expect(() => deepSanitizeFormValues(obj)).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Utility functions for form data handling in trigger plugin components
|
||||
*/
|
||||
|
||||
/**
|
||||
* Sanitizes form values by converting null/undefined to empty strings
|
||||
* This ensures React form inputs don't receive null values which can cause warnings
|
||||
*/
|
||||
export const sanitizeFormValues = (values: Record<string, any>): Record<string, string> => {
|
||||
return Object.fromEntries(
|
||||
Object.entries(values).map(([key, value]) => [
|
||||
key,
|
||||
value === null || value === undefined ? '' : String(value),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep sanitizes form values while preserving nested objects structure
|
||||
* Useful for complex form schemas with nested properties
|
||||
*/
|
||||
export const deepSanitizeFormValues = (values: Record<string, any>, visited = new WeakSet()): Record<string, any> => {
|
||||
if (visited.has(values))
|
||||
return {}
|
||||
|
||||
visited.add(values)
|
||||
|
||||
const result: Record<string, any> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value === null || value === undefined)
|
||||
result[key] = ''
|
||||
else if (typeof value === 'object' && !Array.isArray(value))
|
||||
result[key] = deepSanitizeFormValues(value, visited)
|
||||
else
|
||||
result[key] = value
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates required fields in form data
|
||||
* Returns the first missing required field or null if all are present
|
||||
*/
|
||||
export const findMissingRequiredField = (
|
||||
formData: Record<string, any>,
|
||||
requiredFields: Array<{ name: string; label: any }>,
|
||||
): { name: string; label: any } | null => {
|
||||
for (const field of requiredFields) {
|
||||
if (!formData[field.name] || formData[field.name] === '')
|
||||
return field
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SimpleSelect } from '@/app/components/base/select'
|
||||
import type { ScheduleFrequency } from '../types'
|
||||
|
||||
type FrequencySelectorProps = {
|
||||
frequency: ScheduleFrequency
|
||||
onChange: (frequency: ScheduleFrequency) => void
|
||||
}
|
||||
|
||||
const FrequencySelector = ({ frequency, onChange }: FrequencySelectorProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const frequencies = useMemo(() => [
|
||||
{ value: 'frequency-header', name: t('workflow.nodes.triggerSchedule.frequency.label'), isGroup: true },
|
||||
{ value: 'hourly', name: t('workflow.nodes.triggerSchedule.frequency.hourly') },
|
||||
{ value: 'daily', name: t('workflow.nodes.triggerSchedule.frequency.daily') },
|
||||
{ value: 'weekly', name: t('workflow.nodes.triggerSchedule.frequency.weekly') },
|
||||
{ value: 'monthly', name: t('workflow.nodes.triggerSchedule.frequency.monthly') },
|
||||
], [t])
|
||||
|
||||
return (
|
||||
<SimpleSelect
|
||||
key={`${frequency}-${frequencies[0]?.name}`} // Include translation in key to force re-render
|
||||
items={frequencies}
|
||||
defaultValue={frequency}
|
||||
onSelect={item => onChange(item.value as ScheduleFrequency)}
|
||||
placeholder={t('workflow.nodes.triggerSchedule.selectFrequency')}
|
||||
className="w-full py-2"
|
||||
wrapperClassName="h-auto"
|
||||
optionWrapClassName="min-w-40"
|
||||
notClearable={true}
|
||||
allowSearch={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default FrequencySelector
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiCalendarLine, RiCodeLine } from '@remixicon/react'
|
||||
import { SegmentedControl } from '@/app/components/base/segmented-control'
|
||||
import type { ScheduleMode } from '../types'
|
||||
|
||||
type ModeSwitcherProps = {
|
||||
mode: ScheduleMode
|
||||
onChange: (mode: ScheduleMode) => void
|
||||
}
|
||||
|
||||
const ModeSwitcher = ({ mode, onChange }: ModeSwitcherProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const options = [
|
||||
{
|
||||
Icon: RiCalendarLine,
|
||||
text: t('workflow.nodes.triggerSchedule.mode.visual'),
|
||||
value: 'visual' as const,
|
||||
},
|
||||
{
|
||||
Icon: RiCodeLine,
|
||||
text: t('workflow.nodes.triggerSchedule.mode.cron'),
|
||||
value: 'cron' as const,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<SegmentedControl
|
||||
options={options}
|
||||
value={mode}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModeSwitcher
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Asterisk, CalendarCheckLine } from '@/app/components/base/icons/src/vender/workflow'
|
||||
import type { ScheduleMode } from '../types'
|
||||
|
||||
type ModeToggleProps = {
|
||||
mode: ScheduleMode
|
||||
onChange: (mode: ScheduleMode) => void
|
||||
}
|
||||
|
||||
const ModeToggle = ({ mode, onChange }: ModeToggleProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleToggle = () => {
|
||||
const newMode = mode === 'visual' ? 'cron' : 'visual'
|
||||
onChange(newMode)
|
||||
}
|
||||
|
||||
const currentText = mode === 'visual'
|
||||
? t('workflow.nodes.triggerSchedule.useCronExpression')
|
||||
: t('workflow.nodes.triggerSchedule.useVisualPicker')
|
||||
|
||||
const currentIcon = mode === 'visual' ? Asterisk : CalendarCheckLine
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
className="flex cursor-pointer items-center gap-1 rounded-lg px-2 py-1 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
>
|
||||
{React.createElement(currentIcon, { className: 'w-4 h-4' })}
|
||||
<span>{currentText}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModeToggle
|
||||
@@ -0,0 +1,90 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiQuestionLine } from '@remixicon/react'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
|
||||
type MonthlyDaysSelectorProps = {
|
||||
selectedDays: (number | 'last')[]
|
||||
onChange: (days: (number | 'last')[]) => void
|
||||
}
|
||||
|
||||
const MonthlyDaysSelector = ({ selectedDays, onChange }: MonthlyDaysSelectorProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleDayClick = (day: number | 'last') => {
|
||||
const current = selectedDays || []
|
||||
const newSelected = current.includes(day)
|
||||
? current.filter(d => d !== day)
|
||||
: [...current, day]
|
||||
// Ensure at least one day is selected (consistent with WeekdaySelector)
|
||||
onChange(newSelected.length > 0 ? newSelected : [day])
|
||||
}
|
||||
|
||||
const isDaySelected = (day: number | 'last') => selectedDays?.includes(day) || false
|
||||
|
||||
const days = Array.from({ length: 31 }, (_, i) => i + 1)
|
||||
const rows = [
|
||||
days.slice(0, 7),
|
||||
days.slice(7, 14),
|
||||
days.slice(14, 21),
|
||||
days.slice(21, 28),
|
||||
[29, 30, 31, 'last' as const],
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="mb-2 block text-xs font-medium text-text-tertiary">
|
||||
{t('workflow.nodes.triggerSchedule.days')}
|
||||
</label>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{rows.map((row, rowIndex) => (
|
||||
<div key={rowIndex} className="grid grid-cols-7 gap-1.5">
|
||||
{row.map(day => (
|
||||
<button
|
||||
key={day}
|
||||
type="button"
|
||||
onClick={() => handleDayClick(day)}
|
||||
className={`rounded-lg border bg-components-option-card-option-bg py-1 text-xs transition-colors ${
|
||||
day === 'last' ? 'col-span-2 min-w-0' : ''
|
||||
} ${
|
||||
isDaySelected(day)
|
||||
? 'border-util-colors-blue-brand-blue-brand-600 text-text-secondary'
|
||||
: 'border-divider-subtle text-text-tertiary hover:border-divider-regular hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{day === 'last' ? (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span>{t('workflow.nodes.triggerSchedule.lastDay')}</span>
|
||||
<Tooltip
|
||||
popupContent={t('workflow.nodes.triggerSchedule.lastDayTooltip')}
|
||||
>
|
||||
<RiQuestionLine className="h-3 w-3 text-text-quaternary" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
day
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{/* Fill empty cells in the last row (Last day takes 2 cols, so need 1 less) */}
|
||||
{rowIndex === rows.length - 1 && Array.from({ length: 7 - row.length - 1 }, (_, i) => (
|
||||
<div key={`empty-${i}`} className="invisible"></div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Warning message for day 31 - aligned with grid */}
|
||||
{selectedDays?.includes(31) && (
|
||||
<div className="mt-1.5 grid grid-cols-7 gap-1.5">
|
||||
<div className="col-span-7 text-xs text-gray-500">
|
||||
{t('workflow.nodes.triggerSchedule.lastDayTooltip')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MonthlyDaysSelector
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ScheduleTriggerNodeType } from '../types'
|
||||
import { getFormattedExecutionTimes } from '../utils/execution-time-calculator'
|
||||
|
||||
type NextExecutionTimesProps = {
|
||||
data: ScheduleTriggerNodeType
|
||||
}
|
||||
|
||||
const NextExecutionTimes = ({ data }: NextExecutionTimesProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!data.frequency)
|
||||
return null
|
||||
|
||||
const executionTimes = getFormattedExecutionTimes(data, 5)
|
||||
|
||||
if (executionTimes.length === 0)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-medium text-gray-500">
|
||||
{t('workflow.nodes.triggerSchedule.nextExecutionTimes')}
|
||||
</label>
|
||||
<div className="flex min-h-[80px] flex-col rounded-xl bg-components-input-bg-normal py-2">
|
||||
{executionTimes.map((time, index) => (
|
||||
<div key={index} className="flex items-baseline text-xs">
|
||||
<span className="w-6 select-none text-right font-mono font-normal leading-[150%] tracking-wider text-text-quaternary">
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<span className="pl-2 pr-3 font-mono font-normal leading-[150%] tracking-wider text-text-secondary">
|
||||
{time}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NextExecutionTimes
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Slider from '@/app/components/base/slider'
|
||||
|
||||
type OnMinuteSelectorProps = {
|
||||
value?: number
|
||||
onChange: (value: number) => void
|
||||
}
|
||||
|
||||
const OnMinuteSelector = ({ value = 0, onChange }: OnMinuteSelectorProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-gray-500">
|
||||
{t('workflow.nodes.triggerSchedule.onMinute')}
|
||||
</label>
|
||||
<div className="relative flex h-8 items-center rounded-lg bg-components-input-bg-normal">
|
||||
<div className="flex h-full w-12 shrink-0 items-center justify-center text-[13px] text-components-input-text-filled">
|
||||
{value}
|
||||
</div>
|
||||
<div className="absolute left-12 top-0 h-full w-px bg-components-panel-bg"></div>
|
||||
<div className="flex h-full grow items-center pl-4 pr-3">
|
||||
<Slider
|
||||
className="w-full"
|
||||
value={value}
|
||||
min={0}
|
||||
max={59}
|
||||
step={1}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OnMinuteSelector
|
||||
@@ -0,0 +1,57 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type WeekdaySelectorProps = {
|
||||
selectedDays: string[]
|
||||
onChange: (days: string[]) => void
|
||||
}
|
||||
|
||||
const WeekdaySelector = ({ selectedDays, onChange }: WeekdaySelectorProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const weekdays = [
|
||||
{ key: 'sun', label: 'Sun' },
|
||||
{ key: 'mon', label: 'Mon' },
|
||||
{ key: 'tue', label: 'Tue' },
|
||||
{ key: 'wed', label: 'Wed' },
|
||||
{ key: 'thu', label: 'Thu' },
|
||||
{ key: 'fri', label: 'Fri' },
|
||||
{ key: 'sat', label: 'Sat' },
|
||||
]
|
||||
|
||||
const handleDaySelect = (dayKey: string) => {
|
||||
const current = selectedDays || []
|
||||
const newSelected = current.includes(dayKey)
|
||||
? current.filter(d => d !== dayKey)
|
||||
: [...current, dayKey]
|
||||
onChange(newSelected.length > 0 ? newSelected : [dayKey])
|
||||
}
|
||||
|
||||
const isDaySelected = (dayKey: string) => selectedDays.includes(dayKey)
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="mb-2 block text-xs font-medium text-text-tertiary">
|
||||
{t('workflow.nodes.triggerSchedule.weekdays')}
|
||||
</label>
|
||||
<div className="flex gap-1.5">
|
||||
{weekdays.map(day => (
|
||||
<button
|
||||
key={day.key}
|
||||
type="button"
|
||||
className={`flex-1 rounded-lg border bg-components-option-card-option-bg py-1 text-xs transition-colors ${
|
||||
isDaySelected(day.key)
|
||||
? 'border-util-colors-blue-brand-blue-brand-600 text-text-secondary'
|
||||
: 'border-divider-subtle text-text-tertiary hover:border-divider-regular hover:text-text-secondary'
|
||||
}`}
|
||||
onClick={() => handleDaySelect(day.key)}
|
||||
>
|
||||
{day.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WeekdaySelector
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ScheduleTriggerNodeType } from './types'
|
||||
|
||||
export const getDefaultScheduleConfig = (): Partial<ScheduleTriggerNodeType> => ({
|
||||
mode: 'visual',
|
||||
frequency: 'daily',
|
||||
visual_config: {
|
||||
time: '12:00 AM',
|
||||
weekdays: ['sun'],
|
||||
on_minute: 0,
|
||||
monthly_days: [1],
|
||||
},
|
||||
})
|
||||
|
||||
export const getDefaultVisualConfig = () => ({
|
||||
time: '12:00 AM',
|
||||
weekdays: ['sun'],
|
||||
on_minute: 0,
|
||||
monthly_days: [1],
|
||||
})
|
||||
167
web/app/components/workflow/nodes/trigger-schedule/default.ts
Normal file
167
web/app/components/workflow/nodes/trigger-schedule/default.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { BlockEnum } from '../../types'
|
||||
import type { NodeDefault } from '../../types'
|
||||
import type { ScheduleTriggerNodeType } from './types'
|
||||
import { isValidCronExpression } from './utils/cron-parser'
|
||||
import { getNextExecutionTimes } from './utils/execution-time-calculator'
|
||||
import { getDefaultScheduleConfig } from './constants'
|
||||
import { genNodeMetaData } from '../../utils'
|
||||
|
||||
const isValidTimeFormat = (time: string): boolean => {
|
||||
const timeRegex = /^(0?\d|1[0-2]):[0-5]\d (AM|PM)$/
|
||||
if (!timeRegex.test(time)) return false
|
||||
|
||||
const [timePart, period] = time.split(' ')
|
||||
const [hour, minute] = timePart.split(':')
|
||||
const hourNum = Number.parseInt(hour, 10)
|
||||
const minuteNum = Number.parseInt(minute, 10)
|
||||
|
||||
return hourNum >= 1 && hourNum <= 12
|
||||
&& minuteNum >= 0 && minuteNum <= 59
|
||||
&& ['AM', 'PM'].includes(period)
|
||||
}
|
||||
|
||||
const validateHourlyConfig = (config: any, t: any): string => {
|
||||
if (config.on_minute === undefined || config.on_minute < 0 || config.on_minute > 59)
|
||||
return t('workflow.nodes.triggerSchedule.invalidOnMinute')
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const validateDailyConfig = (config: any, t: any): string => {
|
||||
const i18nPrefix = 'workflow.errorMsg'
|
||||
|
||||
if (!config.time)
|
||||
return t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.time') })
|
||||
|
||||
if (!isValidTimeFormat(config.time))
|
||||
return t('workflow.nodes.triggerSchedule.invalidTimeFormat')
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const validateWeeklyConfig = (config: any, t: any): string => {
|
||||
const dailyError = validateDailyConfig(config, t)
|
||||
if (dailyError) return dailyError
|
||||
|
||||
const i18nPrefix = 'workflow.errorMsg'
|
||||
|
||||
if (!config.weekdays || config.weekdays.length === 0)
|
||||
return t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.weekdays') })
|
||||
|
||||
const validWeekdays = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']
|
||||
for (const day of config.weekdays) {
|
||||
if (!validWeekdays.includes(day))
|
||||
return t('workflow.nodes.triggerSchedule.invalidWeekday', { weekday: day })
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const validateMonthlyConfig = (config: any, t: any): string => {
|
||||
const dailyError = validateDailyConfig(config, t)
|
||||
if (dailyError) return dailyError
|
||||
|
||||
const i18nPrefix = 'workflow.errorMsg'
|
||||
|
||||
const getMonthlyDays = (): (number | 'last')[] => {
|
||||
if (Array.isArray(config.monthly_days) && config.monthly_days.length > 0)
|
||||
return config.monthly_days
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
const monthlyDays = getMonthlyDays()
|
||||
|
||||
if (monthlyDays.length === 0)
|
||||
return t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.monthlyDay') })
|
||||
|
||||
for (const day of monthlyDays) {
|
||||
if (day !== 'last' && (typeof day !== 'number' || day < 1 || day > 31))
|
||||
return t('workflow.nodes.triggerSchedule.invalidMonthlyDay')
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const validateVisualConfig = (payload: ScheduleTriggerNodeType, t: any): string => {
|
||||
const i18nPrefix = 'workflow.errorMsg'
|
||||
const { visual_config } = payload
|
||||
|
||||
if (!visual_config)
|
||||
return t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.visualConfig') })
|
||||
|
||||
switch (payload.frequency) {
|
||||
case 'hourly':
|
||||
return validateHourlyConfig(visual_config, t)
|
||||
case 'daily':
|
||||
return validateDailyConfig(visual_config, t)
|
||||
case 'weekly':
|
||||
return validateWeeklyConfig(visual_config, t)
|
||||
case 'monthly':
|
||||
return validateMonthlyConfig(visual_config, t)
|
||||
default:
|
||||
return t('workflow.nodes.triggerSchedule.invalidFrequency')
|
||||
}
|
||||
}
|
||||
|
||||
const metaData = genNodeMetaData({
|
||||
sort: 2,
|
||||
type: BlockEnum.TriggerSchedule,
|
||||
helpLinkUri: 'schedule-trigger',
|
||||
isStart: true,
|
||||
})
|
||||
|
||||
const nodeDefault: NodeDefault<ScheduleTriggerNodeType> = {
|
||||
metaData,
|
||||
defaultValue: {
|
||||
...getDefaultScheduleConfig(),
|
||||
cron_expression: '',
|
||||
} as ScheduleTriggerNodeType,
|
||||
checkValid(payload: ScheduleTriggerNodeType, t: any) {
|
||||
const i18nPrefix = 'workflow.errorMsg'
|
||||
let errorMessages = ''
|
||||
if (!errorMessages && !payload.mode)
|
||||
errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.mode') })
|
||||
|
||||
// Validate timezone format if provided (timezone will be auto-filled by use-config.ts if undefined)
|
||||
if (!errorMessages && payload.timezone) {
|
||||
try {
|
||||
Intl.DateTimeFormat(undefined, { timeZone: payload.timezone })
|
||||
}
|
||||
catch {
|
||||
errorMessages = t('workflow.nodes.triggerSchedule.invalidTimezone')
|
||||
}
|
||||
}
|
||||
if (!errorMessages) {
|
||||
if (payload.mode === 'cron') {
|
||||
if (!payload.cron_expression || payload.cron_expression.trim() === '')
|
||||
errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.cronExpression') })
|
||||
else if (!isValidCronExpression(payload.cron_expression))
|
||||
errorMessages = t('workflow.nodes.triggerSchedule.invalidCronExpression')
|
||||
}
|
||||
else if (payload.mode === 'visual') {
|
||||
if (!payload.frequency)
|
||||
errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.frequency') })
|
||||
else
|
||||
errorMessages = validateVisualConfig(payload, t)
|
||||
}
|
||||
}
|
||||
if (!errorMessages) {
|
||||
try {
|
||||
const nextTimes = getNextExecutionTimes(payload, 1)
|
||||
if (nextTimes.length === 0)
|
||||
errorMessages = t('workflow.nodes.triggerSchedule.noValidExecutionTime')
|
||||
}
|
||||
catch {
|
||||
errorMessages = t('workflow.nodes.triggerSchedule.executionTimeCalculationError')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: !errorMessages,
|
||||
errorMessage: errorMessages,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export default nodeDefault
|
||||
31
web/app/components/workflow/nodes/trigger-schedule/node.tsx
Normal file
31
web/app/components/workflow/nodes/trigger-schedule/node.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ScheduleTriggerNodeType } from './types'
|
||||
import type { NodeProps } from '@/app/components/workflow/types'
|
||||
import { getNextExecutionTime } from './utils/execution-time-calculator'
|
||||
|
||||
const i18nPrefix = 'workflow.nodes.triggerSchedule'
|
||||
|
||||
const Node: FC<NodeProps<ScheduleTriggerNodeType>> = ({
|
||||
data,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="mb-1 px-3 py-1">
|
||||
<div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-text-tertiary">
|
||||
{t(`${i18nPrefix}.nextExecutionTime`)}
|
||||
</div>
|
||||
<div className="flex h-[26px] items-center rounded-md bg-workflow-block-parma-bg px-2 text-xs text-text-secondary">
|
||||
<div className="w-0 grow">
|
||||
<div className="truncate" title={getNextExecutionTime(data)}>
|
||||
{getNextExecutionTime(data)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Node)
|
||||
146
web/app/components/workflow/nodes/trigger-schedule/panel.tsx
Normal file
146
web/app/components/workflow/nodes/trigger-schedule/panel.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ScheduleTriggerNodeType } from './types'
|
||||
import Field from '@/app/components/workflow/nodes/_base/components/field'
|
||||
import type { NodePanelProps } from '@/app/components/workflow/types'
|
||||
import ModeToggle from './components/mode-toggle'
|
||||
import FrequencySelector from './components/frequency-selector'
|
||||
import WeekdaySelector from './components/weekday-selector'
|
||||
import TimePicker from '@/app/components/base/date-and-time-picker/time-picker'
|
||||
import NextExecutionTimes from './components/next-execution-times'
|
||||
import MonthlyDaysSelector from './components/monthly-days-selector'
|
||||
import OnMinuteSelector from './components/on-minute-selector'
|
||||
import Input from '@/app/components/base/input'
|
||||
import useConfig from './use-config'
|
||||
|
||||
const i18nPrefix = 'workflow.nodes.triggerSchedule'
|
||||
|
||||
const Panel: FC<NodePanelProps<ScheduleTriggerNodeType>> = ({
|
||||
id,
|
||||
data,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
inputs,
|
||||
setInputs,
|
||||
handleModeChange,
|
||||
handleFrequencyChange,
|
||||
handleCronExpressionChange,
|
||||
handleWeekdaysChange,
|
||||
handleTimeChange,
|
||||
handleOnMinuteChange,
|
||||
} = useConfig(id, data)
|
||||
|
||||
return (
|
||||
<div className='mt-2'>
|
||||
<div className='space-y-4 px-4 pb-3 pt-2'>
|
||||
<Field
|
||||
title={t(`${i18nPrefix}.title`)}
|
||||
operations={
|
||||
<ModeToggle
|
||||
mode={inputs.mode}
|
||||
onChange={handleModeChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
|
||||
{inputs.mode === 'visual' && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-gray-500">
|
||||
{t('workflow.nodes.triggerSchedule.frequencyLabel')}
|
||||
</label>
|
||||
<FrequencySelector
|
||||
frequency={inputs.frequency || 'daily'}
|
||||
onChange={handleFrequencyChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
{inputs.frequency === 'hourly' ? (
|
||||
<OnMinuteSelector
|
||||
value={inputs.visual_config?.on_minute}
|
||||
onChange={handleOnMinuteChange}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<label className="mb-2 block text-xs font-medium text-gray-500">
|
||||
{t('workflow.nodes.triggerSchedule.time')}
|
||||
</label>
|
||||
<TimePicker
|
||||
notClearable={true}
|
||||
timezone={inputs.timezone}
|
||||
value={inputs.visual_config?.time || '12:00 AM'}
|
||||
triggerFullWidth={true}
|
||||
onChange={(time) => {
|
||||
if (time) {
|
||||
const timeString = time.format('h:mm A')
|
||||
handleTimeChange(timeString)
|
||||
}
|
||||
}}
|
||||
onClear={() => {
|
||||
handleTimeChange('12:00 AM')
|
||||
}}
|
||||
placeholder={t('workflow.nodes.triggerSchedule.selectTime')}
|
||||
showTimezone={true}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{inputs.frequency === 'weekly' && (
|
||||
<WeekdaySelector
|
||||
selectedDays={inputs.visual_config?.weekdays || []}
|
||||
onChange={handleWeekdaysChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{inputs.frequency === 'monthly' && (
|
||||
<MonthlyDaysSelector
|
||||
selectedDays={inputs.visual_config?.monthly_days || [1]}
|
||||
onChange={(days) => {
|
||||
const newInputs = {
|
||||
...inputs,
|
||||
visual_config: {
|
||||
...inputs.visual_config,
|
||||
monthly_days: days,
|
||||
},
|
||||
}
|
||||
setInputs(newInputs)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inputs.mode === 'cron' && (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-gray-500">
|
||||
{t('workflow.nodes.triggerSchedule.cronExpression')}
|
||||
</label>
|
||||
<Input
|
||||
value={inputs.cron_expression || ''}
|
||||
onChange={e => handleCronExpressionChange(e.target.value)}
|
||||
placeholder="0 0 * * *"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div className="border-t border-divider-subtle"></div>
|
||||
|
||||
<NextExecutionTimes data={inputs} />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Panel)
|
||||
20
web/app/components/workflow/nodes/trigger-schedule/types.ts
Normal file
20
web/app/components/workflow/nodes/trigger-schedule/types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { CommonNodeType } from '@/app/components/workflow/types'
|
||||
|
||||
export type ScheduleMode = 'visual' | 'cron'
|
||||
|
||||
export type ScheduleFrequency = 'hourly' | 'daily' | 'weekly' | 'monthly'
|
||||
|
||||
export type VisualConfig = {
|
||||
time?: string
|
||||
weekdays?: string[]
|
||||
on_minute?: number
|
||||
monthly_days?: (number | 'last')[]
|
||||
}
|
||||
|
||||
export type ScheduleTriggerNodeType = CommonNodeType & {
|
||||
mode: ScheduleMode
|
||||
frequency?: ScheduleFrequency
|
||||
cron_expression?: string
|
||||
visual_config?: VisualConfig
|
||||
timezone?: string
|
||||
}
|
||||
110
web/app/components/workflow/nodes/trigger-schedule/use-config.ts
Normal file
110
web/app/components/workflow/nodes/trigger-schedule/use-config.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import type { ScheduleFrequency, ScheduleMode, ScheduleTriggerNodeType } from './types'
|
||||
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
|
||||
import { useNodesReadOnly } from '@/app/components/workflow/hooks'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { getDefaultVisualConfig } from './constants'
|
||||
|
||||
const useConfig = (id: string, payload: ScheduleTriggerNodeType) => {
|
||||
const { nodesReadOnly: readOnly } = useNodesReadOnly()
|
||||
|
||||
const { userProfile } = useAppContext()
|
||||
|
||||
const frontendPayload = useMemo(() => {
|
||||
return {
|
||||
...payload,
|
||||
mode: payload.mode || 'visual',
|
||||
frequency: payload.frequency || 'daily',
|
||||
timezone: payload.timezone || userProfile.timezone || 'UTC',
|
||||
visual_config: {
|
||||
...getDefaultVisualConfig(),
|
||||
...payload.visual_config,
|
||||
},
|
||||
}
|
||||
}, [payload, userProfile.timezone])
|
||||
|
||||
const { inputs, setInputs } = useNodeCrud<ScheduleTriggerNodeType>(id, frontendPayload)
|
||||
|
||||
const handleModeChange = useCallback((mode: ScheduleMode) => {
|
||||
const newInputs = {
|
||||
...inputs,
|
||||
mode,
|
||||
}
|
||||
setInputs(newInputs)
|
||||
}, [inputs, setInputs])
|
||||
|
||||
const handleFrequencyChange = useCallback((frequency: ScheduleFrequency) => {
|
||||
const newInputs = {
|
||||
...inputs,
|
||||
frequency,
|
||||
visual_config: {
|
||||
...inputs.visual_config,
|
||||
...(frequency === 'hourly') && {
|
||||
on_minute: inputs.visual_config?.on_minute ?? 0,
|
||||
},
|
||||
},
|
||||
cron_expression: undefined,
|
||||
}
|
||||
setInputs(newInputs)
|
||||
}, [inputs, setInputs])
|
||||
|
||||
const handleCronExpressionChange = useCallback((value: string) => {
|
||||
const newInputs = {
|
||||
...inputs,
|
||||
cron_expression: value,
|
||||
frequency: undefined,
|
||||
visual_config: undefined,
|
||||
}
|
||||
setInputs(newInputs)
|
||||
}, [inputs, setInputs])
|
||||
|
||||
const handleWeekdaysChange = useCallback((weekdays: string[]) => {
|
||||
const newInputs = {
|
||||
...inputs,
|
||||
visual_config: {
|
||||
...inputs.visual_config,
|
||||
weekdays,
|
||||
},
|
||||
cron_expression: undefined,
|
||||
}
|
||||
setInputs(newInputs)
|
||||
}, [inputs, setInputs])
|
||||
|
||||
const handleTimeChange = useCallback((time: string) => {
|
||||
const newInputs = {
|
||||
...inputs,
|
||||
visual_config: {
|
||||
...inputs.visual_config,
|
||||
time,
|
||||
},
|
||||
cron_expression: undefined,
|
||||
}
|
||||
setInputs(newInputs)
|
||||
}, [inputs, setInputs])
|
||||
|
||||
const handleOnMinuteChange = useCallback((on_minute: number) => {
|
||||
const newInputs = {
|
||||
...inputs,
|
||||
visual_config: {
|
||||
...inputs.visual_config,
|
||||
on_minute,
|
||||
},
|
||||
cron_expression: undefined,
|
||||
}
|
||||
setInputs(newInputs)
|
||||
}, [inputs, setInputs])
|
||||
|
||||
return {
|
||||
readOnly,
|
||||
inputs,
|
||||
setInputs,
|
||||
handleModeChange,
|
||||
handleFrequencyChange,
|
||||
handleCronExpressionChange,
|
||||
handleWeekdaysChange,
|
||||
handleTimeChange,
|
||||
handleOnMinuteChange,
|
||||
}
|
||||
}
|
||||
|
||||
export default useConfig
|
||||
@@ -0,0 +1,84 @@
|
||||
import { CronExpressionParser } from 'cron-parser'
|
||||
|
||||
// Convert a UTC date from cron-parser to user timezone representation
|
||||
// This ensures consistency with other execution time calculations
|
||||
const convertToUserTimezoneRepresentation = (utcDate: Date, timezone: string): Date => {
|
||||
// Get the time string in the target timezone
|
||||
const userTimeStr = utcDate.toLocaleString('en-CA', {
|
||||
timeZone: timezone,
|
||||
hour12: false,
|
||||
})
|
||||
const [dateStr, timeStr] = userTimeStr.split(', ')
|
||||
const [year, month, day] = dateStr.split('-').map(Number)
|
||||
const [hour, minute, second] = timeStr.split(':').map(Number)
|
||||
|
||||
// Create a new Date object representing this time as "local" time
|
||||
// This matches the behavior expected by the execution-time-calculator
|
||||
return new Date(year, month - 1, day, hour, minute, second)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a cron expression and return the next 5 execution times
|
||||
*
|
||||
* @param cronExpression - Standard 5-field cron expression (minute hour day month dayOfWeek)
|
||||
* @param timezone - IANA timezone identifier (e.g., 'UTC', 'America/New_York')
|
||||
* @returns Array of Date objects representing the next 5 execution times
|
||||
*/
|
||||
export const parseCronExpression = (cronExpression: string, timezone: string = 'UTC'): Date[] => {
|
||||
if (!cronExpression || cronExpression.trim() === '')
|
||||
return []
|
||||
|
||||
const parts = cronExpression.trim().split(/\s+/)
|
||||
|
||||
// Support both 5-field format and predefined expressions
|
||||
if (parts.length !== 5 && !cronExpression.startsWith('@'))
|
||||
return []
|
||||
|
||||
try {
|
||||
// Parse the cron expression with timezone support
|
||||
// Use the actual current time for cron-parser to handle properly
|
||||
const interval = CronExpressionParser.parse(cronExpression, {
|
||||
tz: timezone,
|
||||
})
|
||||
|
||||
// Get the next 5 execution times using the take() method
|
||||
const nextCronDates = interval.take(5)
|
||||
|
||||
// Convert CronDate objects to Date objects and ensure they represent
|
||||
// the time in user timezone (consistent with execution-time-calculator.ts)
|
||||
return nextCronDates.map((cronDate) => {
|
||||
const utcDate = cronDate.toDate()
|
||||
return convertToUserTimezoneRepresentation(utcDate, timezone)
|
||||
})
|
||||
}
|
||||
catch {
|
||||
// Return empty array if parsing fails
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a cron expression format and syntax
|
||||
*
|
||||
* @param cronExpression - Standard 5-field cron expression to validate
|
||||
* @returns boolean indicating if the cron expression is valid
|
||||
*/
|
||||
export const isValidCronExpression = (cronExpression: string): boolean => {
|
||||
if (!cronExpression || cronExpression.trim() === '')
|
||||
return false
|
||||
|
||||
const parts = cronExpression.trim().split(/\s+/)
|
||||
|
||||
// Support both 5-field format and predefined expressions
|
||||
if (parts.length !== 5 && !cronExpression.startsWith('@'))
|
||||
return false
|
||||
|
||||
try {
|
||||
// Use cron-parser to validate the expression
|
||||
CronExpressionParser.parse(cronExpression)
|
||||
return true
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import type { ScheduleTriggerNodeType } from '../types'
|
||||
import { isValidCronExpression, parseCronExpression } from './cron-parser'
|
||||
import { convertTimezoneToOffsetStr } from '@/app/components/base/date-and-time-picker/utils/dayjs'
|
||||
|
||||
const DEFAULT_TIMEZONE = 'UTC'
|
||||
|
||||
const resolveTimezone = (timezone?: string): string => {
|
||||
if (timezone)
|
||||
return timezone
|
||||
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || DEFAULT_TIMEZONE
|
||||
}
|
||||
catch {
|
||||
return DEFAULT_TIMEZONE
|
||||
}
|
||||
}
|
||||
|
||||
// Get current time completely in user timezone, no browser timezone involved
|
||||
const getUserTimezoneCurrentTime = (timezone?: string): Date => {
|
||||
const targetTimezone = resolveTimezone(timezone)
|
||||
const now = new Date()
|
||||
const userTimeStr = now.toLocaleString('en-CA', {
|
||||
timeZone: targetTimezone,
|
||||
hour12: false,
|
||||
})
|
||||
const [dateStr, timeStr] = userTimeStr.split(', ')
|
||||
const [year, month, day] = dateStr.split('-').map(Number)
|
||||
const [hour, minute, second] = timeStr.split(':').map(Number)
|
||||
return new Date(year, month - 1, day, hour, minute, second)
|
||||
}
|
||||
|
||||
// Format date that is already in user timezone, no timezone conversion
|
||||
const formatUserTimezoneDate = (date: Date, timezone: string, includeWeekday: boolean = true, includeTimezone: boolean = true): string => {
|
||||
const dateOptions: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}
|
||||
|
||||
if (includeWeekday)
|
||||
dateOptions.weekday = 'long' // Changed from 'short' to 'long' for full weekday name
|
||||
|
||||
const timeOptions: Intl.DateTimeFormatOptions = {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
}
|
||||
|
||||
const dateStr = date.toLocaleDateString('en-US', dateOptions)
|
||||
const timeStr = date.toLocaleTimeString('en-US', timeOptions)
|
||||
|
||||
if (includeTimezone) {
|
||||
const timezoneOffset = convertTimezoneToOffsetStr(timezone)
|
||||
return `${dateStr}, ${timeStr} (${timezoneOffset})`
|
||||
}
|
||||
|
||||
return `${dateStr}, ${timeStr}`
|
||||
}
|
||||
|
||||
// Helper function to get default datetime - consistent with base DatePicker
|
||||
export const getDefaultDateTime = (): Date => {
|
||||
const defaultDate = new Date(2024, 0, 2, 11, 30, 0, 0)
|
||||
return defaultDate
|
||||
}
|
||||
|
||||
export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: number = 5): Date[] => {
|
||||
const timezone = resolveTimezone(data.timezone)
|
||||
|
||||
if (data.mode === 'cron') {
|
||||
if (!data.cron_expression || !isValidCronExpression(data.cron_expression))
|
||||
return []
|
||||
return parseCronExpression(data.cron_expression, timezone).slice(0, count)
|
||||
}
|
||||
|
||||
const times: Date[] = []
|
||||
const defaultTime = data.visual_config?.time || '12:00 AM'
|
||||
|
||||
// Get "today" in user's timezone for display purposes
|
||||
const now = new Date()
|
||||
const userTodayStr = now.toLocaleDateString('en-CA', { timeZone: timezone })
|
||||
const [year, month, day] = userTodayStr.split('-').map(Number)
|
||||
const userToday = new Date(year, month - 1, day, 0, 0, 0, 0)
|
||||
|
||||
if (data.frequency === 'hourly') {
|
||||
const onMinute = data.visual_config?.on_minute ?? 0
|
||||
|
||||
// Get current time completely in user timezone
|
||||
const userCurrentTime = getUserTimezoneCurrentTime(timezone)
|
||||
|
||||
let hour = userCurrentTime.getHours()
|
||||
if (userCurrentTime.getMinutes() >= onMinute)
|
||||
hour += 1 // Start from next hour if current minute has passed
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const execution = new Date(userToday)
|
||||
execution.setHours(hour + i, onMinute, 0, 0)
|
||||
// Handle day overflow
|
||||
if (hour + i >= 24) {
|
||||
execution.setDate(userToday.getDate() + Math.floor((hour + i) / 24))
|
||||
execution.setHours((hour + i) % 24, onMinute, 0, 0)
|
||||
}
|
||||
times.push(execution)
|
||||
}
|
||||
}
|
||||
else if (data.frequency === 'daily') {
|
||||
const [time, period] = defaultTime.split(' ')
|
||||
const [hour, minute] = time.split(':')
|
||||
let displayHour = Number.parseInt(hour)
|
||||
if (period === 'PM' && displayHour !== 12) displayHour += 12
|
||||
if (period === 'AM' && displayHour === 12) displayHour = 0
|
||||
|
||||
// Check if today's configured time has already passed
|
||||
const todayExecution = new Date(userToday)
|
||||
todayExecution.setHours(displayHour, Number.parseInt(minute), 0, 0)
|
||||
|
||||
const userCurrentTime = getUserTimezoneCurrentTime(timezone)
|
||||
|
||||
const startOffset = todayExecution <= userCurrentTime ? 1 : 0
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const execution = new Date(userToday)
|
||||
execution.setDate(userToday.getDate() + startOffset + i)
|
||||
execution.setHours(displayHour, Number.parseInt(minute), 0, 0)
|
||||
times.push(execution)
|
||||
}
|
||||
}
|
||||
else if (data.frequency === 'weekly') {
|
||||
const selectedDays = data.visual_config?.weekdays || ['sun']
|
||||
const dayMap = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 }
|
||||
|
||||
const [time, period] = defaultTime.split(' ')
|
||||
const [hour, minute] = time.split(':')
|
||||
let displayHour = Number.parseInt(hour)
|
||||
if (period === 'PM' && displayHour !== 12) displayHour += 12
|
||||
if (period === 'AM' && displayHour === 12) displayHour = 0
|
||||
|
||||
// Get current time completely in user timezone
|
||||
const userCurrentTime = getUserTimezoneCurrentTime(timezone)
|
||||
|
||||
let executionCount = 0
|
||||
let weekOffset = 0
|
||||
|
||||
while (executionCount < count) {
|
||||
let hasValidDays = false
|
||||
|
||||
for (const selectedDay of selectedDays) {
|
||||
if (executionCount >= count) break
|
||||
|
||||
const targetDay = dayMap[selectedDay as keyof typeof dayMap]
|
||||
if (targetDay === undefined) continue
|
||||
|
||||
hasValidDays = true
|
||||
|
||||
const currentDayOfWeek = userToday.getDay()
|
||||
const daysUntilTarget = (targetDay - currentDayOfWeek + 7) % 7
|
||||
|
||||
// Check if today's configured time has already passed
|
||||
const todayAtTargetTime = new Date(userToday)
|
||||
todayAtTargetTime.setHours(displayHour, Number.parseInt(minute), 0, 0)
|
||||
|
||||
let adjustedDays = daysUntilTarget
|
||||
if (daysUntilTarget === 0 && todayAtTargetTime <= userCurrentTime)
|
||||
adjustedDays = 7
|
||||
|
||||
const execution = new Date(userToday)
|
||||
execution.setDate(userToday.getDate() + adjustedDays + (weekOffset * 7))
|
||||
execution.setHours(displayHour, Number.parseInt(minute), 0, 0)
|
||||
|
||||
// Only add if execution time is in the future
|
||||
if (execution > userCurrentTime) {
|
||||
times.push(execution)
|
||||
executionCount++
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasValidDays) break
|
||||
weekOffset++
|
||||
}
|
||||
|
||||
times.sort((a, b) => a.getTime() - b.getTime())
|
||||
}
|
||||
else if (data.frequency === 'monthly') {
|
||||
const getSelectedDays = (): (number | 'last')[] => {
|
||||
if (data.visual_config?.monthly_days && data.visual_config.monthly_days.length > 0)
|
||||
return data.visual_config.monthly_days
|
||||
|
||||
return [1]
|
||||
}
|
||||
|
||||
const selectedDays = [...new Set(getSelectedDays())]
|
||||
const [time, period] = defaultTime.split(' ')
|
||||
const [hour, minute] = time.split(':')
|
||||
let displayHour = Number.parseInt(hour)
|
||||
if (period === 'PM' && displayHour !== 12) displayHour += 12
|
||||
if (period === 'AM' && displayHour === 12) displayHour = 0
|
||||
|
||||
// Get current time completely in user timezone
|
||||
const userCurrentTime = getUserTimezoneCurrentTime(timezone)
|
||||
|
||||
let executionCount = 0
|
||||
let monthOffset = 0
|
||||
|
||||
while (executionCount < count) {
|
||||
const targetMonth = new Date(userToday.getFullYear(), userToday.getMonth() + monthOffset, 1)
|
||||
const daysInMonth = new Date(targetMonth.getFullYear(), targetMonth.getMonth() + 1, 0).getDate()
|
||||
|
||||
const monthlyExecutions: Date[] = []
|
||||
const processedDays = new Set<number>()
|
||||
|
||||
for (const selectedDay of selectedDays) {
|
||||
let targetDay: number
|
||||
|
||||
if (selectedDay === 'last') {
|
||||
targetDay = daysInMonth
|
||||
}
|
||||
else {
|
||||
const dayNumber = selectedDay as number
|
||||
if (dayNumber > daysInMonth)
|
||||
continue
|
||||
|
||||
targetDay = dayNumber
|
||||
}
|
||||
|
||||
if (processedDays.has(targetDay))
|
||||
continue
|
||||
|
||||
processedDays.add(targetDay)
|
||||
|
||||
const execution = new Date(targetMonth.getFullYear(), targetMonth.getMonth(), targetDay, displayHour, Number.parseInt(minute), 0, 0)
|
||||
|
||||
// Only add if execution time is in the future
|
||||
if (execution > userCurrentTime)
|
||||
monthlyExecutions.push(execution)
|
||||
}
|
||||
|
||||
monthlyExecutions.sort((a, b) => a.getTime() - b.getTime())
|
||||
|
||||
for (const execution of monthlyExecutions) {
|
||||
if (executionCount >= count) break
|
||||
times.push(execution)
|
||||
executionCount++
|
||||
}
|
||||
|
||||
monthOffset++
|
||||
}
|
||||
}
|
||||
else {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const execution = new Date(userToday)
|
||||
execution.setDate(userToday.getDate() + i)
|
||||
times.push(execution)
|
||||
}
|
||||
}
|
||||
|
||||
return times
|
||||
}
|
||||
|
||||
export const formatExecutionTime = (date: Date, timezone: string | undefined, includeWeekday: boolean = true, includeTimezone: boolean = true): string => {
|
||||
const resolvedTimezone = resolveTimezone(timezone)
|
||||
return formatUserTimezoneDate(date, resolvedTimezone, includeWeekday, includeTimezone)
|
||||
}
|
||||
|
||||
export const getFormattedExecutionTimes = (data: ScheduleTriggerNodeType, count: number = 5): string[] => {
|
||||
const timezone = resolveTimezone(data.timezone)
|
||||
const times = getNextExecutionTimes(data, count)
|
||||
|
||||
return times.map((date) => {
|
||||
const includeWeekday = data.mode === 'visual' && data.frequency === 'weekly'
|
||||
return formatExecutionTime(date, timezone, includeWeekday, true) // Panel shows timezone
|
||||
})
|
||||
}
|
||||
|
||||
export const getNextExecutionTime = (data: ScheduleTriggerNodeType): string => {
|
||||
const timezone = resolveTimezone(data.timezone)
|
||||
|
||||
// Return placeholder for cron mode with empty or invalid expression
|
||||
if (data.mode === 'cron') {
|
||||
if (!data.cron_expression || !isValidCronExpression(data.cron_expression))
|
||||
return '--'
|
||||
}
|
||||
|
||||
// Get Date objects (not formatted strings)
|
||||
const times = getNextExecutionTimes(data, 1)
|
||||
if (times.length === 0) {
|
||||
const userCurrentTime = getUserTimezoneCurrentTime(timezone)
|
||||
const fallbackDate = new Date(userCurrentTime.getFullYear(), userCurrentTime.getMonth(), userCurrentTime.getDate(), 12, 0, 0, 0)
|
||||
const includeWeekday = data.mode === 'visual' && data.frequency === 'weekly'
|
||||
return formatExecutionTime(fallbackDate, timezone, includeWeekday, false) // Node doesn't show timezone
|
||||
}
|
||||
|
||||
// Format the first execution time without timezone for node display
|
||||
const includeWeekday = data.mode === 'visual' && data.frequency === 'weekly'
|
||||
return formatExecutionTime(times[0], timezone, includeWeekday, false) // Node doesn't show timezone
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import { isValidCronExpression, parseCronExpression } from './cron-parser'
|
||||
import { getNextExecutionTime, getNextExecutionTimes } from './execution-time-calculator'
|
||||
import type { ScheduleTriggerNodeType } from '../types'
|
||||
|
||||
// Comprehensive integration tests for cron-parser and execution-time-calculator compatibility
|
||||
describe('cron-parser + execution-time-calculator integration', () => {
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers()
|
||||
jest.setSystemTime(new Date('2024-01-15T10:00:00Z'))
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
const createCronData = (overrides: Partial<ScheduleTriggerNodeType> = {}): ScheduleTriggerNodeType => ({
|
||||
id: 'test-cron',
|
||||
type: 'schedule-trigger',
|
||||
mode: 'cron',
|
||||
frequency: 'daily',
|
||||
timezone: 'UTC',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('backward compatibility validation', () => {
|
||||
it('maintains exact behavior for legacy cron expressions', () => {
|
||||
const legacyExpressions = [
|
||||
'15 10 1 * *', // Monthly 1st at 10:15
|
||||
'0 0 * * 0', // Weekly Sunday midnight
|
||||
'*/5 * * * *', // Every 5 minutes
|
||||
'0 9-17 * * 1-5', // Business hours weekdays
|
||||
'30 14 * * 1', // Monday 14:30
|
||||
'0 0 1,15 * *', // 1st and 15th midnight
|
||||
]
|
||||
|
||||
legacyExpressions.forEach((expression) => {
|
||||
// Test direct cron-parser usage
|
||||
const directResult = parseCronExpression(expression, 'UTC')
|
||||
expect(directResult).toHaveLength(5)
|
||||
expect(isValidCronExpression(expression)).toBe(true)
|
||||
|
||||
// Test through execution-time-calculator
|
||||
const data = createCronData({ cron_expression: expression })
|
||||
const calculatorResult = getNextExecutionTimes(data, 5)
|
||||
|
||||
expect(calculatorResult).toHaveLength(5)
|
||||
|
||||
// Results should be identical
|
||||
directResult.forEach((directDate, index) => {
|
||||
const calcDate = calculatorResult[index]
|
||||
expect(calcDate.getTime()).toBe(directDate.getTime())
|
||||
expect(calcDate.getHours()).toBe(directDate.getHours())
|
||||
expect(calcDate.getMinutes()).toBe(directDate.getMinutes())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('validates timezone handling consistency', () => {
|
||||
const timezones = ['UTC', 'America/New_York', 'Asia/Tokyo', 'Europe/London']
|
||||
const expression = '0 12 * * *' // Daily noon
|
||||
|
||||
timezones.forEach((timezone) => {
|
||||
// Direct cron-parser call
|
||||
const directResult = parseCronExpression(expression, timezone)
|
||||
|
||||
// Through execution-time-calculator
|
||||
const data = createCronData({ cron_expression: expression, timezone })
|
||||
const calculatorResult = getNextExecutionTimes(data, 5)
|
||||
|
||||
expect(directResult).toHaveLength(5)
|
||||
expect(calculatorResult).toHaveLength(5)
|
||||
|
||||
// All results should show noon (12:00) in their respective timezone
|
||||
directResult.forEach(date => expect(date.getHours()).toBe(12))
|
||||
calculatorResult.forEach(date => expect(date.getHours()).toBe(12))
|
||||
|
||||
// Cross-validation: results should be identical
|
||||
directResult.forEach((directDate, index) => {
|
||||
expect(calculatorResult[index].getTime()).toBe(directDate.getTime())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('error handling consistency', () => {
|
||||
const invalidExpressions = [
|
||||
'', // Empty string
|
||||
' ', // Whitespace only
|
||||
'60 10 1 * *', // Invalid minute
|
||||
'15 25 1 * *', // Invalid hour
|
||||
'15 10 32 * *', // Invalid day
|
||||
'15 10 1 13 *', // Invalid month
|
||||
'15 10 1', // Too few fields
|
||||
'15 10 1 * * *', // Too many fields
|
||||
'invalid expression', // Completely invalid
|
||||
]
|
||||
|
||||
invalidExpressions.forEach((expression) => {
|
||||
// Direct cron-parser calls
|
||||
expect(isValidCronExpression(expression)).toBe(false)
|
||||
expect(parseCronExpression(expression, 'UTC')).toEqual([])
|
||||
|
||||
// Through execution-time-calculator
|
||||
const data = createCronData({ cron_expression: expression })
|
||||
const result = getNextExecutionTimes(data, 5)
|
||||
expect(result).toEqual([])
|
||||
|
||||
// getNextExecutionTime should return '--' for invalid cron
|
||||
const timeString = getNextExecutionTime(data)
|
||||
expect(timeString).toBe('--')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('enhanced features integration', () => {
|
||||
it('month and day abbreviations work end-to-end', () => {
|
||||
const enhancedExpressions = [
|
||||
{ expr: '0 9 1 JAN *', month: 0, day: 1, hour: 9 }, // January 1st 9 AM
|
||||
{ expr: '0 15 * * MON', weekday: 1, hour: 15 }, // Monday 3 PM
|
||||
{ expr: '30 10 15 JUN,DEC *', month: [5, 11], day: 15, hour: 10, minute: 30 }, // Jun/Dec 15th
|
||||
{ expr: '0 12 * JAN-MAR *', month: [0, 1, 2], hour: 12 }, // Q1 noon
|
||||
]
|
||||
|
||||
enhancedExpressions.forEach(({ expr, month, day, weekday, hour, minute = 0 }) => {
|
||||
// Validate through both paths
|
||||
expect(isValidCronExpression(expr)).toBe(true)
|
||||
|
||||
const directResult = parseCronExpression(expr, 'UTC')
|
||||
const data = createCronData({ cron_expression: expr })
|
||||
const calculatorResult = getNextExecutionTimes(data, 3)
|
||||
|
||||
expect(directResult.length).toBeGreaterThan(0)
|
||||
expect(calculatorResult.length).toBeGreaterThan(0)
|
||||
|
||||
// Validate expected properties
|
||||
const validateDate = (date: Date) => {
|
||||
expect(date.getHours()).toBe(hour)
|
||||
expect(date.getMinutes()).toBe(minute)
|
||||
|
||||
if (month !== undefined) {
|
||||
if (Array.isArray(month))
|
||||
expect(month).toContain(date.getMonth())
|
||||
else
|
||||
expect(date.getMonth()).toBe(month)
|
||||
}
|
||||
|
||||
if (day !== undefined)
|
||||
expect(date.getDate()).toBe(day)
|
||||
|
||||
if (weekday !== undefined)
|
||||
expect(date.getDay()).toBe(weekday)
|
||||
}
|
||||
|
||||
directResult.forEach(validateDate)
|
||||
calculatorResult.forEach(validateDate)
|
||||
})
|
||||
})
|
||||
|
||||
it('predefined expressions work through execution-time-calculator', () => {
|
||||
const predefExpressions = [
|
||||
{ expr: '@daily', hour: 0, minute: 0 },
|
||||
{ expr: '@weekly', hour: 0, minute: 0, weekday: 0 }, // Sunday
|
||||
{ expr: '@monthly', hour: 0, minute: 0, day: 1 }, // 1st of month
|
||||
{ expr: '@yearly', hour: 0, minute: 0, month: 0, day: 1 }, // Jan 1st
|
||||
]
|
||||
|
||||
predefExpressions.forEach(({ expr, hour, minute, weekday, day, month }) => {
|
||||
expect(isValidCronExpression(expr)).toBe(true)
|
||||
|
||||
const data = createCronData({ cron_expression: expr })
|
||||
const result = getNextExecutionTimes(data, 3)
|
||||
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
|
||||
result.forEach((date) => {
|
||||
expect(date.getHours()).toBe(hour)
|
||||
expect(date.getMinutes()).toBe(minute)
|
||||
|
||||
if (weekday !== undefined) expect(date.getDay()).toBe(weekday)
|
||||
if (day !== undefined) expect(date.getDate()).toBe(day)
|
||||
if (month !== undefined) expect(date.getMonth()).toBe(month)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('special characters integration', () => {
|
||||
const specialExpressions = [
|
||||
'0 9 ? * 1', // ? wildcard for day
|
||||
'0 12 * * 7', // Sunday as 7
|
||||
'0 15 L * *', // Last day of month
|
||||
]
|
||||
|
||||
specialExpressions.forEach((expr) => {
|
||||
// Should validate and parse successfully
|
||||
expect(isValidCronExpression(expr)).toBe(true)
|
||||
|
||||
const directResult = parseCronExpression(expr, 'UTC')
|
||||
const data = createCronData({ cron_expression: expr })
|
||||
const calculatorResult = getNextExecutionTimes(data, 2)
|
||||
|
||||
expect(directResult.length).toBeGreaterThan(0)
|
||||
expect(calculatorResult.length).toBeGreaterThan(0)
|
||||
|
||||
// Results should be consistent
|
||||
expect(calculatorResult[0].getHours()).toBe(directResult[0].getHours())
|
||||
expect(calculatorResult[0].getMinutes()).toBe(directResult[0].getMinutes())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('DST and timezone edge cases', () => {
|
||||
it('handles DST transitions consistently', () => {
|
||||
// Test around DST spring forward (March 2024)
|
||||
jest.setSystemTime(new Date('2024-03-08T10:00:00Z'))
|
||||
|
||||
const expression = '0 2 * * *' // 2 AM daily (problematic during DST)
|
||||
const timezone = 'America/New_York'
|
||||
|
||||
const directResult = parseCronExpression(expression, timezone)
|
||||
const data = createCronData({ cron_expression: expression, timezone })
|
||||
const calculatorResult = getNextExecutionTimes(data, 5)
|
||||
|
||||
expect(directResult.length).toBeGreaterThan(0)
|
||||
expect(calculatorResult.length).toBeGreaterThan(0)
|
||||
|
||||
// Both should handle DST gracefully
|
||||
// During DST spring forward, 2 AM becomes 3 AM - this is correct behavior
|
||||
directResult.forEach(date => expect([2, 3]).toContain(date.getHours()))
|
||||
calculatorResult.forEach(date => expect([2, 3]).toContain(date.getHours()))
|
||||
|
||||
// Results should be identical
|
||||
directResult.forEach((directDate, index) => {
|
||||
expect(calculatorResult[index].getTime()).toBe(directDate.getTime())
|
||||
})
|
||||
})
|
||||
|
||||
it('complex timezone scenarios', () => {
|
||||
const scenarios = [
|
||||
{ tz: 'Asia/Kolkata', expr: '30 14 * * *', expectedHour: 14, expectedMinute: 30 }, // UTC+5:30
|
||||
{ tz: 'Australia/Adelaide', expr: '0 8 * * *', expectedHour: 8, expectedMinute: 0 }, // UTC+9:30/+10:30
|
||||
{ tz: 'Pacific/Kiritimati', expr: '0 12 * * *', expectedHour: 12, expectedMinute: 0 }, // UTC+14
|
||||
]
|
||||
|
||||
scenarios.forEach(({ tz, expr, expectedHour, expectedMinute }) => {
|
||||
const directResult = parseCronExpression(expr, tz)
|
||||
const data = createCronData({ cron_expression: expr, timezone: tz })
|
||||
const calculatorResult = getNextExecutionTimes(data, 2)
|
||||
|
||||
expect(directResult.length).toBeGreaterThan(0)
|
||||
expect(calculatorResult.length).toBeGreaterThan(0)
|
||||
|
||||
// Validate expected time
|
||||
directResult.forEach((date) => {
|
||||
expect(date.getHours()).toBe(expectedHour)
|
||||
expect(date.getMinutes()).toBe(expectedMinute)
|
||||
})
|
||||
|
||||
calculatorResult.forEach((date) => {
|
||||
expect(date.getHours()).toBe(expectedHour)
|
||||
expect(date.getMinutes()).toBe(expectedMinute)
|
||||
})
|
||||
|
||||
// Cross-validate consistency
|
||||
expect(calculatorResult[0].getTime()).toBe(directResult[0].getTime())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('performance and reliability', () => {
|
||||
it('handles high-frequency expressions efficiently', () => {
|
||||
const highFreqExpressions = [
|
||||
'*/1 * * * *', // Every minute
|
||||
'*/5 * * * *', // Every 5 minutes
|
||||
'0,15,30,45 * * * *', // Every 15 minutes
|
||||
]
|
||||
|
||||
highFreqExpressions.forEach((expr) => {
|
||||
const start = performance.now()
|
||||
|
||||
// Test both direct and through calculator
|
||||
const directResult = parseCronExpression(expr, 'UTC')
|
||||
const data = createCronData({ cron_expression: expr })
|
||||
const calculatorResult = getNextExecutionTimes(data, 5)
|
||||
|
||||
const end = performance.now()
|
||||
|
||||
expect(directResult).toHaveLength(5)
|
||||
expect(calculatorResult).toHaveLength(5)
|
||||
expect(end - start).toBeLessThan(100) // Should be fast
|
||||
|
||||
// Results should be consistent
|
||||
directResult.forEach((directDate, index) => {
|
||||
expect(calculatorResult[index].getTime()).toBe(directDate.getTime())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('stress test with complex expressions', () => {
|
||||
const complexExpressions = [
|
||||
'15,45 8-18 1,15 JAN-MAR MON-FRI', // Business hours, specific days, Q1, weekdays
|
||||
'0 */2 ? * SUN#1,SUN#3', // First and third Sunday, every 2 hours
|
||||
'30 9 L * *', // Last day of month, 9:30 AM
|
||||
]
|
||||
|
||||
complexExpressions.forEach((expr) => {
|
||||
if (isValidCronExpression(expr)) {
|
||||
const directResult = parseCronExpression(expr, 'America/New_York')
|
||||
const data = createCronData({
|
||||
cron_expression: expr,
|
||||
timezone: 'America/New_York',
|
||||
})
|
||||
const calculatorResult = getNextExecutionTimes(data, 3)
|
||||
|
||||
expect(directResult.length).toBeGreaterThan(0)
|
||||
expect(calculatorResult.length).toBeGreaterThan(0)
|
||||
|
||||
// Validate consistency where results exist
|
||||
const minLength = Math.min(directResult.length, calculatorResult.length)
|
||||
for (let i = 0; i < minLength; i++)
|
||||
expect(calculatorResult[i].getTime()).toBe(directResult[i].getTime())
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('format compatibility', () => {
|
||||
it('getNextExecutionTime formatting consistency', () => {
|
||||
const testCases = [
|
||||
{ expr: '0 9 * * *', timezone: 'UTC' },
|
||||
{ expr: '30 14 * * 1-5', timezone: 'America/New_York' },
|
||||
{ expr: '@daily', timezone: 'Asia/Tokyo' },
|
||||
]
|
||||
|
||||
testCases.forEach(({ expr, timezone }) => {
|
||||
const data = createCronData({ cron_expression: expr, timezone })
|
||||
const timeString = getNextExecutionTime(data)
|
||||
|
||||
// Should return a formatted time string, not '--'
|
||||
expect(timeString).not.toBe('--')
|
||||
expect(typeof timeString).toBe('string')
|
||||
expect(timeString.length).toBeGreaterThan(0)
|
||||
|
||||
// Should contain expected format elements
|
||||
expect(timeString).toMatch(/\d+:\d+/) // Time format
|
||||
expect(timeString).toMatch(/AM|PM/) // 12-hour format
|
||||
expect(timeString).toMatch(/\d{4}/) // Year
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,297 @@
|
||||
'use client'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import React, { useCallback, useMemo } from 'react'
|
||||
import { RiDeleteBinLine } from '@remixicon/react'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import { SimpleSelect } from '@/app/components/base/select'
|
||||
import { replaceSpaceWithUnderscoreInVarNameInput } from '@/utils/var'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
// Tiny utility to judge whether a cell value is effectively present
|
||||
const isPresent = (v: unknown): boolean => {
|
||||
if (typeof v === 'string') return v.trim() !== ''
|
||||
return !(v === '' || v === null || v === undefined || v === false)
|
||||
}
|
||||
// Column configuration types for table components
|
||||
export type ColumnType = 'input' | 'select' | 'switch' | 'custom'
|
||||
|
||||
export type SelectOption = {
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export type ColumnConfig = {
|
||||
key: string
|
||||
title: string
|
||||
type: ColumnType
|
||||
width?: string // CSS class for width (e.g., 'w-1/2', 'w-[140px]')
|
||||
placeholder?: string
|
||||
options?: SelectOption[] // For select type
|
||||
render?: (value: unknown, row: GenericTableRow, index: number, onChange: (value: unknown) => void) => ReactNode
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
export type GenericTableRow = {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type GenericTableProps = {
|
||||
title: string
|
||||
columns: ColumnConfig[]
|
||||
data: GenericTableRow[]
|
||||
onChange: (data: GenericTableRow[]) => void
|
||||
readonly?: boolean
|
||||
placeholder?: string
|
||||
emptyRowData: GenericTableRow // Template for new empty rows
|
||||
className?: string
|
||||
showHeader?: boolean // Whether to show column headers
|
||||
}
|
||||
|
||||
// Internal type for stable mapping between rendered rows and data indices
|
||||
type DisplayRow = {
|
||||
row: GenericTableRow
|
||||
dataIndex: number | null // null indicates the trailing UI-only row
|
||||
isVirtual: boolean // whether this row is the extra empty row for adding new items
|
||||
}
|
||||
|
||||
const GenericTable: FC<GenericTableProps> = ({
|
||||
title,
|
||||
columns,
|
||||
data,
|
||||
onChange,
|
||||
readonly = false,
|
||||
placeholder,
|
||||
emptyRowData,
|
||||
className,
|
||||
showHeader = false,
|
||||
}) => {
|
||||
// Build the rows to display while keeping a stable mapping to original data
|
||||
const displayRows = useMemo<DisplayRow[]>(() => {
|
||||
// Helper to check empty
|
||||
const isEmptyRow = (r: GenericTableRow) =>
|
||||
Object.values(r).every(v => v === '' || v === null || v === undefined || v === false)
|
||||
|
||||
if (readonly)
|
||||
return data.map((r, i) => ({ row: r, dataIndex: i, isVirtual: false }))
|
||||
|
||||
const hasData = data.length > 0
|
||||
const rows: DisplayRow[] = []
|
||||
|
||||
if (!hasData) {
|
||||
// Initialize with exactly one empty row when there is no data
|
||||
rows.push({ row: { ...emptyRowData }, dataIndex: null, isVirtual: true })
|
||||
return rows
|
||||
}
|
||||
|
||||
// Add configured rows, hide intermediate empty ones, keep mapping
|
||||
data.forEach((r, i) => {
|
||||
const isEmpty = isEmptyRow(r)
|
||||
// Skip empty rows except the very last configured row
|
||||
if (isEmpty && i < data.length - 1)
|
||||
return
|
||||
rows.push({ row: r, dataIndex: i, isVirtual: false })
|
||||
})
|
||||
|
||||
// If the last configured row has content, append a trailing empty row
|
||||
const lastHasContent = !isEmptyRow(data[data.length - 1])
|
||||
if (lastHasContent)
|
||||
rows.push({ row: { ...emptyRowData }, dataIndex: null, isVirtual: true })
|
||||
|
||||
return rows
|
||||
}, [data, emptyRowData, readonly])
|
||||
|
||||
const removeRow = useCallback((dataIndex: number) => {
|
||||
if (readonly) return
|
||||
if (dataIndex < 0 || dataIndex >= data.length) return // ignore virtual rows
|
||||
const newData = data.filter((_, i) => i !== dataIndex)
|
||||
onChange(newData)
|
||||
}, [data, readonly, onChange])
|
||||
|
||||
const updateRow = useCallback((dataIndex: number | null, key: string, value: unknown) => {
|
||||
if (readonly) return
|
||||
|
||||
if (dataIndex !== null && dataIndex < data.length) {
|
||||
// Editing existing configured row
|
||||
const newData = [...data]
|
||||
newData[dataIndex] = { ...newData[dataIndex], [key]: value }
|
||||
onChange(newData)
|
||||
return
|
||||
}
|
||||
|
||||
// Editing the trailing UI-only empty row: create a new configured row
|
||||
const newRow = { ...emptyRowData, [key]: value }
|
||||
const next = [...data, newRow]
|
||||
onChange(next)
|
||||
}, [data, emptyRowData, onChange, readonly])
|
||||
|
||||
// Determine the primary identifier column just once
|
||||
const primaryKey = useMemo(() => (
|
||||
columns.find(col => col.key === 'key' || col.key === 'name')?.key ?? 'key'
|
||||
), [columns])
|
||||
|
||||
const renderCell = (column: ColumnConfig, row: GenericTableRow, dataIndex: number | null) => {
|
||||
const value = row[column.key]
|
||||
const handleChange = (newValue: unknown) => updateRow(dataIndex, column.key, newValue)
|
||||
|
||||
switch (column.type) {
|
||||
case 'input':
|
||||
return (
|
||||
<Input
|
||||
value={(value as string) || ''}
|
||||
onChange={(e) => {
|
||||
// Format variable names (replace spaces with underscores)
|
||||
if (column.key === 'key' || column.key === 'name')
|
||||
replaceSpaceWithUnderscoreInVarNameInput(e.target)
|
||||
handleChange(e.target.value)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
e.currentTarget.blur()
|
||||
}
|
||||
}}
|
||||
placeholder={column.placeholder}
|
||||
disabled={readonly}
|
||||
wrapperClassName="w-full min-w-0"
|
||||
className={cn(
|
||||
// Ghost/inline style: looks like plain text until focus/hover
|
||||
'h-6 rounded-none border-0 bg-transparent px-0 py-0 shadow-none',
|
||||
'hover:border-transparent hover:bg-transparent focus:border-transparent focus:bg-transparent',
|
||||
'system-sm-regular text-text-secondary placeholder:text-text-quaternary',
|
||||
)}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<SimpleSelect
|
||||
items={column.options || []}
|
||||
defaultValue={value as string | undefined}
|
||||
onSelect={item => handleChange(item.value)}
|
||||
disabled={readonly}
|
||||
placeholder={column.placeholder}
|
||||
hideChecked={false}
|
||||
notClearable={true}
|
||||
// wrapper provides compact height, trigger is transparent like text
|
||||
wrapperClassName="h-6 w-full min-w-0"
|
||||
className={cn(
|
||||
'h-6 rounded-none bg-transparent pl-0 pr-6 text-text-secondary',
|
||||
'hover:bg-transparent focus-visible:bg-transparent group-hover/simple-select:bg-transparent',
|
||||
)}
|
||||
optionWrapClassName="w-26 min-w-26 z-[60] -ml-3"
|
||||
/>
|
||||
)
|
||||
|
||||
case 'switch':
|
||||
return (
|
||||
<div className="flex h-7 items-center">
|
||||
<Checkbox
|
||||
id={`${column.key}-${String(dataIndex ?? 'v')}`}
|
||||
checked={Boolean(value)}
|
||||
onCheck={() => handleChange(!value)}
|
||||
disabled={readonly}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'custom':
|
||||
return column.render ? column.render(value, row, (dataIndex ?? -1), handleChange) : null
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const renderTable = () => {
|
||||
return (
|
||||
<div className="rounded-lg border border-divider-regular">
|
||||
{showHeader && (
|
||||
<div className="system-xs-medium-uppercase flex h-7 items-center leading-7 text-text-tertiary">
|
||||
{columns.map((column, index) => (
|
||||
<div
|
||||
key={column.key}
|
||||
className={cn(
|
||||
'h-full pl-3',
|
||||
column.width && column.width.startsWith('w-') ? 'shrink-0' : 'flex-1',
|
||||
column.width,
|
||||
// Add right border except for last column
|
||||
index < columns.length - 1 && 'border-r border-divider-regular',
|
||||
)}
|
||||
>
|
||||
{column.title}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="divide-y divide-divider-subtle">
|
||||
{displayRows.map(({ row, dataIndex, isVirtual: _isVirtual }, renderIndex) => {
|
||||
const rowKey = `row-${renderIndex}`
|
||||
|
||||
// Check if primary identifier column has content
|
||||
const primaryValue = row[primaryKey]
|
||||
const hasContent = isPresent(primaryValue)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={rowKey}
|
||||
className={cn(
|
||||
'group relative flex border-t border-divider-regular',
|
||||
hasContent ? 'hover:bg-state-destructive-hover' : 'hover:bg-state-base-hover',
|
||||
)}
|
||||
style={{ minHeight: '28px' }}
|
||||
>
|
||||
{columns.map((column, columnIndex) => (
|
||||
<div
|
||||
key={column.key}
|
||||
className={cn(
|
||||
'shrink-0 pl-3',
|
||||
column.width,
|
||||
// Add right border except for last column
|
||||
columnIndex < columns.length - 1 && 'border-r border-divider-regular',
|
||||
)}
|
||||
>
|
||||
{renderCell(column, row, dataIndex)}
|
||||
</div>
|
||||
))}
|
||||
{!readonly && dataIndex !== null && hasContent && (
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRow(dataIndex)}
|
||||
className="p-1"
|
||||
aria-label="Delete row"
|
||||
>
|
||||
<RiDeleteBinLine className="h-3.5 w-3.5 text-text-destructive" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show placeholder only when readonly and there is no data configured
|
||||
const showPlaceholder = readonly && data.length === 0
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h4 className="system-sm-semibold-uppercase text-text-secondary">{title}</h4>
|
||||
</div>
|
||||
|
||||
{showPlaceholder ? (
|
||||
<div className="flex h-7 items-center justify-center rounded-lg border border-divider-regular bg-components-panel-bg text-xs font-normal leading-[18px] text-text-quaternary">
|
||||
{placeholder}
|
||||
</div>
|
||||
) : (
|
||||
renderTable()
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(GenericTable)
|
||||
@@ -0,0 +1,78 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import GenericTable from './generic-table'
|
||||
import type { ColumnConfig, GenericTableRow } from './generic-table'
|
||||
import type { WebhookHeader } from '../types'
|
||||
|
||||
type HeaderTableProps = {
|
||||
readonly?: boolean
|
||||
headers?: WebhookHeader[]
|
||||
onChange: (headers: WebhookHeader[]) => void
|
||||
}
|
||||
|
||||
const HeaderTable: FC<HeaderTableProps> = ({
|
||||
readonly = false,
|
||||
headers = [],
|
||||
onChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
// Define columns for header table - matching prototype design
|
||||
const columns: ColumnConfig[] = [
|
||||
{
|
||||
key: 'name',
|
||||
title: t('workflow.nodes.triggerWebhook.varName'),
|
||||
type: 'input',
|
||||
width: 'flex-1',
|
||||
placeholder: t('workflow.nodes.triggerWebhook.varNamePlaceholder'),
|
||||
},
|
||||
{
|
||||
key: 'required',
|
||||
title: t('workflow.nodes.triggerWebhook.required'),
|
||||
type: 'switch',
|
||||
width: 'w-[88px]',
|
||||
},
|
||||
]
|
||||
|
||||
// No default prefilled row; table initializes with one empty row
|
||||
|
||||
// Empty row template for new rows
|
||||
const emptyRowData: GenericTableRow = {
|
||||
name: '',
|
||||
required: false,
|
||||
}
|
||||
|
||||
// Convert WebhookHeader[] to GenericTableRow[]
|
||||
const tableData: GenericTableRow[] = headers.map(header => ({
|
||||
name: header.name,
|
||||
required: header.required,
|
||||
}))
|
||||
|
||||
// Handle data changes
|
||||
const handleDataChange = (data: GenericTableRow[]) => {
|
||||
const newHeaders: WebhookHeader[] = data
|
||||
.filter(row => row.name && typeof row.name === 'string' && row.name.trim() !== '')
|
||||
.map(row => ({
|
||||
name: (row.name as string) || '',
|
||||
required: !!row.required,
|
||||
}))
|
||||
onChange(newHeaders)
|
||||
}
|
||||
|
||||
return (
|
||||
<GenericTable
|
||||
title="Header Parameters"
|
||||
columns={columns}
|
||||
data={tableData}
|
||||
onChange={handleDataChange}
|
||||
readonly={readonly}
|
||||
placeholder={t('workflow.nodes.triggerWebhook.noHeaders')}
|
||||
emptyRowData={emptyRowData}
|
||||
showHeader={true}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(HeaderTable)
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useRef } from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type ParagraphInputProps = {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const ParagraphInput: FC<ParagraphInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
className,
|
||||
}) => {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const lines = value ? value.split('\n') : ['']
|
||||
const lineCount = Math.max(3, lines.length)
|
||||
|
||||
return (
|
||||
<div className={cn('rounded-xl bg-components-input-bg-normal px-3 pb-2 pt-3', className)}>
|
||||
<div className="relative">
|
||||
<div className="pointer-events-none absolute left-0 top-0 flex flex-col">
|
||||
{Array.from({ length: lineCount }, (_, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="flex h-[20px] select-none items-center font-mono text-xs leading-[20px] text-text-quaternary"
|
||||
>
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className="w-full resize-none border-0 bg-transparent pl-6 font-mono text-xs leading-[20px] text-text-secondary outline-none placeholder:text-text-quaternary"
|
||||
style={{
|
||||
minHeight: `${Math.max(3, lineCount) * 20}px`,
|
||||
lineHeight: '20px',
|
||||
}}
|
||||
rows={Math.max(3, lineCount)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ParagraphInput)
|
||||
@@ -0,0 +1,112 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import GenericTable from './generic-table'
|
||||
import type { ColumnConfig, GenericTableRow } from './generic-table'
|
||||
import type { WebhookParameter } from '../types'
|
||||
import { createParameterTypeOptions, normalizeParameterType } from '../utils/parameter-type-utils'
|
||||
import { VarType } from '@/app/components/workflow/types'
|
||||
|
||||
type ParameterTableProps = {
|
||||
title: string
|
||||
parameters: WebhookParameter[]
|
||||
onChange: (params: WebhookParameter[]) => void
|
||||
readonly?: boolean
|
||||
placeholder?: string
|
||||
contentType?: string
|
||||
}
|
||||
|
||||
const ParameterTable: FC<ParameterTableProps> = ({
|
||||
title,
|
||||
parameters,
|
||||
onChange,
|
||||
readonly,
|
||||
placeholder,
|
||||
contentType,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
// Memoize typeOptions to prevent unnecessary re-renders that cause SimpleSelect state resets
|
||||
const typeOptions = useMemo(() =>
|
||||
createParameterTypeOptions(contentType),
|
||||
[contentType],
|
||||
)
|
||||
|
||||
// Define columns based on component type - matching prototype design
|
||||
const columns: ColumnConfig[] = [
|
||||
{
|
||||
key: 'key',
|
||||
title: t('workflow.nodes.triggerWebhook.varName'),
|
||||
type: 'input',
|
||||
width: 'flex-1',
|
||||
placeholder: t('workflow.nodes.triggerWebhook.varNamePlaceholder'),
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
title: t('workflow.nodes.triggerWebhook.varType'),
|
||||
type: 'select',
|
||||
width: 'w-[120px]',
|
||||
placeholder: t('workflow.nodes.triggerWebhook.varType'),
|
||||
options: typeOptions,
|
||||
},
|
||||
{
|
||||
key: 'required',
|
||||
title: t('workflow.nodes.triggerWebhook.required'),
|
||||
type: 'switch',
|
||||
width: 'w-[88px]',
|
||||
},
|
||||
]
|
||||
|
||||
// Choose sensible default type for new rows according to content type
|
||||
const defaultTypeValue: VarType = typeOptions[0]?.value || 'string'
|
||||
|
||||
// Empty row template for new rows
|
||||
const emptyRowData: GenericTableRow = {
|
||||
key: '',
|
||||
type: defaultTypeValue,
|
||||
required: false,
|
||||
}
|
||||
|
||||
const tableData: GenericTableRow[] = parameters.map(param => ({
|
||||
key: param.name,
|
||||
type: param.type,
|
||||
required: param.required,
|
||||
}))
|
||||
|
||||
const handleDataChange = (data: GenericTableRow[]) => {
|
||||
// For text/plain, enforce single text body semantics: keep only first non-empty row and force string type
|
||||
// For application/octet-stream, enforce single file body semantics: keep only first non-empty row and force file type
|
||||
const isTextPlain = (contentType || '').toLowerCase() === 'text/plain'
|
||||
const isOctetStream = (contentType || '').toLowerCase() === 'application/octet-stream'
|
||||
|
||||
const normalized = data
|
||||
.filter(row => typeof row.key === 'string' && (row.key as string).trim() !== '')
|
||||
.map(row => ({
|
||||
name: String(row.key),
|
||||
type: isTextPlain ? VarType.string : isOctetStream ? VarType.file : normalizeParameterType((row.type as string)),
|
||||
required: Boolean(row.required),
|
||||
}))
|
||||
|
||||
const newParams: WebhookParameter[] = (isTextPlain || isOctetStream)
|
||||
? normalized.slice(0, 1)
|
||||
: normalized
|
||||
|
||||
onChange(newParams)
|
||||
}
|
||||
|
||||
return (
|
||||
<GenericTable
|
||||
title={title}
|
||||
columns={columns}
|
||||
data={tableData}
|
||||
onChange={handleDataChange}
|
||||
readonly={readonly}
|
||||
placeholder={placeholder || t('workflow.nodes.triggerWebhook.noParameters')}
|
||||
emptyRowData={emptyRowData}
|
||||
showHeader={true}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default ParameterTable
|
||||
64
web/app/components/workflow/nodes/trigger-webhook/default.ts
Normal file
64
web/app/components/workflow/nodes/trigger-webhook/default.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { BlockEnum } from '../../types'
|
||||
import type { NodeDefault } from '../../types'
|
||||
import { genNodeMetaData } from '../../utils'
|
||||
import type { WebhookTriggerNodeType } from './types'
|
||||
import { isValidParameterType } from './utils/parameter-type-utils'
|
||||
import { createWebhookRawVariable } from './utils/raw-variable'
|
||||
|
||||
const metaData = genNodeMetaData({
|
||||
sort: 3,
|
||||
type: BlockEnum.TriggerWebhook,
|
||||
helpLinkUri: 'webhook-trigger',
|
||||
isStart: true,
|
||||
})
|
||||
|
||||
const nodeDefault: NodeDefault<WebhookTriggerNodeType> = {
|
||||
metaData,
|
||||
defaultValue: {
|
||||
webhook_url: '',
|
||||
method: 'POST',
|
||||
content_type: 'application/json',
|
||||
headers: [],
|
||||
params: [],
|
||||
body: [],
|
||||
async_mode: true,
|
||||
status_code: 200,
|
||||
response_body: '',
|
||||
variables: [createWebhookRawVariable()],
|
||||
},
|
||||
checkValid(payload: WebhookTriggerNodeType, t: any) {
|
||||
// Require webhook_url to be configured
|
||||
if (!payload.webhook_url || payload.webhook_url.trim() === '') {
|
||||
return {
|
||||
isValid: false,
|
||||
errorMessage: t('workflow.nodes.triggerWebhook.validation.webhookUrlRequired'),
|
||||
}
|
||||
}
|
||||
|
||||
// Validate parameter types for params and body
|
||||
const parametersWithTypes = [
|
||||
...(payload.params || []),
|
||||
...(payload.body || []),
|
||||
]
|
||||
|
||||
for (const param of parametersWithTypes) {
|
||||
// Validate parameter type is valid
|
||||
if (!isValidParameterType(param.type)) {
|
||||
return {
|
||||
isValid: false,
|
||||
errorMessage: t('workflow.nodes.triggerWebhook.validation.invalidParameterType', {
|
||||
name: param.name,
|
||||
type: param.type,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
errorMessage: '',
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export default nodeDefault
|
||||
25
web/app/components/workflow/nodes/trigger-webhook/node.tsx
Normal file
25
web/app/components/workflow/nodes/trigger-webhook/node.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import type { WebhookTriggerNodeType } from './types'
|
||||
import type { NodeProps } from '@/app/components/workflow/types'
|
||||
|
||||
const Node: FC<NodeProps<WebhookTriggerNodeType>> = ({
|
||||
data,
|
||||
}) => {
|
||||
return (
|
||||
<div className="mb-1 px-3 py-1">
|
||||
<div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-text-tertiary">
|
||||
URL
|
||||
</div>
|
||||
<div className="flex h-[26px] items-center rounded-md bg-workflow-block-parma-bg px-2 text-xs text-text-secondary">
|
||||
<div className="w-0 grow">
|
||||
<div className="truncate" title={data.webhook_url || '--'}>
|
||||
{data.webhook_url || '--'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Node)
|
||||
240
web/app/components/workflow/nodes/trigger-webhook/panel.tsx
Normal file
240
web/app/components/workflow/nodes/trigger-webhook/panel.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import type { FC } from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import type { HttpMethod, WebhookTriggerNodeType } from './types'
|
||||
import useConfig from './use-config'
|
||||
import ParameterTable from './components/parameter-table'
|
||||
import HeaderTable from './components/header-table'
|
||||
import ParagraphInput from './components/paragraph-input'
|
||||
import { OutputVariablesContent } from './utils/render-output-vars'
|
||||
import Field from '@/app/components/workflow/nodes/_base/components/field'
|
||||
import Split from '@/app/components/workflow/nodes/_base/components/split'
|
||||
import OutputVars from '@/app/components/workflow/nodes/_base/components/output-vars'
|
||||
import type { NodePanelProps } from '@/app/components/workflow/types'
|
||||
import InputWithCopy from '@/app/components/base/input-with-copy'
|
||||
import { InputNumber } from '@/app/components/base/input-number'
|
||||
import { SimpleSelect } from '@/app/components/base/select'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { isPrivateOrLocalAddress } from '@/utils/urlValidation'
|
||||
|
||||
const i18nPrefix = 'workflow.nodes.triggerWebhook'
|
||||
|
||||
const HTTP_METHODS = [
|
||||
{ name: 'GET', value: 'GET' },
|
||||
{ name: 'POST', value: 'POST' },
|
||||
{ name: 'PUT', value: 'PUT' },
|
||||
{ name: 'DELETE', value: 'DELETE' },
|
||||
{ name: 'PATCH', value: 'PATCH' },
|
||||
{ name: 'HEAD', value: 'HEAD' },
|
||||
]
|
||||
|
||||
const CONTENT_TYPES = [
|
||||
{ name: 'application/json', value: 'application/json' },
|
||||
{ name: 'application/x-www-form-urlencoded', value: 'application/x-www-form-urlencoded' },
|
||||
{ name: 'text/plain', value: 'text/plain' },
|
||||
{ name: 'application/octet-stream', value: 'application/octet-stream' },
|
||||
{ name: 'multipart/form-data', value: 'multipart/form-data' },
|
||||
]
|
||||
|
||||
const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({
|
||||
id,
|
||||
data,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [debugUrlCopied, setDebugUrlCopied] = React.useState(false)
|
||||
const [outputVarsCollapsed, setOutputVarsCollapsed] = useState(false)
|
||||
const {
|
||||
readOnly,
|
||||
inputs,
|
||||
handleMethodChange,
|
||||
handleContentTypeChange,
|
||||
handleHeadersChange,
|
||||
handleParamsChange,
|
||||
handleBodyChange,
|
||||
handleStatusCodeChange,
|
||||
handleStatusCodeBlur,
|
||||
handleResponseBodyChange,
|
||||
generateWebhookUrl,
|
||||
} = useConfig(id, data)
|
||||
|
||||
// Ensure we only attempt to generate URL once for a newly created node without url
|
||||
const hasRequestedUrlRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (!readOnly && !inputs.webhook_url && !hasRequestedUrlRef.current) {
|
||||
hasRequestedUrlRef.current = true
|
||||
void generateWebhookUrl()
|
||||
}
|
||||
}, [readOnly, inputs.webhook_url, generateWebhookUrl])
|
||||
|
||||
return (
|
||||
<div className='mt-2'>
|
||||
<div className='space-y-4 px-4 pb-3 pt-2'>
|
||||
{/* Webhook URL Section */}
|
||||
<Field title={t(`${i18nPrefix}.webhookUrl`)}>
|
||||
<div className="space-y-1">
|
||||
<div className="flex gap-1" style={{ height: '32px' }}>
|
||||
<div className="w-26 shrink-0">
|
||||
<SimpleSelect
|
||||
items={HTTP_METHODS}
|
||||
defaultValue={inputs.method}
|
||||
onSelect={item => handleMethodChange(item.value as HttpMethod)}
|
||||
disabled={readOnly}
|
||||
className="h-8 pr-8 text-sm"
|
||||
wrapperClassName="h-8"
|
||||
optionWrapClassName="w-26 min-w-26 z-[5]"
|
||||
allowSearch={false}
|
||||
notClearable={true}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1" style={{ width: '284px' }}>
|
||||
<InputWithCopy
|
||||
value={inputs.webhook_url || ''}
|
||||
placeholder={t(`${i18nPrefix}.webhookUrlPlaceholder`)}
|
||||
readOnly
|
||||
onCopy={() => {
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t(`${i18nPrefix}.urlCopied`),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{inputs.webhook_debug_url && (
|
||||
<div className="space-y-2">
|
||||
<Tooltip
|
||||
popupContent={debugUrlCopied ? t(`${i18nPrefix}.debugUrlCopied`) : t(`${i18nPrefix}.debugUrlCopy`)}
|
||||
popupClassName="system-xs-regular text-text-primary bg-components-tooltip-bg border border-components-panel-border shadow-lg backdrop-blur-sm rounded-md px-1.5 py-1"
|
||||
position="top"
|
||||
offset={{ mainAxis: -20 }}
|
||||
needsDelay={true}
|
||||
>
|
||||
<div
|
||||
className="flex cursor-pointer gap-1.5 rounded-lg px-1 py-1.5 transition-colors"
|
||||
style={{ width: '368px', height: '38px' }}
|
||||
onClick={() => {
|
||||
copy(inputs.webhook_debug_url || '')
|
||||
setDebugUrlCopied(true)
|
||||
setTimeout(() => setDebugUrlCopied(false), 2000)
|
||||
}}
|
||||
>
|
||||
<div className="mt-0.5 w-0.5 bg-divider-regular" style={{ height: '28px' }}></div>
|
||||
<div className="flex-1" style={{ width: '352px', height: '32px' }}>
|
||||
<div className="text-xs leading-4 text-text-tertiary">
|
||||
{t(`${i18nPrefix}.debugUrlTitle`)}
|
||||
</div>
|
||||
<div className="truncate text-xs leading-4 text-text-primary">
|
||||
{inputs.webhook_debug_url}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{isPrivateOrLocalAddress(inputs.webhook_debug_url) && (
|
||||
<div className="system-xs-regular mt-1 px-0 py-[2px] text-text-warning">
|
||||
{t(`${i18nPrefix}.debugUrlPrivateAddressWarning`)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{/* Content Type */}
|
||||
<Field title={t(`${i18nPrefix}.contentType`)}>
|
||||
<div className="w-full">
|
||||
<SimpleSelect
|
||||
items={CONTENT_TYPES}
|
||||
defaultValue={inputs.content_type}
|
||||
onSelect={item => handleContentTypeChange(item.value as string)}
|
||||
disabled={readOnly}
|
||||
className="h-8 text-sm"
|
||||
wrapperClassName="h-8"
|
||||
optionWrapClassName="min-w-48 z-[5]"
|
||||
allowSearch={false}
|
||||
notClearable={true}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{/* Query Parameters */}
|
||||
<ParameterTable
|
||||
readonly={readOnly}
|
||||
title="Query Parameters"
|
||||
parameters={inputs.params}
|
||||
onChange={handleParamsChange}
|
||||
placeholder={t(`${i18nPrefix}.noQueryParameters`)}
|
||||
/>
|
||||
|
||||
{/* Header Parameters */}
|
||||
<HeaderTable
|
||||
readonly={readOnly}
|
||||
headers={inputs.headers}
|
||||
onChange={handleHeadersChange}
|
||||
/>
|
||||
|
||||
{/* Request Body Parameters */}
|
||||
<ParameterTable
|
||||
readonly={readOnly}
|
||||
title="Request Body Parameters"
|
||||
parameters={inputs.body}
|
||||
onChange={handleBodyChange}
|
||||
placeholder={t(`${i18nPrefix}.noBodyParameters`)}
|
||||
contentType={inputs.content_type}
|
||||
/>
|
||||
|
||||
<Split />
|
||||
|
||||
{/* Response Configuration */}
|
||||
<Field title={t(`${i18nPrefix}.responseConfiguration`)}>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="system-sm-medium text-text-tertiary">
|
||||
{t(`${i18nPrefix}.statusCode`)}
|
||||
</label>
|
||||
<InputNumber
|
||||
value={inputs.status_code}
|
||||
onChange={(value) => {
|
||||
handleStatusCodeChange(value || 200)
|
||||
}}
|
||||
disabled={readOnly}
|
||||
wrapClassName="w-[120px]"
|
||||
className="h-8"
|
||||
defaultValue={200}
|
||||
onBlur={() => {
|
||||
handleStatusCodeBlur(inputs.status_code)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="system-sm-medium mb-2 block text-text-tertiary">
|
||||
{t(`${i18nPrefix}.responseBody`)}
|
||||
</label>
|
||||
<ParagraphInput
|
||||
value={inputs.response_body}
|
||||
onChange={handleResponseBodyChange}
|
||||
placeholder={t(`${i18nPrefix}.responseBodyPlaceholder`)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Split />
|
||||
|
||||
<div className=''>
|
||||
<OutputVars
|
||||
collapsed={outputVarsCollapsed}
|
||||
onCollapse={setOutputVarsCollapsed}
|
||||
>
|
||||
<OutputVariablesContent variables={inputs.variables} />
|
||||
</OutputVars>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Panel
|
||||
35
web/app/components/workflow/nodes/trigger-webhook/types.ts
Normal file
35
web/app/components/workflow/nodes/trigger-webhook/types.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { CommonNodeType, VarType, Variable } from '@/app/components/workflow/types'
|
||||
|
||||
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD'
|
||||
|
||||
export type ArrayElementType = 'string' | 'number' | 'boolean' | 'object'
|
||||
|
||||
export const getArrayElementType = (arrayType: `array[${ArrayElementType}]`): ArrayElementType => {
|
||||
const match = arrayType.match(/^array\[(.+)\]$/)
|
||||
return (match?.[1] as ArrayElementType) || 'string'
|
||||
}
|
||||
|
||||
export type WebhookParameter = {
|
||||
name: string
|
||||
type: VarType
|
||||
required: boolean
|
||||
}
|
||||
|
||||
export type WebhookHeader = {
|
||||
name: string
|
||||
required: boolean
|
||||
}
|
||||
|
||||
export type WebhookTriggerNodeType = CommonNodeType & {
|
||||
webhook_url?: string
|
||||
webhook_debug_url?: string
|
||||
method: HttpMethod
|
||||
content_type: string
|
||||
headers: WebhookHeader[]
|
||||
params: WebhookParameter[]
|
||||
body: WebhookParameter[]
|
||||
async_mode: boolean
|
||||
status_code: number
|
||||
response_body: string
|
||||
variables: Variable[]
|
||||
}
|
||||
251
web/app/components/workflow/nodes/trigger-webhook/use-config.ts
Normal file
251
web/app/components/workflow/nodes/trigger-webhook/use-config.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { useCallback } from 'react'
|
||||
import { produce } from 'immer'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { HttpMethod, WebhookHeader, WebhookParameter, WebhookTriggerNodeType } from './types'
|
||||
|
||||
import { useNodesReadOnly, useWorkflow } from '@/app/components/workflow/hooks'
|
||||
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { fetchWebhookUrl } from '@/service/apps'
|
||||
import type { Variable } from '@/app/components/workflow/types'
|
||||
import { VarType } from '@/app/components/workflow/types'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { checkKeys, hasDuplicateStr } from '@/utils/var'
|
||||
import { WEBHOOK_RAW_VARIABLE_NAME } from './utils/raw-variable'
|
||||
|
||||
const useConfig = (id: string, payload: WebhookTriggerNodeType) => {
|
||||
const { t } = useTranslation()
|
||||
const { nodesReadOnly: readOnly } = useNodesReadOnly()
|
||||
const { inputs, setInputs } = useNodeCrud<WebhookTriggerNodeType>(id, payload)
|
||||
const appId = useAppStore.getState().appDetail?.id
|
||||
const { isVarUsedInNodes, removeUsedVarInNodes } = useWorkflow()
|
||||
|
||||
const handleMethodChange = useCallback((method: HttpMethod) => {
|
||||
setInputs(produce(inputs, (draft) => {
|
||||
draft.method = method
|
||||
}))
|
||||
}, [inputs, setInputs])
|
||||
|
||||
const handleContentTypeChange = useCallback((contentType: string) => {
|
||||
setInputs(produce(inputs, (draft) => {
|
||||
const previousContentType = draft.content_type
|
||||
draft.content_type = contentType
|
||||
|
||||
// If the content type changes, reset body parameters and their variables, as the variable types might differ.
|
||||
// However, we could consider retaining variables that are compatible with the new content type later.
|
||||
if (previousContentType !== contentType) {
|
||||
draft.body = []
|
||||
if (draft.variables) {
|
||||
const bodyVariables = draft.variables.filter(v => v.label === 'body')
|
||||
bodyVariables.forEach((v) => {
|
||||
if (isVarUsedInNodes([id, v.variable]))
|
||||
removeUsedVarInNodes([id, v.variable])
|
||||
})
|
||||
|
||||
draft.variables = draft.variables.filter(v => v.label !== 'body')
|
||||
}
|
||||
}
|
||||
}))
|
||||
}, [inputs, setInputs, id, isVarUsedInNodes, removeUsedVarInNodes])
|
||||
|
||||
const syncVariablesInDraft = useCallback((
|
||||
draft: WebhookTriggerNodeType,
|
||||
newData: (WebhookParameter | WebhookHeader)[],
|
||||
sourceType: 'param' | 'header' | 'body',
|
||||
) => {
|
||||
if (!draft.variables)
|
||||
draft.variables = []
|
||||
|
||||
const sanitizedEntries = newData.map(item => ({
|
||||
item,
|
||||
sanitizedName: sourceType === 'header' ? item.name.replace(/-/g, '_') : item.name,
|
||||
}))
|
||||
|
||||
const hasReservedConflict = sanitizedEntries.some(entry => entry.sanitizedName === WEBHOOK_RAW_VARIABLE_NAME)
|
||||
if (hasReservedConflict) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('appDebug.varKeyError.keyAlreadyExists', {
|
||||
key: t('appDebug.variableConfig.varName'),
|
||||
}),
|
||||
})
|
||||
return false
|
||||
}
|
||||
const existingOtherVarNames = new Set(
|
||||
draft.variables
|
||||
.filter(v => v.label !== sourceType && v.variable !== WEBHOOK_RAW_VARIABLE_NAME)
|
||||
.map(v => v.variable),
|
||||
)
|
||||
|
||||
const crossScopeConflict = sanitizedEntries.find(entry => existingOtherVarNames.has(entry.sanitizedName))
|
||||
if (crossScopeConflict) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('appDebug.varKeyError.keyAlreadyExists', {
|
||||
key: crossScopeConflict.sanitizedName,
|
||||
}),
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
if(hasDuplicateStr(sanitizedEntries.map(entry => entry.sanitizedName))) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('appDebug.varKeyError.keyAlreadyExists', {
|
||||
key: t('appDebug.variableConfig.varName'),
|
||||
}),
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
for (const { sanitizedName } of sanitizedEntries) {
|
||||
const { isValid, errorMessageKey } = checkKeys([sanitizedName], false)
|
||||
if (!isValid) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t(`appDebug.varKeyError.${errorMessageKey}`, {
|
||||
key: t('appDebug.variableConfig.varName'),
|
||||
}),
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Create set of new variable names for this source
|
||||
const newVarNames = new Set(sanitizedEntries.map(entry => entry.sanitizedName))
|
||||
|
||||
// Find variables from current source that will be deleted and clean up references
|
||||
draft.variables
|
||||
.filter(v => v.label === sourceType && !newVarNames.has(v.variable))
|
||||
.forEach((v) => {
|
||||
// Clean up references if variable is used in other nodes
|
||||
if (isVarUsedInNodes([id, v.variable]))
|
||||
removeUsedVarInNodes([id, v.variable])
|
||||
})
|
||||
|
||||
// Remove variables that no longer exist in newData for this specific source type
|
||||
draft.variables = draft.variables.filter((v) => {
|
||||
// Keep variables from other sources
|
||||
if (v.label !== sourceType) return true
|
||||
return newVarNames.has(v.variable)
|
||||
})
|
||||
|
||||
// Add or update variables
|
||||
sanitizedEntries.forEach(({ item, sanitizedName }) => {
|
||||
const existingVarIndex = draft.variables.findIndex(v => v.variable === sanitizedName)
|
||||
|
||||
const inputVarType = 'type' in item
|
||||
? item.type
|
||||
: VarType.string // Default to string for headers
|
||||
|
||||
const newVar: Variable = {
|
||||
value_type: inputVarType,
|
||||
label: sourceType, // Use sourceType as label to identify source
|
||||
variable: sanitizedName,
|
||||
value_selector: [],
|
||||
required: item.required,
|
||||
}
|
||||
|
||||
if (existingVarIndex >= 0)
|
||||
draft.variables[existingVarIndex] = newVar
|
||||
else
|
||||
draft.variables.push(newVar)
|
||||
})
|
||||
return true
|
||||
}, [t, id, isVarUsedInNodes, removeUsedVarInNodes])
|
||||
|
||||
const handleParamsChange = useCallback((params: WebhookParameter[]) => {
|
||||
setInputs(produce(inputs, (draft) => {
|
||||
draft.params = params
|
||||
syncVariablesInDraft(draft, params, 'param')
|
||||
}))
|
||||
}, [inputs, setInputs, syncVariablesInDraft])
|
||||
|
||||
const handleHeadersChange = useCallback((headers: WebhookHeader[]) => {
|
||||
setInputs(produce(inputs, (draft) => {
|
||||
draft.headers = headers
|
||||
syncVariablesInDraft(draft, headers, 'header')
|
||||
}))
|
||||
}, [inputs, setInputs, syncVariablesInDraft])
|
||||
|
||||
const handleBodyChange = useCallback((body: WebhookParameter[]) => {
|
||||
setInputs(produce(inputs, (draft) => {
|
||||
draft.body = body
|
||||
syncVariablesInDraft(draft, body, 'body')
|
||||
}))
|
||||
}, [inputs, setInputs, syncVariablesInDraft])
|
||||
|
||||
const handleAsyncModeChange = useCallback((asyncMode: boolean) => {
|
||||
setInputs(produce(inputs, (draft) => {
|
||||
draft.async_mode = asyncMode
|
||||
}))
|
||||
}, [inputs, setInputs])
|
||||
|
||||
const handleStatusCodeChange = useCallback((statusCode: number) => {
|
||||
setInputs(produce(inputs, (draft) => {
|
||||
draft.status_code = statusCode
|
||||
}))
|
||||
}, [inputs, setInputs])
|
||||
|
||||
const handleStatusCodeBlur = useCallback((statusCode: number) => {
|
||||
// Only clamp when user finishes editing (on blur)
|
||||
const clampedStatusCode = Math.min(Math.max(statusCode, 200), 399)
|
||||
|
||||
setInputs(produce(inputs, (draft) => {
|
||||
draft.status_code = clampedStatusCode
|
||||
}))
|
||||
}, [inputs, setInputs])
|
||||
|
||||
const handleResponseBodyChange = useCallback((responseBody: string) => {
|
||||
setInputs(produce(inputs, (draft) => {
|
||||
draft.response_body = responseBody
|
||||
}))
|
||||
}, [inputs, setInputs])
|
||||
|
||||
const generateWebhookUrl = useCallback(async () => {
|
||||
// Idempotency: if we already have a URL, just return it.
|
||||
if (inputs.webhook_url && inputs.webhook_url.length > 0)
|
||||
return
|
||||
|
||||
if (!appId)
|
||||
return
|
||||
|
||||
try {
|
||||
// Call backend to generate or fetch webhook url for this node
|
||||
const response = await fetchWebhookUrl({ appId, nodeId: id })
|
||||
|
||||
const newInputs = produce(inputs, (draft) => {
|
||||
draft.webhook_url = response.webhook_url
|
||||
draft.webhook_debug_url = response.webhook_debug_url
|
||||
})
|
||||
setInputs(newInputs)
|
||||
}
|
||||
catch (error: unknown) {
|
||||
// Fallback to mock URL when API is not ready or request fails
|
||||
// Keep the UI unblocked and allow users to proceed in local/dev environments.
|
||||
console.error('Failed to generate webhook URL:', error)
|
||||
const newInputs = produce(inputs, (draft) => {
|
||||
draft.webhook_url = ''
|
||||
})
|
||||
setInputs(newInputs)
|
||||
}
|
||||
}, [appId, id, inputs, setInputs])
|
||||
|
||||
return {
|
||||
readOnly,
|
||||
inputs,
|
||||
setInputs,
|
||||
handleMethodChange,
|
||||
handleContentTypeChange,
|
||||
handleHeadersChange,
|
||||
handleParamsChange,
|
||||
handleBodyChange,
|
||||
handleAsyncModeChange,
|
||||
handleStatusCodeChange,
|
||||
handleStatusCodeBlur,
|
||||
handleResponseBodyChange,
|
||||
generateWebhookUrl,
|
||||
}
|
||||
}
|
||||
|
||||
export default useConfig
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user