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.

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