refactor: unify trigger node architecture and clean up technical debt (#24886)

Co-authored-by: hjlarry <hjlarry@163.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
lyzno1 2025-09-01 15:47:44 +08:00 committed by GitHub
parent 6d307cc9fc
commit 327b354cc2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 145 additions and 70 deletions

View File

@ -48,7 +48,7 @@ class WebhookData(BaseNodeData):
SYNC = "async" # only support
method: Method = Method.GET
content_type: ContentType = Field(alias="content-type", default=ContentType.JSON)
content_type: ContentType = Field(default=ContentType.JSON)
headers: Sequence[WebhookParameter] = Field(default_factory=list)
params: Sequence[WebhookParameter] = Field(default_factory=list) # query parameters
body: Sequence[WebhookBodyParameter] = Field(default_factory=list)

View File

@ -42,7 +42,7 @@ class TriggerWebhookNode(BaseNode):
"type": "webhook",
"config": {
"method": "get",
"content-type": "application/json",
"content_type": "application/json",
"headers": [],
"params": [],
"body": [],

View File

@ -89,7 +89,7 @@ class TestWebhookService:
"data": {
"title": "Test Webhook",
"method": "post",
"content-type": "application/json",
"content_type": "application/json",
"headers": [
{"name": "Authorization", "required": True},
{"name": "Content-Type", "required": False},

View File

@ -508,7 +508,7 @@ export const RETRIEVAL_OUTPUT_STRUCT = `{
}`
export const SUPPORT_OUTPUT_VARS_NODE = [
BlockEnum.Start, BlockEnum.LLM, BlockEnum.KnowledgeRetrieval, BlockEnum.Code, BlockEnum.TemplateTransform,
BlockEnum.Start, BlockEnum.TriggerWebhook, BlockEnum.LLM, BlockEnum.KnowledgeRetrieval, BlockEnum.Code, BlockEnum.TemplateTransform,
BlockEnum.HttpRequest, BlockEnum.Tool, BlockEnum.VariableAssigner, BlockEnum.VariableAggregator, BlockEnum.QuestionClassifier,
BlockEnum.ParameterExtractor, BlockEnum.Iteration, BlockEnum.Loop,
BlockEnum.DocExtractor, BlockEnum.ListFilter,

View File

@ -22,6 +22,7 @@ import type { StartNodeType } from '@/app/components/workflow/nodes/start/types'
import type { ConversationVariable, EnvironmentVariable, Node, NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types'
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 { WebhookTriggerNodeType } from '@/app/components/workflow/nodes/trigger-webhook/types'
import {
AGENT_OUTPUT_STRUCT,
@ -290,6 +291,36 @@ const formatItem = (
break
}
case BlockEnum.TriggerWebhook: {
const {
variables = [],
} = data as WebhookTriggerNodeType
res.vars = variables.map((v) => {
const type = inputVarTypeToVarType(v.type)
const varRes: Var = {
variable: v.variable,
type,
isParagraph: v.type === InputVarType.paragraph,
isSelect: v.type === InputVarType.select,
options: v.options,
required: v.required,
}
try {
if(type === VarType.object && v.json_schema) {
varRes.children = {
schema: JSON.parse(v.json_schema),
}
}
}
catch (error) {
console.error('Error formatting TriggerWebhook variable:', error)
}
return varRes
})
break
}
case BlockEnum.LLM: {
res.vars = [...LLM_OUTPUT_STRUCT]
if (data.structured_output_enabled && data.structured_output?.schema?.properties && Object.keys(data.structured_output.schema.properties).length > 0) {

View File

@ -1,7 +1,7 @@
import { BlockEnum } from '../../types'
import type { NodeDefault } from '../../types'
import type { PluginTriggerNodeType } from './types'
import { ALL_CHAT_AVAILABLE_BLOCKS, ALL_COMPLETION_AVAILABLE_BLOCKS } from '@/app/components/workflow/blocks'
import { ALL_COMPLETION_AVAILABLE_BLOCKS } from '@/app/components/workflow/blocks'
const nodeDefault: NodeDefault<PluginTriggerNodeType> = {
defaultValue: {
@ -15,8 +15,8 @@ const nodeDefault: NodeDefault<PluginTriggerNodeType> = {
},
getAvailableNextNodes(isChatMode: boolean) {
const nodes = isChatMode
? ALL_CHAT_AVAILABLE_BLOCKS
: ALL_COMPLETION_AVAILABLE_BLOCKS.filter(type => type !== BlockEnum.End)
? []
: ALL_COMPLETION_AVAILABLE_BLOCKS
return nodes.filter(type => type !== BlockEnum.Start)
},
checkValid(payload: PluginTriggerNodeType, t: any) {

View File

@ -32,7 +32,6 @@ describe('Schedule Trigger Node Default', () => {
it('should have correct default value', () => {
expect(nodeDefault.defaultValue.mode).toBe('visual')
expect(nodeDefault.defaultValue.frequency).toBe('weekly')
expect(nodeDefault.defaultValue.enabled).toBe(true)
})
it('should have empty prev nodes', () => {

View File

@ -9,7 +9,6 @@ const createMonthlyConfig = (monthly_days: (number | 'last')[], time = '10:30 AM
monthly_days,
},
timezone,
enabled: true,
})
describe('Monthly Edge Cases', () => {

View File

@ -9,7 +9,6 @@ const createMonthlyConfig = (monthlyDays: (number | 'last')[], time = '10:30 AM'
monthly_days: monthlyDays,
},
timezone: 'UTC',
enabled: true,
id: 'test',
type: 'trigger-schedule',
data: {},
@ -117,8 +116,7 @@ describe('Monthly Multi-Select Execution Time Calculator', () => {
time: '10:30 AM',
},
timezone: 'UTC',
enabled: true,
id: 'test',
id: 'test',
type: 'trigger-schedule',
data: {},
position: { x: 0, y: 0 },

View File

@ -22,7 +22,6 @@ describe('Monthly Validation', () => {
monthly_days: [15],
},
timezone: 'UTC',
enabled: true,
}
const result = nodeDefault.checkValid(config, mockT)
@ -39,7 +38,6 @@ describe('Monthly Validation', () => {
monthly_days: ['last' as const],
},
timezone: 'UTC',
enabled: true,
}
const result = nodeDefault.checkValid(config, mockT)
@ -58,7 +56,6 @@ describe('Monthly Validation', () => {
monthly_days: [1, 15, 30],
},
timezone: 'UTC',
enabled: true,
}
const result = nodeDefault.checkValid(config, mockT)
@ -75,7 +72,6 @@ describe('Monthly Validation', () => {
monthly_days: [1, 15, 'last' as const],
},
timezone: 'UTC',
enabled: true,
}
const result = nodeDefault.checkValid(config, mockT)
@ -92,7 +88,6 @@ describe('Monthly Validation', () => {
monthly_days: [],
},
timezone: 'UTC',
enabled: true,
}
const result = nodeDefault.checkValid(config, mockT)
@ -109,7 +104,6 @@ describe('Monthly Validation', () => {
monthly_days: [1, 35, 15],
},
timezone: 'UTC',
enabled: true,
}
const result = nodeDefault.checkValid(config, mockT)
@ -127,7 +121,6 @@ describe('Monthly Validation', () => {
time: '10:30 AM',
},
timezone: 'UTC',
enabled: true,
}
const result = nodeDefault.checkValid(config, mockT)
@ -144,7 +137,6 @@ describe('Monthly Validation', () => {
monthly_days: [1, 15],
},
timezone: 'UTC',
enabled: true,
}
const result = nodeDefault.checkValid(config, mockT)
@ -161,7 +153,6 @@ describe('Monthly Validation', () => {
monthly_days: Array.from({ length: 31 }, (_, i) => i + 1),
},
timezone: 'UTC',
enabled: true,
}
const result = nodeDefault.checkValid(config, mockT)

View File

@ -15,7 +15,6 @@ const createWeeklyConfig = (
weekdays,
},
timezone,
enabled: true,
})
describe('Weekly Schedule Time Logic Tests', () => {
@ -364,8 +363,7 @@ describe('Weekly Schedule Time Logic Tests', () => {
time: '2:00 PM',
},
timezone: 'UTC',
enabled: true,
}
}
const weeklyTimes = getNextExecutionTimes(weeklyConfig, 1)
const dailyTimes = getNextExecutionTimes(dailyConfig, 1)
@ -389,8 +387,7 @@ describe('Weekly Schedule Time Logic Tests', () => {
time: '2:00 PM',
},
timezone: 'UTC',
enabled: true,
}
}
const weeklyTimes = getNextExecutionTimes(weeklyConfig, 1)
const dailyTimes = getNextExecutionTimes(dailyConfig, 1)

View File

@ -4,7 +4,6 @@ import type { ScheduleTriggerNodeType } from './types'
export const getDefaultScheduleConfig = (): Partial<ScheduleTriggerNodeType> => ({
mode: 'visual',
frequency: 'weekly',
enabled: true,
visual_config: {
time: '11:30 AM',
weekdays: ['sun'],

View File

@ -1,7 +1,7 @@
import { BlockEnum } from '../../types'
import type { NodeDefault } from '../../types'
import type { ScheduleTriggerNodeType } from './types'
import { ALL_CHAT_AVAILABLE_BLOCKS, ALL_COMPLETION_AVAILABLE_BLOCKS } from '@/app/components/workflow/blocks'
import { ALL_COMPLETION_AVAILABLE_BLOCKS } from '@/app/components/workflow/blocks'
import { isValidCronExpression } from './utils/cron-parser'
import { getNextExecutionTimes } from './utils/execution-time-calculator'
import { getDefaultScheduleConfig } from './constants'
@ -114,8 +114,8 @@ const nodeDefault: NodeDefault<ScheduleTriggerNodeType> = {
},
getAvailableNextNodes(isChatMode: boolean) {
const nodes = isChatMode
? ALL_CHAT_AVAILABLE_BLOCKS
: ALL_COMPLETION_AVAILABLE_BLOCKS.filter(type => type !== BlockEnum.End)
? []
: ALL_COMPLETION_AVAILABLE_BLOCKS
return nodes.filter(type => type !== BlockEnum.Start)
},
checkValid(payload: ScheduleTriggerNodeType, t: any) {

View File

@ -17,5 +17,4 @@ export type ScheduleTriggerNodeType = CommonNodeType & {
cron_expression?: string // Cron expression when mode is 'cron'
visual_config?: VisualConfig // User-friendly configuration when mode is 'visual'
timezone: string // User profile timezone (e.g., 'Asia/Shanghai', 'America/New_York')
enabled: boolean // Whether the trigger is active
}

View File

@ -16,7 +16,6 @@ const useConfig = (id: string, payload: ScheduleTriggerNodeType) => {
mode: payload.mode || 'visual',
frequency: payload.frequency || 'weekly',
timezone: userProfile.timezone || 'UTC',
enabled: payload.enabled !== undefined ? payload.enabled : true,
visual_config: {
...getDefaultVisualConfig(),
...payload.visual_config,

View File

@ -14,7 +14,6 @@ const createMockData = (overrides: Partial<ScheduleTriggerNodeType> = {}): Sched
time: '2:30 PM',
},
timezone: 'UTC',
enabled: true,
...overrides,
})

View File

@ -21,7 +21,7 @@ describe('Webhook Trigger Node Default', () => {
// Core webhook configuration
expect(defaultValue.webhook_url).toBe('')
expect(defaultValue.method).toBe('POST')
expect(defaultValue['content-type']).toBe('application/json')
expect(defaultValue.content_type).toBe('application/json')
// Response configuration fields
expect(defaultValue.async_mode).toBe(true)

View File

@ -1,29 +1,29 @@
import { BlockEnum } from '../../types'
import type { NodeDefault } from '../../types'
import type { WebhookTriggerNodeType } from './types'
import { ALL_CHAT_AVAILABLE_BLOCKS, ALL_COMPLETION_AVAILABLE_BLOCKS } from '@/app/components/workflow/blocks'
import { ALL_COMPLETION_AVAILABLE_BLOCKS } from '@/app/components/workflow/blocks'
import type { DefaultValueForm } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
const nodeDefault: NodeDefault<WebhookTriggerNodeType> = {
defaultValue: {
'webhook_url': '',
'method': 'POST',
'content-type': 'application/json',
'headers': [],
'params': [],
'body': [],
'async_mode': true,
'status_code': 200,
'response_body': '',
'default_value': [] as DefaultValueForm[],
webhook_url: '',
method: 'POST',
content_type: 'application/json',
headers: [],
params: [],
body: [],
async_mode: true,
status_code: 200,
response_body: '',
default_value: [] as DefaultValueForm[],
},
getAvailablePrevNodes(_isChatMode: boolean) {
return []
},
getAvailableNextNodes(isChatMode: boolean) {
const nodes = isChatMode
? ALL_CHAT_AVAILABLE_BLOCKS
: ALL_COMPLETION_AVAILABLE_BLOCKS.filter(type => type !== BlockEnum.End)
? []
: ALL_COMPLETION_AVAILABLE_BLOCKS
return nodes.filter(type => type !== BlockEnum.Start)
},
checkValid(_payload: WebhookTriggerNodeType, _t: any) {

View File

@ -135,7 +135,7 @@ const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({
<div className="w-full">
<SimpleSelect
items={CONTENT_TYPES}
defaultValue={inputs['content-type']}
defaultValue={inputs.content_type}
onSelect={item => handleContentTypeChange(item.value as string)}
disabled={readOnly}
className="h-8 text-sm"

View File

@ -1,4 +1,4 @@
import type { CommonNodeType } from '@/app/components/workflow/types'
import type { CommonNodeType, InputVar } from '@/app/components/workflow/types'
import type { DefaultValueForm } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
import type { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
@ -18,17 +18,18 @@ export type WebhookHeader = {
}
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
'http_methods'?: HttpMethod[]
'error_strategy'?: ErrorHandleTypeEnum
'default_value'?: DefaultValueForm[]
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
http_methods?: HttpMethod[]
error_strategy?: ErrorHandleTypeEnum
default_value?: DefaultValueForm[]
variables: InputVar[]
}

View File

@ -1,5 +1,6 @@
import { useCallback } from 'react'
import produce from 'immer'
import { useTranslation } from 'react-i18next'
import type { HttpMethod, WebhookHeader, WebhookParameter, WebhookTriggerNodeType } from './types'
import { useNodesReadOnly } from '@/app/components/workflow/hooks'
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
@ -7,8 +8,13 @@ import { useStore as useAppStore } from '@/app/components/app/store'
import type { DefaultValueForm } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
import type { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
import { fetchWebhookUrl } from '@/service/apps'
import type { InputVar } from '@/app/components/workflow/types'
import { InputVarType } from '@/app/components/workflow/types'
import Toast from '@/app/components/base/toast'
import { hasDuplicateStr } from '@/utils/var'
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
@ -21,27 +27,84 @@ const useConfig = (id: string, payload: WebhookTriggerNodeType) => {
const handleContentTypeChange = useCallback((contentType: string) => {
setInputs(produce(inputs, (draft) => {
draft['content-type'] = contentType
draft.content_type = contentType
}))
}, [inputs, setInputs])
const handleHeadersChange = useCallback((headers: WebhookHeader[]) => {
setInputs(produce(inputs, (draft) => {
draft.headers = headers
}))
}, [inputs, setInputs])
// Helper function to convert ParameterType to InputVarType
const toInputVarType = useCallback((type: string): InputVarType => {
const typeMap: Record<string, InputVarType> = {
string: InputVarType.textInput,
number: InputVarType.number,
boolean: InputVarType.checkbox,
array: InputVarType.textInput, // Arrays as text for now
object: InputVarType.jsonObject,
}
return typeMap[type] || InputVarType.textInput
}, [])
const syncVariablesInDraft = useCallback((
draft: WebhookTriggerNodeType,
newData: (WebhookParameter | WebhookHeader)[],
) => {
if (!draft.variables)
draft.variables = []
if(hasDuplicateStr(newData.map(item => item.name))) {
Toast.notify({
type: 'error',
message: t('appDebug.varKeyError.keyAlreadyExists', {
key: t('appDebug.variableConfig.varName'),
}),
})
return false
}
// Add or update variables
newData.forEach((item) => {
const varName = item.name
const existingVarIndex = draft.variables.findIndex(v => v.variable === varName)
const inputVarType = 'type' in item
? toInputVarType(item.type)
: InputVarType.textInput // Headers default to text
const newVar: InputVar = {
type: inputVarType,
label: varName,
variable: varName,
required: item.required,
}
if (existingVarIndex >= 0)
draft.variables[existingVarIndex] = newVar
else
draft.variables.push(newVar)
})
return true
}, [toInputVarType, t])
const handleParamsChange = useCallback((params: WebhookParameter[]) => {
setInputs(produce(inputs, (draft) => {
draft.params = params
syncVariablesInDraft(draft, params)
}))
}, [inputs, setInputs])
}, [inputs, setInputs, syncVariablesInDraft])
const handleHeadersChange = useCallback((headers: WebhookHeader[]) => {
setInputs(produce(inputs, (draft) => {
draft.headers = headers
syncVariablesInDraft(draft, headers)
}))
}, [inputs, setInputs, syncVariablesInDraft])
const handleBodyChange = useCallback((body: WebhookParameter[]) => {
setInputs(produce(inputs, (draft) => {
draft.body = body
syncVariablesInDraft(draft, body)
}))
}, [inputs, setInputs])
}, [inputs, setInputs, syncVariablesInDraft])
const handleAsyncModeChange = useCallback((asyncMode: boolean) => {
setInputs(produce(inputs, (draft) => {