Nevar pievienot vairāk kā 25 tēmas Tēmai ir jāsākas ar burtu vai ciparu, tā var saturēt domu zīmes ('-') un var būt līdz 35 simboliem gara.

index.tsx 16KB

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