Feat node search (#23685)

Co-authored-by: GuanMu <ballmanjq@gmail.com>
Co-authored-by: zhujiruo <zhujiruo@foxmail.com>
Co-authored-by: Matri Qi <matrixdom@126.com>
Co-authored-by: croatialu <wuli.croatia@foxmail.com>
Co-authored-by: HyaCinth <88471803+HyaCiovo@users.noreply.github.com>
Co-authored-by: lyzno1 <92089059+lyzno1@users.noreply.github.com>
This commit is contained in:
crazywoola
2025-08-10 19:19:52 -07:00
committed by GitHub
parent 36b221b170
commit 7ee170f0a7
41 changed files with 2216 additions and 17 deletions

View File

@@ -8,6 +8,7 @@ import Header from '@/app/components/header'
import { EventEmitterContextProvider } from '@/context/event-emitter'
import { ProviderContextProvider } from '@/context/provider-context'
import { ModalContextProvider } from '@/context/modal-context'
import GotoAnything from '@/app/components/goto-anything'
const Layout = ({ children }: { children: ReactNode }) => {
return (
@@ -22,6 +23,7 @@ const Layout = ({ children }: { children: ReactNode }) => {
<Header />
</HeaderWrapper>
{children}
<GotoAnything />
</ModalContextProvider>
</ProviderContextProvider>
</EventEmitterContextProvider>

View File

@@ -32,7 +32,7 @@ export type InputProps = {
unit?: string
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> & VariantProps<typeof inputVariants>
const Input = ({
const Input = React.forwardRef<HTMLInputElement, InputProps>(({
size,
disabled,
destructive,
@@ -47,12 +47,13 @@ const Input = ({
onChange = noop,
unit,
...props
}: InputProps) => {
}, ref) => {
const { t } = useTranslation()
return (
<div className={cn('relative w-full', wrapperClassName)}>
{showLeftIcon && <RiSearchLine className={cn('absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-components-input-text-placeholder')} />}
<input
ref={ref}
style={styleCss}
className={cn(
'w-full appearance-none border border-transparent bg-components-input-bg-normal py-[7px] text-components-input-text-filled caret-primary-600 outline-none placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs',
@@ -92,6 +93,8 @@ const Input = ({
}
</div>
)
}
})
Input.displayName = 'Input'
export default Input

View File

@@ -0,0 +1,53 @@
import type { ActionItem, AppSearchResult } from './types'
import type { App } from '@/types/app'
import { fetchAppList } from '@/service/apps'
import AppIcon from '../../base/app-icon'
import { AppTypeIcon } from '../../app/type-selector'
import { getRedirectionPath } from '@/utils/app-redirection'
const parser = (apps: App[]): AppSearchResult[] => {
return apps.map(app => ({
id: app.id,
title: app.name,
description: app.description,
type: 'app' as const,
path: getRedirectionPath(true, {
id: app.id,
mode: app.mode,
}),
icon: (
<div className='relative shrink-0'>
<AppIcon
size='large'
iconType={app.icon_type}
icon={app.icon}
background={app.icon_background}
imageUrl={app.icon_url}
/>
<AppTypeIcon wrapperClassName='absolute -bottom-0.5 -right-0.5 w-4 h-4 rounded-[4px] border border-divider-regular outline outline-components-panel-on-panel-item-bg'
className='h-3 w-3' type={app.mode} />
</div>
),
data: app,
}))
}
export const appAction: ActionItem = {
key: '@app',
shortcut: '@app',
title: 'Search Applications',
description: 'Search and navigate to your applications',
// action,
search: async (_, searchTerm = '', locale) => {
const response = (await fetchAppList({
url: 'apps',
params: {
page: 1,
name: searchTerm,
},
}))
const apps = response.data || []
return parser(apps)
},
}

View File

@@ -0,0 +1,68 @@
import { appAction } from './app'
import { knowledgeAction } from './knowledge'
import { pluginAction } from './plugin'
import { workflowNodesAction } from './workflow-nodes'
import type { ActionItem, SearchResult } from './types'
export const Actions = {
app: appAction,
knowledge: knowledgeAction,
plugin: pluginAction,
node: workflowNodesAction,
}
export const searchAnything = async (
locale: string,
query: string,
actionItem?: ActionItem,
): Promise<SearchResult[]> => {
if (actionItem) {
const searchTerm = query.replace(actionItem.key, '').replace(actionItem.shortcut, '').trim()
return await actionItem.search(query, searchTerm, locale)
}
if (query.startsWith('@'))
return []
// Use Promise.allSettled to handle partial failures gracefully
const searchPromises = Object.values(Actions).map(async (action) => {
try {
const results = await action.search(query, query, locale)
return { success: true, data: results, actionType: action.key }
}
catch (error) {
console.warn(`Search failed for ${action.key}:`, error)
return { success: false, data: [], actionType: action.key, error }
}
})
const settledResults = await Promise.allSettled(searchPromises)
const allResults: SearchResult[] = []
const failedActions: string[] = []
settledResults.forEach((result, index) => {
if (result.status === 'fulfilled' && result.value.success) {
allResults.push(...result.value.data)
}
else {
const actionKey = Object.values(Actions)[index]?.key || 'unknown'
failedActions.push(actionKey)
}
})
if (failedActions.length > 0)
console.warn(`Some search actions failed: ${failedActions.join(', ')}`)
return allResults
}
export const matchAction = (query: string, actions: Record<string, ActionItem>) => {
return Object.values(actions).find((action) => {
const reg = new RegExp(`^(${action.key}|${action.shortcut})(?:\\s|$)`)
return reg.test(query)
})
}
export * from './types'
export { appAction, knowledgeAction, pluginAction, workflowNodesAction }

View File

@@ -0,0 +1,50 @@
import type { ActionItem, KnowledgeSearchResult } from './types'
import type { DataSet } from '@/models/datasets'
import { fetchDatasets } from '@/service/datasets'
import { Folder } from '../../base/icons/src/vender/solid/files'
import cn from '@/utils/classnames'
const EXTERNAL_PROVIDER = 'external' as const
const isExternalProvider = (provider: string): boolean => provider === EXTERNAL_PROVIDER
const parser = (datasets: DataSet[]): KnowledgeSearchResult[] => {
return datasets.map((dataset) => {
const path = isExternalProvider(dataset.provider) ? `/datasets/${dataset.id}/hitTesting` : `/datasets/${dataset.id}/documents`
return {
id: dataset.id,
title: dataset.name,
description: dataset.description,
type: 'knowledge' as const,
path,
icon: (
<div className={cn(
'flex shrink-0 items-center justify-center rounded-md border-[0.5px] border-[#E0EAFF] bg-[#F5F8FF] p-2.5',
!dataset.embedding_available && 'opacity-50 hover:opacity-100',
)}>
<Folder className='h-5 w-5 text-[#444CE7]' />
</div>
),
data: dataset,
}
})
}
export const knowledgeAction: ActionItem = {
key: '@knowledge',
shortcut: '@kb',
title: 'Search Knowledge Bases',
description: 'Search and navigate to your knowledge bases',
// action,
search: async (_, searchTerm = '', locale) => {
const response = await fetchDatasets({
url: '/datasets',
params: {
page: 1,
limit: 10,
keyword: searchTerm,
},
})
return parser(response.data)
},
}

View File

@@ -0,0 +1,41 @@
import type { ActionItem, PluginSearchResult } from './types'
import { renderI18nObject } from '@/i18n-config'
import Icon from '../../plugins/card/base/card-icon'
import { postMarketplace } from '@/service/base'
import type { Plugin, PluginsFromMarketplaceResponse } from '../../plugins/types'
import { getPluginIconInMarketplace } from '../../plugins/marketplace/utils'
const parser = (plugins: Plugin[], locale: string): PluginSearchResult[] => {
return plugins.map((plugin) => {
return {
id: plugin.name,
title: renderI18nObject(plugin.label, locale) || plugin.name,
description: renderI18nObject(plugin.brief, locale) || '',
type: 'plugin' as const,
icon: <Icon src={plugin.icon} />,
data: plugin,
}
})
}
export const pluginAction: ActionItem = {
key: '@plugin',
shortcut: '@plugin',
title: 'Search Plugins',
description: 'Search and navigate to your plugins',
search: async (_, searchTerm = '', locale) => {
const response = await postMarketplace<{ data: PluginsFromMarketplaceResponse }>('/plugins/search/advanced', {
body: {
page: 1,
page_size: 10,
query: searchTerm,
type: 'plugin',
},
})
const list = (response.data.plugins || []).map(plugin => ({
...plugin,
icon: getPluginIconInMarketplace(plugin),
}))
return parser(list, locale!)
},
}

View File

@@ -0,0 +1,54 @@
import type { ReactNode } from 'react'
import type { TypeWithI18N } from '../../base/form/types'
import type { App } from '@/types/app'
import type { Plugin } from '../../plugins/types'
import type { DataSet } from '@/models/datasets'
import type { CommonNodeType } from '../../workflow/types'
export type SearchResultType = 'app' | 'knowledge' | 'plugin' | 'workflow-node'
export type BaseSearchResult<T = any> = {
id: string
title: string
description?: string
type: SearchResultType
path?: string
icon?: ReactNode
data: T
}
export type AppSearchResult = {
type: 'app'
} & BaseSearchResult<App>
export type PluginSearchResult = {
type: 'plugin'
} & BaseSearchResult<Plugin>
export type KnowledgeSearchResult = {
type: 'knowledge'
} & BaseSearchResult<DataSet>
export type WorkflowNodeSearchResult = {
type: 'workflow-node'
metadata?: {
nodeId: string
nodeData: CommonNodeType
}
} & BaseSearchResult<CommonNodeType>
export type SearchResult = AppSearchResult | PluginSearchResult | KnowledgeSearchResult | WorkflowNodeSearchResult
export type ActionItem = {
key: '@app' | '@knowledge' | '@plugin' | '@node'
shortcut: string
title: string | TypeWithI18N
description: string
action?: (data: SearchResult) => void
searchFn?: (searchTerm: string) => SearchResult[]
search: (
query: string,
searchTerm: string,
locale?: string,
) => (Promise<SearchResult[]> | SearchResult[])
}

View File

@@ -0,0 +1,44 @@
import type { ActionItem } from './types'
import { BoltIcon } from '@heroicons/react/24/outline'
import i18n from 'i18next'
// Create the workflow nodes action
export const workflowNodesAction: ActionItem = {
key: '@node',
shortcut: '@node',
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) => {
try {
// Use the searchFn if available (set by useWorkflowSearch hook)
if (workflowNodesAction.searchFn) {
// searchFn already returns SearchResult[] type, no need to use parser
return workflowNodesAction.searchFn(searchTerm)
}
// If not in workflow context or search function not registered
if (!searchTerm.trim()) {
return [{
id: 'help',
title: i18n.t('app.gotoAnything.actions.searchWorkflowNodes', { lng: locale }),
description: i18n.t('app.gotoAnything.actions.searchWorkflowNodesHelp', { lng: locale }),
type: 'workflow-node',
path: '#',
data: {} as any,
icon: (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-blue-50 text-blue-600">
<BoltIcon className="h-5 w-5" />
</div>
),
}]
}
return []
}
catch (error) {
console.error('Error searching workflow nodes:', error)
return []
}
},
}

View File

@@ -0,0 +1,50 @@
'use client'
import type { ReactNode } from 'react'
import React, { createContext, useContext, useEffect, useState } from 'react'
import { usePathname } from 'next/navigation'
/**
* Interface for the GotoAnything context
*/
type GotoAnythingContextType = {
/**
* Whether the current page is a workflow page
*/
isWorkflowPage: boolean
}
// Create context with default values
const GotoAnythingContext = createContext<GotoAnythingContextType>({
isWorkflowPage: false,
})
/**
* Hook to use the GotoAnything context
*/
export const useGotoAnythingContext = () => useContext(GotoAnythingContext)
type GotoAnythingProviderProps = {
children: ReactNode
}
/**
* Provider component for GotoAnything context
*/
export const GotoAnythingProvider: React.FC<GotoAnythingProviderProps> = ({ children }) => {
const [isWorkflowPage, setIsWorkflowPage] = useState(false)
const pathname = usePathname()
// Update context based on current pathname
useEffect(() => {
// Check if current path contains workflow
const isWorkflow = pathname?.includes('/workflow') || false
setIsWorkflowPage(isWorkflow)
}, [pathname])
return (
<GotoAnythingContext.Provider value={{ isWorkflowPage }}>
{children}
</GotoAnythingContext.Provider>
)
}

View File

@@ -0,0 +1,395 @@
'use client'
import type { FC } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import Modal from '@/app/components/base/modal'
import Input from '@/app/components/base/input'
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 { GotoAnythingProvider, useGotoAnythingContext } from './context'
import { useQuery } from '@tanstack/react-query'
import { useGetLanguage } from '@/context/i18n'
import { useTranslation } from 'react-i18next'
import InstallFromMarketplace from '../plugins/install-plugin/install-from-marketplace'
import type { Plugin } from '../plugins/types'
import { Command } from 'cmdk'
type Props = {
onHide?: () => void
}
const GotoAnything: FC<Props> = ({
onHide,
}) => {
const router = useRouter()
const defaultLocale = useGetLanguage()
const { isWorkflowPage } = useGotoAnythingContext()
const { t } = useTranslation()
const [show, setShow] = useState<boolean>(false)
const [searchQuery, setSearchQuery] = useState<string>('')
const [cmdVal, setCmdVal] = useState<string>('')
const inputRef = useRef<HTMLInputElement>(null)
// 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 {
// Exclude node action on non-workflow pages
const { app, knowledge, plugin } = AllActions
return { app, knowledge, plugin }
}
}, [isWorkflowPage])
const [activePlugin, setActivePlugin] = useState<Plugin>()
// Handle keyboard shortcuts
const handleToggleModal = useCallback((e: KeyboardEvent) => {
// Allow closing when modal is open, even if focus is in the search input
if (!show && isEventTargetInputArea(e.target as HTMLElement))
return
e.preventDefault()
setShow((prev) => {
if (!prev) {
// Opening modal - reset search state
setSearchQuery('')
}
return !prev
})
}, [show])
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.k`, handleToggleModal, {
exactMatch: true,
useCapture: true,
})
useKeyPress(['esc'], (e) => {
if (show) {
e.preventDefault()
setShow(false)
setSearchQuery('')
}
})
const searchQueryDebouncedValue = useDebounce(searchQuery.trim(), {
wait: 300,
})
const searchMode = useMemo(() => {
const query = searchQueryDebouncedValue.toLowerCase()
const action = matchAction(query, Actions)
return action ? action.key : 'general'
}, [searchQueryDebouncedValue, Actions])
const { data: searchResults = [], isLoading, isError, error } = useQuery(
{
queryKey: [
'goto-anything',
'search-result',
searchQueryDebouncedValue,
searchMode,
isWorkflowPage,
defaultLocale,
Object.keys(Actions).sort().join(','),
],
queryFn: async () => {
const query = searchQueryDebouncedValue.toLowerCase()
const action = matchAction(query, Actions)
return await searchAnything(defaultLocale, query, action)
},
enabled: !!searchQueryDebouncedValue,
staleTime: 30000,
gcTime: 300000,
},
)
// Handle navigation to selected result
const handleNavigate = useCallback((result: SearchResult) => {
setShow(false)
setSearchQuery('')
switch (result.type) {
case 'plugin':
setActivePlugin(result.data)
break
case 'workflow-node':
// Handle workflow node selection and navigation
if (result.metadata?.nodeId)
selectWorkflowNode(result.metadata.nodeId, true)
break
default:
if (result.path)
router.push(result.path)
}
}, [router])
// Group results by type
const groupedResults = useMemo(() => searchResults.reduce((acc, result) => {
if (!acc[result.type])
acc[result.type] = []
acc[result.type].push(result)
return acc
}, {} as { [key: string]: SearchResult[] }),
[searchResults])
const emptyResult = useMemo(() => {
if (searchResults.length || !searchQueryDebouncedValue.trim() || isLoading)
return null
const isCommandSearch = searchMode !== 'general'
const commandType = isCommandSearch ? searchMode.replace('@', '') : ''
if (isError) {
return (
<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
<div>
<div className='text-sm font-medium text-red-500'>{t('app.gotoAnything.searchTemporarilyUnavailable')}</div>
<div className='mt-1 text-xs text-text-quaternary'>
{t('app.gotoAnything.servicesUnavailableMessage')}
</div>
</div>
</div>
)
}
return (
<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
<div>
<div className='text-sm font-medium'>
{isCommandSearch
? (() => {
const keyMap: Record<string, string> = {
app: 'app.gotoAnything.emptyState.noAppsFound',
plugin: 'app.gotoAnything.emptyState.noPluginsFound',
knowledge: 'app.gotoAnything.emptyState.noKnowledgeBasesFound',
node: 'app.gotoAnything.emptyState.noWorkflowNodesFound',
}
return t(keyMap[commandType] || 'app.gotoAnything.noResults')
})()
: t('app.gotoAnything.noResults')
}
</div>
<div className='mt-1 text-xs text-text-quaternary'>
{isCommandSearch
? t('app.gotoAnything.emptyState.tryDifferentTerm', { mode: searchMode })
: t('app.gotoAnything.emptyState.trySpecificSearch', { shortcuts: Object.values(Actions).map(action => action.shortcut).join(', ') })
}
</div>
</div>
</div>
)
}, [searchResults, searchQueryDebouncedValue, Actions, searchMode, isLoading, isError])
const defaultUI = useMemo(() => {
if (searchQueryDebouncedValue.trim())
return null
return (<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
<div>
<div className='text-sm font-medium'>{t('app.gotoAnything.searchTitle')}</div>
<div className='mt-3 space-y-2 text-xs text-text-quaternary'>
{Object.values(Actions).map(action => (
<div key={action.key} className='flex items-center gap-2'>
<span className='inline-flex items-center rounded bg-gray-200 px-2 py-1 font-mono text-xs font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-200'>{action.shortcut}</span>
<span>{(() => {
const keyMap: Record<string, string> = {
'@app': 'app.gotoAnything.actions.searchApplicationsDesc',
'@plugin': 'app.gotoAnything.actions.searchPluginsDesc',
'@knowledge': 'app.gotoAnything.actions.searchKnowledgeBasesDesc',
'@node': 'app.gotoAnything.actions.searchWorkflowNodesDesc',
}
return t(keyMap[action.key])
})()}</span>
</div>
))}
</div>
</div>
</div>)
}, [searchQueryDebouncedValue, Actions])
useEffect(() => {
if (show) {
requestAnimationFrame(() => {
inputRef.current?.focus()
})
}
return () => {
setCmdVal('')
}
}, [show])
return (
<>
<Modal
isShow={show}
onClose={() => {
setShow(false)
setSearchQuery('')
onHide?.()
}}
closable={false}
className='!w-[480px] !p-0'
>
<div className='flex flex-col rounded-2xl border border-components-panel-border bg-components-panel-bg shadow-xl'>
<Command
className='outline-none'
value={cmdVal}
onValueChange={setCmdVal}
>
<div className='flex items-center gap-3 border-b border-divider-subtle bg-components-panel-bg-blur px-4 py-3'>
<RiSearchLine className='h-4 w-4 text-text-quaternary' />
<div className='flex flex-1 items-center gap-2'>
<Input
ref={inputRef}
value={searchQuery}
placeholder={t('app.gotoAnything.searchPlaceholder')}
onChange={(e) => {
setCmdVal('')
setSearchQuery(e.target.value)
}}
className='flex-1 !border-0 !bg-transparent !shadow-none'
wrapperClassName='flex-1 !border-0 !bg-transparent'
autoFocus
/>
{searchMode !== 'general' && (
<div className='flex items-center gap-1 rounded bg-blue-50 px-2 py-[2px] text-xs font-medium text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'>
<span>{searchMode.replace('@', '').toUpperCase()}</span>
</div>
)}
</div>
<div className='text-xs text-text-quaternary'>
<span className='system-kbd rounded bg-gray-200 px-1 py-[2px] font-mono text-gray-700 dark:bg-gray-800 dark:text-gray-100'>
{isMac() ? '⌘' : 'Ctrl'}
</span>
<span className='system-kbd ml-1 rounded bg-gray-200 px-1 py-[2px] font-mono text-gray-700 dark:bg-gray-800 dark:text-gray-100'>
K
</span>
</div>
</div>
<Command.List className='max-h-[275px] min-h-[240px] overflow-y-auto'>
{isLoading && (
<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
<div className="flex items-center gap-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-gray-600"></div>
<span className="text-sm">{t('app.gotoAnything.searching')}</span>
</div>
</div>
)}
{isError && (
<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
<div>
<div className="text-sm font-medium text-red-500">{t('app.gotoAnything.searchFailed')}</div>
<div className="mt-1 text-xs text-text-quaternary">
{error.message}
</div>
</div>
</div>
)}
{!isLoading && !isError && (
<>
{Object.entries(groupedResults).map(([type, results], groupIndex) => (
<Command.Group key={groupIndex} heading={(() => {
const typeMap: Record<string, string> = {
'app': 'app.gotoAnything.groups.apps',
'plugin': 'app.gotoAnything.groups.plugins',
'knowledge': 'app.gotoAnything.groups.knowledgeBases',
'workflow-node': 'app.gotoAnything.groups.workflowNodes',
}
return t(typeMap[type] || `${type}s`)
})()} className='p-2 capitalize text-text-secondary'>
{results.map(result => (
<Command.Item
key={`${result.type}-${result.id}`}
value={result.title}
className='flex cursor-pointer items-center gap-3 rounded-md p-3 will-change-[background-color] aria-[selected=true]:bg-state-base-hover data-[selected=true]:bg-state-base-hover'
onSelect={() => handleNavigate(result)}
>
{result.icon}
<div className='min-w-0 flex-1'>
<div className='truncate font-medium text-text-secondary'>
{result.title}
</div>
{result.description && (
<div className='mt-0.5 truncate text-xs text-text-quaternary'>
{result.description}
</div>
)}
</div>
<div className='text-xs capitalize text-text-quaternary'>
{result.type}
</div>
</Command.Item>
))}
</Command.Group>
))}
{emptyResult}
{defaultUI}
</>
)}
</Command.List>
{(!!searchResults.length || isError) && (
<div className='border-t border-divider-subtle bg-components-panel-bg-blur px-4 py-2 text-xs text-text-tertiary'>
<div className='flex items-center justify-between'>
<span>
{isError ? (
<span className='text-red-500'>{t('app.gotoAnything.someServicesUnavailable')}</span>
) : (
<>
{t('app.gotoAnything.resultCount', { count: searchResults.length })}
{searchMode !== 'general' && (
<span className='ml-2 opacity-60'>
{t('app.gotoAnything.inScope', { scope: searchMode.replace('@', '') })}
</span>
)}
</>
)}
</span>
<span className='opacity-60'>
{searchMode !== 'general'
? t('app.gotoAnything.clearToSearchAll')
: t('app.gotoAnything.useAtForSpecific')
}
</span>
</div>
</div>
)}
</Command>
</div>
</Modal>
{
activePlugin && (
<InstallFromMarketplace
manifest={activePlugin}
uniqueIdentifier={activePlugin.latest_package_identifier}
onClose={() => setActivePlugin(undefined)}
onSuccess={() => setActivePlugin(undefined)}
/>
)
}
</>
)
}
/**
* GotoAnything component with context provider
*/
const GotoAnythingWithContext: FC<Props> = (props) => {
return (
<GotoAnythingProvider>
<GotoAnything {...props} />
</GotoAnythingProvider>
)
}
export default GotoAnythingWithContext

View File

@@ -18,3 +18,4 @@ export * from './use-workflow-mode'
export * from './use-workflow-refresh-draft'
export * from './use-inspect-vars-crud'
export * from './use-set-workflow-vars-with-value'
export * from './use-workflow-search'

View File

@@ -0,0 +1,123 @@
'use client'
import { useCallback, useEffect, useMemo } from 'react'
import { useNodes } from 'reactflow'
import { useNodesInteractions } from './use-nodes-interactions'
import type { CommonNodeType } from '../types'
import { workflowNodesAction } from '@/app/components/goto-anything/actions/workflow-nodes'
import BlockIcon from '@/app/components/workflow/block-icon'
import { setupNodeSelectionListener } from '../utils/node-navigation'
/**
* Hook to register workflow nodes search functionality
*/
export const useWorkflowSearch = () => {
const nodes = useNodes()
const { handleNodeSelect } = useNodesInteractions()
// Filter and process nodes for search
const searchableNodes = useMemo(() => {
const filteredNodes = nodes.filter((node) => {
if (!node.id || !node.data || node.type === 'sticky') return false
const nodeData = node.data as CommonNodeType
const nodeType = nodeData?.type
const internalStartNodes = ['iteration-start', 'loop-start']
return !internalStartNodes.includes(nodeType)
})
const result = filteredNodes
.map((node) => {
const nodeData = node.data as CommonNodeType
return {
id: node.id,
title: nodeData?.title || nodeData?.type || 'Untitled',
type: nodeData?.type || '',
desc: nodeData?.desc || '',
blockType: nodeData?.type,
nodeData,
}
})
return result
}, [nodes])
// Create search function for workflow nodes
const searchWorkflowNodes = useCallback((query: string) => {
if (!searchableNodes.length || !query.trim()) return []
const searchTerm = query.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(searchTerm)) score += 100
else if (titleMatch.includes(searchTerm)) score += 50
else if (typeMatch === searchTerm) score += 80
else if (typeMatch.includes(searchTerm)) score += 30
else if (descMatch.includes(searchTerm)) score += 20
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"
/>
),
metadata: {
nodeId: node.id,
nodeData: node.nodeData,
},
// Add required data property for SearchResult type
data: node.nodeData,
}
: null
})
.filter((node): node is NonNullable<typeof node> => node !== null)
.sort((a, b) => {
const aTitle = a.title.toLowerCase()
const bTitle = b.title.toLowerCase()
if (aTitle.startsWith(searchTerm) && !bTitle.startsWith(searchTerm)) return -1
if (!aTitle.startsWith(searchTerm) && bTitle.startsWith(searchTerm)) return 1
return 0
})
return results
}, [searchableNodes])
// Directly set the search function on the action object
useEffect(() => {
if (searchableNodes.length > 0) {
// Set the search function directly on the action
workflowNodesAction.searchFn = searchWorkflowNodes
}
return () => {
// Clean up when component unmounts
workflowNodesAction.searchFn = undefined
}
}, [searchableNodes, searchWorkflowNodes])
// Set up node selection event listener using the utility function
useEffect(() => {
return setupNodeSelectionListener(handleNodeSelect)
}, [handleNodeSelect])
return null
}

View File

@@ -58,6 +58,7 @@ import { CUSTOM_LOOP_START_NODE } from './nodes/loop-start/constants'
import CustomSimpleNode from './simple-node'
import { CUSTOM_SIMPLE_NODE } from './simple-node/constants'
import Operator from './operator'
import { useWorkflowSearch } from './hooks/use-workflow-search'
import Control from './operator/control'
import CustomEdge from './custom-edge'
import CustomConnectionLine from './custom-connection-line'
@@ -68,6 +69,7 @@ import NodeContextmenu from './node-contextmenu'
import SelectionContextmenu from './selection-contextmenu'
import SyncingDataModal from './syncing-data-modal'
import LimitTips from './limit-tips'
import { setupScrollToNodeListener } from './utils/node-navigation'
import {
useStore,
useWorkflowStore,
@@ -280,6 +282,14 @@ export const Workflow: FC<WorkflowProps> = memo(({
})
useShortcuts()
// Initialize workflow node search functionality
useWorkflowSearch()
// Set up scroll to node event listener using the utility function
useEffect(() => {
return setupScrollToNodeListener(nodes, reactflow)
}, [nodes, reactflow])
const { fetchInspectVars } = useSetWorkflowVarsWithValue()
useEffect(() => {
fetchInspectVars()

View File

@@ -52,7 +52,9 @@ const Operator = ({ handleUndo, handleRedo }: OperatorProps) => {
}
>
<div className='flex justify-between px-1 pb-2'>
<UndoRedo handleUndo={handleUndo} handleRedo={handleRedo} />
<div className='flex items-center gap-2'>
<UndoRedo handleUndo={handleUndo} handleRedo={handleRedo} />
</div>
<VariableTrigger />
<div className='relative'>
<MiniMap

View File

@@ -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])

View File

@@ -0,0 +1,124 @@
/**
* Node navigation utilities for workflow
* This module provides functions for node selection, focusing and scrolling in workflow
*/
/**
* Interface for node selection event detail
*/
export type NodeSelectionDetail = {
nodeId: string;
focus?: boolean;
}
/**
* Select a node in the workflow
* @param nodeId - The ID of the node to select
* @param focus - Whether to focus/scroll to the node
*/
export function selectWorkflowNode(nodeId: string, focus = false): void {
// Create and dispatch a custom event for node selection
const event = new CustomEvent('workflow:select-node', {
detail: {
nodeId,
focus,
},
})
document.dispatchEvent(event)
}
/**
* Scroll to a specific node in the workflow
* @param nodeId - The ID of the node to scroll to
*/
export function scrollToWorkflowNode(nodeId: string): void {
// Create and dispatch a custom event for scrolling to node
const event = new CustomEvent('workflow:scroll-to-node', {
detail: { nodeId },
})
document.dispatchEvent(event)
}
/**
* Setup node selection event listener
* @param handleNodeSelect - Function to handle node selection
* @returns Cleanup function
*/
export function setupNodeSelectionListener(
handleNodeSelect: (nodeId: string) => void,
): () => void {
// Event handler for node selection
const handleNodeSelection = (event: CustomEvent<NodeSelectionDetail>) => {
const { nodeId, focus } = event.detail
if (nodeId) {
// Select the node
handleNodeSelect(nodeId)
// If focus is requested, scroll to the node
if (focus) {
// Use a small timeout to ensure node selection happens first
setTimeout(() => {
scrollToWorkflowNode(nodeId)
}, 100)
}
}
}
// Add event listener
document.addEventListener(
'workflow:select-node',
handleNodeSelection as EventListener,
)
// Return cleanup function
return () => {
document.removeEventListener(
'workflow:select-node',
handleNodeSelection as EventListener,
)
}
}
/**
* Setup scroll to node event listener with ReactFlow
* @param nodes - The workflow nodes
* @param reactflow - The ReactFlow instance
* @returns Cleanup function
*/
export function setupScrollToNodeListener(
nodes: any[],
reactflow: any,
): () => void {
// Event handler for scrolling to node
const handleScrollToNode = (event: CustomEvent<NodeSelectionDetail>) => {
const { nodeId } = event.detail
if (nodeId) {
// Find the target node
const node = nodes.find(n => n.id === nodeId)
if (node) {
// Use ReactFlow's fitView API to scroll to the node
reactflow.fitView({
nodes: [node],
padding: 0.2,
duration: 800,
minZoom: 0.5,
maxZoom: 1,
})
}
}
}
// Add event listener
document.addEventListener(
'workflow:scroll-to-node',
handleScrollToNode as EventListener,
)
// Return cleanup function
return () => {
document.removeEventListener(
'workflow:scroll-to-node',
handleScrollToNode as EventListener,
)
}
}