useDocsSearch.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  1. 'use client'
  2. import { compact, debounce, uniqBy } from 'lodash'
  3. import { useCallback, useMemo, useReducer, useRef } from 'react'
  4. import { isFeatureEnabled } from '../enabled-features'
  5. const NUMBER_SOURCES = 2
  6. const BRIVEN_URL = process.env.NEXT_PUBLIC_BRIVEN_URL
  7. const BRIVEN_ANON_KEY = process.env.NEXT_PUBLIC_BRIVEN_ANON_KEY
  8. const FUNCTIONS_URL = '/functions/v1/'
  9. enum PageType {
  10. Markdown = 'markdown',
  11. Reference = 'reference',
  12. Integration = 'partner-integration',
  13. GithubDiscussion = 'github-discussions',
  14. Troubleshooting = 'troubleshooting',
  15. }
  16. interface PageSection {
  17. heading: string
  18. slug: string
  19. }
  20. interface Page {
  21. id: number
  22. path: string
  23. type: PageType
  24. title: string
  25. subtitle: string | null
  26. description: string | null
  27. sections: PageSection[]
  28. }
  29. type SearchState =
  30. | {
  31. status: 'initial'
  32. key: number
  33. }
  34. | {
  35. status: 'loading'
  36. key: number
  37. staleResults: Page[]
  38. }
  39. | {
  40. status: 'partialResults'
  41. key: number
  42. results: Page[]
  43. }
  44. | {
  45. status: 'fullResults'
  46. key: number
  47. results: Page[]
  48. }
  49. | {
  50. status: 'noResults'
  51. key: number
  52. }
  53. | {
  54. status: 'error'
  55. key: number
  56. message: string
  57. }
  58. type Action =
  59. | {
  60. type: 'resultsReturned'
  61. key: number
  62. sourcesLoaded: number
  63. results: unknown[]
  64. }
  65. | {
  66. type: 'newSearchDispatched'
  67. key: number
  68. }
  69. | {
  70. type: 'reset'
  71. key: number
  72. }
  73. | {
  74. type: 'errored'
  75. key: number
  76. sourcesLoaded: number
  77. message: string
  78. }
  79. function reshapeResults(result: unknown): Page | null {
  80. if (typeof result !== 'object' || result === null) {
  81. return null
  82. }
  83. if (!('id' in result && 'path' in result && 'type' in result && 'title' in result)) {
  84. return null
  85. }
  86. const sections: PageSection[] = []
  87. if (
  88. 'headings' in result &&
  89. Array.isArray(result.headings) &&
  90. 'slugs' in result &&
  91. Array.isArray(result.slugs) &&
  92. result.headings.length === result.slugs.length
  93. ) {
  94. result.headings.forEach((heading, idx) => {
  95. const slug = (result.slugs as Array<string>)[idx]
  96. if (heading && slug) {
  97. sections.push({ heading, slug })
  98. }
  99. })
  100. }
  101. return {
  102. id: result.id as number,
  103. path: result.path as string,
  104. type: result.type as PageType,
  105. title: result.title as string,
  106. subtitle: 'subtitle' in result ? (result.subtitle as string) : null,
  107. description: 'description' in result ? (result.description as string) : null,
  108. sections,
  109. }
  110. }
  111. function reducer(state: SearchState, action: Action): SearchState {
  112. // Ignore responses from outdated async functions
  113. if (state.key > action.key) {
  114. return state
  115. }
  116. switch (action.type) {
  117. case 'resultsReturned':
  118. const allSourcesLoaded = action.sourcesLoaded === NUMBER_SOURCES
  119. const newResults = compact(action.results.map(reshapeResults))
  120. // If the new responses are from the same request as the current responses,
  121. // combine the responses.
  122. // If the new responses are from a fresher request, replace the current responses.
  123. const allResults =
  124. state.status === 'partialResults' && state.key === action.key
  125. ? uniqBy(state.results.concat(newResults), (res) => res.id)
  126. : newResults
  127. if (!allResults.length) {
  128. return allSourcesLoaded
  129. ? {
  130. status: 'noResults',
  131. key: action.key,
  132. }
  133. : {
  134. status: 'loading',
  135. key: action.key,
  136. staleResults:
  137. 'results' in state
  138. ? state.results
  139. : 'staleResults' in state
  140. ? state.staleResults
  141. : [],
  142. }
  143. }
  144. return allSourcesLoaded
  145. ? {
  146. status: 'fullResults',
  147. key: action.key,
  148. results: allResults,
  149. }
  150. : {
  151. status: 'partialResults',
  152. key: action.key,
  153. results: allResults,
  154. }
  155. case 'newSearchDispatched':
  156. return {
  157. status: 'loading',
  158. key: action.key,
  159. staleResults:
  160. 'results' in state ? state.results : 'staleResults' in state ? state.staleResults : [],
  161. }
  162. case 'reset':
  163. return {
  164. status: 'initial',
  165. key: action.key,
  166. }
  167. case 'errored':
  168. // At least one search has failed and all non-failing searches have come back empty
  169. if (action.sourcesLoaded === NUMBER_SOURCES && !('results' in state)) {
  170. return {
  171. status: 'error',
  172. key: action.key,
  173. message: action.message,
  174. }
  175. }
  176. return state
  177. default:
  178. return state
  179. }
  180. }
  181. const useDocsSearch = () => {
  182. const [state, dispatch] = useReducer(reducer, { status: 'initial', key: 0 })
  183. const key = useRef(0)
  184. const handleSearch = useCallback(async (query: string) => {
  185. key.current += 1
  186. const localKey = key.current
  187. dispatch({ type: 'newSearchDispatched', key: localKey })
  188. let sourcesLoaded = 0
  189. const useAlternateSearchIndex = !isFeatureEnabled('search:fullIndex')
  190. const searchEndpoint = useAlternateSearchIndex ? 'docs_search_fts_nimbus' : 'docs_search_fts'
  191. fetch(`${BRIVEN_URL}/rest/v1/rpc/${searchEndpoint}`, {
  192. method: 'POST',
  193. headers: {
  194. 'content-type': 'application/json',
  195. ...(BRIVEN_ANON_KEY && {
  196. apikey: BRIVEN_ANON_KEY,
  197. authorization: `Bearer ${BRIVEN_ANON_KEY}`,
  198. }),
  199. },
  200. body: JSON.stringify({ query: query.trim() }),
  201. })
  202. .then((res) => res.json())
  203. .then((data) => {
  204. sourcesLoaded += 1
  205. if (!Array.isArray(data)) {
  206. dispatch({
  207. type: 'errored',
  208. key: localKey,
  209. sourcesLoaded,
  210. message: data?.message ?? '',
  211. })
  212. } else {
  213. dispatch({
  214. type: 'resultsReturned',
  215. key: localKey,
  216. sourcesLoaded,
  217. results: data,
  218. })
  219. }
  220. })
  221. .catch((error: unknown) => {
  222. sourcesLoaded += 1
  223. console.error(`[ERROR] Error fetching Full Text Search results: ${error}`)
  224. dispatch({
  225. type: 'errored',
  226. key: localKey,
  227. sourcesLoaded,
  228. message: '',
  229. })
  230. })
  231. fetch(`${BRIVEN_URL}${FUNCTIONS_URL}search-embeddings`, {
  232. method: 'POST',
  233. body: JSON.stringify({ query, useAlternateSearchIndex }),
  234. })
  235. .then((response) => response.json())
  236. .then((results) => {
  237. if (!Array.isArray(results)) {
  238. throw Error("didn't get expected results array")
  239. }
  240. sourcesLoaded += 1
  241. dispatch({
  242. type: 'resultsReturned',
  243. key: localKey,
  244. sourcesLoaded,
  245. results,
  246. })
  247. })
  248. .catch((error) => {
  249. sourcesLoaded += 1
  250. dispatch({
  251. type: 'errored',
  252. key: localKey,
  253. sourcesLoaded,
  254. message: error.message ?? '',
  255. })
  256. })
  257. }, [])
  258. const debouncedSearch = useMemo(() => debounce(handleSearch, 150), [handleSearch])
  259. const resetSearch = useCallback(() => {
  260. key.current += 1
  261. dispatch({
  262. type: 'reset',
  263. key: key.current,
  264. })
  265. }, [])
  266. return {
  267. searchState: state,
  268. handleDocsSearch: handleSearch,
  269. handleDocsSearchDebounced: debouncedSearch,
  270. resetSearch,
  271. }
  272. }
  273. export { useDocsSearch, PageType as DocsSearchResultType }
  274. export type { Page as DocsSearchResult, PageSection as DocsSearchResultSection }