You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493
  1. 'use client'
  2. import type { FC } from 'react'
  3. import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
  4. import { useRouter } from 'next/navigation'
  5. import Modal from '@/app/components/base/modal'
  6. import Input from '@/app/components/base/input'
  7. import { useDebounce, useKeyPress } from 'ahooks'
  8. import { getKeyboardKeyCodeBySystem, isEventTargetInputArea, isMac } from '@/app/components/workflow/utils/common'
  9. import { selectWorkflowNode } from '@/app/components/workflow/utils/node-navigation'
  10. import { RiSearchLine } from '@remixicon/react'
  11. import { type SearchResult, createActions, matchAction, searchAnything } from './actions'
  12. import { GotoAnythingProvider, useGotoAnythingContext } from './context'
  13. import { slashCommandRegistry } from './actions/commands/registry'
  14. import { useQuery } from '@tanstack/react-query'
  15. import { useGetLanguage } from '@/context/i18n'
  16. import { useTranslation } from 'react-i18next'
  17. import InstallFromMarketplace from '../plugins/install-plugin/install-from-marketplace'
  18. import type { Plugin } from '../plugins/types'
  19. import { Command } from 'cmdk'
  20. import CommandSelector from './command-selector'
  21. import { SlashCommandProvider } from './actions/commands'
  22. type Props = {
  23. onHide?: () => void
  24. }
  25. const GotoAnything: FC<Props> = ({
  26. onHide,
  27. }) => {
  28. const router = useRouter()
  29. const defaultLocale = useGetLanguage()
  30. const { isWorkflowPage, isRagPipelinePage } = useGotoAnythingContext()
  31. const { t } = useTranslation()
  32. const [show, setShow] = useState<boolean>(false)
  33. const [searchQuery, setSearchQuery] = useState<string>('')
  34. const [cmdVal, setCmdVal] = useState<string>('_')
  35. const inputRef = useRef<HTMLInputElement>(null)
  36. // Filter actions based on context
  37. const Actions = useMemo(() => {
  38. // Create actions based on current page context
  39. return createActions(isWorkflowPage, isRagPipelinePage)
  40. }, [isWorkflowPage, isRagPipelinePage])
  41. const [activePlugin, setActivePlugin] = useState<Plugin>()
  42. // Handle keyboard shortcuts
  43. const handleToggleModal = useCallback((e: KeyboardEvent) => {
  44. // Allow closing when modal is open, even if focus is in the search input
  45. if (!show && isEventTargetInputArea(e.target as HTMLElement))
  46. return
  47. e.preventDefault()
  48. setShow((prev) => {
  49. if (!prev) {
  50. // Opening modal - reset search state
  51. setSearchQuery('')
  52. }
  53. return !prev
  54. })
  55. }, [show])
  56. useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.k`, handleToggleModal, {
  57. exactMatch: true,
  58. useCapture: true,
  59. })
  60. useKeyPress(['esc'], (e) => {
  61. if (show) {
  62. e.preventDefault()
  63. setShow(false)
  64. setSearchQuery('')
  65. }
  66. })
  67. const searchQueryDebouncedValue = useDebounce(searchQuery.trim(), {
  68. wait: 300,
  69. })
  70. const isCommandsMode = searchQuery.trim() === '@' || searchQuery.trim() === '/'
  71. || (searchQuery.trim().startsWith('@') && !matchAction(searchQuery.trim(), Actions))
  72. || (searchQuery.trim().startsWith('/') && !matchAction(searchQuery.trim(), Actions))
  73. const searchMode = useMemo(() => {
  74. if (isCommandsMode) {
  75. // Distinguish between @ (scopes) and / (commands) mode
  76. if (searchQuery.trim().startsWith('@'))
  77. return 'scopes'
  78. else if (searchQuery.trim().startsWith('/'))
  79. return 'commands'
  80. return 'commands' // default fallback
  81. }
  82. const query = searchQueryDebouncedValue.toLowerCase()
  83. const action = matchAction(query, Actions)
  84. if (!action)
  85. return 'general'
  86. return action.key === '/' ? '@command' : action.key
  87. }, [searchQueryDebouncedValue, Actions, isCommandsMode, searchQuery])
  88. const { data: searchResults = [], isLoading, isError, error } = useQuery(
  89. {
  90. queryKey: [
  91. 'goto-anything',
  92. 'search-result',
  93. searchQueryDebouncedValue,
  94. searchMode,
  95. isWorkflowPage,
  96. isRagPipelinePage,
  97. defaultLocale,
  98. Object.keys(Actions).sort().join(','),
  99. ],
  100. queryFn: async () => {
  101. const query = searchQueryDebouncedValue.toLowerCase()
  102. const action = matchAction(query, Actions)
  103. return await searchAnything(defaultLocale, query, action, Actions)
  104. },
  105. enabled: !!searchQueryDebouncedValue && !isCommandsMode,
  106. staleTime: 30000,
  107. gcTime: 300000,
  108. },
  109. )
  110. // Prevent automatic selection of the first option when cmdVal is not set
  111. const clearSelection = () => {
  112. setCmdVal('_')
  113. }
  114. const handleCommandSelect = useCallback((commandKey: string) => {
  115. // Check if it's a slash command
  116. if (commandKey.startsWith('/')) {
  117. const commandName = commandKey.substring(1)
  118. const handler = slashCommandRegistry.findCommand(commandName)
  119. // If it's a direct mode command, execute immediately
  120. if (handler?.mode === 'direct' && handler.execute) {
  121. handler.execute()
  122. setShow(false)
  123. setSearchQuery('')
  124. return
  125. }
  126. }
  127. // Otherwise, proceed with the normal flow (submenu mode)
  128. setSearchQuery(`${commandKey} `)
  129. clearSelection()
  130. setTimeout(() => {
  131. inputRef.current?.focus()
  132. }, 0)
  133. }, [])
  134. // Handle navigation to selected result
  135. const handleNavigate = useCallback((result: SearchResult) => {
  136. setShow(false)
  137. setSearchQuery('')
  138. switch (result.type) {
  139. case 'command': {
  140. // Execute slash commands
  141. const action = Actions.slash
  142. action?.action?.(result)
  143. break
  144. }
  145. case 'plugin':
  146. setActivePlugin(result.data)
  147. break
  148. case 'workflow-node':
  149. // Handle workflow node selection and navigation
  150. if (result.metadata?.nodeId)
  151. selectWorkflowNode(result.metadata.nodeId, true)
  152. break
  153. default:
  154. if (result.path)
  155. router.push(result.path)
  156. }
  157. }, [router])
  158. // Group results by type
  159. const groupedResults = useMemo(() => searchResults.reduce((acc, result) => {
  160. if (!acc[result.type])
  161. acc[result.type] = []
  162. acc[result.type].push(result)
  163. return acc
  164. }, {} as { [key: string]: SearchResult[] }),
  165. [searchResults])
  166. const emptyResult = useMemo(() => {
  167. if (searchResults.length || !searchQuery.trim() || isLoading || isCommandsMode)
  168. return null
  169. const isCommandSearch = searchMode !== 'general'
  170. const commandType = isCommandSearch ? searchMode.replace('@', '') : ''
  171. if (isError) {
  172. return (
  173. <div className="flex items-center justify-center py-8 text-center text-text-tertiary">
  174. <div>
  175. <div className='text-sm font-medium text-red-500'>{t('app.gotoAnything.searchTemporarilyUnavailable')}</div>
  176. <div className='mt-1 text-xs text-text-quaternary'>
  177. {t('app.gotoAnything.servicesUnavailableMessage')}
  178. </div>
  179. </div>
  180. </div>
  181. )
  182. }
  183. return (
  184. <div className="flex items-center justify-center py-8 text-center text-text-tertiary">
  185. <div>
  186. <div className='text-sm font-medium'>
  187. {isCommandSearch
  188. ? (() => {
  189. const keyMap: Record<string, string> = {
  190. app: 'app.gotoAnything.emptyState.noAppsFound',
  191. plugin: 'app.gotoAnything.emptyState.noPluginsFound',
  192. knowledge: 'app.gotoAnything.emptyState.noKnowledgeBasesFound',
  193. node: 'app.gotoAnything.emptyState.noWorkflowNodesFound',
  194. }
  195. return t(keyMap[commandType] || 'app.gotoAnything.noResults')
  196. })()
  197. : t('app.gotoAnything.noResults')
  198. }
  199. </div>
  200. <div className='mt-1 text-xs text-text-quaternary'>
  201. {isCommandSearch
  202. ? t('app.gotoAnything.emptyState.tryDifferentTerm')
  203. : t('app.gotoAnything.emptyState.trySpecificSearch', { shortcuts: Object.values(Actions).map(action => action.shortcut).join(', ') })
  204. }
  205. </div>
  206. </div>
  207. </div>
  208. )
  209. }, [searchResults, searchQuery, Actions, searchMode, isLoading, isError, isCommandsMode])
  210. const defaultUI = useMemo(() => {
  211. if (searchQuery.trim())
  212. return null
  213. return (<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
  214. <div>
  215. <div className='text-sm font-medium'>{t('app.gotoAnything.searchTitle')}</div>
  216. <div className='mt-3 space-y-1 text-xs text-text-quaternary'>
  217. <div>{t('app.gotoAnything.searchHint')}</div>
  218. <div>{t('app.gotoAnything.commandHint')}</div>
  219. <div>{t('app.gotoAnything.slashHint')}</div>
  220. </div>
  221. </div>
  222. </div>)
  223. }, [searchQuery, Actions])
  224. useEffect(() => {
  225. if (show) {
  226. requestAnimationFrame(() => {
  227. inputRef.current?.focus()
  228. })
  229. }
  230. }, [show])
  231. return (
  232. <>
  233. <SlashCommandProvider />
  234. <Modal
  235. isShow={show}
  236. onClose={() => {
  237. setShow(false)
  238. setSearchQuery('')
  239. clearSelection()
  240. onHide?.()
  241. }}
  242. closable={false}
  243. className='!w-[480px] !p-0'
  244. highPriority={true}
  245. >
  246. <div className='flex flex-col rounded-2xl border border-components-panel-border bg-components-panel-bg shadow-xl'>
  247. <Command
  248. className='outline-none'
  249. value={cmdVal}
  250. onValueChange={setCmdVal}
  251. disablePointerSelection
  252. loop
  253. >
  254. <div className='flex items-center gap-3 border-b border-divider-subtle bg-components-panel-bg-blur px-4 py-3'>
  255. <RiSearchLine className='h-4 w-4 text-text-quaternary' />
  256. <div className='flex flex-1 items-center gap-2'>
  257. <Input
  258. ref={inputRef}
  259. value={searchQuery}
  260. placeholder={t('app.gotoAnything.searchPlaceholder')}
  261. onChange={(e) => {
  262. setSearchQuery(e.target.value)
  263. if (!e.target.value.startsWith('@') && !e.target.value.startsWith('/'))
  264. clearSelection()
  265. }}
  266. onKeyDown={(e) => {
  267. if (e.key === 'Enter') {
  268. const query = searchQuery.trim()
  269. // Check if it's a complete slash command
  270. if (query.startsWith('/')) {
  271. const commandName = query.substring(1).split(' ')[0]
  272. const handler = slashCommandRegistry.findCommand(commandName)
  273. // If it's a direct mode command, execute immediately
  274. if (handler?.mode === 'direct' && handler.execute) {
  275. e.preventDefault()
  276. handler.execute()
  277. setShow(false)
  278. setSearchQuery('')
  279. }
  280. }
  281. }
  282. }}
  283. className='flex-1 !border-0 !bg-transparent !shadow-none'
  284. wrapperClassName='flex-1 !border-0 !bg-transparent'
  285. autoFocus
  286. />
  287. {searchMode !== 'general' && (
  288. <div className='flex items-center gap-1 rounded bg-gray-100 px-2 py-[2px] text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300'>
  289. <span>{(() => {
  290. if (searchMode === 'scopes')
  291. return 'SCOPES'
  292. else if (searchMode === 'commands')
  293. return 'COMMANDS'
  294. else
  295. return searchMode.replace('@', '').toUpperCase()
  296. })()}</span>
  297. </div>
  298. )}
  299. </div>
  300. <div className='text-xs text-text-quaternary'>
  301. <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'>
  302. {isMac() ? '⌘' : 'Ctrl'}
  303. </span>
  304. <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'>
  305. K
  306. </span>
  307. </div>
  308. </div>
  309. <Command.List className='h-[240px] overflow-y-auto'>
  310. {isLoading && (
  311. <div className="flex items-center justify-center py-8 text-center text-text-tertiary">
  312. <div className="flex items-center gap-2">
  313. <div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-gray-600"></div>
  314. <span className="text-sm">{t('app.gotoAnything.searching')}</span>
  315. </div>
  316. </div>
  317. )}
  318. {isError && (
  319. <div className="flex items-center justify-center py-8 text-center text-text-tertiary">
  320. <div>
  321. <div className="text-sm font-medium text-red-500">{t('app.gotoAnything.searchFailed')}</div>
  322. <div className="mt-1 text-xs text-text-quaternary">
  323. {error.message}
  324. </div>
  325. </div>
  326. </div>
  327. )}
  328. {!isLoading && !isError && (
  329. <>
  330. {isCommandsMode ? (
  331. <CommandSelector
  332. actions={Actions}
  333. onCommandSelect={handleCommandSelect}
  334. searchFilter={searchQuery.trim().substring(1)}
  335. commandValue={cmdVal}
  336. onCommandValueChange={setCmdVal}
  337. originalQuery={searchQuery.trim()}
  338. />
  339. ) : (
  340. Object.entries(groupedResults).map(([type, results], groupIndex) => (
  341. <Command.Group key={groupIndex} heading={(() => {
  342. const typeMap: Record<string, string> = {
  343. 'app': 'app.gotoAnything.groups.apps',
  344. 'plugin': 'app.gotoAnything.groups.plugins',
  345. 'knowledge': 'app.gotoAnything.groups.knowledgeBases',
  346. 'workflow-node': 'app.gotoAnything.groups.workflowNodes',
  347. 'command': 'app.gotoAnything.groups.commands',
  348. }
  349. return t(typeMap[type] || `${type}s`)
  350. })()} className='p-2 capitalize text-text-secondary'>
  351. {results.map(result => (
  352. <Command.Item
  353. key={`${result.type}-${result.id}`}
  354. value={result.title}
  355. 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'
  356. onSelect={() => handleNavigate(result)}
  357. >
  358. {result.icon}
  359. <div className='min-w-0 flex-1'>
  360. <div className='truncate font-medium text-text-secondary'>
  361. {result.title}
  362. </div>
  363. {result.description && (
  364. <div className='mt-0.5 truncate text-xs text-text-quaternary'>
  365. {result.description}
  366. </div>
  367. )}
  368. </div>
  369. <div className='text-xs capitalize text-text-quaternary'>
  370. {result.type}
  371. </div>
  372. </Command.Item>
  373. ))}
  374. </Command.Group>
  375. ))
  376. )}
  377. {!isCommandsMode && emptyResult}
  378. {!isCommandsMode && defaultUI}
  379. </>
  380. )}
  381. </Command.List>
  382. {/* Always show footer to prevent height jumping */}
  383. <div className='border-t border-divider-subtle bg-components-panel-bg-blur px-4 py-2 text-xs text-text-tertiary'>
  384. <div className='flex min-h-[16px] items-center justify-between'>
  385. {(!!searchResults.length || isError) ? (
  386. <>
  387. <span>
  388. {isError ? (
  389. <span className='text-red-500'>{t('app.gotoAnything.someServicesUnavailable')}</span>
  390. ) : (
  391. <>
  392. {t('app.gotoAnything.resultCount', { count: searchResults.length })}
  393. {searchMode !== 'general' && (
  394. <span className='ml-2 opacity-60'>
  395. {t('app.gotoAnything.inScope', { scope: searchMode.replace('@', '') })}
  396. </span>
  397. )}
  398. </>
  399. )}
  400. </span>
  401. <span className='opacity-60'>
  402. {searchMode !== 'general'
  403. ? t('app.gotoAnything.clearToSearchAll')
  404. : t('app.gotoAnything.useAtForSpecific')
  405. }
  406. </span>
  407. </>
  408. ) : (
  409. <>
  410. <span className='opacity-60'>
  411. {(() => {
  412. if (isCommandsMode)
  413. return t('app.gotoAnything.selectToNavigate')
  414. if (searchQuery.trim())
  415. return t('app.gotoAnything.searching')
  416. return t('app.gotoAnything.startTyping')
  417. })()}
  418. </span>
  419. <span className='opacity-60'>
  420. {searchQuery.trim() || isCommandsMode
  421. ? t('app.gotoAnything.tips')
  422. : t('app.gotoAnything.pressEscToClose')}
  423. </span>
  424. </>
  425. )}
  426. </div>
  427. </div>
  428. </Command>
  429. </div>
  430. </Modal>
  431. {
  432. activePlugin && (
  433. <InstallFromMarketplace
  434. manifest={activePlugin}
  435. uniqueIdentifier={activePlugin.latest_package_identifier}
  436. onClose={() => setActivePlugin(undefined)}
  437. onSuccess={() => setActivePlugin(undefined)}
  438. />
  439. )
  440. }
  441. </>
  442. )
  443. }
  444. /**
  445. * GotoAnything component with context provider
  446. */
  447. const GotoAnythingWithContext: FC<Props> = (props) => {
  448. return (
  449. <GotoAnythingProvider>
  450. <GotoAnything {...props} />
  451. </GotoAnythingProvider>
  452. )
  453. }
  454. export default GotoAnythingWithContext