| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495 |
- '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 { slashCommandRegistry } from './actions/commands/registry'
- 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'
- import CommandSelector from './command-selector'
- import { SlashCommandProvider } from './actions/commands'
-
- 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 {
- const { app, knowledge, plugin, slash } = AllActions
- return { app, knowledge, plugin, slash }
- }
- }, [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 isCommandsMode = searchQuery.trim() === '@' || searchQuery.trim() === '/'
- || (searchQuery.trim().startsWith('@') && !matchAction(searchQuery.trim(), Actions))
- || (searchQuery.trim().startsWith('/') && !matchAction(searchQuery.trim(), Actions))
-
- const searchMode = useMemo(() => {
- if (isCommandsMode) {
- // Distinguish between @ (scopes) and / (commands) mode
- if (searchQuery.trim().startsWith('@'))
- return 'scopes'
- else if (searchQuery.trim().startsWith('/'))
- return 'commands'
- return 'commands' // default fallback
- }
-
- const query = searchQueryDebouncedValue.toLowerCase()
- const action = matchAction(query, Actions)
- return action
- ? (action.key === '/' ? '@command' : action.key)
- : 'general'
- }, [searchQueryDebouncedValue, Actions, isCommandsMode, searchQuery])
-
- 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 && !isCommandsMode,
- staleTime: 30000,
- gcTime: 300000,
- },
- )
-
- // Prevent automatic selection of the first option when cmdVal is not set
- const clearSelection = () => {
- setCmdVal('_')
- }
-
- const handleCommandSelect = useCallback((commandKey: string) => {
- // Check if it's a slash command
- if (commandKey.startsWith('/')) {
- const commandName = commandKey.substring(1)
- const handler = slashCommandRegistry.findCommand(commandName)
-
- // If it's a direct mode command, execute immediately
- if (handler?.mode === 'direct' && handler.execute) {
- handler.execute()
- setShow(false)
- setSearchQuery('')
- return
- }
- }
-
- // Otherwise, proceed with the normal flow (submenu mode)
- setSearchQuery(`${commandKey} `)
- clearSelection()
- setTimeout(() => {
- inputRef.current?.focus()
- }, 0)
- }, [])
-
- // Handle navigation to selected result
- const handleNavigate = useCallback((result: SearchResult) => {
- setShow(false)
- setSearchQuery('')
-
- switch (result.type) {
- case 'command': {
- // Execute slash commands
- const action = Actions.slash
- action?.action?.(result)
- break
- }
- 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 || !searchQuery.trim() || isLoading || isCommandsMode)
- 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')
- : t('app.gotoAnything.emptyState.trySpecificSearch', { shortcuts: Object.values(Actions).map(action => action.shortcut).join(', ') })
- }
- </div>
- </div>
- </div>
- )
- }, [searchResults, searchQuery, Actions, searchMode, isLoading, isError, isCommandsMode])
-
- const defaultUI = useMemo(() => {
- if (searchQuery.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-1 text-xs text-text-quaternary'>
- <div>{t('app.gotoAnything.searchHint')}</div>
- <div>{t('app.gotoAnything.commandHint')}</div>
- <div>{t('app.gotoAnything.slashHint')}</div>
- </div>
- </div>
- </div>)
- }, [searchQuery, Actions])
-
- useEffect(() => {
- if (show) {
- requestAnimationFrame(() => {
- inputRef.current?.focus()
- })
- }
- }, [show])
-
- return (
- <>
- <SlashCommandProvider />
- <Modal
- isShow={show}
- onClose={() => {
- setShow(false)
- setSearchQuery('')
- clearSelection()
- onHide?.()
- }}
- closable={false}
- className='!w-[480px] !p-0'
- highPriority={true}
- >
- <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}
- disablePointerSelection
- loop
- >
- <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) => {
- setSearchQuery(e.target.value)
- if (!e.target.value.startsWith('@') && !e.target.value.startsWith('/'))
- clearSelection()
- }}
- onKeyDown={(e) => {
- if (e.key === 'Enter') {
- const query = searchQuery.trim()
- // Check if it's a complete slash command
- if (query.startsWith('/')) {
- const commandName = query.substring(1).split(' ')[0]
- const handler = slashCommandRegistry.findCommand(commandName)
-
- // If it's a direct mode command, execute immediately
- if (handler?.mode === 'direct' && handler.execute) {
- e.preventDefault()
- handler.execute()
- setShow(false)
- setSearchQuery('')
- }
- }
- }
- }}
- 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>{(() => {
- if (searchMode === 'scopes')
- return 'SCOPES'
- else if (searchMode === 'commands')
- return 'COMMANDS'
- else
- return 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='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 && (
- <>
- {isCommandsMode ? (
- <CommandSelector
- actions={Actions}
- onCommandSelect={handleCommandSelect}
- searchFilter={searchQuery.trim().substring(1)}
- commandValue={cmdVal}
- onCommandValueChange={setCmdVal}
- originalQuery={searchQuery.trim()}
- />
- ) : (
- 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',
- 'command': 'app.gotoAnything.groups.commands',
- }
- 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>
- ))
- )}
- {!isCommandsMode && emptyResult}
- {!isCommandsMode && defaultUI}
- </>
- )}
- </Command.List>
-
- {/* Always show footer to prevent height jumping */}
- <div className='border-t border-divider-subtle bg-components-panel-bg-blur px-4 py-2 text-xs text-text-tertiary'>
- <div className='flex min-h-[16px] items-center justify-between'>
- {(!!searchResults.length || isError) ? (
- <>
- <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>
- </>
- ) : (
- <>
- <span className='opacity-60'>
- {isCommandsMode
- ? t('app.gotoAnything.selectToNavigate')
- : searchQuery.trim()
- ? t('app.gotoAnything.searching')
- : t('app.gotoAnything.startTyping')
- }
- </span>
- <span className='opacity-60'>
- {searchQuery.trim() || isCommandsMode
- ? t('app.gotoAnything.tips')
- : t('app.gotoAnything.pressEscToClose')
- }
- </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
|