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.

index.tsx 15KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  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 { useQuery } from '@tanstack/react-query'
  14. import { useGetLanguage } from '@/context/i18n'
  15. import { useTranslation } from 'react-i18next'
  16. import InstallFromMarketplace from '../plugins/install-plugin/install-from-marketplace'
  17. import type { Plugin } from '../plugins/types'
  18. import { Command } from 'cmdk'
  19. type Props = {
  20. onHide?: () => void
  21. }
  22. const GotoAnything: FC<Props> = ({
  23. onHide,
  24. }) => {
  25. const router = useRouter()
  26. const defaultLocale = useGetLanguage()
  27. const { isWorkflowPage } = useGotoAnythingContext()
  28. const { t } = useTranslation()
  29. const [show, setShow] = useState<boolean>(false)
  30. const [searchQuery, setSearchQuery] = useState<string>('')
  31. const [cmdVal, setCmdVal] = useState<string>('')
  32. const inputRef = useRef<HTMLInputElement>(null)
  33. // Filter actions based on context
  34. const Actions = useMemo(() => {
  35. // Create a filtered copy of actions based on current page context
  36. if (isWorkflowPage) {
  37. // Include all actions on workflow pages
  38. return AllActions
  39. }
  40. else {
  41. // Exclude node action on non-workflow pages
  42. const { app, knowledge, plugin } = AllActions
  43. return { app, knowledge, plugin }
  44. }
  45. }, [isWorkflowPage])
  46. const [activePlugin, setActivePlugin] = useState<Plugin>()
  47. // Handle keyboard shortcuts
  48. const handleToggleModal = useCallback((e: KeyboardEvent) => {
  49. // Allow closing when modal is open, even if focus is in the search input
  50. if (!show && isEventTargetInputArea(e.target as HTMLElement))
  51. return
  52. e.preventDefault()
  53. setShow((prev) => {
  54. if (!prev) {
  55. // Opening modal - reset search state
  56. setSearchQuery('')
  57. }
  58. return !prev
  59. })
  60. }, [show])
  61. useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.k`, handleToggleModal, {
  62. exactMatch: true,
  63. useCapture: true,
  64. })
  65. useKeyPress(['esc'], (e) => {
  66. if (show) {
  67. e.preventDefault()
  68. setShow(false)
  69. setSearchQuery('')
  70. }
  71. })
  72. const searchQueryDebouncedValue = useDebounce(searchQuery.trim(), {
  73. wait: 300,
  74. })
  75. const searchMode = useMemo(() => {
  76. const query = searchQueryDebouncedValue.toLowerCase()
  77. const action = matchAction(query, Actions)
  78. return action ? action.key : 'general'
  79. }, [searchQueryDebouncedValue, Actions])
  80. const { data: searchResults = [], isLoading, isError, error } = useQuery(
  81. {
  82. queryKey: [
  83. 'goto-anything',
  84. 'search-result',
  85. searchQueryDebouncedValue,
  86. searchMode,
  87. isWorkflowPage,
  88. defaultLocale,
  89. Object.keys(Actions).sort().join(','),
  90. ],
  91. queryFn: async () => {
  92. const query = searchQueryDebouncedValue.toLowerCase()
  93. const action = matchAction(query, Actions)
  94. return await searchAnything(defaultLocale, query, action)
  95. },
  96. enabled: !!searchQueryDebouncedValue,
  97. staleTime: 30000,
  98. gcTime: 300000,
  99. },
  100. )
  101. // Handle navigation to selected result
  102. const handleNavigate = useCallback((result: SearchResult) => {
  103. setShow(false)
  104. setSearchQuery('')
  105. switch (result.type) {
  106. case 'plugin':
  107. setActivePlugin(result.data)
  108. break
  109. case 'workflow-node':
  110. // Handle workflow node selection and navigation
  111. if (result.metadata?.nodeId)
  112. selectWorkflowNode(result.metadata.nodeId, true)
  113. break
  114. default:
  115. if (result.path)
  116. router.push(result.path)
  117. }
  118. }, [router])
  119. // Group results by type
  120. const groupedResults = useMemo(() => searchResults.reduce((acc, result) => {
  121. if (!acc[result.type])
  122. acc[result.type] = []
  123. acc[result.type].push(result)
  124. return acc
  125. }, {} as { [key: string]: SearchResult[] }),
  126. [searchResults])
  127. const emptyResult = useMemo(() => {
  128. if (searchResults.length || !searchQueryDebouncedValue.trim() || isLoading)
  129. return null
  130. const isCommandSearch = searchMode !== 'general'
  131. const commandType = isCommandSearch ? searchMode.replace('@', '') : ''
  132. if (isError) {
  133. return (
  134. <div className="flex items-center justify-center py-8 text-center text-text-tertiary">
  135. <div>
  136. <div className='text-sm font-medium text-red-500'>{t('app.gotoAnything.searchTemporarilyUnavailable')}</div>
  137. <div className='mt-1 text-xs text-text-quaternary'>
  138. {t('app.gotoAnything.servicesUnavailableMessage')}
  139. </div>
  140. </div>
  141. </div>
  142. )
  143. }
  144. return (
  145. <div className="flex items-center justify-center py-8 text-center text-text-tertiary">
  146. <div>
  147. <div className='text-sm font-medium'>
  148. {isCommandSearch
  149. ? (() => {
  150. const keyMap: Record<string, string> = {
  151. app: 'app.gotoAnything.emptyState.noAppsFound',
  152. plugin: 'app.gotoAnything.emptyState.noPluginsFound',
  153. knowledge: 'app.gotoAnything.emptyState.noKnowledgeBasesFound',
  154. node: 'app.gotoAnything.emptyState.noWorkflowNodesFound',
  155. }
  156. return t(keyMap[commandType] || 'app.gotoAnything.noResults')
  157. })()
  158. : t('app.gotoAnything.noResults')
  159. }
  160. </div>
  161. <div className='mt-1 text-xs text-text-quaternary'>
  162. {isCommandSearch
  163. ? t('app.gotoAnything.emptyState.tryDifferentTerm', { mode: searchMode })
  164. : t('app.gotoAnything.emptyState.trySpecificSearch', { shortcuts: Object.values(Actions).map(action => action.shortcut).join(', ') })
  165. }
  166. </div>
  167. </div>
  168. </div>
  169. )
  170. }, [searchResults, searchQueryDebouncedValue, Actions, searchMode, isLoading, isError])
  171. const defaultUI = useMemo(() => {
  172. if (searchQueryDebouncedValue.trim())
  173. return null
  174. return (<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
  175. <div>
  176. <div className='text-sm font-medium'>{t('app.gotoAnything.searchTitle')}</div>
  177. <div className='mt-3 space-y-2 text-xs text-text-quaternary'>
  178. {Object.values(Actions).map(action => (
  179. <div key={action.key} className='flex items-center gap-2'>
  180. <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>
  181. <span>{(() => {
  182. const keyMap: Record<string, string> = {
  183. '@app': 'app.gotoAnything.actions.searchApplicationsDesc',
  184. '@plugin': 'app.gotoAnything.actions.searchPluginsDesc',
  185. '@knowledge': 'app.gotoAnything.actions.searchKnowledgeBasesDesc',
  186. '@node': 'app.gotoAnything.actions.searchWorkflowNodesDesc',
  187. }
  188. return t(keyMap[action.key])
  189. })()}</span>
  190. </div>
  191. ))}
  192. </div>
  193. </div>
  194. </div>)
  195. }, [searchQueryDebouncedValue, Actions])
  196. useEffect(() => {
  197. if (show) {
  198. requestAnimationFrame(() => {
  199. inputRef.current?.focus()
  200. })
  201. }
  202. return () => {
  203. setCmdVal('')
  204. }
  205. }, [show])
  206. return (
  207. <>
  208. <Modal
  209. isShow={show}
  210. onClose={() => {
  211. setShow(false)
  212. setSearchQuery('')
  213. onHide?.()
  214. }}
  215. closable={false}
  216. className='!w-[480px] !p-0'
  217. >
  218. <div className='flex flex-col rounded-2xl border border-components-panel-border bg-components-panel-bg shadow-xl'>
  219. <Command
  220. className='outline-none'
  221. value={cmdVal}
  222. onValueChange={setCmdVal}
  223. >
  224. <div className='flex items-center gap-3 border-b border-divider-subtle bg-components-panel-bg-blur px-4 py-3'>
  225. <RiSearchLine className='h-4 w-4 text-text-quaternary' />
  226. <div className='flex flex-1 items-center gap-2'>
  227. <Input
  228. ref={inputRef}
  229. value={searchQuery}
  230. placeholder={t('app.gotoAnything.searchPlaceholder')}
  231. onChange={(e) => {
  232. setCmdVal('')
  233. setSearchQuery(e.target.value)
  234. }}
  235. className='flex-1 !border-0 !bg-transparent !shadow-none'
  236. wrapperClassName='flex-1 !border-0 !bg-transparent'
  237. autoFocus
  238. />
  239. {searchMode !== 'general' && (
  240. <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'>
  241. <span>{searchMode.replace('@', '').toUpperCase()}</span>
  242. </div>
  243. )}
  244. </div>
  245. <div className='text-xs text-text-quaternary'>
  246. <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'>
  247. {isMac() ? '⌘' : 'Ctrl'}
  248. </span>
  249. <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'>
  250. K
  251. </span>
  252. </div>
  253. </div>
  254. <Command.List className='max-h-[275px] min-h-[240px] overflow-y-auto'>
  255. {isLoading && (
  256. <div className="flex items-center justify-center py-8 text-center text-text-tertiary">
  257. <div className="flex items-center gap-2">
  258. <div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-gray-600"></div>
  259. <span className="text-sm">{t('app.gotoAnything.searching')}</span>
  260. </div>
  261. </div>
  262. )}
  263. {isError && (
  264. <div className="flex items-center justify-center py-8 text-center text-text-tertiary">
  265. <div>
  266. <div className="text-sm font-medium text-red-500">{t('app.gotoAnything.searchFailed')}</div>
  267. <div className="mt-1 text-xs text-text-quaternary">
  268. {error.message}
  269. </div>
  270. </div>
  271. </div>
  272. )}
  273. {!isLoading && !isError && (
  274. <>
  275. {Object.entries(groupedResults).map(([type, results], groupIndex) => (
  276. <Command.Group key={groupIndex} heading={(() => {
  277. const typeMap: Record<string, string> = {
  278. 'app': 'app.gotoAnything.groups.apps',
  279. 'plugin': 'app.gotoAnything.groups.plugins',
  280. 'knowledge': 'app.gotoAnything.groups.knowledgeBases',
  281. 'workflow-node': 'app.gotoAnything.groups.workflowNodes',
  282. }
  283. return t(typeMap[type] || `${type}s`)
  284. })()} className='p-2 capitalize text-text-secondary'>
  285. {results.map(result => (
  286. <Command.Item
  287. key={`${result.type}-${result.id}`}
  288. value={result.title}
  289. 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'
  290. onSelect={() => handleNavigate(result)}
  291. >
  292. {result.icon}
  293. <div className='min-w-0 flex-1'>
  294. <div className='truncate font-medium text-text-secondary'>
  295. {result.title}
  296. </div>
  297. {result.description && (
  298. <div className='mt-0.5 truncate text-xs text-text-quaternary'>
  299. {result.description}
  300. </div>
  301. )}
  302. </div>
  303. <div className='text-xs capitalize text-text-quaternary'>
  304. {result.type}
  305. </div>
  306. </Command.Item>
  307. ))}
  308. </Command.Group>
  309. ))}
  310. {emptyResult}
  311. {defaultUI}
  312. </>
  313. )}
  314. </Command.List>
  315. {(!!searchResults.length || isError) && (
  316. <div className='border-t border-divider-subtle bg-components-panel-bg-blur px-4 py-2 text-xs text-text-tertiary'>
  317. <div className='flex items-center justify-between'>
  318. <span>
  319. {isError ? (
  320. <span className='text-red-500'>{t('app.gotoAnything.someServicesUnavailable')}</span>
  321. ) : (
  322. <>
  323. {t('app.gotoAnything.resultCount', { count: searchResults.length })}
  324. {searchMode !== 'general' && (
  325. <span className='ml-2 opacity-60'>
  326. {t('app.gotoAnything.inScope', { scope: searchMode.replace('@', '') })}
  327. </span>
  328. )}
  329. </>
  330. )}
  331. </span>
  332. <span className='opacity-60'>
  333. {searchMode !== 'general'
  334. ? t('app.gotoAnything.clearToSearchAll')
  335. : t('app.gotoAnything.useAtForSpecific')
  336. }
  337. </span>
  338. </div>
  339. </div>
  340. )}
  341. </Command>
  342. </div>
  343. </Modal>
  344. {
  345. activePlugin && (
  346. <InstallFromMarketplace
  347. manifest={activePlugin}
  348. uniqueIdentifier={activePlugin.latest_package_identifier}
  349. onClose={() => setActivePlugin(undefined)}
  350. onSuccess={() => setActivePlugin(undefined)}
  351. />
  352. )
  353. }
  354. </>
  355. )
  356. }
  357. /**
  358. * GotoAnything component with context provider
  359. */
  360. const GotoAnythingWithContext: FC<Props> = (props) => {
  361. return (
  362. <GotoAnythingProvider>
  363. <GotoAnything {...props} />
  364. </GotoAnythingProvider>
  365. )
  366. }
  367. export default GotoAnythingWithContext