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:
Yeuoly
2025-11-12 17:59:37 +08:00
committed by GitHub
parent ca7794305b
commit b76e17b25d
785 changed files with 41186 additions and 3725 deletions

View File

@@ -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])

View File

@@ -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: {},

View File

@@ -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}

View File

@@ -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

View File

@@ -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

View File

@@ -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}

View File

@@ -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)

View File

@@ -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

View File

@@ -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,

View File

@@ -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(() => {

View File

@@ -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' />

View File

@@ -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

View File

@@ -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)

View File

@@ -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}
/>
)
}

View File

@@ -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)

View File

@@ -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>

View File

@@ -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[]

View File

@@ -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>

View File

@@ -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}
/>}

View File

@@ -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 && (
<>

View File

@@ -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
}

View File

@@ -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>
)

View File

@@ -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}

View File

@@ -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

View File

@@ -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>
}

View File

@@ -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,

View File

@@ -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)

View 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
}

View File

@@ -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
}

View File

@@ -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,
}

View File

@@ -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' },

View File

@@ -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,

View File

@@ -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>
)
}

View File

@@ -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 = {

View File

@@ -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,
}
},
}

View File

@@ -30,6 +30,7 @@ const Panel: FC<NodePanelProps<EndNodeType>> = ({
<Field
title={t(`${i18nPrefix}.output.variable`)}
required
operations={
!readOnly ? <AddButton onClick={handleAddVariable} /> : undefined
}

View File

@@ -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,

View File

@@ -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 || ''}

View File

@@ -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 },
}
})

View File

@@ -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}

View File

@@ -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}

View File

@@ -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)

View File

@@ -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}

View File

@@ -30,6 +30,7 @@ export enum Type {
arrayNumber = 'array[number]',
arrayObject = 'array[object]',
file = 'file',
enumType = 'enum',
}
export enum ArrayType {

View File

@@ -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)

View File

@@ -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, {

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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:

View File

@@ -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,
},

View File

@@ -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}

View File

@@ -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)

View File

@@ -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,
},

View File

@@ -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}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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>

View File

@@ -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}
/>
)

View File

@@ -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'

View File

@@ -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'
/>
))
}

View File

@@ -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 && (

View File

@@ -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>
)
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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,
}
}

View 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)

View 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)

View 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

View File

@@ -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

View 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

View File

@@ -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()
})
})
})

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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],
})

View 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

View 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)

View 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)

View 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
}

View 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

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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
})
})
})
})

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View 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

View 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)

View 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

View 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[]
}

View 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