| @@ -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<string, ActionItem>, | |||
| ): Promise<SearchResult[]> => { | |||
| 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) => { | |||
| @@ -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 [] | |||
| } | |||
| }, | |||
| } | |||
| @@ -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) | |||
| @@ -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<GotoAnythingContextType>({ | |||
| isWorkflowPage: false, | |||
| isRagPipelinePage: false, | |||
| }) | |||
| /** | |||
| @@ -33,17 +38,28 @@ type GotoAnythingProviderProps = { | |||
| */ | |||
| export const GotoAnythingProvider: React.FC<GotoAnythingProviderProps> = ({ 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 ( | |||
| <GotoAnythingContext.Provider value={{ isWorkflowPage }}> | |||
| <GotoAnythingContext.Provider value={{ isWorkflowPage, isRagPipelinePage }}> | |||
| {children} | |||
| </GotoAnythingContext.Provider> | |||
| ) | |||
| @@ -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<Props> = ({ | |||
| }) => { | |||
| const router = useRouter() | |||
| const defaultLocale = useGetLanguage() | |||
| const { isWorkflowPage } = useGotoAnythingContext() | |||
| const { isWorkflowPage, isRagPipelinePage } = useGotoAnythingContext() | |||
| const { t } = useTranslation() | |||
| const [show, setShow] = useState<boolean>(false) | |||
| const [searchQuery, setSearchQuery] = useState<string>('') | |||
| @@ -38,16 +38,9 @@ const GotoAnything: FC<Props> = ({ | |||
| // 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<Plugin>() | |||
| @@ -99,9 +92,11 @@ const GotoAnything: FC<Props> = ({ | |||
| 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<Props> = ({ | |||
| 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<Props> = ({ | |||
| ) : ( | |||
| <> | |||
| <span className='opacity-60'> | |||
| {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') | |||
| })()} | |||
| </span> | |||
| <span className='opacity-60'> | |||
| {searchQuery.trim() || isCommandsMode | |||
| ? t('app.gotoAnything.tips') | |||
| : t('app.gotoAnything.pressEscToClose') | |||
| } | |||
| : t('app.gotoAnything.pressEscToClose')} | |||
| </span> | |||
| </> | |||
| )} | |||
| @@ -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[]) | |||
| @@ -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: ( | |||
| <BlockIcon | |||
| type={node.blockType} | |||
| className="shrink-0" | |||
| size="sm" | |||
| toolIcon={node.toolIcon} | |||
| /> | |||
| ), | |||
| metadata: { | |||
| nodeId: node.id, | |||
| nodeData: node.nodeData, | |||
| }, | |||
| data: node.nodeData, | |||
| score, | |||
| } : null | |||
| }) | |||
| .filter((node): node is NonNullable<typeof node> => 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 | |||
| } | |||