diff --git a/content/rest/orgs/rule-suites.md b/content/rest/orgs/rule-suites.md index a65b41ee107f..0931bb9018c2 100644 --- a/content/rest/orgs/rule-suites.md +++ b/content/rest/orgs/rule-suites.md @@ -5,7 +5,7 @@ intro: Use the REST API to manage rule suites for organizations. versions: # DO NOT MANUALLY EDIT. CHANGES WILL BE OVERWRITTEN BY A 🤖 fpt: '*' ghec: '*' - ghes: '>=3.12' + ghes: '*' topics: - API autogenerated: rest diff --git a/content/rest/repos/rule-suites.md b/content/rest/repos/rule-suites.md index e0ddf3f85a4a..e9aca2db9666 100644 --- a/content/rest/repos/rule-suites.md +++ b/content/rest/repos/rule-suites.md @@ -5,7 +5,7 @@ intro: Use the REST API to manage rule suites for repositories. versions: # DO NOT MANUALLY EDIT. CHANGES WILL BE OVERWRITTEN BY A 🤖 fpt: '*' ghec: '*' - ghes: '>=3.12' + ghes: '*' topics: - API autogenerated: rest diff --git a/data/ui.yml b/data/ui.yml index 634b9e1e9177..af393dc67bd3 100644 --- a/data/ui.yml +++ b/data/ui.yml @@ -38,20 +38,22 @@ search: beta_tag: Beta return_to_search: Return to search clear_search_query: Clear + view_all_search_results: View all {{length}} results + no_results_found: No results found ai: disclaimer: Copilot uses AI. Check for mistakes by reviewing the links in the response. references: References from these articles loading_status_message: Loading Copilot response... done_loading_status_message: Done loading Copilot response - unable_to_answer: Sorry, I'm unable to answer that question. Please try a different query. + unable_to_answer: Sorry, I'm unable to answer that question. Please try a different query or search our docs. copy_answer: Copy answer copied_announcement: Copied! thumbs_up: This answer was helpful thumbs_down: This answer was not helpful thumbs_announcement: Thank you for your feedback! failure: - autocomplete_title: There was an error loading autocomplete results. - ai_title: There was an error loading the AI assistant. + general_title: There was an error loading search results. + ai_title: There was an error loading Copilot. description: You can still use this field to search our docs. old_search: description: Enter a search term to find it in the GitHub Docs. diff --git a/src/events/lib/schema.ts b/src/events/lib/schema.ts index 1993002f4289..98cbb97d3dff 100644 --- a/src/events/lib/schema.ts +++ b/src/events/lib/schema.ts @@ -262,6 +262,22 @@ const exit = { }, } +const keyboard = { + type: 'object', + additionalProperties: false, + required: ['pressed_key', 'pressed_on'], + properties: { + pressed_key: { + type: 'string', + description: 'The key the user pressed.', + }, + pressed_on: { + type: 'string', + description: 'The element/identifier the user pressed the key on.', + }, + }, +} + const link = { type: 'object', additionalProperties: false, @@ -400,6 +416,7 @@ const aiSearchResult = { 'ai_search_result_query', 'ai_search_result_response', 'ai_search_result_links_json', + 'ai_search_result_provided_answer', ], properties: { context, @@ -420,6 +437,10 @@ const aiSearchResult = { description: 'Dynamic JSON string of an array of "link" objects in the form: [{ "type": "reference" | "inline", "url": "https://..", "product": "issues" | "pages" | ... }, ...]', }, + ai_search_result_provided_answer: { + type: 'boolean', + description: 'Whether the GPT was able to answer the query.', + }, }, } @@ -584,6 +605,7 @@ const validation = { export const schemas = { page, exit, + keyboard, link, hover, search, @@ -600,6 +622,7 @@ export const schemas = { export const hydroNames = { page: 'docs.v0.PageEvent', exit: 'docs.v0.ExitEvent', + keyboard: 'docs.v0.KeyboardEvent', link: 'docs.v0.LinkEvent', hover: 'docs.v0.HoverEvent', search: 'docs.v0.SearchEvent', diff --git a/src/events/types.ts b/src/events/types.ts index bbb1863a79ce..760487ece42c 100644 --- a/src/events/types.ts +++ b/src/events/types.ts @@ -2,6 +2,7 @@ export enum EventType { aiSearchResult = 'aiSearchResult', page = 'page', exit = 'exit', + keyboard = 'keyboard', link = 'link', hover = 'hover', search = 'search', @@ -59,6 +60,7 @@ export type EventPropsByType = { // Dynamic JSON string of an array of "link" objects in the form: // [{ "type": "reference" | "inline", "url": "https://..", "product": "issues" | "pages" | ... }, ...] ai_search_result_links_json: string + ai_search_result_provided_answer: boolean } [EventType.clipboard]: { clipboard_operation: string @@ -82,6 +84,10 @@ export type EventPropsByType = { hover_url: string hover_samesite?: boolean } + [EventType.keyboard]: { + pressed_key: string + pressed_on: string + } [EventType.link]: { link_url: string link_samesite?: boolean diff --git a/src/fixtures/fixtures/data/ui.yml b/src/fixtures/fixtures/data/ui.yml index 634b9e1e9177..af393dc67bd3 100644 --- a/src/fixtures/fixtures/data/ui.yml +++ b/src/fixtures/fixtures/data/ui.yml @@ -38,20 +38,22 @@ search: beta_tag: Beta return_to_search: Return to search clear_search_query: Clear + view_all_search_results: View all {{length}} results + no_results_found: No results found ai: disclaimer: Copilot uses AI. Check for mistakes by reviewing the links in the response. references: References from these articles loading_status_message: Loading Copilot response... done_loading_status_message: Done loading Copilot response - unable_to_answer: Sorry, I'm unable to answer that question. Please try a different query. + unable_to_answer: Sorry, I'm unable to answer that question. Please try a different query or search our docs. copy_answer: Copy answer copied_announcement: Copied! thumbs_up: This answer was helpful thumbs_down: This answer was not helpful thumbs_announcement: Thank you for your feedback! failure: - autocomplete_title: There was an error loading autocomplete results. - ai_title: There was an error loading the AI assistant. + general_title: There was an error loading search results. + ai_title: There was an error loading Copilot. description: You can still use this field to search our docs. old_search: description: Enter a search term to find it in the GitHub Docs. diff --git a/src/fixtures/tests/playwright-rendering.spec.ts b/src/fixtures/tests/playwright-rendering.spec.ts index 8769cd6eadb4..dc4f2836f4fa 100644 --- a/src/fixtures/tests/playwright-rendering.spec.ts +++ b/src/fixtures/tests/playwright-rendering.spec.ts @@ -82,22 +82,53 @@ test('open new search, and perform a general search', async ({ page }) => { await page.getByTestId('search').click() await page.getByTestId('overlay-search-input').fill('serve playwright') - // Let new suggestions load + // Wait for the results to load + // NOTE: In the UI we wait for results to load before allowing "enter", because we don't want + // to allow an unnecessary request when there are no search results. Easier to wait 1 second await page.waitForTimeout(1000) - // Navigate to general search item, "serve playwright" - await page.keyboard.press('ArrowDown') - // Select the general search item, "serve playwright" + // Press enter to perform general search await page.keyboard.press('Enter') - await expect(page).toHaveURL(/\/search\?query=serve\+playwright/) + await expect(page).toHaveURL( + /\/search\?search-overlay-input=serve\+playwright&query=serve\+playwright/, + ) await expect(page).toHaveTitle(/\d Search results for "serve playwright"/) + // The first result should be "For Playwright" await page.getByRole('link', { name: 'For Playwright' }).click() await expect(page).toHaveURL(/\/get-started\/foo\/for-playwright$/) await expect(page).toHaveTitle(/For Playwright/) }) +test('open new search, and select a general search article', async ({ page }) => { + test.skip(!SEARCH_TESTS, 'No local Elasticsearch, no tests involving search') + + await page.goto('/') + + // Enable the AI search experiment by overriding the control group + await page.evaluate(() => { + // @ts-expect-error overrideControlGroup is a custom function added to the window object + window.overrideControlGroup('ai_search_experiment', 'treatment') + }) + + await page.getByTestId('search').click() + + await page.getByTestId('overlay-search-input').fill('serve playwright') + // Let new suggestions load + await page.waitForTimeout(1000) + // Navigate to general search item, "For Playwright" + await page.keyboard.press('ArrowDown') + // Select the general search item, "For Playwright" + await page.keyboard.press('Enter') + + // We should now be on the page for "For Playwright" + await expect(page).toHaveURL( + /\/get-started\/foo\/for-playwright\?search-overlay-input=serve\+playwright$/, + ) + await expect(page).toHaveTitle(/For Playwright/) +}) + test('open new search, and get auto-complete results', async ({ page }) => { test.skip(!SEARCH_TESTS, 'No local Elasticsearch, no tests involving search') diff --git a/src/frame/components/hooks/useQueryParam.ts b/src/frame/components/hooks/useQueryParam.ts index f590e1ebc166..53ceb35552c0 100644 --- a/src/frame/components/hooks/useQueryParam.ts +++ b/src/frame/components/hooks/useQueryParam.ts @@ -2,7 +2,7 @@ // The `queryParam` variable returned from this method are stateful and will be set to the query param on page load import { useRouter } from 'next/router' -import { useEffect, useState } from 'react' +import { useState, useEffect } from 'react' import { parseDebug } from '@/search/components/hooks/useQuery' type UseQueryParamReturn = { @@ -23,69 +23,48 @@ export function useQueryParam( ): UseQueryParamReturn { const router = useRouter() - // Determine the initial value of the query param - let initialQueryParam = '' - const paramValue = router.query[queryParamKey] - - if (paramValue) { - if (Array.isArray(paramValue)) { - initialQueryParam = paramValue[0] - } else { - initialQueryParam = paramValue - } - } - - const debugValue = parseDebug(router.query.debug) - - // Return type will be set based on overloads - const [queryParamString, setQueryParamState] = useState(initialQueryParam) - const [debug] = useState(debugValue) + const [queryParamString, setQueryParamState] = useState('') + const [debug, setDebug] = useState(false) + const queryParam: string | boolean = isBoolean ? queryParamString === 'true' : queryParamString - // If the query param changes in the URL, update the state + // Only set the initial query param values on page load, the rest of the time we use React state useEffect(() => { + let initialQueryParam = '' const paramValue = router.query[queryParamKey] - if (paramValue) { - if (Array.isArray(paramValue)) { - setQueryParamState(paramValue[0]) - } else { - setQueryParamState(paramValue) - } + initialQueryParam = Array.isArray(paramValue) ? paramValue[0] : paramValue } - }, [router.query, queryParamKey]) - - // Determine the type of queryParam based on isBoolean - const queryParam: string | boolean = isBoolean ? queryParamString === 'true' : queryParamString + setQueryParamState(initialQueryParam) + setDebug(parseDebug(router.query.debug || '') || false) + }, [queryParamKey, router.pathname]) const setQueryParam = (value: string | boolean) => { - const { pathname, hash, search } = window.location - - let newValue: string = value as string - - // If it's a false boolean or empty string, just remove the query param - if (!value) { - newValue = '' - } else if (typeof value === 'boolean') { - newValue = 'true' - } - - const params = new URLSearchParams(search) + const newValue = typeof value === 'boolean' ? (value ? 'true' : '') : value + const [asPathWithoutHash] = router.asPath.split('#') + const [asPathRoot, asPathQuery = ''] = asPathWithoutHash.split('?') + const currentParams = new URLSearchParams(asPathQuery) if (newValue) { - params.set(queryParamKey, newValue) + currentParams.set(queryParamKey, newValue) } else { - params.delete(queryParamKey) + currentParams.delete(queryParamKey) + } + const paramsString = currentParams.toString() ? `?${currentParams.toString()}` : '' + let newUrl = `${asPathRoot}${paramsString}` + if (asPathRoot !== '/' && router.locale) { + newUrl = `${router.locale}${asPathRoot}${paramsString}` + } + if (!newUrl.startsWith('/')) { + newUrl = `/${newUrl}` } - const newSearch = params.toString() - const newUrl = newSearch ? `${pathname}?${newSearch}${hash}` : `${pathname}${hash}` + router.replace(newUrl, undefined, { shallow: true, locale: router.locale, scroll: false }) - window.history.replaceState({}, '', newUrl) setQueryParamState(newValue) } return { debug, queryParam: queryParam as any, // Type will be set based on overloads - setQueryParam: setQueryParam as any, + setQueryParam, } } diff --git a/src/frame/components/page-header/Header.tsx b/src/frame/components/page-header/Header.tsx index 2f4ca7e3defc..d291d63a0dea 100644 --- a/src/frame/components/page-header/Header.tsx +++ b/src/frame/components/page-header/Header.tsx @@ -73,6 +73,18 @@ export const Header = () => { return () => window.removeEventListener('keydown', close) }, []) + // Listen for '/' so we can open the search overlay when pressed. (only enabled for showNewSearch is true for new search experience) + useEffect(() => { + const open = (e: KeyboardEvent) => { + if (e.key === '/' && showNewSearch && !isSearchOpen) { + e.preventDefault() + setIsSearchOpen(true) + } + } + window.addEventListener('keydown', open) + return () => window.removeEventListener('keydown', open) + }, [isSearchOpen, showNewSearch]) + // For the UI in smaller browser widths, and focus the picker menu button when the search // input is closed. useEffect(() => { diff --git a/src/redirects/middleware/handle-redirects.ts b/src/redirects/middleware/handle-redirects.ts index 070f5bb42a22..c92ecd608fda 100644 --- a/src/redirects/middleware/handle-redirects.ts +++ b/src/redirects/middleware/handle-redirects.ts @@ -26,7 +26,12 @@ export default function handleRedirects(req: ExtendedRequest, res: Response, nex if (req.path === '/') { const language = getLanguage(req) languageCacheControl(res) - return res.redirect(302, `/${language}`) + // Forward query params to the new URL + let queryParams = new URLSearchParams((req?.query as any) || '').toString() + if (queryParams) { + queryParams = `?${queryParams}` + } + return res.redirect(302, `/${language}${queryParams}`) } // begin redirect handling diff --git a/src/search/components/helpers/ai-search-links-json.ts b/src/search/components/helpers/ai-search-links-json.ts index 290a8b588e58..257f867134ae 100644 --- a/src/search/components/helpers/ai-search-links-json.ts +++ b/src/search/components/helpers/ai-search-links-json.ts @@ -10,7 +10,7 @@ type LinksJSON = Array<{ // // We include the JSON string in our analytics events so we can see the // most popular sourced references, among other things. -export function generateAiSearchLinksJson( +export function generateAISearchLinksJson( sourcesBuffer: Array<{ url: string }>, aiResponse: string, ): string { diff --git a/src/search/components/helpers/execute-search-actions.ts b/src/search/components/helpers/execute-search-actions.ts index ea02a2e96487..6a306c54d228 100644 --- a/src/search/components/helpers/execute-search-actions.ts +++ b/src/search/components/helpers/execute-search-actions.ts @@ -1,5 +1,5 @@ import { EventType } from '@/events/types' -import { AutocompleteSearchResponse } from '@/search/types' +import { CombinedSearchResponse } from '@/search/types' import { DEFAULT_VERSION } from '@/versions/components/useVersion' import { NextRouter } from 'next/router' import { sendEvent } from 'src/events/components/events' @@ -8,7 +8,7 @@ import { SEARCH_OVERLAY_EVENT_GROUP } from '@/events/components/event-groups' // Search context values for identifying each search event export const GENERAL_SEARCH_CONTEXT = 'general-search' export const AI_SEARCH_CONTEXT = 'ai-search' -export const AI_AUTOCOMPLETE_SEARCH_CONTEXT = 'ai-search-autocomplete' +export const COMBINED_SEARCH_CONTEXT = 'combined-search' // The logic that redirects to the /search page with the proper query params // The query params will be consumed in the general search middleware @@ -32,10 +32,15 @@ export function executeGeneralSearch( asPath += `/${currentVersion}` } asPath += '/search' - const params = new URLSearchParams({ query: localQuery }) + const params = new URLSearchParams(window.location.search || {}) + params.set('query', localQuery) if (debug) { params.set('debug', '1') } + // Close the search overlay + if (params.has('search-overlay-open')) { + params.delete('search-overlay-open') + } asPath += `?${params}` router.push(asPath) } @@ -66,8 +71,10 @@ export async function executeAISearch( return response } -// The AJAX request logic that fetches the autocomplete options for AI autocomplete sugggestions -export async function executeAIAutocompleteSearch( +/** + * The AJAX request logic that fetches combined search results AI autocomplete suggestions + general search suggestions + */ +export async function executeCombinedSearch( router: NextRouter, version: string, query: string, @@ -79,7 +86,7 @@ export async function executeAIAutocompleteSearch( type: EventType.search, // TODO: Remove PII so we can include the actual query search_query: 'REDACTED', - search_context: AI_AUTOCOMPLETE_SEARCH_CONTEXT, + search_context: COMBINED_SEARCH_CONTEXT, eventGroupKey: SEARCH_OVERLAY_EVENT_GROUP, eventGroupId: eventGroupId, }) @@ -94,7 +101,7 @@ export async function executeAIAutocompleteSearch( // Always fetch 4 results for autocomplete params.set('size', '4') - const response = await fetch(`/api/search/ai-search-autocomplete/v1?${params}`, { + const response = await fetch(`/api/search/combined-search/v1?${params}`, { headers: { 'Content-Type': 'application/json', }, @@ -106,8 +113,11 @@ export async function executeAIAutocompleteSearch( `Failed to fetch ai autocomplete search results.\nStatus ${response.status}\n${response.statusText}`, ) } - const results = (await response.json()) as AutocompleteSearchResponse + + const results = (await response.json()) as CombinedSearchResponse + return { - aiAutocompleteOptions: results?.hits || [], + aiAutocompleteOptions: results?.aiAutocompleteSuggestions, + generalSearchResults: results?.generalSearchResults, } } diff --git a/src/search/components/hooks/useAISearchAutocomplete.ts b/src/search/components/hooks/useAISearchAutocomplete.ts index c3fa55074385..3585158b8236 100644 --- a/src/search/components/hooks/useAISearchAutocomplete.ts +++ b/src/search/components/hooks/useAISearchAutocomplete.ts @@ -1,22 +1,24 @@ import { useState, useRef, useCallback, useEffect } from 'react' import debounce from 'lodash/debounce' import { NextRouter } from 'next/router' -import { AutocompleteSearchHit } from '@/search/types' -import { executeAIAutocompleteSearch } from '@/search/components/helpers/execute-search-actions' +import { AutocompleteSearchHit, GeneralSearchHit } from '@/search/types' +import { executeCombinedSearch } from '@/search/components/helpers/execute-search-actions' -type AutocompleteOptions = { +type SearchOptions = { aiAutocompleteOptions: AutocompleteSearchHit[] + generalSearchResults: GeneralSearchHit[] + totalGeneralSearchResults: number } -type UseAutocompleteProps = { +type UseCombinedSearchProps = { router: NextRouter currentVersion: string debug: boolean eventGroupIdRef: React.MutableRefObject } -type UseAutocompleteReturn = { - autoCompleteOptions: AutocompleteOptions +type UseCombinedSearchReturn = { + autoCompleteOptions: SearchOptions searchLoading: boolean setSearchLoading: (loading: boolean) => void searchError: boolean @@ -28,7 +30,7 @@ const DEBOUNCE_TIME = 300 // In milliseconds // Results are only cached for the current session // We cache results so if a user presses backspace, we can show the results immediately without burdening the API -let sessionCache = {} as Record +let sessionCache = {} as Record // Helper to incorporate version & locale into the cache key function getCacheKey(query: string, version: string, locale: string) { @@ -40,14 +42,16 @@ function getCacheKey(query: string, version: string, locale: string) { // 1. Debouncing the request to prevent multiple requests while the user is typing // 2. Caching the results of the request so if the user presses backspace, we can show the results immediately without burdening the API // 3. Aborting in-flight requests if the user types again before the previous request has completed -export function useAISearchAutocomplete({ +export function useCombinedSearchResults({ router, currentVersion, debug, eventGroupIdRef, -}: UseAutocompleteProps): UseAutocompleteReturn { - const [autoCompleteOptions, setAutoCompleteOptions] = useState({ +}: UseCombinedSearchProps): UseCombinedSearchReturn { + const [searchOptions, setSearchOptions] = useState({ aiAutocompleteOptions: [], + generalSearchResults: [], + totalGeneralSearchResults: 0, }) const [searchLoading, setSearchLoading] = useState(true) const [searchError, setSearchError] = useState(false) @@ -75,24 +79,24 @@ export function useAISearchAutocomplete({ abortControllerRef.current.abort() } + setSearchLoading(true) + // Build cache key based on query, version, and locale const cacheKey = getCacheKey(queryValue, currentVersion, router.locale || 'en') // Check if the result is in cache if (sessionCache[cacheKey]) { - setAutoCompleteOptions(sessionCache[cacheKey]) + setSearchOptions(sessionCache[cacheKey]) setSearchLoading(false) return } - setSearchLoading(true) - // Create a new AbortController for the new request const controller = new AbortController() abortControllerRef.current = controller try { - const { aiAutocompleteOptions } = await executeAIAutocompleteSearch( + const { aiAutocompleteOptions, generalSearchResults } = await executeCombinedSearch( router, currentVersion, queryValue, @@ -101,15 +105,17 @@ export function useAISearchAutocomplete({ eventGroupIdRef.current, ) - const results: AutocompleteOptions = { - aiAutocompleteOptions, + const results = { + aiAutocompleteOptions: aiAutocompleteOptions.hits, + generalSearchResults: generalSearchResults?.hits || [], + totalGeneralSearchResults: generalSearchResults?.meta?.found?.value || 0, } // Update cache sessionCache[cacheKey] = results // Update state with fetched results - setAutoCompleteOptions(results) + setSearchOptions(results) setSearchLoading(false) } catch (error: any) { if (error.name === 'AbortError') { @@ -137,8 +143,10 @@ export function useAISearchAutocomplete({ }, []) const clearAutocompleteResults = useCallback(() => { - setAutoCompleteOptions({ + setSearchOptions({ aiAutocompleteOptions: [], + generalSearchResults: [], + totalGeneralSearchResults: 0, }) setSearchLoading(false) setSearchError(false) @@ -152,7 +160,7 @@ export function useAISearchAutocomplete({ }, []) return { - autoCompleteOptions, + autoCompleteOptions: searchOptions, searchLoading, setSearchLoading, searchError, diff --git a/src/search/components/hooks/useLocalStorageCache.ts b/src/search/components/hooks/useAISearchLocalStorageCache.ts similarity index 82% rename from src/search/components/hooks/useLocalStorageCache.ts rename to src/search/components/hooks/useAISearchLocalStorageCache.ts index 8ffb6bf548dd..e201d5653acc 100644 --- a/src/search/components/hooks/useLocalStorageCache.ts +++ b/src/search/components/hooks/useAISearchLocalStorageCache.ts @@ -11,17 +11,12 @@ interface CacheIndexEntry { } /** - * Custom hook for managing a localStorage cache - * The cache uses an index to track the keys of cached items, and a separate item in localStorage + * We cache AI Search response as individual entries in localStorage, with a separate index to track the keys. * This allows the cache to be updated without having to read a single large entry into memory and parse it each time a key is accessed * - * Cached items are cached under a prefix, for a fixed number of days, and the cache is limited to a fixed number of entries set by the following: - * @param cacheKeyPrefix - Prefix for cache keys in localStorage. - * @param maxEntries - Maximum number of entries that can be stored in the cache. - * @param expirationDays - Number of days before a cache entry expires. - * @returns An object containing getItem and setItem functions. + * Cached items are cached under a prefix, for a fixed number of days */ -function useLocalStorageCache( +export function useAISearchLocalStorageCache( cacheKeyPrefix: string = 'ai-query-cache', maxEntries: number = 1000, expirationDays: number = 30, @@ -59,7 +54,6 @@ function useLocalStorageCache( const now = Date.now() const expirationTime = cachedItem.timestamp + expirationDays * 24 * 60 * 60 * 1000 if (now < expirationTime) { - // Item is still valid return cachedItem.data } else { // Item expired, remove it @@ -77,10 +71,8 @@ function useLocalStorageCache( const now = Date.now() const cachedItem: CachedItem = { data, timestamp: now } - // Store the item localStorage.setItem(key, JSON.stringify(cachedItem)) - // Update index const indexStr = localStorage.getItem(cacheIndexKey) let index: CacheIndexEntry[] = [] if (indexStr) { @@ -113,10 +105,6 @@ function useLocalStorageCache( [cacheKeyPrefix, maxEntries], ) - /** - * Updates the cache index using a provided updater function. - * @param updateFn - A function that takes the current index and returns the updated index. - */ const updateCacheIndex = (updateFn: (index: CacheIndexEntry[]) => CacheIndexEntry[]): void => { const indexStr = localStorage.getItem(cacheIndexKey) let index: CacheIndexEntry[] = [] @@ -134,5 +122,3 @@ function useLocalStorageCache( return { getItem, setItem } } - -export default useLocalStorageCache diff --git a/src/search/components/hooks/useMultiQueryParams.ts b/src/search/components/hooks/useMultiQueryParams.ts new file mode 100644 index 000000000000..07f5935a7a09 --- /dev/null +++ b/src/search/components/hooks/useMultiQueryParams.ts @@ -0,0 +1,72 @@ +import { useRouter } from 'next/router' +import { useState, useEffect, useRef } from 'react' + +export type QueryParams = { + 'search-overlay-input': string + 'search-overlay-ask-ai': string // "true" or "" + debug: string +} + +const initialKeys: (keyof QueryParams)[] = [ + 'search-overlay-input', + 'search-overlay-ask-ai', + 'debug', +] + +// When we need to update 2 query params simultaneously, we can use this hook to prevent race conditions +export function useMultiQueryParams() { + const router = useRouter() + const pushTimeoutRef = useRef | null>(null) + + const getInitialParams = (): QueryParams => { + const searchParams = + typeof window !== 'undefined' + ? new URLSearchParams(window.location.search) + : new URLSearchParams(router.asPath.split('?')[1] || '') + const params: QueryParams = { + 'search-overlay-input': searchParams.get('search-overlay-input') || '', + 'search-overlay-ask-ai': searchParams.get('search-overlay-ask-ai') || '', + debug: searchParams.get('debug') || '', + } + return params + } + + const [params, setParams] = useState(getInitialParams) + + // Only set the initial query param values on page load, the rest of the time we use React state + useEffect(() => { + setParams(getInitialParams()) + }, [router.pathname]) + + const updateParams = (updates: Partial) => { + const newParams = { ...params, ...updates } + const [asPathWithoutHash] = router.asPath.split('#') + const [asPathRoot, asPathQuery = ''] = asPathWithoutHash.split('?') + const searchParams = new URLSearchParams(asPathQuery) + initialKeys.forEach((key) => { + if (key === 'search-overlay-ask-ai') { + newParams[key] === 'true' ? searchParams.set(key, 'true') : searchParams.delete(key) + } else { + newParams[key] ? searchParams.set(key, newParams[key]) : searchParams.delete(key) + } + }) + const paramsString = searchParams.toString() ? `?${searchParams.toString()}` : '' + let newUrl = `${asPathRoot}${paramsString}` + if (asPathRoot !== '/' && router.locale) { + newUrl = `${router.locale}${asPathRoot}${paramsString}` + } + if (!newUrl.startsWith('/')) { + newUrl = `/${newUrl}` + } + + // Debounce the router push so we don't push a new URL for every keystroke + if (pushTimeoutRef.current) clearTimeout(pushTimeoutRef.current) + pushTimeoutRef.current = setTimeout(() => { + router.replace(newUrl, undefined, { shallow: true, locale: router.locale, scroll: false }) + }, 100) + + setParams(newParams) + } + + return { params, updateParams } +} diff --git a/src/search/components/input/AskAIResults.module.scss b/src/search/components/input/AskAIResults.module.scss index 01fea7d4fc82..e5c98cded4c2 100644 --- a/src/search/components/input/AskAIResults.module.scss +++ b/src/search/components/input/AskAIResults.module.scss @@ -22,6 +22,10 @@ $mutedTextColor: var(--fgColor-muted, var(--color-fg-muted, #656d76)); margin-top: 4px; margin-bottom: 16px; padding: $bodyPadding; + ul, + ol { + list-style-type: decimal !important; + } } .referencesTitle { diff --git a/src/search/components/input/AskAIResults.tsx b/src/search/components/input/AskAIResults.tsx index 21c7576530f1..4cb299ae9ec1 100644 --- a/src/search/components/input/AskAIResults.tsx +++ b/src/search/components/input/AskAIResults.tsx @@ -5,14 +5,14 @@ import { useTranslation } from '@/languages/components/useTranslation' import { ActionList, IconButton, Spinner } from '@primer/react' import { CheckIcon, CopyIcon, FileIcon, ThumbsdownIcon, ThumbsupIcon } from '@primer/octicons-react' import { announce } from '@primer/live-region-element' -import useLocalStorageCache from '../hooks/useLocalStorageCache' +import { useAISearchLocalStorageCache } from '../hooks/useAISearchLocalStorageCache' import { UnrenderedMarkdownContent } from '@/frame/components/ui/MarkdownContent/UnrenderedMarkdownContent' import styles from './AskAIResults.module.scss' import { fixIncompleteMarkdown } from '../helpers/fix-incomplete-markdown' import useClipboard from '@/rest/components/useClipboard' import { sendEvent, uuidv4 } from '@/events/components/events' import { EventType } from '@/events/types' -import { generateAiSearchLinksJson } from '../helpers/ai-search-links-json' +import { generateAISearchLinksJson } from '../helpers/ai-search-links-json' import { ASK_AI_EVENT_GROUP } from '@/events/components/event-groups' import type { AIReference } from '../types' @@ -20,14 +20,15 @@ type AIQueryResultsProps = { query: string version: string debug: boolean - setAISearchError: () => void + setAISearchError: (isError?: boolean) => void references: AIReference[] setReferences: (references: AIReference[]) => void referencesIndexOffset: number referenceOnSelect: (url: string) => void selectedIndex: number - setSelectedIndex: (index: number) => void - askAiEventGroupId: React.MutableRefObject + askAIEventGroupId: React.MutableRefObject + aiCouldNotAnswer: boolean + setAICouldNotAnswer: (aiCouldNotAnswer: boolean) => void } export function AskAIResults({ @@ -40,8 +41,9 @@ export function AskAIResults({ referencesIndexOffset, referenceOnSelect, selectedIndex, - setSelectedIndex, - askAiEventGroupId, + askAIEventGroupId, + aiCouldNotAnswer, + setAICouldNotAnswer, }: AIQueryResultsProps) { const router = useRouter() const { t } = useTranslation('search') @@ -50,32 +52,53 @@ export function AskAIResults({ const [responseLoading, setResponseLoading] = useState(false) const disclaimerRef = useRef(null) // We cache up to 1000 queries, and expire them after 30 days - const { getItem, setItem } = useLocalStorageCache<{ + const { getItem, setItem } = useAISearchLocalStorageCache<{ query: string message: string sources: AIReference[] - }>('ai-query-cache', 1000, 30) + aiCouldNotAnswer: boolean + }>('ai-query-cache', 1000, 7) const [isCopied, setCopied] = useClipboard(message, { successDuration: 1400 }) const [feedbackSelected, setFeedbackSelected] = useState(null) + const handleAICannotAnswer = () => { + setInitialLoading(false) + setResponseLoading(false) + setAICouldNotAnswer(true) + const cannedResponse = t('search.ai.unable_to_answer') + sendAISearchResultEvent([], cannedResponse, askAIEventGroupId.current, true) + setMessage(cannedResponse) + setReferences([]) + } + // On query change, fetch the new results useEffect(() => { + // If we open this window directly (like from a URL), we need to generate a new event group ID + if (!askAIEventGroupId.current) { + askAIEventGroupId.current = uuidv4() + } let isCancelled = false setMessage('') setReferences([]) + setAICouldNotAnswer(false) setInitialLoading(true) setResponseLoading(true) - askAiEventGroupId.current = uuidv4() disclaimerRef.current?.focus() const cachedData = getItem(query, version, router.locale || 'en') if (cachedData) { setMessage(cachedData.message) setReferences(cachedData.sources) + setAICouldNotAnswer(cachedData.aiCouldNotAnswer || false) setInitialLoading(false) setResponseLoading(false) - sendAISearchResultEvent(cachedData.sources, cachedData.message, askAiEventGroupId.current) + sendAISearchResultEvent( + cachedData.sources, + cachedData.message, + askAIEventGroupId.current, + cachedData.aiCouldNotAnswer, + ) return } @@ -83,26 +106,20 @@ export function AskAIResults({ async function fetchData() { let messageBuffer = '' let sourcesBuffer: AIReference[] = [] + try { const response = await executeAISearch(router, version, query, debug) // Serve canned response. A question that cannot be answered was asked if (response.status === 400) { - setInitialLoading(false) - setResponseLoading(false) - const cannedResponse = t('search.ai.unable_to_answer') - setItem( - query, - { query, message: cannedResponse, sources: [] }, - version, - router.locale || 'en', - ) - return setMessage(cannedResponse) + return handleAICannotAnswer() } if (!response.ok) { console.error( `Failed to fetch search results.\nStatus ${response.status}\n${response.statusText}`, ) return setAISearchError() + } else { + setAISearchError(false) } if (!response.body) { console.error(`ReadableStream not supported in this browser`) @@ -124,7 +141,14 @@ export function AskAIResults({ try { parsedLine = JSON.parse(line) } catch (e) { - console.error('Failed to parse JSON:', e, 'Line:', line) + console.error( + 'Failed to parse JSON:', + e, + 'Line:', + line, + 'Typeof line: ', + typeof line, + ) continue } @@ -151,13 +175,18 @@ export function AskAIResults({ if (!isCancelled && messageBuffer) { setItem( query, - { query, message: messageBuffer, sources: sourcesBuffer }, + { + query, + message: messageBuffer, + sources: sourcesBuffer, + aiCouldNotAnswer: false, + }, version, router.locale || 'en', ) setInitialLoading(false) setResponseLoading(false) - sendAISearchResultEvent(sourcesBuffer, messageBuffer, askAiEventGroupId.current) + sendAISearchResultEvent(sourcesBuffer, messageBuffer, askAIEventGroupId.current, false) } } } @@ -183,13 +212,15 @@ export function AskAIResults({ ) : (
- - {t('search.ai.disclaimer')} - + {!aiCouldNotAnswer && message === '' ? ( + + {t('search.ai.disclaimer')} + + ) : null} {responseLoading ? fixIncompleteMarkdown(message) : message} @@ -214,7 +245,7 @@ export function AskAIResults({ type: EventType.survey, survey_vote: true, eventGroupKey: ASK_AI_EVENT_GROUP, - eventGroupId: askAiEventGroupId.current, + eventGroupId: askAIEventGroupId.current, }) }} > @@ -235,7 +266,7 @@ export function AskAIResults({ type: EventType.survey, survey_vote: false, eventGroupKey: ASK_AI_EVENT_GROUP, - eventGroupId: askAiEventGroupId.current, + eventGroupId: askAIEventGroupId.current, }) }} > @@ -256,13 +287,13 @@ export function AskAIResults({ type: EventType.clipboard, clipboard_operation: 'copy', eventGroupKey: ASK_AI_EVENT_GROUP, - eventGroupId: askAiEventGroupId.current, + eventGroupId: askAIEventGroupId.current, }) }} > ) : null} - {references && references.length > 0 ? ( + {!aiCouldNotAnswer && references && references.length > 0 ? ( <>