From 25b6bc4235d54b44e8697d89cc4d0eafd8949073 Mon Sep 17 00:00:00 2001 From: GuanMu Date: Thu, 17 Jul 2025 12:22:26 +0000 Subject: [PATCH 1/5] feat: Update input component to support forward referencing, add node search functionality to workflow action component, and update Chinese and English translations to support new features --- web/app/components/base/input/index.tsx | 9 +- .../components/workflow/operator/index.tsx | 6 +- .../workflow/operator/node-search.tsx | 314 ++++++++++++++++++ web/i18n/en-US/workflow.ts | 13 + web/i18n/zh-Hans/workflow.ts | 13 + 5 files changed, 351 insertions(+), 4 deletions(-) create mode 100644 web/app/components/workflow/operator/node-search.tsx diff --git a/web/app/components/base/input/index.tsx b/web/app/components/base/input/index.tsx index 30fd90aff8..ae171b0a76 100644 --- a/web/app/components/base/input/index.tsx +++ b/web/app/components/base/input/index.tsx @@ -32,7 +32,7 @@ export type InputProps = { unit?: string } & Omit, 'size'> & VariantProps -const Input = ({ +const Input = React.forwardRef(({ size, disabled, destructive, @@ -47,12 +47,13 @@ const Input = ({ onChange = noop, unit, ...props -}: InputProps) => { +}, ref) => { const { t } = useTranslation() return (
{showLeftIcon && } ) -} +}) + +Input.displayName = 'Input' export default Input diff --git a/web/app/components/workflow/operator/index.tsx b/web/app/components/workflow/operator/index.tsx index 4a472a755f..9a662e34cb 100644 --- a/web/app/components/workflow/operator/index.tsx +++ b/web/app/components/workflow/operator/index.tsx @@ -2,6 +2,7 @@ import { memo, useEffect, useMemo, useRef } from 'react' import { MiniMap } from 'reactflow' import UndoRedo from '../header/undo-redo' import ZoomInOut from './zoom-in-out' +import NodeSearch from './node-search' import VariableTrigger from '../variable-inspect/trigger' import VariableInspectPanel from '../variable-inspect' import { useStore } from '../store' @@ -52,7 +53,10 @@ const Operator = ({ handleUndo, handleRedo }: OperatorProps) => { } >
- +
+ + +
{ + const toolIcon = useToolIcon(node.nodeData) + + return ( +
+ +
+
+ {highlightMatch(node.title, searchQuery)} +
+
+ {highlightMatch(node.type, searchQuery)} +
+ {node.desc && ( +
+ {highlightMatch(node.desc, searchQuery)} +
+ )} +
+
+ ) +} + +const NodeSearch = () => { + const { t } = useTranslation() + const nodes = useNodes() + const [searchQuery, setSearchQuery] = useState('') + const [isOpen, setIsOpen] = useState(false) + const [selectedIndex, setSelectedIndex] = useState(-1) + const { handleNodeSelect } = useNodesInteractions() + const workflowCanvasWidth = useStore(s => s.workflowCanvasWidth) + const resultsRef = useRef(null) + const inputRef = useRef(null) + + const isMac = useMemo(() => { + return typeof navigator !== 'undefined' && navigator.platform.toUpperCase().includes('MAC') + }, []) + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as HTMLElement + if ( + resultsRef.current + && !resultsRef.current.contains(target) + && inputRef.current + && !inputRef.current.contains(target) + ) { + setIsOpen(false) + setSelectedIndex(-1) + } + } + + if (isOpen) + document.addEventListener('mousedown', handleClickOutside) + + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, [isOpen]) + + useEffect(() => { + if (searchQuery.trim()) + setIsOpen(true) + }, [searchQuery]) + + const highlightMatch = useCallback((text: string, query: string) => { + if (!query.trim()) return text + + const safeQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const regex = new RegExp(`(${safeQuery})`, 'gi') + const parts = text.split(regex) + + return parts.map((part, index) => + regex.test(part) ? ( + + {part} + + ) : part, + ) + }, []) + + const searchableNodes = useMemo(() => { + const filteredNodes = nodes.filter((node) => { + return node.id && node.data && node.type !== 'sticky' + }) + + const result = filteredNodes + .map((node) => { + const nodeData = node.data as any + + const processedNode = { + id: node.id, + title: nodeData?.title || nodeData?.type || 'Untitled', + type: nodeData?.type || '', + desc: nodeData?.desc || '', + blockType: nodeData?.type, + nodeData, + } + + return processedNode + }) + + return result + }, [nodes]) + + const filteredResults = useMemo(() => { + if (!searchQuery.trim()) return [] + + const query = searchQuery.toLowerCase() + + const results = searchableNodes + .map((node) => { + const titleMatch = node.title.toLowerCase() + const typeMatch = node.type.toLowerCase() + const descMatch = node.desc.toLowerCase() + + let score = 0 + + if (titleMatch.startsWith(query)) score += 100 + else if (titleMatch.includes(query)) score += 50 + else if (typeMatch === query) score += 80 + else if (typeMatch.includes(query)) score += 30 + else if (descMatch.includes(query)) score += 20 + + return score > 0 ? { ...node, score } : null + }) + .filter((node): node is NonNullable => node !== null) + .sort((a, b) => b.score - a.score) + .slice(0, 8) + + return results + }, [searchableNodes, searchQuery]) + + const handleFocusSearch = useCallback((e: KeyboardEvent) => { + if (isEventTargetInputArea(e.target as HTMLElement)) + return + e.preventDefault() + inputRef.current?.focus() + }, []) + + useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.k`, handleFocusSearch, { + exactMatch: true, + useCapture: true, + }) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!isOpen || filteredResults.length === 0) return + + switch (e.key) { + case 'ArrowDown': { + e.preventDefault() + setSelectedIndex(prev => + prev < filteredResults.length - 1 ? prev + 1 : 0, + ) + break + } + case 'ArrowUp': { + e.preventDefault() + setSelectedIndex(prev => + prev > 0 ? prev - 1 : filteredResults.length - 1, + ) + break + } + case 'Enter': { + e.preventDefault() + if (selectedIndex >= 0 && filteredResults[selectedIndex]) { + handleNodeSelect(filteredResults[selectedIndex].id) + setSearchQuery('') + setIsOpen(false) + setSelectedIndex(-1) + } + break + } + case 'Escape': { + e.preventDefault() + setSearchQuery('') + setIsOpen(false) + setSelectedIndex(-1) + break + } + default: + break + } + } + + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [isOpen, filteredResults, selectedIndex, handleNodeSelect]) + + useEffect(() => { + setSelectedIndex(-1) + }, [searchQuery]) + + useEffect(() => { + if (selectedIndex >= 0 && resultsRef.current) { + const listContainer = resultsRef.current.children[0] as HTMLElement | undefined + const selectedElement = listContainer?.children[selectedIndex] as HTMLElement | undefined + if (selectedElement) + selectedElement.scrollIntoView({ block: 'nearest' }) + } + }, [selectedIndex]) + + const handleInputBlur = useCallback(() => { + setTimeout(() => { + setIsOpen(false) + setSelectedIndex(-1) + }, 200) + }, []) + + const maxResultsWidth = Math.min((workflowCanvasWidth || 800) - 40, 320) + + return ( +
+ 0} + onOpenChange={setIsOpen} + > + +
+ { + setSearchQuery(e.target.value) + setIsOpen(true) + }} + onFocus={() => setIsOpen(true)} + onBlur={handleInputBlur} + onClear={() => setSearchQuery('')} + placeholder={t('workflow.operator.searchNodesShortcut', { + shortcut: isMac ? 'Cmd+K' : 'Ctrl+K', + })} + showLeftIcon + showClearIcon + className='text-sm' + /> +
+
+ + +
+ {filteredResults.length > 0 ? ( +
+
+ {filteredResults.map((node, index) => ( + { + handleNodeSelect(node.id) + setSearchQuery('') + setIsOpen(false) + setSelectedIndex(-1) + }} + highlightMatch={highlightMatch} + /> + ))} +
+
+
+ {t('workflow.operator.searchResults', { count: filteredResults.length })} + + ↑↓ {t('workflow.operator.navigate')} • ↵ {t('workflow.operator.select')} • ⎋ {t('workflow.operator.close')} + +
+
+
+ ) : searchQuery.trim() && ( +
+
{t('workflow.operator.noNodesFound')}
+
{t('workflow.operator.searchHint')}
+
+ )} +
+
+
+
+ ) +} + +export default NodeSearch diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index c56b497ac2..71b8a0f001 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -286,6 +286,14 @@ const translation = { zoomTo50: 'Zoom to 50%', zoomTo100: 'Zoom to 100%', zoomToFit: 'Zoom to Fit', + searchNodes: 'Search nodes', + searchNodesShortcut: 'Search nodes ({{shortcut}})', + noNodesFound: 'No nodes found', + searchResults: '{{count}} result(s)', + navigate: 'navigate', + select: 'select', + close: 'close', + searchHint: 'Try searching by node name or type', }, variableReference: { noAvailableVars: 'No available variables', @@ -940,6 +948,11 @@ const translation = { debug: { settingsTab: 'Settings', lastRunTab: 'Last Run', + copyLastRun: 'Copy Last Run', + noLastRunFound: 'No previous run found', + noMatchingInputsFound: 'No matching inputs found from last run', + lastRunInputsCopied: '{{count}} input(s) copied from last run', + copyLastRunError: 'Failed to copy last run inputs', noData: { description: 'The results of the last run will be displayed here', runThisNode: 'Run this node', diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts index 6bd202d58f..90d7fd3b9d 100644 --- a/web/i18n/zh-Hans/workflow.ts +++ b/web/i18n/zh-Hans/workflow.ts @@ -287,6 +287,14 @@ const translation = { zoomTo50: '缩放到 50%', zoomTo100: '放大到 100%', zoomToFit: '自适应视图', + searchNodes: '搜索节点', + searchNodesShortcut: '搜索节点 ({{shortcut}})', + noNodesFound: '未找到节点', + searchResults: '{{count}}个结果', + navigate: '导航', + select: '选择', + close: '关闭', + searchHint: '尝试按节点名称或类型搜索', }, variableReference: { noAvailableVars: '没有可用变量', @@ -941,6 +949,11 @@ const translation = { debug: { settingsTab: '设置', lastRunTab: '上次运行', + copyLastRun: '复制上次运行值', + noLastRunFound: '未找到上次运行记录', + noMatchingInputsFound: '上次运行中未找到匹配的输入', + lastRunInputsCopied: '已复制{{count}}个输入值', + copyLastRunError: '复制上次运行输入失败', noData: { description: '上次运行的结果将显示在这里', runThisNode: '运行此节点', From 55711dac88c3f843d212fd181cd1549a11b2abe6 Mon Sep 17 00:00:00 2001 From: GuanMu Date: Thu, 17 Jul 2025 12:30:05 +0000 Subject: [PATCH 2/5] fix: Update Chinese and English translations, remove redundant text from the debug section --- web/i18n/en-US/workflow.ts | 9 ++------- web/i18n/zh-Hans/workflow.ts | 9 ++------- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index 71b8a0f001..4b5181c39a 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -947,13 +947,8 @@ const translation = { }, debug: { settingsTab: 'Settings', - lastRunTab: 'Last Run', - copyLastRun: 'Copy Last Run', - noLastRunFound: 'No previous run found', - noMatchingInputsFound: 'No matching inputs found from last run', - lastRunInputsCopied: '{{count}} input(s) copied from last run', - copyLastRunError: 'Failed to copy last run inputs', - noData: { + lastRunTab: 'Last Run', + noData: { description: 'The results of the last run will be displayed here', runThisNode: 'Run this node', }, diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts index 90d7fd3b9d..f0778ccd4b 100644 --- a/web/i18n/zh-Hans/workflow.ts +++ b/web/i18n/zh-Hans/workflow.ts @@ -948,13 +948,8 @@ const translation = { }, debug: { settingsTab: '设置', - lastRunTab: '上次运行', - copyLastRun: '复制上次运行值', - noLastRunFound: '未找到上次运行记录', - noMatchingInputsFound: '上次运行中未找到匹配的输入', - lastRunInputsCopied: '已复制{{count}}个输入值', - copyLastRunError: '复制上次运行输入失败', - noData: { + lastRunTab: '上次运行', + noData: { description: '上次运行的结果将显示在这里', runThisNode: '运行此节点', }, From 4fddba62f6c87a049a7584c90d4fc5561625a9fd Mon Sep 17 00:00:00 2001 From: GuanMu Date: Thu, 17 Jul 2025 12:31:10 +0000 Subject: [PATCH 3/5] fix --- web/i18n/en-US/workflow.ts | 4 ++-- web/i18n/zh-Hans/workflow.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index 4b5181c39a..2f40e3877f 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -947,8 +947,8 @@ const translation = { }, debug: { settingsTab: 'Settings', - lastRunTab: 'Last Run', - noData: { + lastRunTab: 'Last Run', + noData: { description: 'The results of the last run will be displayed here', runThisNode: 'Run this node', }, diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts index f0778ccd4b..e91b818118 100644 --- a/web/i18n/zh-Hans/workflow.ts +++ b/web/i18n/zh-Hans/workflow.ts @@ -948,8 +948,8 @@ const translation = { }, debug: { settingsTab: '设置', - lastRunTab: '上次运行', - noData: { + lastRunTab: '上次运行', + noData: { description: '上次运行的结果将显示在这里', runThisNode: '运行此节点', }, From 33fd0fe4fe8078cba3df5f91d6d6a4969a7dec82 Mon Sep 17 00:00:00 2001 From: GuanMu Date: Thu, 17 Jul 2025 12:46:22 +0000 Subject: [PATCH 4/5] fix: Optimize node search logic, exclude specific node types --- web/app/components/workflow/operator/node-search.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/web/app/components/workflow/operator/node-search.tsx b/web/app/components/workflow/operator/node-search.tsx index 94c2abc602..e889ebae36 100644 --- a/web/app/components/workflow/operator/node-search.tsx +++ b/web/app/components/workflow/operator/node-search.tsx @@ -104,7 +104,13 @@ const NodeSearch = () => { const searchableNodes = useMemo(() => { const filteredNodes = nodes.filter((node) => { - return node.id && node.data && node.type !== 'sticky' + if (!node.id || !node.data || node.type === 'sticky') return false + + const nodeData = node.data as any + const nodeType = nodeData?.type + + const internalStartNodes = ['iteration-start', 'loop-start'] + return !internalStartNodes.includes(nodeType) }) const result = filteredNodes From 253d695714e478d85a9dfa6de5f7c4cf818a6c19 Mon Sep 17 00:00:00 2001 From: GuanMu Date: Sat, 19 Jul 2025 02:50:08 +0000 Subject: [PATCH 5/5] feat: Enhance node search functionality, add search result node data interface, optimize layout constants and search result processing logic --- .../workflow/operator/node-search.tsx | 47 +++++++++++++++---- web/app/components/workflow/panel/record.tsx | 8 ++-- 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/web/app/components/workflow/operator/node-search.tsx b/web/app/components/workflow/operator/node-search.tsx index e889ebae36..455806e6e7 100644 --- a/web/app/components/workflow/operator/node-search.tsx +++ b/web/app/components/workflow/operator/node-search.tsx @@ -9,8 +9,36 @@ import { useStore } from '../store' import { getKeyboardKeyCodeBySystem, isEventTargetInputArea } from '../utils' import cn from '@/utils/classnames' import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem' +import type { BlockEnum, CommonNodeType } from '../types' + +// Constants for layout dimensions and behavior +const SEARCH_LAYOUT_CONSTANTS = { + DEFAULT_CANVAS_WIDTH: 800, + CANVAS_MARGIN: 40, + MAX_RESULTS_WIDTH: 320, + MAX_SEARCH_RESULTS: 8, + BLUR_DELAY_MS: 200, +} as const + +// Interface for search result node data +type SearchResultNode = { + blockType: BlockEnum + nodeData: CommonNodeType + title: string + type: string + desc?: string +} + +// Props interface for SearchResultItem component +type SearchResultItemProps = { + node: SearchResultNode + searchQuery: string + isSelected: boolean + onClick: () => void + highlightMatch: (text: string, query: string) => React.ReactNode +} -const SearchResultItem = ({ node, searchQuery, isSelected, onClick, highlightMatch }: any) => { +const SearchResultItem = ({ node, searchQuery, isSelected, onClick, highlightMatch }: SearchResultItemProps) => { const toolIcon = useToolIcon(node.nodeData) return ( @@ -106,7 +134,7 @@ const NodeSearch = () => { const filteredNodes = nodes.filter((node) => { if (!node.id || !node.data || node.type === 'sticky') return false - const nodeData = node.data as any + const nodeData = node.data as CommonNodeType const nodeType = nodeData?.type const internalStartNodes = ['iteration-start', 'loop-start'] @@ -115,7 +143,7 @@ const NodeSearch = () => { const result = filteredNodes .map((node) => { - const nodeData = node.data as any + const nodeData = node.data as CommonNodeType const processedNode = { id: node.id, @@ -155,7 +183,7 @@ const NodeSearch = () => { }) .filter((node): node is NonNullable => node !== null) .sort((a, b) => b.score - a.score) - .slice(0, 8) + .slice(0, SEARCH_LAYOUT_CONSTANTS.MAX_SEARCH_RESULTS) return results }, [searchableNodes, searchQuery]) @@ -234,10 +262,13 @@ const NodeSearch = () => { setTimeout(() => { setIsOpen(false) setSelectedIndex(-1) - }, 200) + }, SEARCH_LAYOUT_CONSTANTS.BLUR_DELAY_MS) }, []) - const maxResultsWidth = Math.min((workflowCanvasWidth || 800) - 40, 320) + const maxResultsWidth = Math.min( + (workflowCanvasWidth || SEARCH_LAYOUT_CONSTANTS.DEFAULT_CANVAS_WIDTH) - SEARCH_LAYOUT_CONSTANTS.CANVAS_MARGIN, + SEARCH_LAYOUT_CONSTANTS.MAX_RESULTS_WIDTH, + ) return (
@@ -250,7 +281,7 @@ const NodeSearch = () => {
{ setSearchQuery(e.target.value) @@ -305,7 +336,7 @@ const NodeSearch = () => {
) : searchQuery.trim() && ( -
+
{t('workflow.operator.noNodesFound')}
{t('workflow.operator.searchHint')}
diff --git a/web/app/components/workflow/panel/record.tsx b/web/app/components/workflow/panel/record.tsx index 534bc633a6..c8a63f094c 100644 --- a/web/app/components/workflow/panel/record.tsx +++ b/web/app/components/workflow/panel/record.tsx @@ -1,5 +1,5 @@ import { memo, useCallback } from 'react' -import type { WorkflowDataUpdater } from '../types' +import type { WorkflowRunDetailResponse } from '@/models/log' import Run from '../run' import { useStore } from '../store' import { useWorkflowUpdate } from '../hooks' @@ -9,12 +9,12 @@ const Record = () => { const historyWorkflowData = useStore(s => s.historyWorkflowData) const { handleUpdateWorkflowCanvas } = useWorkflowUpdate() - const handleResultCallback = useCallback((res: any) => { - const graph: WorkflowDataUpdater = res.graph + const handleResultCallback = useCallback((res: WorkflowRunDetailResponse) => { + const graph = res.graph handleUpdateWorkflowCanvas({ nodes: graph.nodes, edges: graph.edges, - viewport: graph.viewport, + viewport: graph.viewport || { x: 0, y: 0, zoom: 1 }, }) }, [handleUpdateWorkflowCanvas])