diff --git a/web/app/components/workflow/panel/workflow-preview.tsx b/web/app/components/workflow/panel/workflow-preview.tsx index 2c797e05d6..49ca3c4544 100644 --- a/web/app/components/workflow/panel/workflow-preview.tsx +++ b/web/app/components/workflow/panel/workflow-preview.tsx @@ -204,6 +204,7 @@ const WorkflowPreview = () => { )} {currentTab === 'TRACING' && !workflowRunningData?.tracing?.length && ( diff --git a/web/app/components/workflow/run/hooks/useTracingSearch.ts b/web/app/components/workflow/run/hooks/useTracingSearch.ts new file mode 100644 index 0000000000..f5455a67ea --- /dev/null +++ b/web/app/components/workflow/run/hooks/useTracingSearch.ts @@ -0,0 +1,133 @@ +import { useCallback, useMemo, useState } from 'react' +import type { NodeTracing } from '@/types/workflow' + +type UseTracingSearchProps = { + treeNodes: NodeTracing[] +} + +type UseTracingSearchReturn = { + searchQuery: string + setSearchQuery: (query: string) => void + filteredNodes: NodeTracing[] + clearSearch: () => void + searchStats: { + matched: number + total: number + } +} + +export const useTracingSearch = ({ treeNodes }: UseTracingSearchProps): UseTracingSearchReturn => { + const [searchQuery, setSearchQuery] = useState('') + + // Recursively count all nodes including children + const countNodesRecursively = useCallback((nodes: NodeTracing[]): number => { + return nodes.reduce((count, node) => { + let nodeCount = 1 + if (node.parallelDetail?.children) + nodeCount += countNodesRecursively(node.parallelDetail.children) + return count + nodeCount + }, 0) + }, []) + + // Deep recursive search filtering logic + const filteredNodes = useMemo(() => { + if (!searchQuery.trim()) return treeNodes + + const query = searchQuery.toLowerCase().trim() + + // Deep search object content with proper typing + const searchInObject = (obj: unknown): boolean => { + if (!obj) return false + + if (typeof obj === 'string') return obj.toLowerCase().includes(query) + if (typeof obj === 'number') return obj.toString().includes(query) + if (typeof obj === 'boolean') return obj.toString().includes(query) + + if (Array.isArray(obj)) + return obj.some(item => searchInObject(item)) + + if (typeof obj === 'object' && obj !== null) + return Object.values(obj as Record).some(value => searchInObject(value)) + + return false + } + + // Search all content in a single node with safe property access + const searchInNode = (node: NodeTracing): boolean => { + // Safe string search with nullish coalescing + const titleMatch = node.title?.toLowerCase().includes(query) ?? false + const nodeTypeMatch = node.node_type?.toLowerCase().includes(query) ?? false + const statusMatch = (node as NodeTracing & { status?: string }).status?.toLowerCase().includes(query) ?? false + + // Search in node data with proper type checking + const inputsMatch = searchInObject(node.inputs) + const outputsMatch = searchInObject(node.outputs) + const processDataMatch = searchInObject((node as NodeTracing & { process_data?: unknown }).process_data) + const metadataMatch = searchInObject((node as NodeTracing & { execution_metadata?: unknown }).execution_metadata) + + return titleMatch || nodeTypeMatch || statusMatch || inputsMatch || outputsMatch || processDataMatch || metadataMatch + } + + // Recursively search node and all its children + const searchNodeRecursively = (node: NodeTracing): boolean => { + // Search current node + if (searchInNode(node)) return true + + // Search parallel branch children with safe access + if (node.parallelDetail?.children) + return node.parallelDetail.children.some((child: NodeTracing) => searchNodeRecursively(child)) + + return false + } + + // Recursively filter node tree while maintaining hierarchy + const filterNodesRecursively = (nodes: NodeTracing[]): NodeTracing[] => { + return nodes.reduce((acc: NodeTracing[], node: NodeTracing) => { + const nodeMatches = searchInNode(node) + const hasMatchingChildren = node.parallelDetail?.children + ? node.parallelDetail.children.some((child: NodeTracing) => searchNodeRecursively(child)) + : false + + if (nodeMatches || hasMatchingChildren) { + const filteredNode = { ...node } + + // If has parallel children, recursively filter them + if (node.parallelDetail?.children) { + const filteredChildren = filterNodesRecursively(node.parallelDetail.children) + if (filteredChildren.length > 0) { + filteredNode.parallelDetail = { + ...node.parallelDetail, + children: filteredChildren, + } + } + } + + acc.push(filteredNode) + } + + return acc + }, []) + } + + return filterNodesRecursively(treeNodes) + }, [treeNodes, searchQuery]) + + // Clear search function + const clearSearch = useCallback(() => { + setSearchQuery('') + }, []) + + // Calculate search statistics + const searchStats = useMemo(() => ({ + matched: countNodesRecursively(filteredNodes), + total: countNodesRecursively(treeNodes), + }), [filteredNodes, treeNodes, countNodesRecursively]) + + return { + searchQuery, + setSearchQuery, + filteredNodes, + clearSearch, + searchStats, + } +} diff --git a/web/app/components/workflow/run/index.tsx b/web/app/components/workflow/run/index.tsx index 8b996032e2..d87529d7cf 100644 --- a/web/app/components/workflow/run/index.tsx +++ b/web/app/components/workflow/run/index.tsx @@ -162,6 +162,7 @@ const RunPanel: FC = ({ hideResult, activeTab = 'RESULT', runID, getRe )} diff --git a/web/app/components/workflow/run/tracing-panel.tsx b/web/app/components/workflow/run/tracing-panel.tsx index a6e9bf9dd4..ff157653a5 100644 --- a/web/app/components/workflow/run/tracing-panel.tsx +++ b/web/app/components/workflow/run/tracing-panel.tsx @@ -9,7 +9,9 @@ React, import cn from 'classnames' import { RiArrowDownSLine, + RiCloseLine, RiMenu4Line, + RiSearchLine, } from '@remixicon/react' import { useTranslation } from 'react-i18next' import { useLogs } from './hooks' @@ -17,12 +19,14 @@ import NodePanel from './node' import SpecialResultPanel from './special-result-panel' import type { NodeTracing } from '@/types/workflow' import formatNodeList from '@/app/components/workflow/run/utils/format-log' +import { useTracingSearch } from './hooks/useTracingSearch' type TracingPanelProps = { list: NodeTracing[] className?: string hideNodeInfo?: boolean hideNodeProcessDetail?: boolean + enableSearch?: boolean } const TracingPanel: FC = ({ @@ -30,9 +34,21 @@ const TracingPanel: FC = ({ className, hideNodeInfo = false, hideNodeProcessDetail = false, + enableSearch = false, }) => { const { t } = useTranslation() + const treeNodes = formatNodeList(list, t) + + // Search functionality using custom hook + const { + searchQuery, + setSearchQuery, + filteredNodes, + clearSearch, + searchStats, + } = useTracingSearch({ treeNodes }) + const [collapsedNodes, setCollapsedNodes] = useState>(new Set()) const [hoveredParallel, setHoveredParallel] = useState(null) @@ -185,13 +201,62 @@ const TracingPanel: FC = ({ return (
{ e.stopPropagation() e.nativeEvent.stopImmediatePropagation() }} > - {treeNodes.map(renderNode)} + {/* Search input */} + {enableSearch && ( +
+
+ + setSearchQuery(e.target.value)} + placeholder={t('workflow.common.searchNodes') || 'Search nodes, types, inputs, outputs...'} + className="system-sm-regular block h-[18px] grow appearance-none border-0 bg-transparent text-components-input-text-filled outline-none placeholder:text-components-input-text-placeholder" + autoComplete="off" + /> + {searchQuery && ( +
+ +
+ )} +
+
+ )} + + {/* Search results statistics */} + {enableSearch && searchQuery && ( +
+
+ {filteredNodes.length === 0 + ? t('workflow.common.noSearchResults') + : t('workflow.common.searchResults', { + matched: searchStats.matched, + total: searchStats.total, + })} +
+
+ )} + + {/* Empty search results hint */} + {enableSearch && searchQuery && filteredNodes.length === 0 && ( +
+
{t('workflow.common.noSearchResults')}
+
+ {t('workflow.common.searchHint', { query: searchQuery })} +
+
+ )} + + {/* Tracing content */} +
+ {filteredNodes.length > 0 && filteredNodes.map(renderNode)} +
) } diff --git a/web/i18n/de-DE/workflow.ts b/web/i18n/de-DE/workflow.ts index de2c3ce38d..9642394575 100644 --- a/web/i18n/de-DE/workflow.ts +++ b/web/i18n/de-DE/workflow.ts @@ -40,6 +40,10 @@ const translation = { maxTreeDepth: 'Maximales Limit von {{depth}} Knoten pro Ast', workflowProcess: 'Arbeitsablauf', notRunning: 'Noch nicht ausgeführt', + searchNodes: 'Knoten, Typen, Eingaben, Ausgaben suchen...', + noSearchResults: 'Keine Suchergebnisse', + searchResults: '{{matched}} von {{total}} Knoten', + searchHint: 'Versuchen Sie andere Schlüsselwörter für "{{query}}"', previewPlaceholder: 'Geben Sie den Inhalt in das Feld unten ein, um das Debuggen des Chatbots zu starten', effectVarConfirm: { title: 'Variable entfernen', diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index 763739ba32..5d3aa09b3d 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -50,6 +50,10 @@ const translation = { needAnswerNode: 'The Answer node must be added', workflowProcess: 'Workflow Process', notRunning: 'Not running yet', + searchNodes: 'Search nodes, types, inputs, outputs...', + noSearchResults: 'No search results', + searchResults: '{{matched}} of {{total}} nodes', + searchHint: 'Try different keywords for "{{query}}"', previewPlaceholder: 'Enter content in the box below to start debugging the Chatbot', effectVarConfirm: { title: 'Remove Variable', diff --git a/web/i18n/es-ES/workflow.ts b/web/i18n/es-ES/workflow.ts index 3c509934df..541b75d193 100644 --- a/web/i18n/es-ES/workflow.ts +++ b/web/i18n/es-ES/workflow.ts @@ -40,6 +40,10 @@ const translation = { maxTreeDepth: 'Límite máximo de {{depth}} nodos por rama', workflowProcess: 'Proceso de flujo de trabajo', notRunning: 'Aún no se está ejecutando', + searchNodes: 'Buscar nodos, tipos, entradas, salidas...', + noSearchResults: 'Sin resultados de búsqueda', + searchResults: '{{matched}} de {{total}} nodos', + searchHint: 'Prueba con diferentes palabras clave para "{{query}}"', previewPlaceholder: 'Ingrese contenido en el cuadro de abajo para comenzar a depurar el Chatbot', effectVarConfirm: { title: 'Eliminar variable', diff --git a/web/i18n/fr-FR/workflow.ts b/web/i18n/fr-FR/workflow.ts index 96bead7ff2..1a227b664a 100644 --- a/web/i18n/fr-FR/workflow.ts +++ b/web/i18n/fr-FR/workflow.ts @@ -40,6 +40,10 @@ const translation = { maxTreeDepth: 'Limite maximale de {{depth}} nœuds par branche', workflowProcess: 'Processus de flux de travail', notRunning: 'Pas encore en cours d\'exécution', + searchNodes: 'Rechercher nœuds, types, entrées, sorties...', + noSearchResults: 'Aucun résultat de recherche', + searchResults: '{{matched}} sur {{total}} nœuds', + searchHint: 'Essayez des mots-clés différents pour "{{query}}"', previewPlaceholder: 'Entrez le contenu dans la boîte ci-dessous pour commencer à déboguer le Chatbot', effectVarConfirm: { title: 'Supprimer la variable', diff --git a/web/i18n/ja-JP/workflow.ts b/web/i18n/ja-JP/workflow.ts index 035bba61a6..5e4f04800e 100644 --- a/web/i18n/ja-JP/workflow.ts +++ b/web/i18n/ja-JP/workflow.ts @@ -50,6 +50,10 @@ const translation = { needAnswerNode: '回答ブロックを追加する必要があります', workflowProcess: 'ワークフロー処理', notRunning: 'まだ実行されていません', + searchNodes: 'ノード、タイプ、入力、出力を検索...', + noSearchResults: '検索結果がありません', + searchResults: '{{total}}件中{{matched}}件のノード', + searchHint: '"{{query}}"について別のキーワードを試してください', previewPlaceholder: '入力欄にテキストを入力してチャットボットのデバッグを開始', effectVarConfirm: { title: '変数の削除', diff --git a/web/i18n/ko-KR/workflow.ts b/web/i18n/ko-KR/workflow.ts index be6c78f3ef..721cfc5cce 100644 --- a/web/i18n/ko-KR/workflow.ts +++ b/web/i18n/ko-KR/workflow.ts @@ -40,6 +40,10 @@ const translation = { maxTreeDepth: '분기당 최대 {{depth}} 노드 제한', workflowProcess: '워크플로우 과정', notRunning: '아직 실행되지 않음', + searchNodes: '노드, 유형, 입력, 출력 검색...', + noSearchResults: '검색 결과 없음', + searchResults: '{{total}}개 중 {{matched}}개 노드', + searchHint: '"{{query}}"에 대해 다른 키워드를 시도해 보세요', previewPlaceholder: '디버깅을 시작하려면 아래 상자에 내용을 입력하세요', effectVarConfirm: { title: '변수 제거', diff --git a/web/i18n/ru-RU/workflow.ts b/web/i18n/ru-RU/workflow.ts index d8452122ad..c1fc9f8620 100644 --- a/web/i18n/ru-RU/workflow.ts +++ b/web/i18n/ru-RU/workflow.ts @@ -40,6 +40,10 @@ const translation = { maxTreeDepth: 'Максимальный предел {{depth}} узлов на ветку', workflowProcess: 'Процесс рабочего процесса', notRunning: 'Еще не запущено', + searchNodes: 'Поиск узлов, типов, входов, выходов...', + noSearchResults: 'Нет результатов поиска', + searchResults: '{{matched}} из {{total}} узлов', + searchHint: 'Попробуйте другие ключевые слова для "{{query}}"', previewPlaceholder: 'Введите текст в поле ниже, чтобы начать отладку чат-бота', effectVarConfirm: { title: 'Удалить переменную', diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts index 81e207f67e..cb5d7f36a1 100644 --- a/web/i18n/zh-Hans/workflow.ts +++ b/web/i18n/zh-Hans/workflow.ts @@ -49,6 +49,10 @@ const translation = { needAnswerNode: '必须添加直接回复节点', workflowProcess: '工作流', notRunning: '尚未运行', + searchNodes: '搜索节点、类型、输入、输出...', + noSearchResults: '无搜索结果', + searchResults: '{{matched}} / {{total}} 个节点', + searchHint: '尝试使用不同的关键词搜索 "{{query}}"', previewPlaceholder: '在下面的框中输入内容开始调试聊天机器人', effectVarConfirm: { title: '移除变量', diff --git a/web/i18n/zh-Hant/workflow.ts b/web/i18n/zh-Hant/workflow.ts index 0ffdde7713..dc7361bbef 100644 --- a/web/i18n/zh-Hant/workflow.ts +++ b/web/i18n/zh-Hant/workflow.ts @@ -42,6 +42,10 @@ const translation = { needAnswerNode: '必須添加直接回覆節點', workflowProcess: '工作流', notRunning: '尚未運行', + searchNodes: '搜索節點、類型、輸入、輸出...', + noSearchResults: '無搜索結果', + searchResults: '{{matched}} / {{total}} 個節點', + searchHint: '嘗試使用不同的關鍵詞搜索 "{{query}}"', previewPlaceholder: '在下面的框中輸入內容開始調試聊天機器人', effectVarConfirm: { title: '移除變量',