diff --git a/web/app/components/goto-anything/actions/index.ts b/web/app/components/goto-anything/actions/index.ts index 62bf9cc04c..0d4986f144 100644 --- a/web/app/components/goto-anything/actions/index.ts +++ b/web/app/components/goto-anything/actions/index.ts @@ -167,10 +167,39 @@ import { appAction } from './app' import { knowledgeAction } from './knowledge' import { pluginAction } from './plugin' import { workflowNodesAction } from './workflow-nodes' +import { ragPipelineNodesAction } from './rag-pipeline-nodes' import type { ActionItem, SearchResult } from './types' import { slashAction } from './commands' import { slashCommandRegistry } from './commands/registry' +// Create dynamic Actions based on context +export const createActions = (isWorkflowPage: boolean, isRagPipelinePage: boolean) => { + const baseActions = { + slash: slashAction, + app: appAction, + knowledge: knowledgeAction, + plugin: pluginAction, + } + + // Add appropriate node search based on context + if (isRagPipelinePage) { + return { + ...baseActions, + node: ragPipelineNodesAction, + } + } + else if (isWorkflowPage) { + return { + ...baseActions, + node: workflowNodesAction, + } + } + + // Default actions without node search + return baseActions +} + +// Legacy export for backward compatibility export const Actions = { slash: slashAction, app: appAction, @@ -183,6 +212,7 @@ export const searchAnything = async ( locale: string, query: string, actionItem?: ActionItem, + dynamicActions?: Record, ): Promise => { if (actionItem) { const searchTerm = query.replace(actionItem.key, '').replace(actionItem.shortcut, '').trim() @@ -198,7 +228,7 @@ export const searchAnything = async ( if (query.startsWith('@') || query.startsWith('/')) return [] - const globalSearchActions = Object.values(Actions) + const globalSearchActions = Object.values(dynamicActions || Actions) // Use Promise.allSettled to handle partial failures gracefully const searchPromises = globalSearchActions.map(async (action) => { diff --git a/web/app/components/goto-anything/actions/rag-pipeline-nodes.tsx b/web/app/components/goto-anything/actions/rag-pipeline-nodes.tsx new file mode 100644 index 0000000000..dc632e4999 --- /dev/null +++ b/web/app/components/goto-anything/actions/rag-pipeline-nodes.tsx @@ -0,0 +1,24 @@ +import type { ActionItem } from './types' + +// Create the RAG pipeline nodes action +export const ragPipelineNodesAction: ActionItem = { + key: '@node', + shortcut: '@node', + title: 'Search RAG Pipeline Nodes', + description: 'Find and jump to nodes in the current RAG pipeline by name or type', + searchFn: undefined, // Will be set by useRagPipelineSearch hook + search: async (_, searchTerm = '', _locale) => { + try { + // Use the searchFn if available (set by useRagPipelineSearch hook) + if (ragPipelineNodesAction.searchFn) + return ragPipelineNodesAction.searchFn(searchTerm) + + // If not in RAG pipeline context, return empty array + return [] + } + catch (error) { + console.warn('RAG pipeline nodes search failed:', error) + return [] + } + }, +} diff --git a/web/app/components/goto-anything/actions/workflow-nodes.tsx b/web/app/components/goto-anything/actions/workflow-nodes.tsx index 29b6517be1..b9aa61705b 100644 --- a/web/app/components/goto-anything/actions/workflow-nodes.tsx +++ b/web/app/components/goto-anything/actions/workflow-nodes.tsx @@ -7,7 +7,7 @@ export const workflowNodesAction: ActionItem = { title: 'Search Workflow Nodes', description: 'Find and jump to nodes in the current workflow by name or type', searchFn: undefined, // Will be set by useWorkflowSearch hook - search: async (_, searchTerm = '', locale) => { + search: async (_, searchTerm = '', _locale) => { try { // Use the searchFn if available (set by useWorkflowSearch hook) if (workflowNodesAction.searchFn) diff --git a/web/app/components/goto-anything/context.tsx b/web/app/components/goto-anything/context.tsx index bfe41bcfb8..fee4b72c91 100644 --- a/web/app/components/goto-anything/context.tsx +++ b/web/app/components/goto-anything/context.tsx @@ -12,11 +12,16 @@ type GotoAnythingContextType = { * Whether the current page is a workflow page */ isWorkflowPage: boolean + /** + * Whether the current page is a RAG pipeline page + */ + isRagPipelinePage: boolean } // Create context with default values const GotoAnythingContext = createContext({ isWorkflowPage: false, + isRagPipelinePage: false, }) /** @@ -33,17 +38,28 @@ type GotoAnythingProviderProps = { */ export const GotoAnythingProvider: React.FC = ({ children }) => { const [isWorkflowPage, setIsWorkflowPage] = useState(false) + const [isRagPipelinePage, setIsRagPipelinePage] = useState(false) const pathname = usePathname() - // Update context based on current pathname + // Update context based on current pathname using more robust route matching useEffect(() => { - // Check if current path contains workflow - const isWorkflow = pathname?.includes('/workflow') || false + if (!pathname) { + setIsWorkflowPage(false) + setIsRagPipelinePage(false) + return + } + + // Workflow pages: /app/[appId]/workflow or /workflow/[token] (shared) + const isWorkflow = /^\/app\/[^/]+\/workflow$/.test(pathname) || /^\/workflow\/[^/]+$/.test(pathname) + // RAG Pipeline pages: /datasets/[datasetId]/pipeline + const isRagPipeline = /^\/datasets\/[^/]+\/pipeline$/.test(pathname) + setIsWorkflowPage(isWorkflow) + setIsRagPipelinePage(isRagPipeline) }, [pathname]) return ( - + {children} ) diff --git a/web/app/components/goto-anything/index.tsx b/web/app/components/goto-anything/index.tsx index 104401a813..c3b198a005 100644 --- a/web/app/components/goto-anything/index.tsx +++ b/web/app/components/goto-anything/index.tsx @@ -9,7 +9,7 @@ import { useDebounce, useKeyPress } from 'ahooks' import { getKeyboardKeyCodeBySystem, isEventTargetInputArea, isMac } from '@/app/components/workflow/utils/common' import { selectWorkflowNode } from '@/app/components/workflow/utils/node-navigation' import { RiSearchLine } from '@remixicon/react' -import { Actions as AllActions, type SearchResult, matchAction, searchAnything } from './actions' +import { type SearchResult, createActions, matchAction, searchAnything } from './actions' import { GotoAnythingProvider, useGotoAnythingContext } from './context' import { slashCommandRegistry } from './actions/commands/registry' import { useQuery } from '@tanstack/react-query' @@ -29,7 +29,7 @@ const GotoAnything: FC = ({ }) => { const router = useRouter() const defaultLocale = useGetLanguage() - const { isWorkflowPage } = useGotoAnythingContext() + const { isWorkflowPage, isRagPipelinePage } = useGotoAnythingContext() const { t } = useTranslation() const [show, setShow] = useState(false) const [searchQuery, setSearchQuery] = useState('') @@ -38,16 +38,9 @@ const GotoAnything: FC = ({ // Filter actions based on context const Actions = useMemo(() => { - // Create a filtered copy of actions based on current page context - if (isWorkflowPage) { - // Include all actions on workflow pages - return AllActions - } - else { - const { app, knowledge, plugin, slash } = AllActions - return { app, knowledge, plugin, slash } - } - }, [isWorkflowPage]) + // Create actions based on current page context + return createActions(isWorkflowPage, isRagPipelinePage) + }, [isWorkflowPage, isRagPipelinePage]) const [activePlugin, setActivePlugin] = useState() @@ -99,9 +92,11 @@ const GotoAnything: FC = ({ const query = searchQueryDebouncedValue.toLowerCase() const action = matchAction(query, Actions) - return action - ? (action.key === '/' ? '@command' : action.key) - : 'general' + + if (!action) + return 'general' + + return action.key === '/' ? '@command' : action.key }, [searchQueryDebouncedValue, Actions, isCommandsMode, searchQuery]) const { data: searchResults = [], isLoading, isError, error } = useQuery( @@ -112,13 +107,14 @@ const GotoAnything: FC = ({ searchQueryDebouncedValue, searchMode, isWorkflowPage, + isRagPipelinePage, defaultLocale, Object.keys(Actions).sort().join(','), ], queryFn: async () => { const query = searchQueryDebouncedValue.toLowerCase() const action = matchAction(query, Actions) - return await searchAnything(defaultLocale, query, action) + return await searchAnything(defaultLocale, query, action, Actions) }, enabled: !!searchQueryDebouncedValue && !isCommandsMode, staleTime: 30000, @@ -446,18 +442,20 @@ const GotoAnything: FC = ({ ) : ( <> - {isCommandsMode - ? t('app.gotoAnything.selectToNavigate') - : searchQuery.trim() - ? t('app.gotoAnything.searching') - : t('app.gotoAnything.startTyping') - } + {(() => { + if (isCommandsMode) + return t('app.gotoAnything.selectToNavigate') + + if (searchQuery.trim()) + return t('app.gotoAnything.searching') + + return t('app.gotoAnything.startTyping') + })()} {searchQuery.trim() || isCommandsMode ? t('app.gotoAnything.tips') - : t('app.gotoAnything.pressEscToClose') - } + : t('app.gotoAnything.pressEscToClose')} )} diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-children.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-children.tsx index 61a31abcf6..09e97d414f 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-children.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-children.tsx @@ -16,6 +16,7 @@ import { } from '@/app/components/workflow/hooks' import { useEventEmitterContextContext } from '@/context/event-emitter' import PublishToast from './publish-toast' +import { useRagPipelineSearch } from '../hooks/use-rag-pipeline-search' const RagPipelineChildren = () => { const { eventEmitter } = useEventEmitterContextContext() @@ -30,6 +31,9 @@ const RagPipelineChildren = () => { handleExportDSL, } = useDSL() + // Initialize RAG pipeline search functionality + useRagPipelineSearch() + eventEmitter?.useSubscription((v: any) => { if (v.type === DSL_EXPORT_CHECK) setSecretEnvList(v.payload.data as EnvironmentVariable[]) diff --git a/web/app/components/rag-pipeline/hooks/use-rag-pipeline-search.tsx b/web/app/components/rag-pipeline/hooks/use-rag-pipeline-search.tsx new file mode 100644 index 0000000000..70415b8200 --- /dev/null +++ b/web/app/components/rag-pipeline/hooks/use-rag-pipeline-search.tsx @@ -0,0 +1,168 @@ +'use client' + +import { useCallback, useEffect, useMemo } from 'react' +import { useNodes } from 'reactflow' +import { useNodesInteractions } from '@/app/components/workflow/hooks/use-nodes-interactions' +import type { CommonNodeType } from '@/app/components/workflow/types' +import { ragPipelineNodesAction } from '@/app/components/goto-anything/actions/rag-pipeline-nodes' +import BlockIcon from '@/app/components/workflow/block-icon' +import { setupNodeSelectionListener } from '@/app/components/workflow/utils/node-navigation' +import { BlockEnum } from '@/app/components/workflow/types' +import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types' +import type { ToolNodeType } from '@/app/components/workflow/nodes/tool/types' +import type { KnowledgeRetrievalNodeType } from '@/app/components/workflow/nodes/knowledge-retrieval/types' +import { useGetToolIcon } from '@/app/components/workflow/hooks/use-tool-icon' + +/** + * Hook to register RAG pipeline nodes search functionality + */ +export const useRagPipelineSearch = () => { + const nodes = useNodes() + const { handleNodeSelect } = useNodesInteractions() + const getToolIcon = useGetToolIcon() + + // Process nodes to create searchable data structure + const searchableNodes = useMemo(() => { + return nodes.map((node) => { + const nodeData = node.data as CommonNodeType + const title = nodeData.title || nodeData.type || 'Untitled Node' + let desc = nodeData.desc || '' + + // Keep the original node title for consistency with workflow display + // Only enhance description for better search context + if (nodeData.type === BlockEnum.Tool) { + const toolData = nodeData as ToolNodeType + desc = toolData.tool_description || toolData.tool_label || desc + } + + if (nodeData.type === BlockEnum.LLM) { + const llmData = nodeData as LLMNodeType + if (llmData.model?.provider && llmData.model?.name) + desc = `${llmData.model.name} (${llmData.model.provider}) - ${llmData.model.mode || desc}` + } + + if (nodeData.type === BlockEnum.KnowledgeRetrieval) { + const knowledgeData = nodeData as KnowledgeRetrievalNodeType + if (knowledgeData.dataset_ids?.length) + desc = `Knowledge Retrieval with ${knowledgeData.dataset_ids.length} datasets - ${desc}` + } + + return { + id: node.id, + title, + desc, + type: nodeData.type, + blockType: nodeData.type, + nodeData, + toolIcon: getToolIcon(nodeData), + modelInfo: nodeData.type === BlockEnum.LLM ? { + provider: (nodeData as LLMNodeType).model?.provider, + name: (nodeData as LLMNodeType).model?.name, + mode: (nodeData as LLMNodeType).model?.mode, + } : { + provider: undefined, + name: undefined, + mode: undefined, + }, + } + }) + }, [nodes, getToolIcon]) + + // Calculate relevance score for search results + const calculateScore = useCallback((node: { + title: string; + type: string; + desc: string; + modelInfo: { provider?: string; name?: string; mode?: string } + }, searchTerm: string): number => { + if (!searchTerm) return 1 + + let score = 0 + const term = searchTerm.toLowerCase() + + // Title match (highest priority) + if (node.title.toLowerCase().includes(term)) + score += 10 + + // Type match + if (node.type.toLowerCase().includes(term)) + score += 8 + + // Description match + if (node.desc.toLowerCase().includes(term)) + score += 5 + + // Model info matches (for LLM nodes) + if (node.modelInfo.provider?.toLowerCase().includes(term)) + score += 6 + if (node.modelInfo.name?.toLowerCase().includes(term)) + score += 6 + if (node.modelInfo.mode?.toLowerCase().includes(term)) + score += 4 + + return score + }, []) + + // Create search function for RAG pipeline nodes + const searchRagPipelineNodes = useCallback((query: string) => { + if (!searchableNodes.length) return [] + + const searchTerm = query.toLowerCase().trim() + + const results = searchableNodes + .map((node) => { + const score = calculateScore(node, searchTerm) + + return score > 0 ? { + id: node.id, + title: node.title, + description: node.desc || node.type, + type: 'workflow-node' as const, + path: `#${node.id}`, + icon: ( + + ), + metadata: { + nodeId: node.id, + nodeData: node.nodeData, + }, + data: node.nodeData, + score, + } : null + }) + .filter((node): node is NonNullable => node !== null) + .sort((a, b) => { + // If no search term, sort alphabetically + if (!searchTerm) return a.title.localeCompare(b.title) + // Sort by relevance score (higher score first) + return (b.score || 0) - (a.score || 0) + }) + + return results + }, [searchableNodes, calculateScore]) + + // Directly set the search function on the action object + useEffect(() => { + if (searchableNodes.length > 0) { + // Set the search function directly on the action + ragPipelineNodesAction.searchFn = searchRagPipelineNodes + } + + return () => { + // Clean up when component unmounts + ragPipelineNodesAction.searchFn = undefined + } + }, [searchableNodes, searchRagPipelineNodes]) + + // Set up node selection event listener using the utility function + useEffect(() => { + return setupNodeSelectionListener(handleNodeSelect) + }, [handleNodeSelect]) + + return null +}