diff --git a/web/app/components/workflow/block-icon.tsx b/web/app/components/workflow/block-icon.tsx index 600673dfbe..b34901bec4 100644 --- a/web/app/components/workflow/block-icon.tsx +++ b/web/app/components/workflow/block-icon.tsx @@ -108,7 +108,7 @@ const BlockIcon: FC = ({ `} > { - type !== BlockEnum.Tool && ( + type !== BlockEnum.Tool && type !== BlockEnum.TriggerPlugin && ( getIcon(type, (type === BlockEnum.TriggerSchedule || type === BlockEnum.TriggerWebhook) ? (size === 'xs' ? 'w-4 h-4' : 'w-4.5 h-4.5') @@ -117,7 +117,7 @@ const BlockIcon: FC = ({ ) } { - type === BlockEnum.Tool && toolIcon && ( + (type === BlockEnum.Tool || type === BlockEnum.TriggerPlugin) && toolIcon && ( <> { typeof toolIcon === 'string' diff --git a/web/app/components/workflow/block-selector/all-start-blocks.tsx b/web/app/components/workflow/block-selector/all-start-blocks.tsx new file mode 100644 index 0000000000..32b80d1cf7 --- /dev/null +++ b/web/app/components/workflow/block-selector/all-start-blocks.tsx @@ -0,0 +1,61 @@ +'use client' +import { useRef } from 'react' +import { useTranslation } from 'react-i18next' +import type { BlockEnum } from '../types' +import type { ToolDefaultValue } from './types' +import StartBlocks from './start-blocks' +import TriggerPluginSelector from './trigger-plugin-selector' +import { ENTRY_NODE_TYPES } from './constants' +import cn from '@/utils/classnames' +import Link from 'next/link' +import { RiArrowRightUpLine } from '@remixicon/react' +import { getMarketplaceUrl } from '@/utils/var' + +type AllStartBlocksProps = { + className?: string + searchText: string + onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void + availableBlocksTypes?: BlockEnum[] +} + +const AllStartBlocks = ({ + className, + searchText, + onSelect, + availableBlocksTypes, +}: AllStartBlocksProps) => { + const { t } = useTranslation() + const wrapElemRef = useRef(null) + + return ( +
+
+ + + +
+ + {/* Footer - Same as Tools tab marketplace footer */} + + {t('plugin.findMoreInMarketplace')} + + +
+ ) +} + +export default AllStartBlocks diff --git a/web/app/components/workflow/block-selector/constants.tsx b/web/app/components/workflow/block-selector/constants.tsx index 9c429edba8..ea313c5dab 100644 --- a/web/app/components/workflow/block-selector/constants.tsx +++ b/web/app/components/workflow/block-selector/constants.tsx @@ -21,14 +21,16 @@ export const START_BLOCKS: Block[] = [ title: 'Webhook Trigger', description: 'HTTP callback trigger', }, - { - classification: BlockClassificationEnum.Default, - type: BlockEnum.TriggerPlugin, - title: 'Plugin Trigger', - description: 'Third-party integration trigger', - }, ] +// Entry node types that can start a workflow +export const ENTRY_NODE_TYPES = [ + BlockEnum.Start, + BlockEnum.TriggerSchedule, + BlockEnum.TriggerWebhook, + BlockEnum.TriggerPlugin, +] as const + export const BLOCKS: Block[] = [ { classification: BlockClassificationEnum.Default, diff --git a/web/app/components/workflow/block-selector/index.tsx b/web/app/components/workflow/block-selector/index.tsx index 96433b37ef..b3123ecf1a 100644 --- a/web/app/components/workflow/block-selector/index.tsx +++ b/web/app/components/workflow/block-selector/index.tsx @@ -93,7 +93,7 @@ const NodeSelector: FC = ({ }, []) const searchPlaceholder = useMemo(() => { if (activeTab === TabsEnum.Start) - return t('workflow.tabs.searchBlock') + return t('workflow.tabs.searchTrigger') if (activeTab === TabsEnum.Blocks) return t('workflow.tabs.searchBlock') if (activeTab === TabsEnum.Tools) @@ -137,7 +137,7 @@ const NodeSelector: FC = ({ onActiveTabChange={handleActiveTabChange} filterElem={
e.stopPropagation()}> - {activeTab === TabsEnum.Blocks && ( + {(activeTab === TabsEnum.Start || activeTab === TabsEnum.Blocks) && ( { return START_BLOCKS.filter((block) => { - return block.title.toLowerCase().includes(searchText.toLowerCase()) - && availableBlocksTypes.includes(block.type) + // Filter by search text + if (!block.title.toLowerCase().includes(searchText.toLowerCase())) + return false + + // availableBlocksTypes now contains properly filtered entry node types from parent + return availableBlocksTypes.includes(block.type) }) }, [searchText, availableBlocksTypes]) @@ -60,13 +65,18 @@ const StartBlocks = ({ className='mr-2 shrink-0' type={block.type} /> -
{block.title}
+
+ {block.title} + {block.type === BlockEnumValues.Start && ( + {t('workflow.blocks.originalStartNode')} + )} +
- ), [nodesExtraData, onSelect]) + ), [nodesExtraData, onSelect, t]) return ( -
+
{isEmpty && (
{t('workflow.tabs.noResult')} @@ -74,7 +84,16 @@ const StartBlocks = ({ )} {!isEmpty && (
- {filteredBlocks.map(renderBlock)} + {filteredBlocks.map((block, index) => ( +
+ {renderBlock(block)} + {block.type === BlockEnumValues.Start && index < filteredBlocks.length - 1 && ( +
+
+
+ )} +
+ ))}
)}
diff --git a/web/app/components/workflow/block-selector/tabs.tsx b/web/app/components/workflow/block-selector/tabs.tsx index 9c6e4253f4..9ae7731bb7 100644 --- a/web/app/components/workflow/block-selector/tabs.tsx +++ b/web/app/components/workflow/block-selector/tabs.tsx @@ -6,7 +6,7 @@ import { useTabs } from './hooks' import type { ToolDefaultValue } from './types' import { TabsEnum } from './types' import Blocks from './blocks' -import StartBlocks from './start-blocks' +import AllStartBlocks from './all-start-blocks' import AllTools from './all-tools' import cn from '@/utils/classnames' @@ -66,7 +66,7 @@ const Tabs: FC = ({ { activeTab === TabsEnum.Start && !noBlocks && (
- void + searchText: string +} + +const TriggerPluginSelector = ({ + onSelect, + searchText, +}: TriggerPluginSelectorProps) => { + return ( + + ) +} + +export default memo(TriggerPluginSelector) diff --git a/web/app/components/workflow/block-selector/trigger-plugin/action-item.tsx b/web/app/components/workflow/block-selector/trigger-plugin/action-item.tsx new file mode 100644 index 0000000000..75c954431b --- /dev/null +++ b/web/app/components/workflow/block-selector/trigger-plugin/action-item.tsx @@ -0,0 +1,88 @@ +'use client' +import type { FC } from 'react' +import React from 'react' +import type { ToolWithProvider } from '../../types' +import { BlockEnum } from '../../types' +import type { ToolDefaultValue } from '../types' +import Tooltip from '@/app/components/base/tooltip' +import type { Tool } from '@/app/components/tools/types' +import { useGetLanguage } from '@/context/i18n' +import BlockIcon from '../../block-icon' +import cn from '@/utils/classnames' +import { useTranslation } from 'react-i18next' + +type Props = { + provider: ToolWithProvider + payload: Tool + disabled?: boolean + isAdded?: boolean + onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void +} + +const TriggerPluginActionItem: FC = ({ + provider, + payload, + onSelect, + disabled, + isAdded, +}) => { + const { t } = useTranslation() + const language = useGetLanguage() + + return ( + + +
{payload.label[language]}
+
{payload.description[language]}
+
+ )} + > +
{ + if (disabled) return + const params: Record = {} + if (payload.parameters) { + payload.parameters.forEach((item) => { + params[item.name] = '' + }) + } + onSelect(BlockEnum.TriggerPlugin, { + provider_id: provider.id, + provider_type: provider.type, + provider_name: provider.name, + tool_name: payload.name, + tool_label: payload.label[language], + tool_description: payload.description[language], + title: payload.label[language], + is_team_authorization: provider.is_team_authorization, + output_schema: payload.output_schema, + paramSchemas: payload.parameters, + params, + meta: provider.meta, + }) + }} + > +
+ {payload.label[language]} +
+ {isAdded && ( +
{t('tools.addToolModal.added')}
+ )} +
+ + ) +} +export default React.memo(TriggerPluginActionItem) diff --git a/web/app/components/workflow/block-selector/trigger-plugin/item.tsx b/web/app/components/workflow/block-selector/trigger-plugin/item.tsx new file mode 100644 index 0000000000..0a60a96773 --- /dev/null +++ b/web/app/components/workflow/block-selector/trigger-plugin/item.tsx @@ -0,0 +1,132 @@ +'use client' +import type { FC } from 'react' +import React, { useEffect, useMemo, useRef } from 'react' +import cn from '@/utils/classnames' +import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react' +import { useGetLanguage } from '@/context/i18n' +import { CollectionType } from '../../../tools/types' +import type { ToolWithProvider } from '../../types' +import { BlockEnum } from '../../types' +import type { ToolDefaultValue } from '../types' +import TriggerPluginActionItem from './action-item' +import BlockIcon from '../../block-icon' +import { useTranslation } from 'react-i18next' + +type Props = { + className?: string + payload: ToolWithProvider + hasSearchText: boolean + onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void +} + +const TriggerPluginItem: FC = ({ + className, + payload, + hasSearchText, + onSelect, +}) => { + const { t } = useTranslation() + const language = useGetLanguage() + const notShowProvider = payload.type === CollectionType.workflow + const actions = payload.tools + const hasAction = !notShowProvider + const [isFold, setFold] = React.useState(true) + const ref = useRef(null) + + useEffect(() => { + if (hasSearchText && isFold) { + setFold(false) + return + } + if (!hasSearchText && !isFold) + setFold(true) + }, [hasSearchText]) + + const FoldIcon = isFold ? RiArrowRightSLine : RiArrowDownSLine + + const groupName = useMemo(() => { + if (payload.type === CollectionType.builtIn) + return payload.author + + if (payload.type === CollectionType.custom) + return t('workflow.tabs.customTool') + + if (payload.type === CollectionType.workflow) + return t('workflow.tabs.workflowTool') + + return '' + }, [payload.author, payload.type, t]) + + return ( +
+
+
{ + if (hasAction) { + setFold(!isFold) + return + } + + const tool = actions[0] + const params: Record = {} + if (tool.parameters) { + tool.parameters.forEach((item) => { + params[item.name] = '' + }) + } + onSelect(BlockEnum.TriggerPlugin, { + provider_id: payload.id, + provider_type: payload.type, + provider_name: payload.name, + tool_name: tool.name, + tool_label: tool.label[language], + tool_description: tool.description[language], + title: tool.label[language], + is_team_authorization: payload.is_team_authorization, + output_schema: tool.output_schema, + paramSchemas: tool.parameters, + params, + }) + }} + > +
+ +
+ {notShowProvider ? actions[0]?.label[language] : payload.label[language]} + {groupName} +
+
+ +
+ {hasAction && ( + + )} +
+
+ + {!notShowProvider && hasAction && !isFold && ( + actions.map(action => ( + + )) + )} +
+
+ ) +} +export default React.memo(TriggerPluginItem) diff --git a/web/app/components/workflow/block-selector/trigger-plugin/list.tsx b/web/app/components/workflow/block-selector/trigger-plugin/list.tsx new file mode 100644 index 0000000000..293a516f3f --- /dev/null +++ b/web/app/components/workflow/block-selector/trigger-plugin/list.tsx @@ -0,0 +1,51 @@ +'use client' +import { memo, useMemo } from 'react' +import { useAllBuiltInTools } from '@/service/use-tools' +import TriggerPluginItem from './item' +import type { BlockEnum } from '../../types' +import type { ToolDefaultValue } from '../types' +import { useGetLanguage } from '@/context/i18n' + +type TriggerPluginListProps = { + onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void + searchText: string +} + +const TriggerPluginList = ({ + onSelect, + searchText, +}: TriggerPluginListProps) => { + const { data: buildInTools = [] } = useAllBuiltInTools() + const language = useGetLanguage() + + const triggerPlugins = useMemo(() => { + return buildInTools.filter((toolWithProvider) => { + if (toolWithProvider.tools.length === 0) return false + + if (!searchText) return true + + return toolWithProvider.name.toLowerCase().includes(searchText.toLowerCase()) + || toolWithProvider.tools.some(tool => + tool.label[language].toLowerCase().includes(searchText.toLowerCase()), + ) + }) + }, [buildInTools, searchText, language]) + + if (!triggerPlugins.length) + return null + + return ( +
+ {triggerPlugins.map(plugin => ( + + ))} +
+ ) +} + +export default memo(TriggerPluginList) diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index fdfb25b04d..2bbd675a19 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -18,6 +18,7 @@ import { } from 'reactflow' import { unionBy } from 'lodash-es' import type { ToolDefaultValue } from '../block-selector/types' +import { ENTRY_NODE_TYPES } from '../block-selector/constants' import type { Edge, Node, @@ -63,6 +64,24 @@ import { WorkflowHistoryEvent, useWorkflowHistory } from './use-workflow-history import useInspectVarsCrud from './use-inspect-vars-crud' import { getNodeUsedVars } from '../nodes/_base/components/variable/utils' +// Helper function to check if a node is an entry node +const isEntryNode = (nodeType: BlockEnum): boolean => { + return ENTRY_NODE_TYPES.includes(nodeType as any) +} + +// Helper function to check if entry node can be deleted +const canDeleteEntryNode = (nodes: Node[], nodeId: string): boolean => { + const targetNode = nodes.find(node => node.id === nodeId) + if (!targetNode || !isEntryNode(targetNode.data.type)) + return true // Non-entry nodes can always be deleted + + // Count all entry nodes + const entryNodes = nodes.filter(node => isEntryNode(node.data.type)) + + // Can delete if there's more than one entry node + return entryNodes.length > 1 +} + export const useNodesInteractions = () => { const { t } = useTranslation() const store = useStoreApi() @@ -548,15 +567,17 @@ export const useNodesInteractions = () => { } = store.getState() const nodes = getNodes() + + // Check if entry node can be deleted (must keep at least one entry node) + if (!canDeleteEntryNode(nodes, nodeId)) + return // Cannot delete the last entry node + const currentNodeIndex = nodes.findIndex(node => node.id === nodeId) const currentNode = nodes[currentNodeIndex] if (!currentNode) return - if (currentNode.data.type === BlockEnum.Start) - return - deleteNodeInspectorVars(nodeId) if (currentNode.data.type === BlockEnum.Iteration) { const iterationChildren = nodes.filter(node => node.parentId === currentNode.id) @@ -1388,7 +1409,9 @@ export const useNodesInteractions = () => { } = store.getState() const nodes = getNodes() - const bundledNodes = nodes.filter(node => node.data._isBundled && node.data.type !== BlockEnum.Start) + const bundledNodes = nodes.filter(node => + node.data._isBundled && canDeleteEntryNode(nodes, node.id), + ) if (bundledNodes.length) { bundledNodes.forEach(node => handleNodeDelete(node.id)) @@ -1400,7 +1423,9 @@ export const useNodesInteractions = () => { if (edgeSelected) return - const selectedNode = nodes.find(node => node.data.selected && node.data.type !== BlockEnum.Start) + const selectedNode = nodes.find(node => + node.data.selected && canDeleteEntryNode(nodes, node.id), + ) if (selectedNode) handleNodeDelete(selectedNode.id) diff --git a/web/app/components/workflow/hooks/use-workflow.ts b/web/app/components/workflow/hooks/use-workflow.ts index 0aaaad689a..b6ccb45df9 100644 --- a/web/app/components/workflow/hooks/use-workflow.ts +++ b/web/app/components/workflow/hooks/use-workflow.ts @@ -506,7 +506,7 @@ export const useToolIcon = (data: Node['data']) => { const toolIcon = useMemo(() => { if (!data) return '' - if (data.type === BlockEnum.Tool) { + if (data.type === BlockEnum.Tool || data.type === BlockEnum.TriggerPlugin) { let targetTools = workflowTools if (data.provider_type === CollectionType.builtIn) targetTools = buildInTools diff --git a/web/app/components/workflow/nodes/trigger-plugin/node.tsx b/web/app/components/workflow/nodes/trigger-plugin/node.tsx index 03b8010cce..f40baec58e 100644 --- a/web/app/components/workflow/nodes/trigger-plugin/node.tsx +++ b/web/app/components/workflow/nodes/trigger-plugin/node.tsx @@ -1,24 +1,53 @@ import type { FC } from 'react' import React from 'react' -import { useTranslation } from 'react-i18next' import type { PluginTriggerNodeType } from './types' import type { NodeProps } from '@/app/components/workflow/types' -const i18nPrefix = 'workflow.nodes.triggerPlugin' - const Node: FC> = ({ data, }) => { - const { t } = useTranslation() + const { config = {} } = data + const configKeys = Object.keys(config) + + if (!data.plugin_name && configKeys.length === 0) + return null return (
-
- {t(`${i18nPrefix}.nodeTitle`)} -
{data.plugin_name && ( -
+
{data.plugin_name} + {data.event_type && ( +
+ {data.event_type} +
+ )} +
+ )} + + {configKeys.length > 0 && ( +
+ {configKeys.map((key, index) => ( +
+
+ {key} +
+
+ {typeof config[key] === 'string' && config[key].includes('secret') + ? '********' + : String(config[key] || '')} +
+
+ ))}
)}
diff --git a/web/app/components/workflow/nodes/trigger-plugin/panel.tsx b/web/app/components/workflow/nodes/trigger-plugin/panel.tsx index 7913d06415..1538a66706 100644 --- a/web/app/components/workflow/nodes/trigger-plugin/panel.tsx +++ b/web/app/components/workflow/nodes/trigger-plugin/panel.tsx @@ -1,25 +1,35 @@ import type { FC } from 'react' import React from 'react' -import { useTranslation } from 'react-i18next' import type { PluginTriggerNodeType } from './types' import Field from '@/app/components/workflow/nodes/_base/components/field' import type { NodePanelProps } from '@/app/components/workflow/types' -const i18nPrefix = 'workflow.nodes.triggerPlugin' - const Panel: FC> = ({ - id, data, }) => { - const { t } = useTranslation() - return (
- -
- {t(`${i18nPrefix}.configPlaceholder`)} -
+ + {data.plugin_name ? ( +
+
+ {data.plugin_name} + {data.event_type && ( + + {data.event_type} + + )} +
+
+ Plugin trigger configured +
+
+ ) : ( +
+ No plugin selected. Configure this trigger in the workflow canvas. +
+ )}
diff --git a/web/app/components/workflow/nodes/trigger-plugin/types.ts b/web/app/components/workflow/nodes/trigger-plugin/types.ts index b61f32ea4f..00d85e0753 100644 --- a/web/app/components/workflow/nodes/trigger-plugin/types.ts +++ b/web/app/components/workflow/nodes/trigger-plugin/types.ts @@ -1,8 +1,12 @@ import type { CommonNodeType } from '@/app/components/workflow/types' +import type { CollectionType } from '@/app/components/tools/types' export type PluginTriggerNodeType = CommonNodeType & { plugin_id?: string plugin_name?: string event_type?: string config?: Record + provider_id?: string + provider_type?: CollectionType + provider_name?: string } diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index a5806dce81..ef2b48e6b0 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -224,6 +224,7 @@ const translation = { 'start': 'Start', 'blocks': 'Nodes', 'searchTool': 'Search tool', + 'searchTrigger': 'Search triggers...', 'tools': 'Tools', 'allTool': 'All', 'plugin': 'Plugin', @@ -240,6 +241,7 @@ const translation = { }, blocks: { 'start': 'User Input', + 'originalStartNode': 'original start node', 'end': 'End', 'answer': 'Answer', 'llm': 'LLM', @@ -979,11 +981,6 @@ const translation = { nodeTitle: '🔗 Webhook Trigger', configPlaceholder: 'Webhook trigger configuration will be implemented here', }, - triggerPlugin: { - title: 'Plugin Trigger', - nodeTitle: '🔌 Plugin Trigger', - configPlaceholder: 'Plugin trigger configuration will be implemented here', - }, }, triggerStatus: { enabled: 'TRIGGER', diff --git a/web/i18n/ja-JP/workflow.ts b/web/i18n/ja-JP/workflow.ts index 3f1d4d9da1..48ead1f5f6 100644 --- a/web/i18n/ja-JP/workflow.ts +++ b/web/i18n/ja-JP/workflow.ts @@ -223,6 +223,7 @@ const translation = { 'searchBlock': 'ブロック検索', 'blocks': 'ブロック', 'searchTool': 'ツール検索', + 'searchTrigger': 'トリガー検索...', 'tools': 'ツール', 'allTool': 'すべて', 'customTool': 'カスタム', @@ -240,6 +241,7 @@ const translation = { }, blocks: { 'start': '開始', + 'originalStartNode': '元の開始ノード', 'end': '終了', 'answer': '回答', 'llm': 'LLM', @@ -979,11 +981,6 @@ const translation = { nodeTitle: '🔗 Webhook トリガー', configPlaceholder: 'Webhook トリガーの設定がここに実装されます', }, - triggerPlugin: { - title: 'プラグイントリガー', - nodeTitle: '🔌 プラグイントリガー', - configPlaceholder: 'プラグイントリガーの設定がここに実装されます', - }, }, tracing: { stopBy: '{{user}}によって停止', diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts index 370051a257..e965ec34dc 100644 --- a/web/i18n/zh-Hans/workflow.ts +++ b/web/i18n/zh-Hans/workflow.ts @@ -223,6 +223,7 @@ const translation = { 'searchBlock': '搜索节点', 'blocks': '节点', 'searchTool': '搜索工具', + 'searchTrigger': '搜索触发器...', 'tools': '工具', 'allTool': '全部', 'plugin': '插件', @@ -240,6 +241,7 @@ const translation = { }, blocks: { 'start': '开始', + 'originalStartNode': '原始开始节点', 'end': '结束', 'answer': '直接回复', 'llm': 'LLM', @@ -979,11 +981,6 @@ const translation = { title: 'Webhook 触发器', nodeTitle: '🔗 Webhook 触发器', }, - triggerPlugin: { - title: '插件触发器', - nodeTitle: '🔌 插件触发器', - configPlaceholder: '插件触发器配置将在此处实现', - }, }, tracing: { stopBy: '由{{user}}终止', diff --git a/web/service/use-triggers.ts b/web/service/use-triggers.ts new file mode 100644 index 0000000000..c02f9ec053 --- /dev/null +++ b/web/service/use-triggers.ts @@ -0,0 +1,25 @@ +import { useQuery } from '@tanstack/react-query' +import { get } from './base' +import type { ToolWithProvider } from '@/app/components/workflow/types' + +const NAME_SPACE = 'triggers' + +// Get all plugins that support trigger functionality +// TODO: Backend API not implemented yet - replace with actual triggers endpoint +export const useAllTriggerPlugins = (enabled = true) => { + return useQuery({ + queryKey: [NAME_SPACE, 'all'], + queryFn: () => get('/workspaces/current/triggers/plugins'), + enabled, + }) +} + +// Get trigger-capable plugins by type (schedule, webhook, etc.) +// TODO: Backend API not implemented yet - replace with actual triggers endpoint +export const useTriggerPluginsByType = (triggerType: string, enabled = true) => { + return useQuery({ + queryKey: [NAME_SPACE, 'byType', triggerType], + queryFn: () => get(`/workspaces/current/triggers/plugins?type=${triggerType}`), + enabled: enabled && !!triggerType, + }) +}