1
0
mirror of synced 2025-12-19 18:10:59 -05:00

Ai search UI (#53026)

Co-authored-by: Kevin Heis <heiskr@users.noreply.github.com>
Co-authored-by: Ashish Keshan <ashishkeshan@github.com>
This commit is contained in:
Evan Bonsignori
2025-02-05 11:46:58 -08:00
committed by GitHub
parent 52aec0f08a
commit b099e4a9e3
62 changed files with 4473 additions and 1435 deletions

View File

@@ -15,6 +15,7 @@ header:
ghes_release_notes_upgrade_patch_and_release: 📣 This is not the <a href="#{{ latestPatch }}">latest patch release</a> of this release series, and this is not the <a href="/enterprise-server@{{ latestRelease }}/admin/release-notes">latest release</a> of Enterprise Server. ghes_release_notes_upgrade_patch_and_release: 📣 This is not the <a href="#{{ latestPatch }}">latest patch release</a> of this release series, and this is not the <a href="/enterprise-server@{{ latestRelease }}/admin/release-notes">latest release</a> of Enterprise Server.
sign_up_cta: Sign up sign_up_cta: Sign up
menu: Menu menu: Menu
open_menu_label: Open menu
go_home: Home go_home: Home
picker: picker:
language_picker_label: Language language_picker_label: Language
@@ -23,6 +24,37 @@ picker:
release_notes: release_notes:
banner_text: GitHub began rolling these changes out to enterprises on banner_text: GitHub began rolling these changes out to enterprises on
search: search:
input:
experimental_tag: Experimental
aria_label: Open search overlay
placeholder: Search or ask AI assistant
overlay:
input_aria_label: Search or ask AI assistant
suggestions_list_aria_label: Search suggestions
ai_suggestions_list_aria_label: AI search suggestions
general_suggestions_list_aria_label: Docs search suggestions
general_autocomplete_list_heading: Search docs
ai_autocomplete_list_heading: Ask AI
give_feedback: Give feedback
beta_tag: Beta
return_to_search: Return to search
clear_search_query: Clear
ai:
disclaimer: This is an experimental generative AI response. All information should be verified prior to use.
references: References from these articles
loading_status_message: Loading AI response...
done_loading_status_message: Done loading AI response
unable_to_answer: Sorry, I'm unable to answer that question. Please try a different query.
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.
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. description: Enter a search term to find it in the GitHub Docs.
placeholder: Search GitHub Docs placeholder: Search GitHub Docs
label: Search GitHub Docs label: Search GitHub Docs

2087
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -246,8 +246,8 @@
"@primer/behaviors": "^1.7.0", "@primer/behaviors": "^1.7.0",
"@primer/css": "^21.3.1", "@primer/css": "^21.3.1",
"@primer/live-region-element": "^0.7.0", "@primer/live-region-element": "^0.7.0",
"@primer/octicons": "^19.11.0", "@primer/octicons": "^19.14.0",
"@primer/octicons-react": "^19.11.0", "@primer/octicons-react": "^19.14.0",
"@primer/react": "36.27.0", "@primer/react": "36.27.0",
"accept-language-parser": "^1.5.0", "accept-language-parser": "^1.5.0",
"ajv": "^8.17.1", "ajv": "^8.17.1",
@@ -304,6 +304,7 @@
"quick-lru": "7.0.0", "quick-lru": "7.0.0",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-markdown": "^9.0.1",
"rehype-highlight": "^7.0.0", "rehype-highlight": "^7.0.0",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"rehype-slug": "^6.0.0", "rehype-slug": "^6.0.0",
@@ -356,6 +357,7 @@
"@types/react": "18.3.3", "@types/react": "18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@types/semver": "^7.5.8", "@types/semver": "^7.5.8",
"@types/styled-components": "^5.1.34",
"@types/tcp-port-used": "1.0.4", "@types/tcp-port-used": "1.0.4",
"@types/website-scraper": "^1.2.10", "@types/website-scraper": "^1.2.10",
"@typescript-eslint/eslint-plugin": "^8.7.0", "@typescript-eslint/eslint-plugin": "^8.7.0",

View File

@@ -0,0 +1,3 @@
export const ASK_AI_EVENT_GROUP = 'ask-ai'
export const SEARCH_OVERLAY_EVENT_GROUP = 'search-overlay'
export const GENERAL_SEARCH_RESULTS = 'general-search-results'

View File

@@ -36,7 +36,7 @@ function resetPageParams() {
// Temporary polyfill for crypto.randomUUID() // Temporary polyfill for crypto.randomUUID()
// Necessary for localhost development (doesn't have https://) // Necessary for localhost development (doesn't have https://)
function uuidv4(): string { export function uuidv4(): string {
try { try {
return crypto.randomUUID() return crypto.randomUUID()
} catch { } catch {
@@ -64,10 +64,14 @@ function getMetaContent(name: string) {
export function sendEvent<T extends EventType>({ export function sendEvent<T extends EventType>({
type, type,
version = '1.0.0', version = '1.0.0',
eventGroupKey,
eventGroupId,
...props ...props
}: { }: {
type: T type: T
version?: string version?: string
eventGroupKey?: string
eventGroupId?: string
} & EventPropsByType[T]) { } & EventPropsByType[T]) {
const body = { const body = {
type, type,
@@ -113,6 +117,10 @@ export function sendEvent<T extends EventType>({
code_display_preference: Cookies.get('annotate-mode'), code_display_preference: Cookies.get('annotate-mode'),
experiment_variation: getExperimentVariationForContext(getMetaContent('path-language')), experiment_variation: getExperimentVariationForContext(getMetaContent('path-language')),
// Event grouping
event_group_key: eventGroupKey,
event_group_id: eventGroupId,
}, },
...props, ...props,
@@ -295,6 +303,7 @@ function initCopyButtonEvent() {
const target = evt.target as HTMLElement const target = evt.target as HTMLElement
const button = target.closest('.js-btn-copy') as HTMLButtonElement const button = target.closest('.js-btn-copy') as HTMLButtonElement
if (!button) return if (!button) return
sendEvent({ sendEvent({
type: EventType.clipboard, type: EventType.clipboard,
clipboard_operation: 'copy', clipboard_operation: 'copy',
@@ -310,12 +319,19 @@ function initLinkEvent() {
if (!link) return if (!link) return
const sameSite = link.origin === location.origin const sameSite = link.origin === location.origin
const container = target.closest(`[data-container]`) as HTMLElement | null const container = target.closest(`[data-container]`) as HTMLElement | null
// We can attach `data-group-key` and `data-group-id` to any anchor element to include them in the event
const eventGroupKey = link?.dataset?.groupKey || undefined
const eventGroupId = link?.dataset?.groupId || undefined
sendEvent({ sendEvent({
type: EventType.link, type: EventType.link,
link_url: link.href, link_url: link.href,
link_samesite: sameSite, link_samesite: sameSite,
link_samepage: sameSite && link.pathname === location.pathname, link_samepage: sameSite && link.pathname === location.pathname,
link_container: container?.dataset.container, link_container: container?.dataset.container,
eventGroupKey,
eventGroupId,
}) })
}) })

View File

@@ -16,6 +16,14 @@ type Experiment = {
export type ExperimentNames = 'ai_search_experiment' export type ExperimentNames = 'ai_search_experiment'
export const EXPERIMENTS = { export const EXPERIMENTS = {
ai_search_experiment: {
key: 'ai_search_experiment',
isActive: true, // Set to false when the experiment is over
percentOfUsersToGetExperiment: 0, // 10% of users will get the experiment
includeVariationInContext: true, // All events will include the `experiment_variation` of the `ai_search_experiment`
limitToLanguages: ['en'], // Only users with the `en` language will be included in the experiment
alwaysShowForStaff: true, // When set to true, staff will always see the experiment (determined by the `staffonly` cookie)
},
/* Add new experiments here, example: /* Add new experiments here, example:
'example_experiment': { 'example_experiment': {
key: 'example_experiment', key: 'example_experiment',

View File

@@ -182,6 +182,17 @@ const context = {
type: 'string', type: 'string',
description: 'The variation this user we bucketed in is in, such as control or treatment.', description: 'The variation this user we bucketed in is in, such as control or treatment.',
}, },
// Event Grouping. The comination of key + id should be unique
event_group_key: {
type: 'string',
description: 'A enum indentifier (e.g. "ask-ai") used to put events into a specific group.',
},
event_group_id: {
type: 'string',
description:
'A unique id (uuid) that can be used to identify a group of events made by a user during the same session.',
},
}, },
} }
@@ -380,6 +391,38 @@ const searchResult = {
}, },
} }
const aiSearchResult = {
type: 'object',
additionalProperties: false,
required: [
'type',
'context',
'ai_search_result_query',
'ai_search_result_response',
'ai_search_result_links_json',
],
properties: {
context,
type: {
type: 'string',
pattern: '^aiSearchResult$',
},
ai_search_result_query: {
type: 'string',
description: 'The query the user searched for.',
},
ai_search_result_response: {
type: 'number',
description: "The GPT's response to the query.",
},
ai_search_result_links_json: {
type: 'number',
description:
'Dynamic JSON string of an array of "link" objects in the form: [{ "type": "reference" | "inline", "url": "https://..", "product": "issues" | "pages" | ... }, ...]',
},
},
}
const survey = { const survey = {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
@@ -432,7 +475,7 @@ const experiment = {
}, },
experiment_variation: { experiment_variation: {
type: 'string', type: 'string',
description: 'The variation this user we bucketed in, such as control or treatment.', description: 'The variation this user we bucketed in is in, such as control or treatment.',
}, },
experiment_success: { experiment_success: {
type: 'boolean', type: 'boolean',
@@ -545,6 +588,7 @@ export const schemas = {
hover, hover,
search, search,
searchResult, searchResult,
aiSearchResult,
survey, survey,
experiment, experiment,
clipboard, clipboard,
@@ -560,6 +604,7 @@ export const hydroNames = {
hover: 'docs.v0.HoverEvent', hover: 'docs.v0.HoverEvent',
search: 'docs.v0.SearchEvent', search: 'docs.v0.SearchEvent',
searchResult: 'docs.v0.SearchResultEvent', searchResult: 'docs.v0.SearchResultEvent',
aiSearchResult: 'docs.v0.AISearchResultsEvent',
survey: 'docs.v0.SurveyEvent', survey: 'docs.v0.SurveyEvent',
experiment: 'docs.v0.ExperimentEvent', experiment: 'docs.v0.ExperimentEvent',
clipboard: 'docs.v0.ClipboardEvent', clipboard: 'docs.v0.ClipboardEvent',

View File

@@ -1,4 +1,5 @@
export enum EventType { export enum EventType {
aiSearchResult = 'aiSearchResult',
page = 'page', page = 'page',
exit = 'exit', exit = 'exit',
link = 'link', link = 'link',
@@ -46,10 +47,19 @@ export type EventProps = {
color_mode_preference: string color_mode_preference: string
os_preference: string os_preference: string
code_display_preference: string code_display_preference: string
event_group_key?: string
event_group_id?: string
} }
} }
export type EventPropsByType = { export type EventPropsByType = {
[EventType.aiSearchResult]: {
ai_search_result_query: string
ai_search_result_response: string
// 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
}
[EventType.clipboard]: { [EventType.clipboard]: {
clipboard_operation: string clipboard_operation: string
clipboard_target?: string clipboard_target?: string

View File

@@ -15,6 +15,7 @@ header:
ghes_release_notes_upgrade_patch_and_release: 📣 This is not the <a href="#{{ latestPatch }}">latest patch release</a> of this release series, and this is not the <a href="/enterprise-server@{{ latestRelease }}/admin/release-notes">latest release</a> of Enterprise Server. ghes_release_notes_upgrade_patch_and_release: 📣 This is not the <a href="#{{ latestPatch }}">latest patch release</a> of this release series, and this is not the <a href="/enterprise-server@{{ latestRelease }}/admin/release-notes">latest release</a> of Enterprise Server.
sign_up_cta: Sign up sign_up_cta: Sign up
menu: Menu menu: Menu
open_menu_label: Open menu
go_home: Home go_home: Home
picker: picker:
language_picker_label: Language language_picker_label: Language
@@ -23,6 +24,37 @@ picker:
release_notes: release_notes:
banner_text: GitHub began rolling these changes out to enterprises on banner_text: GitHub began rolling these changes out to enterprises on
search: search:
input:
experimental_tag: Experimental
aria_label: Open search overlay
placeholder: Search or ask AI assistant
overlay:
input_aria_label: Search or ask AI assistant
suggestions_list_aria_label: Search suggestions
ai_suggestions_list_aria_label: AI search suggestions
general_suggestions_list_aria_label: Docs search suggestions
general_autocomplete_list_heading: Search docs
ai_autocomplete_list_heading: Ask AI
give_feedback: Give feedback
beta_tag: Beta
return_to_search: Return to search
clear_search_query: Clear
ai:
disclaimer: This is an experimental generative AI response. All information should be verified prior to use.
references: References from these articles
loading_status_message: Loading AI response...
done_loading_status_message: Done loading AI response
unable_to_answer: Sorry, I'm unable to answer that question. Please try a different query.
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.
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. description: Enter a search term to find it in the GitHub Docs.
placeholder: Search GitHub Docs placeholder: Search GitHub Docs
label: Search GitHub Docs label: Search GitHub Docs

View File

@@ -68,6 +68,90 @@ test('do a search from home page and click on "Foo" page', async ({ page }) => {
await expect(page).toHaveTitle(/For Playwright/) await expect(page).toHaveTitle(/For Playwright/)
}) })
test('open new search, and perform a general search', 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, "serve playwright"
await page.keyboard.press('ArrowDown')
// Select the general search item, "serve playwright"
await page.keyboard.press('Enter')
await expect(page).toHaveURL(/\/search\?query=serve\+playwright/)
await expect(page).toHaveTitle(/\d Search results for "serve 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 get auto-complete results', 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()
let listGroup = page.getByTestId('ai-autocomplete-suggestions')
await expect(listGroup).toBeVisible()
let listItems = listGroup.locator('li')
await expect(listItems).toHaveCount(5)
// Top 5 queries from queries.json fixture's 'topQueries'
let expectedTexts = [
'What is GitHub and how do I get started?',
'What is GitHub Copilot and how do I get started?',
'How do I connect to GitHub with SSH?',
'How do I generate a personal access token?',
'How do I clone a repository?',
]
for (let i = 0; i < expectedTexts.length; i++) {
await expect(listItems.nth(i)).toHaveText(expectedTexts[i])
}
const searchInput = await page.getByTestId('overlay-search-input')
await expect(searchInput).toBeVisible()
await expect(searchInput).toBeEnabled()
// Type the text "rest" into the search input
await searchInput.fill('rest')
// Ask AI suggestions
listGroup = page.getByTestId('ai-autocomplete-suggestions')
listItems = listGroup.locator('li')
await expect(listItems).toHaveCount(3)
await expect(listGroup).toBeVisible()
expectedTexts = [
'rest',
'How do I manage OAuth app access restrictions for my organization?',
'How do I test my SSH connection to GitHub?',
]
for (let i = 0; i < expectedTexts.length; i++) {
await expect(listItems.nth(i)).toHaveText(expectedTexts[i])
}
})
test('search from enterprise-cloud and filter by top-level Fooing', async ({ page }) => { test('search from enterprise-cloud and filter by top-level Fooing', async ({ page }) => {
test.skip(!SEARCH_TESTS, 'No local Elasticsearch, no tests involving search') test.skip(!SEARCH_TESTS, 'No local Elasticsearch, no tests involving search')

View File

@@ -138,6 +138,7 @@ const DEFAULT_UI_NAMESPACES = [
'alerts', 'alerts',
'header', 'header',
'search', 'search',
'old_search',
'survey', 'survey',
'toc', 'toc',
'meta', 'meta',

View File

@@ -0,0 +1,92 @@
// A generic hook for getting and setting a query parameter without reloading the page
// 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 { parseDebug } from '@/search/components/hooks/useQuery'
type UseQueryParamReturn<T extends string | boolean> = {
debug: boolean
queryParam: T
setQueryParam: (value: T) => void
}
// Overloads so we can use this for a boolean or string query param
// eslint-disable-next-line no-redeclare
export function useQueryParam(queryParamKey: string, isBoolean: true): UseQueryParamReturn<boolean>
// eslint-disable-next-line no-redeclare
export function useQueryParam(queryParamKey: string, isBoolean?: false): UseQueryParamReturn<string>
// eslint-disable-next-line no-redeclare
export function useQueryParam(
queryParamKey: string,
isBoolean?: boolean,
): UseQueryParamReturn<any> {
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<string>(initialQueryParam)
const [debug] = useState<boolean>(debugValue)
// If the query param changes in the URL, update the state
useEffect(() => {
console.log('updating state')
const paramValue = router.query[queryParamKey]
if (paramValue) {
if (Array.isArray(paramValue)) {
setQueryParamState(paramValue[0])
} else {
setQueryParamState(paramValue)
}
}
}, [router.query, queryParamKey])
// Determine the type of queryParam based on isBoolean
const queryParam: string | boolean = isBoolean ? queryParamString === 'true' : queryParamString
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)
if (newValue) {
params.set(queryParamKey, newValue)
} else {
params.delete(queryParamKey)
}
const newSearch = params.toString()
const newUrl = newSearch ? `${pathname}?${newSearch}${hash}` : `${pathname}${hash}`
window.history.replaceState({}, '', newUrl)
setQueryParamState(newValue)
}
return {
debug,
queryParam: queryParam as any, // Type will be set based on overloads
setQueryParam: setQueryParam as any,
}
}

View File

@@ -10,43 +10,6 @@
z-index: 3 !important; z-index: 3 !important;
} }
// Contains the search input, language picker, and sign-up button. When the
// search input is open and up to sm (where the language picker and sign-up
// button are hidden) we need to take up almost all the header width but then at
// md and above we don't want the search input to take up the header width.
.widgetsContainer {
width: 100%;
@include breakpoint(md) {
width: auto;
}
}
// Contains the search input and used when the smaller width search input UI is
// closed to hide the full width input, but as the width increases to md and
// above we show the search input along the other UI widgets (the menu button,
// the language picker, etc.)
.searchContainerWithClosedSearch {
display: none;
@include breakpoint(md) {
display: block;
}
}
// Contains the search input and used when the smaller width search input UI is
// open and we set it full width but as the browser width increases to md and
// above we don't take up the whole width anymore since we now show other UI
// widgets.
.searchContainerWithOpenSearch {
width: 100%;
margin-right: -1px;
@include breakpoint(md) {
width: auto;
}
}
// Contains the logo and version picker and used when the smaller width search // Contains the logo and version picker and used when the smaller width search
// input UI is closed. // input UI is closed.
.logoWithClosedSearch { .logoWithClosedSearch {

View File

@@ -1,32 +1,28 @@
import { Suspense, useCallback, useEffect, useRef, useState } from 'react' import { Suspense, useCallback, useEffect, useRef, useState } from 'react'
import cx from 'classnames' import cx from 'classnames'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { ActionList, ActionMenu, Dialog, IconButton } from '@primer/react' import { Dialog, IconButton } from '@primer/react'
import { import { MarkGithubIcon, ThreeBarsIcon } from '@primer/octicons-react'
KebabHorizontalIcon,
LinkExternalIcon,
MarkGithubIcon,
SearchIcon,
ThreeBarsIcon,
XIcon,
} from '@primer/octicons-react'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import { DEFAULT_VERSION, useVersion } from 'src/versions/components/useVersion' import { DEFAULT_VERSION, useVersion } from 'src/versions/components/useVersion'
import { Link } from 'src/frame/components/Link' import { Link } from 'src/frame/components/Link'
import { useMainContext } from 'src/frame/components/context/MainContext' import { useMainContext } from 'src/frame/components/context/MainContext'
import { useHasAccount } from 'src/frame/components/hooks/useHasAccount'
import { LanguagePicker } from 'src/languages/components/LanguagePicker'
import { HeaderNotifications } from 'src/frame/components/page-header/HeaderNotifications' import { HeaderNotifications } from 'src/frame/components/page-header/HeaderNotifications'
import { ApiVersionPicker } from 'src/rest/components/ApiVersionPicker' import { ApiVersionPicker } from 'src/rest/components/ApiVersionPicker'
import { useTranslation } from 'src/languages/components/useTranslation' import { useTranslation } from 'src/languages/components/useTranslation'
import { Search } from 'src/search/components/Search'
import { Breadcrumbs } from 'src/frame/components/page-header/Breadcrumbs' import { Breadcrumbs } from 'src/frame/components/page-header/Breadcrumbs'
import { VersionPicker } from 'src/versions/components/VersionPicker' import { VersionPicker } from 'src/versions/components/VersionPicker'
import { SidebarNav } from 'src/frame/components/sidebar/SidebarNav' import { SidebarNav } from 'src/frame/components/sidebar/SidebarNav'
import { AllProductsLink } from 'src/frame/components/sidebar/AllProductsLink' import { AllProductsLink } from 'src/frame/components/sidebar/AllProductsLink'
import styles from './Header.module.scss' import styles from './Header.module.scss'
import { OldHeaderSearchAndWidgets } from './OldHeaderSearchAndWidgets'
import { HeaderSearchAndWidgets } from './HeaderSearchAndWidgets'
import { useInnerWindowWidth } from './hooks/useInnerWindowWidth'
import { EXPERIMENTS } from '@/events/components/experiments/experiments'
import { useShouldShowExperiment } from '@/events/components/experiments/useShouldShowExperiment'
import { useQueryParam } from '@/frame/components/hooks/useQueryParam'
const DomainNameEdit = dynamic(() => import('src/links/components/DomainNameEdit'), { const DomainNameEdit = dynamic(() => import('src/links/components/DomainNameEdit'), {
ssr: false, ssr: false,
@@ -39,9 +35,11 @@ export const Header = () => {
const { currentVersion } = useVersion() const { currentVersion } = useVersion()
const { t } = useTranslation(['header']) const { t } = useTranslation(['header'])
const isRestPage = currentProduct && currentProduct.id === 'rest' const isRestPage = currentProduct && currentProduct.id === 'rest'
const [isSearchOpen, setIsSearchOpen] = useState(false) const { queryParam: isSearchOpen, setQueryParam: setIsSearchOpen } = useQueryParam(
'search-overlay-open',
true,
)
const [scroll, setScroll] = useState(false) const [scroll, setScroll] = useState(false)
const { hasAccount } = useHasAccount()
const [isSidebarOpen, setIsSidebarOpen] = useState(false) const [isSidebarOpen, setIsSidebarOpen] = useState(false)
const openSidebar = useCallback(() => setIsSidebarOpen(true), [isSidebarOpen]) const openSidebar = useCallback(() => setIsSidebarOpen(true), [isSidebarOpen])
const closeSidebar = useCallback(() => setIsSidebarOpen(false), [isSidebarOpen]) const closeSidebar = useCallback(() => setIsSidebarOpen(false), [isSidebarOpen])
@@ -50,12 +48,14 @@ export const Header = () => {
const { asPath } = useRouter() const { asPath } = useRouter()
const isSearchResultsPage = router.route === '/search' const isSearchResultsPage = router.route === '/search'
const isEarlyAccessPage = currentProduct && currentProduct.id === 'early-access' const isEarlyAccessPage = currentProduct && currentProduct.id === 'early-access'
const signupCTAVisible = const { width } = useInnerWindowWidth()
hasAccount === false && // don't show if `null`
(currentVersion === DEFAULT_VERSION || currentVersion === 'enterprise-cloud@latest')
const { width } = useWidth()
const returnFocusRef = useRef(null) const returnFocusRef = useRef(null)
const showNewSearch = useShouldShowExperiment(
EXPERIMENTS.ai_search_experiment,
router.locale as string,
)
useEffect(() => { useEffect(() => {
function onScroll() { function onScroll() {
setScroll(window.scrollY > 10) setScroll(window.scrollY > 10)
@@ -120,32 +120,6 @@ export const Header = () => {
} }
}, []) }, [])
function useWidth() {
const hasWindow = typeof window !== 'undefined'
function getWidth() {
const width = hasWindow ? window.innerWidth : null
return {
width,
}
}
const [width, setWidth] = useState(getWidth())
useEffect(() => {
if (hasWindow) {
const handleResize = function () {
setWidth(getWidth())
}
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}
}, [hasWindow])
return width
}
let homeURL = `/${router.locale}` let homeURL = `/${router.locale}`
if (currentVersion !== DEFAULT_VERSION) { if (currentVersion !== DEFAULT_VERSION) {
homeURL += `/${currentVersion}` homeURL += `/${currentVersion}`
@@ -172,6 +146,10 @@ export const Header = () => {
> >
<div <div
className="d-flex flex-justify-between p-2 flex-items-center flex-wrap" className="d-flex flex-justify-between p-2 flex-items-center flex-wrap"
style={{
// In the rare case of header overflow, create a pleasant gap between the rows
rowGap: '1rem',
}}
data-testid="desktop-header" data-testid="desktop-header"
> >
<div <div
@@ -198,160 +176,19 @@ export const Header = () => {
</div> </div>
)} )}
</div> </div>
{showNewSearch ? (
<div className={cx('d-flex flex-items-center', isSearchOpen && styles.widgetsContainer)}> <HeaderSearchAndWidgets
{/* <!-- GitHub.com homepage and 404 page has a stylized search; Enterprise homepages do not --> */} isSearchOpen={isSearchOpen}
{error !== '404' && ( setIsSearchOpen={setIsSearchOpen}
<div width={width}
className={cx(
isSearchOpen
? styles.searchContainerWithOpenSearch
: styles.searchContainerWithClosedSearch,
'mr-3',
)}
>
<Search isSearchOpen={isSearchOpen} />
</div>
)}
<div className={cx('d-none d-lg-flex flex-items-center', signupCTAVisible && 'mr-3')}>
<LanguagePicker />
</div>
{signupCTAVisible && (
<div data-testid="header-signup" className="border-left">
<a
href="https://github.com/signup?ref_cta=Sign+up&ref_loc=docs+header&ref_page=docs"
target="_blank"
rel="noopener"
className="d-none d-lg-flex ml-3 btn color-fg-muted"
>
{t`sign_up_cta`}
</a>
</div>
)}
<IconButton
className={cx(
'hide-lg hide-xl',
!isSearchOpen ? 'd-flex flex-items-center' : 'd-none',
)}
data-testid="mobile-search-button"
onClick={() => setIsSearchOpen(!isSearchOpen)}
aria-label="Open Search Bar"
aria-expanded={isSearchOpen ? 'true' : 'false'}
icon={SearchIcon}
/> />
<IconButton ) : (
className="px-3" <OldHeaderSearchAndWidgets
data-testid="mobile-search-button" isSearchOpen={isSearchOpen}
onClick={() => setIsSearchOpen(!isSearchOpen)} setIsSearchOpen={setIsSearchOpen}
aria-label="Close Search Bar" width={width}
aria-expanded={isSearchOpen ? 'true' : 'false'}
icon={XIcon}
sx={
isSearchOpen
? {
// The x button to close the small width search UI when search is open, as the
// browser width increases to md and above we no longer show that search UI so
// the close search button is hidden as well.
// breakpoint(md)
'@media (min-width: 768px)': {
display: 'none',
},
}
: {
display: 'none',
}
}
/> />
)}
{/* The ... navigation menu at medium and smaller widths */}
<div>
<ActionMenu aria-labelledby="menu-title">
<ActionMenu.Anchor>
<IconButton
data-testid="mobile-menu"
icon={KebabHorizontalIcon}
aria-label="Open Menu"
sx={
isSearchOpen
? // The ... menu button when the smaller width search UI is open. Since the search
// UI is open, we don't show the button at smaller widths but we do show it as
// the browser width increases to md, and then at lg and above widths we hide
// the button again since the pickers and sign-up button are shown in the header.
{
marginLeft: '8px',
display: 'none',
// breakpoint(md)
'@media (min-width: 768px)': {
display: 'inline-block',
marginLeft: '4px',
},
// breakpoint(lg)
'@media (min-width: 1012px)': {
display: 'none',
},
}
: // The ... menu button when the smaller width search UI is closed, the button is
// shown up to md. At lg and above we don't show the button since the pickers
// and sign-up button are shown in the header.
{
marginLeft: '16px',
'@media (min-width: 768px)': {
marginLeft: '0',
},
'@media (min-width: 1012px)': {
display: 'none',
},
}
}
/>
</ActionMenu.Anchor>
<ActionMenu.Overlay align="start">
<ActionList>
<ActionList.Group data-testid="open-mobile-menu">
{width && width > 544 ? (
<LanguagePicker mediumOrLower={true} />
) : (
<LanguagePicker xs={true} />
)}
<ActionList.Divider />
{width && width < 545 && (
<>
<VersionPicker xs={true} />
<ActionList.Divider />
{showDomainNameEdit && (
<>
<Suspense>
<DomainNameEdit xs={true} />
</Suspense>
<ActionList.Divider />
</>
)}
</>
)}
{signupCTAVisible && (
<ActionList.LinkItem
href="https://github.com/signup?ref_cta=Sign+up&ref_loc=docs+header&ref_page=docs"
target="_blank"
rel="noopener"
data-testid="mobile-signup"
className="d-flex color-fg-muted"
>
{t`sign_up_cta`}
<LinkExternalIcon
className="height-full float-right"
aria-label="(external site)"
/>
</ActionList.LinkItem>
)}{' '}
</ActionList.Group>
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
</div>
</div>
</div> </div>
{!isHomepageVersion && !isSearchResultsPage && ( {!isHomepageVersion && !isSearchResultsPage && (
<div className="d-flex flex-items-center d-xxl-none mt-2" data-testid="header-subnav"> <div className="d-flex flex-items-center d-xxl-none mt-2" data-testid="header-subnav">

View File

@@ -0,0 +1,149 @@
import { Suspense } from 'react'
import cx from 'classnames'
import { KebabHorizontalIcon, LinkExternalIcon } from '@primer/octicons-react'
import { IconButton, ActionMenu, ActionList } from '@primer/react'
import { LanguagePicker } from '@/languages/components/LanguagePicker'
import { useTranslation } from '@/languages/components/useTranslation'
import DomainNameEdit from '@/links/components/DomainNameEdit'
import { VersionPicker } from '@/versions/components/VersionPicker'
import { DEFAULT_VERSION, useVersion } from '@/versions/components/useVersion'
import { useHasAccount } from '../hooks/useHasAccount'
import { SearchBarButton } from '@/search/components/input/SearchBarButton'
import { useBreakpoint } from '@/search/components/hooks/useBreakpoint'
type Props = {
isSearchOpen: boolean
setIsSearchOpen: (value: boolean) => void
width: number | null
}
export function HeaderSearchAndWidgets({ isSearchOpen, setIsSearchOpen, width }: Props) {
const { currentVersion } = useVersion()
const { t } = useTranslation(['header'])
const isLarge = useBreakpoint('large')
const { hasAccount } = useHasAccount()
const signupCTAVisible =
hasAccount === false && // don't show if `null`
(currentVersion === DEFAULT_VERSION || currentVersion === 'enterprise-cloud@latest')
const showDomainNameEdit = currentVersion.startsWith('enterprise-server@')
const SearchButton = (
<SearchBarButton isSearchOpen={isSearchOpen} setIsSearchOpen={setIsSearchOpen} />
)
return (
<>
{/* At larger & up widths we show the search as an input. This doesn't need to be grouped with the widgets */}
{isLarge ? SearchButton : null}
<div className={cx('d-flex flex-items-center', isSearchOpen && 'd-none')}>
<div className={cx('d-none d-lg-flex flex-items-center', signupCTAVisible && 'mr-3')}>
<LanguagePicker />
</div>
{signupCTAVisible && (
<div data-testid="header-signup" className="border-left">
<a
href="https://github.com/signup?ref_cta=Sign+up&ref_loc=docs+header&ref_page=docs"
target="_blank"
rel="noopener"
className="d-none d-lg-flex ml-3 btn color-fg-muted"
>
{t`sign_up_cta`}
</a>
</div>
)}
{/* Below large widths we show the search as a button which needs to be grouped with the widgets */}
{!isLarge ? SearchButton : null}
{/* The ... navigation menu at medium and smaller widths */}
<div>
<ActionMenu aria-labelledby="menu-title">
<ActionMenu.Anchor>
<IconButton
data-testid="mobile-menu"
icon={KebabHorizontalIcon}
aria-label={t('header.open_menu_label')}
sx={
isSearchOpen
? // The ... menu button when the smaller width search UI is open. Since the search
// UI is open, we don't show the button at smaller widths but we do show it as
// the browser width increases to md, and then at lg and above widths we hide
// the button again since the pickers and sign-up button are shown in the header.
{
marginLeft: '8px',
display: 'none',
// breakpoint(md)
'@media (min-width: 768px)': {
display: 'inline-block',
marginLeft: '4px',
},
// breakpoint(lg)
'@media (min-width: 1012px)': {
display: 'inline-block',
marginLeft: '4px',
},
}
: // The ... menu button when the smaller width search UI is closed, the button is
// shown up to md. At lg and above we don't show the button since the pickers
// and sign-up button are shown in the header.
{
marginLeft: '16px',
'@media (min-width: 1012px)': {
marginLeft: '0',
display: 'none',
},
}
}
/>
</ActionMenu.Anchor>
<ActionMenu.Overlay align="start">
<ActionList>
<ActionList.Group data-testid="open-mobile-menu">
{width && width > 544 ? (
<LanguagePicker mediumOrLower={true} />
) : (
<LanguagePicker xs={true} />
)}
<ActionList.Divider />
{width && width < 545 && (
<>
<VersionPicker xs={true} />
<ActionList.Divider />
{showDomainNameEdit && (
<>
<Suspense>
<DomainNameEdit xs={true} />
</Suspense>
<ActionList.Divider />
</>
)}
</>
)}
{signupCTAVisible && (
<ActionList.LinkItem
href="https://github.com/signup?ref_cta=Sign+up&ref_loc=docs+header&ref_page=docs"
target="_blank"
rel="noopener"
data-testid="mobile-signup"
className="d-flex color-fg-muted"
>
{t`sign_up_cta`}
<LinkExternalIcon
className="height-full float-right"
aria-label="(external site)"
/>
</ActionList.LinkItem>
)}{' '}
</ActionList.Group>
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,39 @@
@import "@primer/css/support/variables/layout.scss";
@import "@primer/css/support/mixins/layout.scss";
// Contains the search input, language picker, and sign-up button. When the
// search input is open and up to sm (where the language picker and sign-up
// button are hidden) we need to take up almost all the header width but then at
// md and above we don't want the search input to take up the header width.
.widgetsContainer {
width: 100%;
@include breakpoint(md) {
width: auto;
}
}
// Contains the search input and used when the smaller width search input UI is
// closed to hide the full width input, but as the width increases to md and
// above we show the search input along the other UI widgets (the menu button,
// the language picker, etc.)
.searchContainerWithClosedSearch {
display: none;
@include breakpoint(md) {
display: block;
}
}
// Contains the search input and used when the smaller width search input UI is
// open and we set it full width but as the browser width increases to md and
// above we don't take up the whole width anymore since we now show other UI
// widgets.
.searchContainerWithOpenSearch {
width: 100%;
margin-right: -1px;
@include breakpoint(md) {
width: auto;
}
}

View File

@@ -0,0 +1,186 @@
import { Suspense } from 'react'
import cx from 'classnames'
import { SearchIcon, XIcon, KebabHorizontalIcon, LinkExternalIcon } from '@primer/octicons-react'
import { IconButton, ActionMenu, ActionList } from '@primer/react'
import { LanguagePicker } from '@/languages/components/LanguagePicker'
import { useTranslation } from '@/languages/components/useTranslation'
import DomainNameEdit from '@/links/components/DomainNameEdit'
import { OldSearchInput } from '@/search/components/input/OldSearchInput'
import { VersionPicker } from '@/versions/components/VersionPicker'
import { DEFAULT_VERSION, useVersion } from '@/versions/components/useVersion'
import { useHasAccount } from '../hooks/useHasAccount'
import { useMainContext } from '../context/MainContext'
import styles from './OldHeaderSearchAndWidgets.module.scss'
type Props = {
isSearchOpen: boolean
setIsSearchOpen: (value: boolean) => void
width: number | null
}
export function OldHeaderSearchAndWidgets({ isSearchOpen, setIsSearchOpen, width }: Props) {
const { error } = useMainContext()
const { currentVersion } = useVersion()
const { t } = useTranslation(['header'])
const { hasAccount } = useHasAccount()
const signupCTAVisible =
hasAccount === false && // don't show if `null`
(currentVersion === DEFAULT_VERSION || currentVersion === 'enterprise-cloud@latest')
const showDomainNameEdit = currentVersion.startsWith('enterprise-server@')
return (
<div className={cx('d-flex flex-items-center', isSearchOpen && styles.widgetsContainer)}>
{/* <!-- GitHub.com homepage and 404 page has a stylized search; Enterprise homepages do not --> */}
{error !== '404' && (
<div
className={cx(
isSearchOpen
? styles.searchContainerWithOpenSearch
: styles.searchContainerWithClosedSearch,
'mr-3',
)}
>
<OldSearchInput isSearchOpen={isSearchOpen} />
</div>
)}
<div className={cx('d-none d-lg-flex flex-items-center', signupCTAVisible && 'mr-3')}>
<LanguagePicker />
</div>
{signupCTAVisible && (
<div data-testid="header-signup" className="border-left">
<a
href="https://github.com/signup?ref_cta=Sign+up&ref_loc=docs+header&ref_page=docs"
target="_blank"
rel="noopener"
className="d-none d-lg-flex ml-3 btn color-fg-muted"
>
{t`sign_up_cta`}
</a>
</div>
)}
<IconButton
className={cx('hide-lg hide-xl', !isSearchOpen ? 'd-flex flex-items-center' : 'd-none')}
data-testid="mobile-search-button"
onClick={() => setIsSearchOpen(!isSearchOpen)}
aria-label="Open Search Bar"
aria-expanded={isSearchOpen ? 'true' : 'false'}
icon={SearchIcon}
/>
<IconButton
className="px-3"
data-testid="mobile-search-button"
onClick={() => setIsSearchOpen(!isSearchOpen)}
aria-label="Close Search Bar"
aria-expanded={isSearchOpen ? 'true' : 'false'}
icon={XIcon}
sx={
isSearchOpen
? {
// The x button to close the small width search UI when search is open, as the
// browser width increases to md and above we no longer show that search UI so
// the close search button is hidden as well.
// breakpoint(md)
'@media (min-width: 768px)': {
display: 'none',
},
}
: {
display: 'none',
}
}
/>
{/* The ... navigation menu at medium and smaller widths */}
<div>
<ActionMenu aria-labelledby="menu-title">
<ActionMenu.Anchor>
<IconButton
data-testid="mobile-menu"
icon={KebabHorizontalIcon}
aria-label="Open Menu"
sx={
isSearchOpen
? // The ... menu button when the smaller width search UI is open. Since the search
// UI is open, we don't show the button at smaller widths but we do show it as
// the browser width increases to md, and then at lg and above widths we hide
// the button again since the pickers and sign-up button are shown in the header.
{
marginLeft: '8px',
display: 'none',
// breakpoint(md)
'@media (min-width: 768px)': {
display: 'inline-block',
marginLeft: '4px',
},
// breakpoint(lg)
'@media (min-width: 1012px)': {
display: 'none',
},
}
: // The ... menu button when the smaller width search UI is closed, the button is
// shown up to md. At lg and above we don't show the button since the pickers
// and sign-up button are shown in the header.
{
marginLeft: '16px',
'@media (min-width: 768px)': {
marginLeft: '0',
},
'@media (min-width: 1012px)': {
display: 'none',
},
}
}
/>
</ActionMenu.Anchor>
<ActionMenu.Overlay align="start">
<ActionList>
<ActionList.Group data-testid="open-mobile-menu">
{width && width > 544 ? (
<LanguagePicker mediumOrLower={true} />
) : (
<LanguagePicker xs={true} />
)}
<ActionList.Divider />
{width && width < 545 && (
<>
<VersionPicker xs={true} />
<ActionList.Divider />
{showDomainNameEdit && (
<>
<Suspense>
<DomainNameEdit xs={true} />
</Suspense>
<ActionList.Divider />
</>
)}
</>
)}
{signupCTAVisible && (
<ActionList.LinkItem
href="https://github.com/signup?ref_cta=Sign+up&ref_loc=docs+header&ref_page=docs"
target="_blank"
rel="noopener"
data-testid="mobile-signup"
className="d-flex color-fg-muted"
>
{t`sign_up_cta`}
<LinkExternalIcon
className="height-full float-right"
aria-label="(external site)"
/>
</ActionList.LinkItem>
)}{' '}
</ActionList.Group>
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
</div>
</div>
)
}

View File

@@ -0,0 +1,27 @@
import { useState, useEffect } from 'react'
export function useInnerWindowWidth() {
const hasWindow = typeof window !== 'undefined'
function getWidth() {
const width = hasWindow ? window.innerWidth : null
return {
width,
}
}
const [width, setWidth] = useState(getWidth())
useEffect(() => {
if (hasWindow) {
const handleResize = function () {
setWidth(getWidth())
}
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}
}, [hasWindow])
return width
}

View File

@@ -3,7 +3,7 @@ import { useRouter } from 'next/router'
import { useMainContext } from 'src/frame/components/context/MainContext' import { useMainContext } from 'src/frame/components/context/MainContext'
import { SidebarProduct } from 'src/landings/components/SidebarProduct' import { SidebarProduct } from 'src/landings/components/SidebarProduct'
import { SidebarSearchAggregates } from 'src/search/components/SidebarSearchAggregates' import { SidebarSearchAggregates } from 'src/search/components/results/SidebarSearchAggregates'
import { AllProductsLink } from './AllProductsLink' import { AllProductsLink } from './AllProductsLink'
import { ApiVersionPicker } from 'src/rest/components/ApiVersionPicker' import { ApiVersionPicker } from 'src/rest/components/ApiVersionPicker'
import { Link } from 'src/frame/components/Link' import { Link } from 'src/frame/components/Link'

View File

@@ -0,0 +1,49 @@
import ReactMarkdown from 'react-markdown'
import type { Components } from 'react-markdown'
import cx from 'classnames'
import remarkGfm from 'remark-gfm'
import styles from './MarkdownContent.module.scss'
export type MarkdownContentPropsT = {
children: string
className?: string
openLinksInNewTab?: boolean
eventGroupKey?: string
eventGroupId?: string
as?: keyof JSX.IntrinsicElements
tabIndex?: number
}
// For content that comes in a Markdown string
// e.g. a GPT Response
export const UnrenderedMarkdownContent = ({
children,
className,
openLinksInNewTab = true,
eventGroupKey = '',
eventGroupId = '',
...restProps
}: MarkdownContentPropsT) => {
// Overrides for ReactMarkdown components
const components = {} as Components
if (openLinksInNewTab) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
components.a = ({ node, ...props }) => (
<a {...props} target="_blank" data-group-key={eventGroupKey} data-group-id={eventGroupId}>
{props.children}
</a>
)
}
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
{...restProps}
className={cx(styles.markdownBody, 'markdown-body', className)}
components={components}
>
{children}
</ReactMarkdown>
)
}

View File

@@ -1 +1 @@
export { default, getServerSideProps } from 'src/search/pages/search' export { default, getServerSideProps } from 'src/search/pages/search-results'

View File

@@ -1,38 +0,0 @@
import { Spinner } from '@primer/react'
import { useEffect, useState } from 'react'
export function Loading() {
const [showLoading, setShowLoading] = useState(false)
useEffect(() => {
let mounted = true
setTimeout(() => {
if (mounted) {
setShowLoading(true)
}
}, 1000)
return () => {
mounted = false
}
}, [])
return showLoading ? <ShowSpinner /> : <ShowNothing />
}
function ShowSpinner() {
return (
<div className="my-12 d-flex flex-justify-center">
<Spinner size="medium" />
</div>
)
}
function ShowNothing() {
return (
// The min heigh is based on inspecting what the height became when it
// does render. Making this match makes the footer to not flicker
// up or down when it goes from showing nothing to something.
<div className="my-12" style={{ minHeight: 105 }}>
{/* Deliberately empty */}
</div>
)
}

View File

@@ -0,0 +1,76 @@
type LinksJSON = Array<{
type: 'reference' | 'inline'
url: string
product: string
}>
// We use this to generate a JSON string that includes all of the links:
// 1. Included in the AI response (inline)
// 2. Used to generate the AI response via an embedding (reference)
//
// We include the JSON string in our analytics events so we can see the
// most popular sourced references, among other things.
export function generateAiSearchLinksJson(
sourcesBuffer: Array<{ url: string }>,
aiResponse: string,
): string {
const linksJson = [] as LinksJSON
const inlineLinks = extractMarkdownLinks(aiResponse)
for (const link of inlineLinks) {
const product = extractProductFromDocsUrl(link)
linksJson.push({
type: 'inline',
url: link,
product,
})
}
for (const source of sourcesBuffer) {
const product = extractProductFromDocsUrl(source.url)
linksJson.push({
type: 'reference',
url: source.url,
product,
})
}
return JSON.stringify(linksJson)
}
// Get all links in a markdown text
function extractMarkdownLinks(markdownResponse: string) {
// This regex matches markdown links of the form [text](url)
// Explanation:
// \[([^\]]+)\] : Matches the link text inside square brackets (one or more non-']' characters).
// \( : Matches the opening parenthesis.
// ([^)]+) : Captures the URL (one or more characters that are not a closing parenthesis).
// \) : Matches the closing parenthesis.
const regex = /\[([^\]]+)\]\(([^)]+)\)/g
const urls = []
let match
while ((match = regex.exec(markdownResponse)) !== null) {
urls.push(match[2])
}
return urls
}
// Given a Docs URL, extract the product name
function extractProductFromDocsUrl(url: string): string {
const pathname = new URL(url).pathname
const segments = pathname.split('/').filter((segment) => segment)
// If the first segment is a language code (2 characters), then product is the next segment.
// Otherwise, assume the first segment is the product.
if (segments.length === 0) {
return ''
}
if (segments[0].length === 2) {
return segments[1] || ''
}
return segments[0]
}

View File

@@ -0,0 +1,123 @@
import { EventType } from '@/events/types'
import { AutocompleteSearchResponse } from '@/search/types'
import { DEFAULT_VERSION } from '@/versions/components/useVersion'
import { NextRouter } from 'next/router'
import { sendEvent } from 'src/events/components/events'
import { ASK_AI_EVENT_GROUP, 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'
// The logic that redirects to the /search page with the proper query params
// The query params will be consumed in the general search middleware
export function executeGeneralSearch(
router: NextRouter,
currentVersion: string,
localQuery: string,
debug = false,
eventGroupId?: string,
) {
sendEvent({
type: EventType.search,
search_query: localQuery,
search_context: GENERAL_SEARCH_CONTEXT,
eventGroupKey: SEARCH_OVERLAY_EVENT_GROUP,
eventGroupId,
})
let asPath = `/${router.locale}`
if (currentVersion !== DEFAULT_VERSION) {
asPath += `/${currentVersion}`
}
asPath += '/search'
const params = new URLSearchParams({ query: localQuery })
if (debug) {
params.set('debug', '1')
}
asPath += `?${params}`
router.push(asPath)
}
export async function executeAISearch(
router: NextRouter,
version: string,
query: string,
debug = false,
eventGroupId?: string,
) {
sendEvent({
type: EventType.search,
// TODO: Remove PII so we can include the actual query
search_query: 'REDACTED',
search_context: AI_SEARCH_CONTEXT,
eventGroupKey: ASK_AI_EVENT_GROUP,
eventGroupId,
})
let language = router.locale || 'en'
const body = {
query,
version,
language,
...(debug && { debug: '1' }),
}
const response = await fetch(`/api/ai-search/v1`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
return response
}
// The AJAX request logic that fetches the autocomplete options for AI autocomplete sugggestions
export async function executeAIAutocompleteSearch(
router: NextRouter,
version: string,
query: string,
debug = false,
abortSignal?: AbortSignal,
eventGroupId?: string,
) {
sendEvent({
type: EventType.search,
// TODO: Remove PII so we can include the actual query
search_query: 'REDACTED',
search_context: AI_AUTOCOMPLETE_SEARCH_CONTEXT,
eventGroupKey: SEARCH_OVERLAY_EVENT_GROUP,
eventGroupId: eventGroupId,
})
let language = router.locale || 'en'
const params = new URLSearchParams({ query: query, version, language })
if (debug) {
params.set('debug', '1')
}
// Always fetch 5 results for autocomplete
params.set('size', '5')
const response = await fetch(`/api/search/ai-search-autocomplete/v1?${params}`, {
headers: {
'Content-Type': 'application/json',
},
// Allow the caller to pass in an AbortSignal to cancel the request
signal: abortSignal || undefined,
})
if (!response.ok) {
throw new Error(
`Failed to fetch ai autocomplete search results.\nStatus ${response.status}\n${response.statusText}`,
)
}
const results = (await response.json()) as AutocompleteSearchResponse
return {
aiAutocompleteOptions: results?.hits || [],
}
}

View File

@@ -0,0 +1,153 @@
// When streaming markdown response, e.g., from a GPT, the response will come in chunks that may have opening tags but no closing tags.
// This function seeks to fix the partial markdown by closing the tags it detects.
export function fixIncompleteMarkdown(content: string): string {
// First, fix code blocks
content = fixCodeBlocks(content)
// Then, fix inline code
content = fixInlineCode(content)
// Then, fix links
content = fixLinks(content)
// Then, fix images
content = fixImages(content)
// Then, fix emphasis (bold, italic, strikethrough)
content = fixEmphasis(content)
// Then, fix tables
content = fixTables(content)
return content
}
function fixCodeBlocks(content: string): string {
const codeBlockRegex = /```/g
const matches = content.match(codeBlockRegex)
const count = matches ? matches.length : 0
if (count % 2 !== 0) {
content += '\n```'
}
return content
}
function fixInlineCode(content: string): string {
const inlineCodeRegex = /`/g
const matches = content.match(inlineCodeRegex)
const count = matches ? matches.length : 0
if (count % 2 !== 0) {
content += '`'
}
return content
}
function fixLinks(content: string): string {
// Handle unclosed link text '['
const linkTextRegex = /\[([^\]]*)$/
if (linkTextRegex.test(content)) {
content += ']'
}
// Handle unclosed link URL '('
const linkURLRegex = /\]\(([^)]*)$/
if (linkURLRegex.test(content)) {
content += ')'
}
return content
}
function fixImages(content: string): string {
// Handle unclosed image alt text '!['
const imageAltTextRegex = /!\[([^\]]*)$/
if (imageAltTextRegex.test(content)) {
content += ']'
}
// Handle unclosed image URL '('
const imageURLRegex = /!\[[^\]]*\]\(([^)]*)$/
if (imageURLRegex.test(content)) {
content += ')'
}
return content
}
function fixEmphasis(content: string): string {
const tokens = ['***', '**', '__', '*', '_', '~~', '~']
const stack: { token: string; index: number }[] = []
let i = 0
while (i < content.length) {
let matched = false
for (const token of tokens) {
if (content.substr(i, token.length) === token) {
if (stack.length > 0 && stack[stack.length - 1].token === token) {
// Closing token found
stack.pop()
} else {
// Opening token found
stack.push({ token, index: i })
}
i += token.length
matched = true
break
}
}
if (!matched) {
i++
}
}
// Close any remaining tokens in reverse order
while (stack.length > 0) {
const { token } = stack.pop()!
content += token
}
return content
}
function fixTables(content: string): string {
const lines = content.split('\n')
let inTable = false
let headerPipeCount = 0
let i = 0
while (i < lines.length) {
const line = lines[i]
if (/^\s*\|.*$/.test(line)) {
// Line starts with '|', possible table line
if (!inTable) {
// Potential start of table
if (i + 1 < lines.length && /^\s*\|[-\s|:]*$/.test(lines[i + 1])) {
// Next line is separator, confirm table header
inTable = true
// Count number of '|' in header line
headerPipeCount = (lines[i].match(/\|/g) || []).length
i += 1 // Move to separator line
} else {
// Not a table, continue
i += 1
continue
}
} else {
// In table body
const linePipeCount = (line.match(/\|/g) || []).length
if (linePipeCount < headerPipeCount) {
// Calculate missing pipes
const missingPipes = headerPipeCount - linePipeCount
// Append missing ' |' to match header columns
lines[i] = line.trimEnd() + ' |'.repeat(missingPipes)
}
}
} else {
// Exiting table
inTable = false
headerPipeCount = 0
}
i += 1
}
return lines.join('\n')
}

View File

@@ -0,0 +1,152 @@
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'
type AutocompleteOptions = {
aiAutocompleteOptions: AutocompleteSearchHit[]
}
type UseAutocompleteProps = {
router: NextRouter
currentVersion: string
debug: boolean
eventGroupIdRef: React.MutableRefObject<string>
}
type UseAutocompleteReturn = {
autoCompleteOptions: AutocompleteOptions
searchLoading: boolean
searchError: boolean
updateAutocompleteResults: (query: string) => void
clearAutocompleteResults: () => void
}
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<string, AutocompleteOptions>
// Helpers surrounding the ai-search-autocomplete request to lessen the # of requests made to our API
// There are 3 methods for reducing the # of requests:
// 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({
router,
currentVersion,
debug,
eventGroupIdRef,
}: UseAutocompleteProps): UseAutocompleteReturn {
const [autoCompleteOptions, setAutoCompleteOptions] = useState<AutocompleteOptions>({
aiAutocompleteOptions: [],
})
const [searchLoading, setSearchLoading] = useState<boolean>(true)
const [searchError, setSearchError] = useState<boolean>(false)
// Support for aborting in-flight requests (e.g. user starts typing while a request is still pending)
const abortControllerRef = useRef<AbortController | null>(null)
// Debounce to prevent requests while user is (quickly) typing
const debouncedFetchRef = useRef<ReturnType<typeof debounce> | null>(null)
useEffect(() => {
debouncedFetchRef.current = debounce((value: string) => {
fetchAutocompleteResults(value)
}, DEBOUNCE_TIME) // 300ms debounce
return () => {
debouncedFetchRef.current?.cancel()
}
}, [])
const fetchAutocompleteResults = useCallback(
async (queryValue: string) => {
// Cancel any ongoing request
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
// Check if the result is in cache
if (sessionCache[queryValue]) {
setAutoCompleteOptions(sessionCache[queryValue])
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(
router,
currentVersion,
queryValue,
debug,
controller.signal, // Pass in the signal to allow the request to be aborted
eventGroupIdRef.current,
)
const results: AutocompleteOptions = {
aiAutocompleteOptions,
}
// Update cache
sessionCache[queryValue] = results
// Update state with fetched results
setAutoCompleteOptions(results)
setSearchLoading(false)
} catch (error: any) {
if (error.name === 'AbortError') {
return
}
console.error(error)
setSearchError(true)
setSearchLoading(false)
}
},
[router, currentVersion, debug],
)
// Entry function called when the user types in the search input
const updateAutocompleteResults = useCallback((queryValue: string) => {
// When the input is empty, don't debounce the request
// We want to immediately show the autocomplete options (that may be cached)
if (queryValue === '') {
debouncedFetchRef.current?.cancel()
fetchAutocompleteResults('')
return
} else {
debouncedFetchRef.current?.(queryValue)
}
}, [])
const clearAutocompleteResults = useCallback(() => {
setAutoCompleteOptions({
aiAutocompleteOptions: [],
})
setSearchLoading(false)
setSearchError(false)
}, [])
// Cleanup function to cancel any ongoing requests when unmounting
useEffect(() => {
return () => {
abortControllerRef.current?.abort()
}
}, [])
return {
autoCompleteOptions,
searchLoading,
searchError,
updateAutocompleteResults,
clearAutocompleteResults,
}
}

View File

@@ -0,0 +1,152 @@
import { useCallback } from 'react'
interface CachedItem<T> {
data: T
timestamp: number
}
interface CacheIndexEntry {
key: string
timestamp: number
}
/**
* 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
* 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.
*/
function useLocalStorageCache<T = any>(
cacheKeyPrefix: string = 'ai-query-cache',
maxEntries: number = 1000,
expirationDays: number = 30,
) {
const cacheIndexKey = `${cacheKeyPrefix}-index`
/**
* Generates a unique key based on the query string.
* @param query - The query string to generate the key from.
* @returns A unique string key.
*/
const generateKey = (query: string): string => {
query = query.trim().toLowerCase()
// Simple hash function to generate a unique key from the query
let hash = 0
for (let i = 0; i < query.length; i++) {
const char = query.charCodeAt(i)
hash = (hash << 5) - hash + char
hash |= 0 // Convert to 32bit integer
}
return `${cacheKeyPrefix}-${Math.abs(hash)}`
}
/**
* Retrieves an item from the cache.
* @param query - The query string associated with the cached data.
* @returns The cached data if valid, otherwise null.
*/
const getItem = useCallback(
(query: string): T | null => {
const key = generateKey(query)
const itemStr = localStorage.getItem(key)
if (!itemStr) return null
let cachedItem: CachedItem<T>
try {
cachedItem = JSON.parse(itemStr)
} catch (e) {
console.error('Failed to parse cached item from localStorage', e)
localStorage.removeItem(key)
return null
}
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
localStorage.removeItem(key)
updateCacheIndex((index) => index.filter((entry) => entry.key !== key))
return null
}
},
[cacheKeyPrefix, expirationDays],
)
/**
* Stores an item in the cache.
* @param query - The query string associated with the data.
* @param data - The data to cache.
*/
const setItem = useCallback(
(query: string, data: T): void => {
const key = generateKey(query)
const now = Date.now()
const cachedItem: CachedItem<T> = { data, timestamp: now }
// Store the item
localStorage.setItem(key, JSON.stringify(cachedItem))
// Update index
const indexStr = localStorage.getItem(cacheIndexKey)
let index: CacheIndexEntry[] = []
if (indexStr) {
try {
index = JSON.parse(indexStr)
} catch (e) {
console.error('Failed to parse cache index from localStorage', e)
}
}
// Remove existing entry for this key if any
index = index.filter((entry) => entry.key !== key)
index.push({ key, timestamp: now })
// If cache exceeds max entries, remove oldest entries
if (index.length > maxEntries) {
// Sort entries by timestamp
index.sort((a, b) => a.timestamp - b.timestamp)
const excess = index.length - maxEntries
const entriesToRemove = index.slice(0, excess)
entriesToRemove.forEach((entry) => {
localStorage.removeItem(entry.key)
})
index = index.slice(excess)
}
// Store updated index
localStorage.setItem(cacheIndexKey, JSON.stringify(index))
},
[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[] = []
if (indexStr) {
try {
index = JSON.parse(indexStr)
} catch (e) {
console.error('Failed to parse cache index from localStorage', e)
}
}
index = updateFn(index)
localStorage.setItem(cacheIndexKey, JSON.stringify(index))
}
return { getItem, setItem }
}
export default useLocalStorageCache

View File

@@ -19,7 +19,7 @@ export const useQuery = (): QueryInfo => {
} }
} }
function parseDebug(debug: string | Array<string> | undefined) { export function parseDebug(debug: string | Array<string> | undefined) {
if (debug === '') { if (debug === '') {
// E.g. `?query=foo&debug` should be treated as truthy // E.g. `?query=foo&debug` should be treated as truthy
return true return true

View File

@@ -0,0 +1,76 @@
@import "@primer/css/support/variables/layout.scss";
$bodyPadding: 0 16px 0px 16px;
$mutedTextColor: var(--fgColor-muted, var(--color-fg-muted, #656d76));
.container {
max-height: 95vh;
overflow-y: auto;
overflow-x: hidden;
max-width: 100%;
width: 100%;
}
.disclaimerText {
display: block;
font-size: small !important;
font-weight: var(--base-text-weight-normal, 400) !important;
color: $mutedTextColor;
margin: 8px 0px 8px 0px;
padding: $bodyPadding;
}
.markdownBodyOverrides {
font-size: small;
margin-top: 4px;
margin-bottom: 16px;
padding: $bodyPadding;
}
.referencesTitle {
font-size: small !important;
font-weight: var(--base-text-weight-normal, 400) !important;
margin: 0;
color: $mutedTextColor;
padding-left: 0 !important;
}
.referencesList {
padding: 0 !important;
padding-left: 16px !important;
li {
padding: $bodyPadding;
margin-left: 0 !important;
padding-left: 0 !important;
a {
color: var(--color-accent-emphasis);
}
}
}
.loadingContainer {
display: flex;
width: 100%;
align-items: center;
justify-content: center;
min-height: 200px;
height: 200px;
}
.displayForScreenReader {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.postAnswerWidgets {
display: flex;
flex-direction: row;
padding-left: 12px !important;
}

View File

@@ -0,0 +1,297 @@
import { useEffect, useRef, useState } from 'react'
import { executeAISearch } from '../helpers/execute-search-actions'
import { useRouter } from 'next/router'
import { useTranslation } from '@/languages/components/useTranslation'
import { ActionList, IconButton, Spinner } from '@primer/react'
import { BookIcon, CheckIcon, CopyIcon, ThumbsdownIcon, ThumbsupIcon } from '@primer/octicons-react'
import { announce } from '@primer/live-region-element'
import useLocalStorageCache from '../hooks/useLocalStorageCache'
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 { ASK_AI_EVENT_GROUP } from '@/events/components/event-groups'
type AIQueryResultsProps = {
query: string
version: string
debug: boolean
setAISearchError: () => void
}
type Source = {
url: string
title: string
index: string
}
export function AskAIResults({ query, version, debug, setAISearchError }: AIQueryResultsProps) {
const router = useRouter()
const { t } = useTranslation('search')
const [message, setMessage] = useState('')
const [sources, setSources] = useState<Source[]>([] as Source[])
const [initialLoading, setInitialLoading] = useState(true)
const [responseLoading, setResponseLoading] = useState(false)
const eventGroupId = useRef<string>('')
const disclaimerRef = useRef<HTMLDivElement>(null)
// We cache up to 1000 queries, and expire them after 30 days
const { getItem, setItem } = useLocalStorageCache<{
query: string
message: string
sources: Source[]
}>('ai-query-cache', 1000, 30)
const [isCopied, setCopied] = useClipboard(message, { successDuration: 1400 })
const [feedbackSelected, setFeedbackSelected] = useState<null | 'up' | 'down'>(null)
// On query change, fetch the new results
useEffect(() => {
let isCancelled = false
setMessage('')
setSources([])
setInitialLoading(true)
setResponseLoading(true)
eventGroupId.current = uuidv4()
disclaimerRef.current?.focus()
const cachedData = getItem(query)
if (cachedData) {
setMessage(cachedData.message)
setSources(cachedData.sources)
setInitialLoading(false)
setResponseLoading(false)
sendAISearchResultEvent(cachedData.sources, cachedData.message, eventGroupId.current)
return
}
// Handler for streamed response from GPT
async function fetchData() {
let messageBuffer = ''
let sourcesBuffer: Source[] = []
try {
const response = await executeAISearch(router, version, query, debug, eventGroupId.current)
// 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: [] })
return setMessage(cannedResponse)
}
if (!response.ok) {
console.error(
`Failed to fetch search results.\nStatus ${response.status}\n${response.statusText}`,
)
return setAISearchError()
}
if (!response.body) {
console.error(`ReadableStream not supported in this browser`)
return setAISearchError()
}
const decoder = new TextDecoder('utf-8')
const reader = response.body.getReader()
let done = false
setInitialLoading(false)
while (!done && !isCancelled) {
const { value, done: readerDone } = await reader.read()
done = readerDone
if (value) {
const chunkStr = decoder.decode(value, { stream: true })
const chunkLines = chunkStr.split('\n').filter((line) => line.trim() !== '')
for (const line of chunkLines) {
let parsedLine
try {
parsedLine = JSON.parse(line)
} catch (e) {
console.error('Failed to parse JSON:', e, 'Line:', line)
continue
}
if (parsedLine.chunkType === 'SOURCES') {
if (!isCancelled) {
sourcesBuffer = sourcesBuffer.concat(parsedLine.sources)
setSources(parsedLine.sources)
}
} else if (parsedLine.chunkType === 'MESSAGE_CHUNK') {
if (!isCancelled) {
messageBuffer += parsedLine.text
setMessage(messageBuffer)
}
}
}
}
}
} catch (error: any) {
if (!isCancelled) {
console.error('Failed to fetch search results:', error)
setAISearchError()
}
} finally {
if (!isCancelled && messageBuffer) {
setItem(query, { query, message: messageBuffer, sources: sourcesBuffer })
setInitialLoading(false)
setResponseLoading(false)
sendAISearchResultEvent(sourcesBuffer, messageBuffer, eventGroupId.current)
}
}
}
fetchData()
return () => {
isCancelled = true
}
}, [query])
return (
<div className={styles.container}>
{/* Hidden status message for screen readers */}
<span role="status" aria-live="polite" className={styles.displayForScreenReader}>
{initialLoading || responseLoading
? t('search.ai.loading_status_message')
: t('search.ai.done_loading_status_message')}
</span>
{initialLoading ? (
<div className={styles.loadingContainer} role="status">
<Spinner />
</div>
) : (
<article aria-busy={responseLoading} aria-live="polite">
<span ref={disclaimerRef} className={styles.disclaimerText}>
{t('search.ai.disclaimer')}
</span>
<UnrenderedMarkdownContent
className={styles.markdownBodyOverrides}
eventGroupKey={ASK_AI_EVENT_GROUP}
eventGroupId={eventGroupId.current}
>
{responseLoading ? fixIncompleteMarkdown(message) : message}
</UnrenderedMarkdownContent>
</article>
)}
{!responseLoading ? (
<div className={styles.postAnswerWidgets}>
<IconButton
icon={ThumbsupIcon}
className={'btn-octicon'}
aria-label={t('ai.thumbs_up')}
sx={{
border: 'none',
backgroundColor: feedbackSelected === 'up' ? '' : 'unset',
boxShadow: 'unset',
color: feedbackSelected === 'up' ? 'var(--fgColor-accent) !important;' : '',
}}
onClick={() => {
setFeedbackSelected('up')
announce(t('ai.thumbs_announcement'))
sendEvent({
type: EventType.survey,
survey_vote: true,
eventGroupKey: ASK_AI_EVENT_GROUP,
eventGroupId: eventGroupId.current,
})
}}
></IconButton>
<IconButton
icon={ThumbsdownIcon}
className={'btn-octicon'}
aria-label={t('ai.thumbs_down')}
sx={{
border: 'none',
backgroundColor: feedbackSelected === 'down' ? '' : 'unset',
boxShadow: 'unset',
color: feedbackSelected === 'down' ? 'var(--fgColor-accent) !important;' : '',
}}
onClick={() => {
setFeedbackSelected('down')
announce(t('ai.thumbs_announcement'))
sendEvent({
type: EventType.survey,
survey_vote: false,
eventGroupKey: ASK_AI_EVENT_GROUP,
eventGroupId: eventGroupId.current,
})
}}
></IconButton>
<IconButton
sx={{
border: 'none',
backgroundColor: 'unset',
boxShadow: 'unset',
color: isCopied ? 'var(--fgColor-accent) !important;' : '',
}}
icon={isCopied ? CheckIcon : CopyIcon}
className="btn-octicon"
aria-label={t('ai.copy_answer')}
onClick={() => {
setCopied()
announce(t('ai.copied_announcement'))
sendEvent({
type: EventType.clipboard,
clipboard_operation: 'copy',
eventGroupKey: ASK_AI_EVENT_GROUP,
eventGroupId: eventGroupId.current,
})
}}
></IconButton>
</div>
) : null}
{sources && sources.length > 0 ? (
<>
<ActionList.Divider aria-hidden="true" />
<ActionList className={styles.referencesList}>
<ActionList.Group>
<ActionList.GroupHeading
as="h2"
aria-label={t('search.ai.references')}
className={styles.referencesTitle}
>
{t('search.ai.references')}
</ActionList.GroupHeading>
{sources.map((source, index) => (
<ActionList.LinkItem
key={index}
target="_blank"
href={`https://docs.github.com${source.index}`}
data-group-key={ASK_AI_EVENT_GROUP}
data-group-id={eventGroupId.current}
>
<ActionList.LeadingVisual aria-hidden="true">
<BookIcon />
</ActionList.LeadingVisual>
{source.title}
</ActionList.LinkItem>
))}
</ActionList.Group>
</ActionList>
</>
) : null}
</div>
)
}
function sendAISearchResultEvent(
sources: Array<{ url: string }>,
message: string,
eventGroupId: string,
) {
let searchResultLinksJson = '[]'
try {
searchResultLinksJson = generateAiSearchLinksJson(sources, message)
} catch (e) {
console.error('Failed to generate search result links JSON:', e)
}
sendEvent({
type: EventType.aiSearchResult,
// TODO: Remove PII so we can include the actual data
ai_search_result_query: 'REDACTED',
ai_search_result_response: 'REDACTED',
ai_search_result_links_json: searchResultLinksJson,
eventGroupKey: ASK_AI_EVENT_GROUP,
eventGroupId: eventGroupId,
})
}

View File

@@ -5,18 +5,19 @@ import { SearchIcon } from '@primer/octicons-react'
import { useTranslation } from 'src/languages/components/useTranslation' import { useTranslation } from 'src/languages/components/useTranslation'
import { DEFAULT_VERSION, useVersion } from 'src/versions/components/useVersion' import { DEFAULT_VERSION, useVersion } from 'src/versions/components/useVersion'
import { useQuery } from 'src/search/components/useQuery' import { useQuery } from 'src/search/components/hooks/useQuery'
import { useBreakpoint } from 'src/search/components/useBreakpoint' import { useBreakpoint } from 'src/search/components/hooks/useBreakpoint'
import { sendEvent } from 'src/events/components/events' import { sendEvent } from 'src/events/components/events'
import { EventType } from 'src/events/types' import { EventType } from 'src/events/types'
import { GENERAL_SEARCH_CONTEXT } from '../helpers/execute-search-actions'
type Props = { isSearchOpen: boolean } type Props = { isSearchOpen: boolean }
export function Search({ isSearchOpen }: Props) { export function OldSearchInput({ isSearchOpen }: Props) {
const router = useRouter() const router = useRouter()
const { query, debug } = useQuery() const { query, debug } = useQuery()
const [localQuery, setLocalQuery] = useState(query) const [localQuery, setLocalQuery] = useState(query)
const { t } = useTranslation('search') const { t } = useTranslation('old_search')
const { currentVersion } = useVersion() const { currentVersion } = useVersion()
const atMediumViewport = useBreakpoint('medium') const atMediumViewport = useBreakpoint('medium')
@@ -56,6 +57,7 @@ export function Search({ isSearchOpen }: Props) {
sendEvent({ sendEvent({
type: EventType.search, type: EventType.search,
search_query: localQuery, search_query: localQuery,
search_context: GENERAL_SEARCH_CONTEXT,
}) })
redirectSearch() redirectSearch()

View File

@@ -0,0 +1,10 @@
# Search Input
This directory contains the view logic (React components) for:
- The search button (that looks like an input)
- The search overlay (that pops up when you press the search button)
- The search overlay shows autocomplete suggestions as the user types
- If the user selects a general search option, we use [../results](../results) to render a new page with results
- If the user selects an "Ask AI" search option, we show AI Results
- AI Results: This component "takes over" a large part of the search overlay after a user asks AI a question.

View File

@@ -0,0 +1,168 @@
@use "./variables.scss" as searchVariables;
@import "@primer/css/support/variables/layout.scss";
@import "@primer/css/support/mixins/layout.scss";
// Shown at smaller widths, just a button
.searchIconButton {
display: flex !important;
align-items: center;
@include breakpoint(sm) {
display: flex !important;
}
@include breakpoint(md) {
display: flex !important;
}
@include breakpoint(lg) {
display: none !important;
}
@include breakpoint(xl) {
display: none !important;
}
}
// Only shown at larger widths, a button that looks like an input
.searchInputButton {
position: relative;
z-index: 2;
align-items: center;
border-radius: 6px;
border: none;
padding: 0;
background-color: var(
--bgColor-default,
var(--color-canvas-default, #ffffff)
) !important;
display: none;
width: searchVariables.$smHeaderSearchInputWidth !important;
@include breakpoint(sm) {
display: none;
}
@include breakpoint(md) {
display: none;
}
@include breakpoint(lg) {
display: flex;
width: searchVariables.$lgHeaderSearchInputWidth !important;
}
@include breakpoint(xl) {
display: flex;
width: searchVariables.$xlHeaderSearchInputWidth !important;
}
}
.searchInputContainer {
align-items: center;
width: 100%;
height: 2rem;
pointer-events: none;
user-select: none;
background-color: var(
--bgColor-default,
var(--color-canvas-default, #ffffff)
) !important;
border: 1px solid
var(
--control-borderColor-rest,
var(--borderColor-default, var(--color-border-default, #d0d7de))
);
border-radius: 6px;
border-bottom-right-radius: unset;
border-top-right-radius: unset;
border-right: none;
}
.searchInputContainer svg {
margin-left: 12px;
overflow: visible !important;
width: 16;
height: 16;
fill: currentColor;
color: var(--fgColor-muted, var(--color-fg-muted, #656d76));
}
.queryText {
line-height: 2rem;
margin-left: var(--base-size-8, 8px) !important;
font-size: var(--h5-size, 14px) !important;
/* Hide overflow */
overflow: hidden;
flex: 1;
justify-self: start;
min-width: 0; /* Essential for proper truncation in some browsers */
text-overflow: ellipsis;
text-align: left;
white-space: nowrap;
}
.placeholder {
color: var(--fgColor-muted, var(--color-fg-muted, #656d76));
font-weight: var(--base-text-weight-normal, 400) !important;
}
.searchIconContainer {
display: flex;
align-items: center;
justify-content: center;
// Should be "flat" next to search bar
border: 1px solid;
border-radius: 6px;
border-top-left-radius: unset;
border-bottom-left-radius: unset;
min-width: 32px;
width: 32px;
height: 32px;
pointer-events: none;
user-select: none;
color: var(--fgColor-muted, var(--color-fg-muted, #656d76));
border-color: var(
--button-default-borderColor-rest,
var(
--button-default-borderColor-rest,
var(--color-btn-border, rgba(31, 35, 40, 0.15))
)
);
background-color: var(
--button-default-bgColor-rest,
var(--color-btn-bg, #f6f8fa)
);
box-shadow: var(
--button-default-shadow-resting,
var(--color-btn-shadow, 0 1px 0 rgba(31, 35, 40, 0.04))
),
var(
--button-default-shadow-inset,
var(--color-btn-inset-shadow, inset 0 1px 0 rgba(255, 255, 255, 0.25))
);
}
.searchIconContainer svg {
overflow: visible !important;
width: 16;
height: 16;
fill: currentColor;
color: var(--fgColor-muted, var(--color-fg-muted, #656d76));
}

View File

@@ -0,0 +1,107 @@
import { useRef } from 'react'
import cx from 'classnames'
import { IconButton, Token } from '@primer/react'
import { SearchIcon, SparklesFillIcon } from '@primer/octicons-react'
import { useTranslation } from 'src/languages/components/useTranslation'
import { SearchOverlay } from './SearchOverlay'
import styles from './SearchBarButton.module.scss'
import { useQueryParam } from '@/frame/components/hooks/useQueryParam'
type Props = {
isSearchOpen: boolean
setIsSearchOpen: (value: boolean) => void
}
export function SearchBarButton({ isSearchOpen, setIsSearchOpen }: Props) {
const { t } = useTranslation('search')
const {
debug,
queryParam: urlSearchInputQuery,
setQueryParam: setUrlSearchInputQuery,
} = useQueryParam('search-overlay-input')
const { queryParam: isAskAIState, setQueryParam: setIsAskAIState } = useQueryParam(
'search-overlay-ask-ai',
true,
)
const buttonRef = useRef(null)
// Handle click events
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
setIsSearchOpen(true)
}
// Handle key down events
const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
if (event.key === 'Enter' || event.key === 'Space') {
event.preventDefault()
setIsSearchOpen(true)
} else if (event.key === 'Escape') {
event.preventDefault()
setIsSearchOpen(false)
}
}
return (
<>
{/* We don't want to show the input when overlay is open */}
{!isSearchOpen ? (
<>
{/* On mobile only the IconButton is shown */}
<IconButton
data-testid="mobile-search-button"
ref={buttonRef}
className={styles.searchIconButton}
onClick={handleClick}
tabIndex={0}
aria-label={t('search.input.aria_label')}
icon={SearchIcon}
/>
{/* On large and up the SearchBarButton is shown */}
<button
data-testid="search"
tabIndex={0}
aria-label={t`search.input.aria_label`}
className={styles.searchInputButton}
onKeyDown={handleKeyDown}
onClick={handleClick}
ref={buttonRef}
>
{/* Styled to look like an input */}
<div
className={cx('d-flex align-items-center flex-grow-1', styles.searchInputContainer)}
aria-hidden
tabIndex={-1}
>
<SparklesFillIcon aria-hidden className="mr-1" />
<Token aria-hidden as="span" text={t('search.input.experimental_tag')} />
<span
className={cx(styles.queryText, !urlSearchInputQuery ? styles.placeholder : null)}
>
{urlSearchInputQuery ? urlSearchInputQuery : t('search.input.placeholder')}
</span>
</div>
<span className={styles.searchIconContainer} aria-hidden tabIndex={-1}>
<SearchIcon />
</span>
</button>
</>
) : (
<SearchOverlay
searchOverlayOpen={isSearchOpen}
parentRef={buttonRef}
debug={debug}
urlSearchInputQuery={urlSearchInputQuery}
setUrlSearchInputQuery={setUrlSearchInputQuery}
isAskAIState={isAskAIState}
setIsAskAIState={setIsAskAIState}
onClose={() => {
setIsSearchOpen(false)
}}
/>
)}
</>
)
}

View File

@@ -0,0 +1,99 @@
@use "./variables.scss" as searchVariables;
@import "@primer/css/support/variables/layout.scss";
@import "@primer/css/support/mixins/layout.scss";
.overlayBackdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(
--overlay-backdrop-bgColor,
var(--color-primer-fg-canvas-backdrop, rgba(31, 35, 40, 0.5))
);
z-index: 1000; /* Ensure it's above other content other than overlay */
}
.overlayContainer {
z-index: 1001; /* Above the backdrop */
top: 0;
left: 0;
width: searchVariables.$smSearchOverlayWidth !important;
max-width: 100%;
height: auto;
max-height: 90vh;
overflow-y: auto;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
@include breakpoint(sm) {
top: 0 !important;
left: 0 !important;
width: searchVariables.$smSearchOverlayWidth !important;
}
@include breakpoint(md) {
top: 0 !important;
left: 0 !important;
width: searchVariables.$mdSearchOverlayWidth !important;
}
@include breakpoint(lg) {
// Using header padding: 8px (p-2 padding) x2
top: 16px !important;
left: calc(50vw - searchVariables.$lgSearchOverlayWidth / 2) !important;
width: searchVariables.$lgSearchOverlayWidth !important;
}
@include breakpoint(xl) {
top: 16px !important;
left: calc(50vw - searchVariables.$xlSearchOverlayWidth / 2) !important;
width: searchVariables.$xlSearchOverlayWidth !important;
}
}
.header {
padding: 12px 14px 0px 14px !important;
width: 100%;
background-color: var(--overlay-bgColor) !important;
}
.footer {
padding: 5px 16px 13px 16px;
width: 100%;
background-color: var(--overlay-bgColor);
}
.betaToken {
color: var(--fgColor-success, var(--fgColor-open, green)) !important;
background-color: var(--overlay-bgColor);
margin-right: 1em;
}
.loadingContainer {
display: flex;
width: 100%;
align-items: center;
justify-content: center;
}
.suggestionsList {
width: 100%;
max-width: 100%;
// In the rare viewport case where the results are taller than the viewport, scroll
max-height: 95vh;
overflow-y: auto;
overflow-x: hidden;
padding: 4px 0 4px 0 !important;
}
.errorBanner {
width: 100%;
margin-top: 8px;
margin-bottom: 4px;
}

View File

@@ -0,0 +1,609 @@
import React, {
useState,
useRef,
RefObject,
useEffect,
KeyboardEvent,
SetStateAction,
useMemo,
} from 'react'
import cx from 'classnames'
import { useRouter } from 'next/router'
import {
ActionList,
Box,
Header,
Link,
Overlay,
Spinner,
Stack,
TextInput,
Token,
} from '@primer/react'
import {
ArrowRightIcon,
SearchIcon,
XCircleFillIcon,
SparklesFillIcon,
ChevronLeftIcon,
} from '@primer/octicons-react'
import { useTranslation } from 'src/languages/components/useTranslation'
import { useVersion } from 'src/versions/components/useVersion'
import { executeGeneralSearch } from '../helpers/execute-search-actions'
import styles from './SearchOverlay.module.scss'
import { Banner } from '@primer/react/drafts'
import { AutocompleteSearchHit } from '@/search/types'
import { useAISearchAutocomplete } from '@/search/components/hooks/useAISearchAutocomplete'
import { AskAIResults } from './AskAIResults'
import { uuidv4 } from '@/events/components/events'
type Props = {
searchOverlayOpen: boolean
parentRef: RefObject<HTMLElement>
debug: boolean
urlSearchInputQuery: string
setUrlSearchInputQuery: (value: string) => void
isAskAIState: boolean
setIsAskAIState: (value: boolean) => void
onClose: () => void
}
// Upon clicking the SearchInput component this overlay will be displayed
export function SearchOverlay({
searchOverlayOpen,
parentRef,
debug,
urlSearchInputQuery,
setUrlSearchInputQuery,
isAskAIState,
setIsAskAIState,
onClose,
}: Props) {
const { t } = useTranslation('search')
const { currentVersion } = useVersion()
const router = useRouter()
const inputRef = useRef<HTMLInputElement>(null)
const suggestionsListHeightRef = useRef<HTMLUListElement>(null)
// We need an array of refs to the list elements so we can focus them when the user uses the arrow keys
const listElementsRef = React.useRef<Array<HTMLLIElement | null>>([])
const [selectedIndex, setSelectedIndex] = useState<number>(0)
const [aiQuery, setAiQuery] = useState<string>(urlSearchInputQuery)
const [aiSearchError, setAISearchError] = useState<boolean>(false)
// Group all events between open / close of the overlay together
const searchEventGroupId = useRef<string>('')
useEffect(() => {
searchEventGroupId.current = uuidv4()
}, [searchOverlayOpen])
const {
autoCompleteOptions,
searchLoading,
searchError: autoCompleteSearchError,
updateAutocompleteResults,
clearAutocompleteResults,
} = useAISearchAutocomplete({
router,
currentVersion,
debug,
eventGroupIdRef: searchEventGroupId,
})
const { aiAutocompleteOptions } = autoCompleteOptions
// Filter out any options that match the local query and replace them with a custom user query option that include isUserQuery: true
const filteredAiOptions = aiAutocompleteOptions.filter(
(option) => option.term !== urlSearchInputQuery,
)
// Create new arrays that prepend the user input
const userInputOptions =
urlSearchInputQuery.trim() !== ''
? [{ term: urlSearchInputQuery, highlights: [], isUserQuery: true }]
: []
const generalOptionsWithUserInput = [...userInputOptions]
const aiOptionsWithUserInput = [...userInputOptions, ...filteredAiOptions]
// Combine options for key navigation
const combinedOptions = [] as Array<{
group: 'general' | 'ai' | string
option: AutocompleteSearchHitWithUserQuery
}>
// On AI Error, don't include AI suggestions, only user input
if (!aiSearchError) {
combinedOptions.push(...aiOptionsWithUserInput.map((option) => ({ group: 'ai', option })))
}
// NOTE: Order of combinedOptions is important, since 'selectedIndex' is used to navigate the combinedOptions array
// Add general options after AI options
combinedOptions.push(
...generalOptionsWithUserInput.map((option) => ({ group: 'general', option })),
)
// Fetch initial search results on open
useEffect(() => {
if (searchOverlayOpen && !isAskAIState) {
searchEventGroupId.current = uuidv4()
updateAutocompleteResults(urlSearchInputQuery)
}
return () => {
clearAutocompleteResults()
}
// We need to update when isAskAIState changes, because we might start a session in the "Ask AI" state, and then switch to the "Search" state
// In this scenario we don't have pre-existing autocomplete results to show, so we need to fetch them
// Additionally, the query may change in the "Ask AI" state, so we need to update the results when we switch back to the "Search" state
}, [searchOverlayOpen, updateAutocompleteResults, clearAutocompleteResults, isAskAIState])
// For keyboard controls, we need to use a ref for the list elements that updates when the options change
useEffect(() => {
listElementsRef.current = listElementsRef.current.slice(
0,
generalOptionsWithUserInput.length + aiOptionsWithUserInput.length,
)
}, [generalOptionsWithUserInput, aiOptionsWithUserInput])
// When loading, capture the last height of the suggestions list so we can use it for the loading div
const previousSuggestionsListHeight = useMemo(() => {
if (suggestionsListHeightRef.current?.clientHeight) {
return suggestionsListHeightRef.current.clientHeight
} else {
return '250' // Default height that looks very close to 5 suggestions (in px)
}
}, [searchLoading])
// When the user types in the search input, update the local query and fetch autocomplete results
const handleSearchQueryChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const newQuery = event.target.value
setUrlSearchInputQuery(newQuery)
setSelectedIndex(0) // Reset selected index when query changes
// We don't need to fetch autocomplete results when asking the AI
if (!isAskAIState) {
updateAutocompleteResults(newQuery)
}
}
// When a general option is selected, execute the search and close the overlay since general search results are in a new page
const generalSearchOptionOnSelect = (selectedOption: AutocompleteSearchHit) => {
if (selectedOption.term) {
executeGeneralSearch(
router,
currentVersion,
selectedOption.term,
debug,
searchEventGroupId.current,
)
onClose()
}
}
// When an AI option is selected, set the AI query and focus the input since ask AI results replace the suggestions
const aiSearchOptionOnSelect = (selectedOption: AutocompleteSearchHit) => {
if (selectedOption.term) {
setIsAskAIState(true)
setUrlSearchInputQuery(selectedOption.term)
setAiQuery(selectedOption.term)
inputRef.current?.focus()
}
}
// On keydown can be called from the input or a list item
// In either case, we want to deal with both focus & selection when up, down, or enter are pressed
const handleKeyDown = (
event: React.KeyboardEvent<HTMLElement>,
// Passed when called from a list item's handler
manuallyPassedIndex?: number,
) => {
if (event.key === 'ArrowDown') {
event.preventDefault()
if (combinedOptions.length > 0) {
let newIndex = 0
if (typeof manuallyPassedIndex !== 'undefined') {
newIndex = (manuallyPassedIndex + 1) % combinedOptions.length
} else {
newIndex = (selectedIndex + 1) % combinedOptions.length
}
setSelectedIndex(newIndex)
if (listElementsRef.current?.[newIndex]) {
listElementsRef.current?.[newIndex]?.focus()
}
}
} else if (event.key === 'ArrowUp') {
event.preventDefault()
if (manuallyPassedIndex === 0 || selectedIndex === 0) {
// Focus the input when the first item is selected
inputRef.current?.focus()
} else if (combinedOptions.length > 0) {
let newIndex = 0
if (typeof manuallyPassedIndex !== 'undefined') {
newIndex = (manuallyPassedIndex - 1 + combinedOptions.length) % combinedOptions.length
} else {
newIndex = (selectedIndex - 1 + combinedOptions.length) % combinedOptions.length
}
setSelectedIndex(newIndex)
if (listElementsRef.current?.[newIndex]) {
listElementsRef.current?.[newIndex]?.focus()
}
} else {
inputRef.current?.focus()
}
} else if (event.key === 'Enter') {
// When AI Search is already open, ask subsequent queries
if (isAskAIState && !aiSearchError) {
if (isAskAIState && urlSearchInputQuery === aiQuery) {
// User has typed same query and pressed enter. Do nothing
return
} else {
event.preventDefault()
return aiSearchOptionOnSelect({ term: urlSearchInputQuery } as AutocompleteSearchHit)
}
}
event.preventDefault()
if (
combinedOptions.length > 0 &&
selectedIndex >= 0 &&
selectedIndex < combinedOptions.length
) {
const selectedItem = combinedOptions[selectedIndex]
if (selectedItem.group === 'general') {
generalSearchOptionOnSelect(selectedItem.option)
} else if (selectedItem.group === 'ai') {
aiSearchOptionOnSelect(selectedItem.option)
}
}
} else if (event.key === 'Escape') {
event.preventDefault()
onClose() // Close the input overlay when Escape is pressed
}
}
// We display different content in the overlay based:
// 1. If either search (autocomplete results or ask AI) has an error
// 2. The user has selected an AI query and we are showing the ask AI results
// 3. The search is loading
// 4. Otherwise, we show the autocomplete suggestions
let OverlayContents = null
// We can still ask AI if there is an autocomplete search error
const inErrorState = aiSearchError || (autoCompleteSearchError && !isAskAIState)
if (inErrorState) {
OverlayContents = (
<>
<ActionList
aria-label={t('search.overlay.suggestions_list_aria_label')}
showDividers
selectionVariant="single"
className={styles.suggestionsList}
ref={suggestionsListHeightRef}
>
{renderSearchGroups(
t,
autoCompleteSearchError ? userInputOptions : generalOptionsWithUserInput,
aiSearchError ? [] : aiOptionsWithUserInput,
generalSearchOptionOnSelect,
aiSearchOptionOnSelect,
selectedIndex,
setSelectedIndex,
listElementsRef,
handleKeyDown,
)}
</ActionList>
{/* Always show the AI Search UI error message when it is needed */}
{aiSearchError && (
<div
style={{
display: 'flex',
flexDirection: 'column',
width: '100%',
}}
>
<ActionList.GroupHeading
as="h3"
tabIndex={-1}
aria-label={t('search.overlay.ai_suggestions_list_aria_label')}
>
<SparklesFillIcon className="mr-1" />
{t('search.overlay.ai_autocomplete_list_heading')}
</ActionList.GroupHeading>
<Banner
tabIndex={0}
className={styles.errorBanner}
title={t('search.failure.ai_title')}
description={t('search.failure.description')}
variant="info"
aria-live="assertive"
role="alert"
/>
</div>
)}
{/* Only show the autocomplete search UI error message in Dev */}
{process.env.NODE_ENV === 'development' && autoCompleteSearchError && !aiSearchError && (
<Banner
tabIndex={0}
className={styles.errorBanner}
title={t('search.failure.autocomplete_title')}
description={t('search.failure.description')}
variant="info"
aria-live="assertive"
role="alert"
/>
)}
</>
)
} else if (isAskAIState) {
OverlayContents = (
<AskAIResults
query={aiQuery}
debug={debug}
version={currentVersion}
setAISearchError={() => {
setAISearchError(true)
}}
/>
)
} else if (searchLoading) {
OverlayContents = (
<Box
role="status"
className={styles.loadingContainer}
sx={{
height: `${previousSuggestionsListHeight}px`,
}}
>
<Spinner />
</Box>
)
} else {
OverlayContents = (
<ActionList
aria-label={t('search.overlay.suggestions_list_aria_label')}
showDividers
selectionVariant="single"
className={styles.suggestionsList}
ref={suggestionsListHeightRef}
>
{renderSearchGroups(
t,
generalOptionsWithUserInput,
aiOptionsWithUserInput,
generalSearchOptionOnSelect,
aiSearchOptionOnSelect,
selectedIndex,
setSelectedIndex,
listElementsRef,
handleKeyDown,
)}
</ActionList>
)
}
const overlayHeadingId = 'overlay-heading'
return (
<>
<div className={styles.overlayBackdrop} />
<Overlay
initialFocusRef={inputRef}
returnFocusRef={parentRef}
ignoreClickRefs={[parentRef]}
onEscape={onClose}
onClickOutside={onClose}
anchorSide="inside-center"
className={cx(styles.overlayContainer, 'position-fixed')}
role="dialog"
aria-modal="true"
aria-labelledby={overlayHeadingId}
>
<Header className={styles.header}>
<TextInput
className="width-full"
data-testid="overlay-search-input"
ref={inputRef}
value={urlSearchInputQuery}
onChange={handleSearchQueryChange}
onKeyDown={handleKeyDown}
leadingVisual={
isAskAIState ? (
<Stack justify="center">
<TextInput.Action
onClick={() => {
setIsAskAIState(false)
}}
icon={ChevronLeftIcon}
aria-label={t('search.overlay.return_to_search')}
/>
</Stack>
) : (
<SearchIcon />
)
}
aria-labelledby={overlayHeadingId}
placeholder={t('search.input.placeholder')}
trailingAction={
<Stack
justify="center"
sx={{
minWidth: '34px',
}}
>
<TextInput.Action
onClick={() => {
setUrlSearchInputQuery('')
if (!isAskAIState) {
updateAutocompleteResults('')
}
}}
icon={XCircleFillIcon}
aria-label={t('search.overlay.clear_search_query')}
/>
</Stack>
}
/>
</Header>
<ActionList.Divider
sx={{
display: inErrorState ? 'none' : 'block',
marginTop: '16px',
width: '100%',
}}
aria-hidden="true"
/>
{OverlayContents}
<ActionList.Divider
sx={{
width: '100%',
}}
/>
<footer key="description" className={styles.footer}>
<Token
as="span"
text="Beta"
className={styles.betaToken}
sx={{
backgroundColor: 'var(--overlay-bg-color)',
}}
/>
<Link as="button">Give Feedback</Link>
</footer>
</Overlay>
</>
)
}
interface AutocompleteSearchHitWithUserQuery extends AutocompleteSearchHit {
isUserQuery?: boolean
}
// Render the autocomplete suggestions with AI suggestions first, headings, and a divider between the two
function renderSearchGroups(
t: any,
generalOptionsWithUserInput: AutocompleteSearchHitWithUserQuery[],
aiOptionsWithUserInput: AutocompleteSearchHitWithUserQuery[],
generalAutocompleteOnSelect: (selectedOption: AutocompleteSearchHit) => void,
aiAutocompleteOnSelect: (selectedOption: AutocompleteSearchHit) => void,
selectedIndex: number,
setSelectedIndex: (value: SetStateAction<number>) => void,
listElementsRef: RefObject<Array<HTMLLIElement | null>>,
handleKeyDown: (
event: KeyboardEvent<HTMLElement>,
manuallyPassedIndex?: number | undefined,
) => void,
) {
const groups = []
if (aiOptionsWithUserInput.length) {
groups.push(
<ActionList.Group key="ai" data-testid="ai-autocomplete-suggestions">
<ActionList.GroupHeading
as="h3"
tabIndex={-1}
aria-label={t('search.overlay.ai_suggestions_list_aria_label')}
>
<SparklesFillIcon className="mr-1" />
{t('search.overlay.ai_autocomplete_list_heading')}
</ActionList.GroupHeading>
{aiOptionsWithUserInput.map((option: AutocompleteSearchHitWithUserQuery, index: number) => {
const isActive = selectedIndex === index
const item = (
<ActionList.Item
key={`ai-${index}`}
className={styles.searchSuggestion}
onClick={() => aiAutocompleteOnSelect(option)}
onKeyDown={(e: React.KeyboardEvent<HTMLLIElement>) => {
handleKeyDown(e, index)
}}
onFocus={() => {
setSelectedIndex(index)
}}
active={isActive}
tabIndex={0}
ref={(element) => {
if (listElementsRef.current) {
listElementsRef.current[index] = element
}
}}
>
<ActionList.LeadingVisual aria-hidden>
<SparklesFillIcon />
</ActionList.LeadingVisual>
{option.term}
<ActionList.TrailingVisual
aria-hidden
sx={{
visibility: isActive ? 'visible' : 'hidden',
}}
>
<ArrowRightIcon />
</ActionList.TrailingVisual>
</ActionList.Item>
)
return item
})}
</ActionList.Group>,
)
if (generalOptionsWithUserInput.length) {
groups.push(<ActionList.Divider key="general-divider" />)
}
}
if (generalOptionsWithUserInput.length) {
groups.push(
<ActionList.Group key="general" data-testid="general-autocomplete-suggestions">
<ActionList.GroupHeading
as="h3"
tabIndex={-1}
aria-label={t('search.overlay.general_suggestions_list_aria_label')}
>
{t('search.overlay.general_autocomplete_list_heading')}
</ActionList.GroupHeading>
{generalOptionsWithUserInput.map(
(option: AutocompleteSearchHitWithUserQuery, index: number) => {
// Since AI Search comes first, we need to add an offset for general search options
const indexWithOffset = aiOptionsWithUserInput.length + index
const isActive = selectedIndex === indexWithOffset
const item = (
<ActionList.Item
key={`general-${indexWithOffset}`}
className={styles.searchSuggestion}
onClick={() => generalAutocompleteOnSelect(option)}
onKeyDown={(e) => {
handleKeyDown(e, indexWithOffset)
}}
onFocus={() => {
setSelectedIndex(indexWithOffset)
}}
active={isActive}
tabIndex={0}
ref={(element) => {
if (listElementsRef.current) {
listElementsRef.current[indexWithOffset] = element
}
}}
>
<ActionList.LeadingVisual aria-hidden>
<SearchIcon />
</ActionList.LeadingVisual>
{option.term}
<ActionList.TrailingVisual
aria-hidden
sx={{
// Hold the space even when not visible to prevent layout shift
visibility: isActive ? 'visible' : 'hidden',
width: '1rem',
}}
>
<ArrowRightIcon />
</ActionList.TrailingVisual>
</ActionList.Item>
)
return item
},
)}
</ActionList.Group>,
)
}
return groups
}

View File

@@ -0,0 +1,11 @@
// Widths of the search bar button at different breakpoints
$smHeaderSearchInputWidth: 100%; // Technically we don't show the search bar at this breakpoint
$mdHeaderSearchInputWidth: 100%; // Technically we don't show the search bar at this breakpoint
$lgHeaderSearchInputWidth: 30rem;
$xlHeaderSearchInputWidth: 40rem;
// Widths of the search overlay popup at different breakpoints
$smSearchOverlayWidth: 100vw;
$mdSearchOverlayWidth: 100vw;
$lgSearchOverlayWidth: 40rem;
$xlSearchOverlayWidth: 50rem;

View File

@@ -4,7 +4,7 @@ import { useMainContext } from 'src/frame/components/context/MainContext'
import { useTranslation } from 'src/languages/components/useTranslation' import { useTranslation } from 'src/languages/components/useTranslation'
export function NoQuery() { export function NoQuery() {
const { t } = useTranslation(['search']) const { t } = useTranslation('old_search')
const mainContext = useMainContext() const mainContext = useMainContext()
// Use TypeScript's "not null assertion" because `context.page` should // Use TypeScript's "not null assertion" because `context.page` should
// will present in main context if it's gotten to the stage of React // will present in main context if it's gotten to the stage of React

View File

@@ -0,0 +1,6 @@
# Search Results
This directory contains the view logic (React components) for:
- Search Results: When a user performs a general search, we show this page with a list of search results
- Sidebar Aggregates: On larger widths we show the "categories" or "aggregates" of the search results that a user can select to filter their results

View File

@@ -14,6 +14,7 @@ import styles from './SearchResults.module.scss'
import type { SearchQueryContentT } from 'src/search/components/types' import type { SearchQueryContentT } from 'src/search/components/types'
import type { GeneralSearchHitWithoutIncludes, GeneralSearchResponse } from 'src/search/types' import type { GeneralSearchHitWithoutIncludes, GeneralSearchResponse } from 'src/search/types'
import type { SearchTotalHits } from '@elastic/elasticsearch/lib/api/types' import type { SearchTotalHits } from '@elastic/elasticsearch/lib/api/types'
import { GENERAL_SEARCH_RESULTS } from '@/events/components/event-groups'
type Props = { type Props = {
results: GeneralSearchResponse results: GeneralSearchResponse
@@ -105,6 +106,7 @@ function SearchResultHit({
href={hit.url} href={hit.url}
className="color-fg-accent search-result-link" className="color-fg-accent search-result-link"
dangerouslySetInnerHTML={{ __html: title }} dangerouslySetInnerHTML={{ __html: title }}
data-group-key={GENERAL_SEARCH_RESULTS}
onClick={() => { onClick={() => {
sendEvent({ sendEvent({
type: EventType.searchResult, type: EventType.searchResult,

View File

@@ -1,4 +1,4 @@
import { useSearchContext } from './context/SearchContext' import { useSearchContext } from '../context/SearchContext'
import { SearchResultsAggregations } from './Aggregations' import { SearchResultsAggregations } from './Aggregations'
export function SidebarSearchAggregates() { export function SidebarSearchAggregates() {

View File

@@ -1,7 +1,7 @@
import { Flash } from '@primer/react' import { Flash } from '@primer/react'
import { useTranslation } from 'src/languages/components/useTranslation' import { useTranslation } from 'src/languages/components/useTranslation'
import type { SearchValidationErrorEntry } from '../types' import type { SearchValidationErrorEntry } from '../../types'
interface Props { interface Props {
errors: SearchValidationErrorEntry[] errors: SearchValidationErrorEntry[]

View File

@@ -3,11 +3,11 @@ import { Heading } from '@primer/react'
import { useTranslation } from 'src/languages/components/useTranslation' import { useTranslation } from 'src/languages/components/useTranslation'
import { DEFAULT_VERSION, useVersion } from 'src/versions/components/useVersion' import { DEFAULT_VERSION, useVersion } from 'src/versions/components/useVersion'
import { useNumberFormatter } from 'src/search/components/useNumberFormatter' import { useNumberFormatter } from 'src/search/components/hooks/useNumberFormatter'
import { SearchResults } from 'src/search/components/SearchResults' import { SearchResults } from 'src/search/components/results/SearchResults'
import { NoQuery } from 'src/search/components/NoQuery' import { NoQuery } from 'src/search/components/results/NoQuery'
import { useMainContext } from 'src/frame/components/context/MainContext' import { useMainContext } from 'src/frame/components/context/MainContext'
import { ValidationErrors } from 'src/search/components/ValidationErrors' import { ValidationErrors } from 'src/search/components/results/ValidationErrors'
import { useSearchContext } from 'src/search/components/context/SearchContext' import { useSearchContext } from 'src/search/components/context/SearchContext'
import type { SearchTotalHits } from '@elastic/elasticsearch/lib/api/types' import type { SearchTotalHits } from '@elastic/elasticsearch/lib/api/types'

View File

@@ -40,29 +40,48 @@ export const aiSearchProxy = async (req: Request, res: Response) => {
} }
try { try {
const stream = got.post(`${process.env.CSE_COPILOT_ENDPOINT}/answers`, { const stream = got.stream.post(`${process.env.CSE_COPILOT_ENDPOINT}/answers`, {
json: body, json: body,
headers: { headers: {
Authorization: getHmacWithEpoch(), Authorization: getHmacWithEpoch(),
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
isStream: true,
}) })
// Set response headers // Handle the upstream response before piping
res.setHeader('Content-Type', 'application/x-ndjson') stream.on('response', (upstreamResponse) => {
res.flushHeaders() if (upstreamResponse.statusCode !== 200) {
const errorMessage = `Upstream server responded with status code ${upstreamResponse.statusCode}`
console.error(errorMessage)
res.status(500).json({ errors: [{ message: errorMessage }] })
stream.destroy()
} else {
// Set response headers
res.setHeader('Content-Type', 'application/x-ndjson')
res.flushHeaders()
// Pipe the got stream directly to the response // Pipe the got stream directly to the response
stream.pipe(res) stream.pipe(res)
}
})
// Handle stream errors // Handle stream errors
stream.on('error', (error) => { stream.on('error', (error: any) => {
console.error('Error streaming from cse-copilot:', error) console.error('Error streaming from cse-copilot:', error)
// Only send error response if headers haven't been sent
if (error?.code === 'ERR_NON_2XX_3XX_RESPONSE') {
return res
.status(400)
.json({ errors: [{ message: 'Sorry I am unable to answer this question.' }] })
}
if (!res.headersSent) { if (!res.headersSent) {
res.status(500).json({ errors: [{ message: 'Internal server error' }] }) res.status(500).json({ errors: [{ message: 'Internal server error' }] })
} else { } else {
// Send error message via the stream
const errorMessage =
JSON.stringify({ errors: [{ message: 'Internal server error' }] }) + '\n'
res.write(errorMessage)
res.end() res.end()
} }
}) })

View File

@@ -14,30 +14,38 @@ export async function getAISearchAutocompleteResults({
indexName, indexName,
query, query,
size, size,
debug = false,
}: AutocompleteResultsArgs): Promise<AutocompleteSearchResponse> { }: AutocompleteResultsArgs): Promise<AutocompleteSearchResponse> {
const t0 = new Date() const t0 = new Date()
const client = getElasticsearchClient() as Client const client = getElasticsearchClient() as Client
const matchQueries = getAISearchAutocompleteMatchQueries(query.trim(), { let searchQuery: any = {
fuzzy: { index: indexName,
minLength: 3, size,
maxLength: 20, // Send absolutely minimal from Elasticsearch to here. Less data => faster.
}, _source_includes: ['term'],
})
const matchQuery = {
bool: {
should: matchQueries,
},
} }
const highlight = getHighlightConfiguration(query, ['term']) const trimmedQuery = query.trim()
// When the query is empty, we want to return the top `size` most popular terms
if (trimmedQuery === '') {
searchQuery.query = { match_all: {} }
searchQuery.sort = [{ popularity: { order: 'desc' } }]
} else {
const matchQueries = getAISearchAutocompleteMatchQueries(trimmedQuery, {
fuzzy: {
minLength: 3,
maxLength: 20,
},
})
const matchQuery: QueryDslQueryContainer = {
bool: {
should: matchQueries,
},
}
const searchQuery = { searchQuery.query = matchQuery
index: indexName, searchQuery.highlight = getHighlightConfiguration(trimmedQuery, ['term'])
highlight,
size,
query: matchQuery,
_source_includes: ['term'],
} }
const result = await client.search<{ term: string }>(searchQuery) const result = await client.search<{ term: string }>(searchQuery)
@@ -46,6 +54,13 @@ export async function getAISearchAutocompleteResults({
const hits = hitsAll.hits.map((hit) => ({ const hits = hitsAll.hits.map((hit) => ({
term: hit._source?.term, term: hit._source?.term,
highlights: (hit.highlight && hit.highlight.term) || [], highlights: (hit.highlight && hit.highlight.term) || [],
...(debug && {
score: hit._score ?? 0.0,
es_url:
process.env.NODE_ENV !== 'production'
? `http://localhost:9200/${indexName}/_doc/${hit._id}`
: '',
}),
})) }))
return { return {

View File

@@ -15,39 +15,62 @@ export async function getAutocompleteSearchResults({
indexName, indexName,
query, query,
size, size,
debug = false,
}: AutocompleteResultsArgs): Promise<AutocompleteSearchResponse> { }: AutocompleteResultsArgs): Promise<AutocompleteSearchResponse> {
const t0 = new Date() const t0 = new Date()
const client = getElasticsearchClient() as Client const client = getElasticsearchClient() as Client
const matchQueries = getAutocompleteMatchQueries(query.trim(), { let searchQuery: any = {
fuzzy: {
minLength: 3,
maxLength: 20,
},
})
const matchQuery = {
bool: {
should: matchQueries,
},
}
const highlight = getHighlightConfiguration(query, ['term'])
const searchQuery = {
index: indexName, index: indexName,
highlight,
size, size,
query: matchQuery,
// Send absolutely minimal from Elasticsearch to here. Less data => faster. // Send absolutely minimal from Elasticsearch to here. Less data => faster.
_source_includes: ['term'], _source_includes: ['term'],
} }
const trimmedQuery = query.trim()
// When the query is empty, return no results
if (trimmedQuery === '') {
return {
meta: {
found: {
value: 0,
relation: 'eq',
},
took: { query_msec: 0, total_msec: new Date().getTime() - t0.getTime() },
size,
},
hits: [],
}
} else {
const matchQueries = getAutocompleteMatchQueries(trimmedQuery, {
fuzzy: {
minLength: 3,
maxLength: 20,
},
})
const matchQuery: QueryDslQueryContainer = {
bool: {
should: matchQueries,
},
}
searchQuery.query = matchQuery
searchQuery.highlight = getHighlightConfiguration(trimmedQuery, ['term'])
}
const result = await client.search<AutocompleteElasticsearchItem>(searchQuery) const result = await client.search<AutocompleteElasticsearchItem>(searchQuery)
const hitsAll = result.hits const hitsAll = result.hits
const hits = hitsAll.hits.map((hit) => ({ const hits = hitsAll.hits.map((hit) => ({
term: hit._source?.term, term: hit._source?.term,
highlights: (hit.highlight && hit.highlight.term) || [], highlights: (hit.highlight && hit.highlight.term) || [],
...(debug && {
score: hit._score ?? 0.0,
es_url:
process.env.NODE_ENV !== 'production'
? `http://localhost:9200/${indexName}/_doc/${hit._id}`
: '',
}),
})) }))
return { return {

View File

@@ -2,6 +2,7 @@ export interface AutocompleteResultsArgs {
indexName: string indexName: string
query: string query: string
size: number size: number
debug?: boolean
} }
export interface FuzzyConfig { export interface FuzzyConfig {

View File

@@ -1,5 +1,6 @@
// Versions used by cse-copilot // Versions used by cse-copilot
import { allVersions } from '@/versions/lib/all-versions' import { allVersions } from '@/versions/lib/all-versions'
import { versionToIndexVersionMap } from '../elasticsearch-versions'
const CSE_COPILOT_DOCS_VERSIONS = ['dotcom', 'ghec', 'ghes'] const CSE_COPILOT_DOCS_VERSIONS = ['dotcom', 'ghec', 'ghes']
// Languages supported by cse-copilot // Languages supported by cse-copilot
@@ -12,7 +13,8 @@ export function getCSECopilotSource(
version: (typeof CSE_COPILOT_DOCS_VERSIONS)[number], version: (typeof CSE_COPILOT_DOCS_VERSIONS)[number],
language: (typeof DOCS_LANGUAGES)[number], language: (typeof DOCS_LANGUAGES)[number],
) { ) {
const cseCopilotDocsVersion = getMiscBaseNameFromVersion(version) const mappedVersion = versionToIndexVersionMap[version]
const cseCopilotDocsVersion = getMiscBaseNameFromVersion(mappedVersion)
if (!CSE_COPILOT_DOCS_VERSIONS.includes(cseCopilotDocsVersion)) { if (!CSE_COPILOT_DOCS_VERSIONS.includes(cseCopilotDocsVersion)) {
throw new Error( throw new Error(
`Invalid 'version' in request body: '${version}'. Must be one of: ${CSE_COPILOT_DOCS_VERSIONS.join(', ')}`, `Invalid 'version' in request body: '${version}'. Must be one of: ${CSE_COPILOT_DOCS_VERSIONS.join(', ')}`,
@@ -23,7 +25,7 @@ export function getCSECopilotSource(
`Invalid 'language' in request body '${language}'. Must be one of: ${DOCS_LANGUAGES.join(', ')}`, `Invalid 'language' in request body '${language}'. Must be one of: ${DOCS_LANGUAGES.join(', ')}`,
) )
} }
return `docs_${version}_${language}` return `docs_${cseCopilotDocsVersion}_${language}`
} }
function getMiscBaseNameFromVersion(Version: string): string { function getMiscBaseNameFromVersion(Version: string): string {

View File

@@ -0,0 +1,120 @@
import { getSearchFromRequestParams } from '@/search/lib/search-request-params/get-search-from-request-params'
import { getAISearchAutocompleteResults } from '@/search/lib/get-elasticsearch-results/ai-search-autocomplete'
import { getAutocompleteSearchResults } from '@/search/lib/get-elasticsearch-results/general-autocomplete'
import { searchCacheControl } from '@/frame/middleware/cache-control'
import { SURROGATE_ENUMS, setFastlySurrogateKey } from '@/frame/middleware/set-fastly-surrogate-key'
import { handleGetSearchResultsError } from '@/search/middleware/search-routes'
import type { Request, Response } from 'express'
import type { CombinedAutocompleteSearchResponse } from '@/search/types'
interface CacheEntry {
timestamp: number
data: CombinedAutocompleteSearchResponse
}
// We want to cache when the query is just an empty string
// These are the defaults that are shown when the user first opens the search overlay
const autocompleteCache = new Map<string, CacheEntry>()
// Within 24 hours the top autocomplete options might be updated
const EMPTY_QUERY_CACHE_KEY = 'emptyQueries'
const CACHE_DURATION_MS = 24 * 60 * 60 * 1000 // 24 hours
const size = 5
export async function combinedAutocompleteRoute(req: Request, res: Response) {
const {
indexName: aiIndexName,
validationErrors: aiValidationErrors,
searchParams: { query: aiQuery, debug },
} = getSearchFromRequestParams(req, 'aiSearchAutocomplete', {
// Force query to override validation to allow empty string
query: typeof req.query.query !== 'string' ? '' : req.query.query,
})
const {
indexName: generalIndexName,
validationErrors: generalValidationErrors,
searchParams: { query: generalQuery },
} = getSearchFromRequestParams(req, 'generalAutocomplete', {
query: typeof req.query.query !== 'string' ? '' : req.query.query,
})
const combinedValidationErrors = aiValidationErrors.concat(generalValidationErrors)
if (combinedValidationErrors.length) {
return res.status(400).json(combinedValidationErrors[0])
}
// Check if both queries are empty
const isEmptyQuery =
(!aiQuery || aiQuery.trim() === '') && (!generalQuery || generalQuery.trim() === '')
if (isEmptyQuery) {
const cached = autocompleteCache.get(EMPTY_QUERY_CACHE_KEY)
const now = Date.now()
if (cached && now - cached.timestamp < CACHE_DURATION_MS) {
// Serve cached results
return res.status(200).json(cached.data)
}
}
try {
// Async fetch both results from Elasticsearch
const [aiSearchResults, generalSearchResults] = await Promise.all([
getAISearchAutocompleteResults({
indexName: aiIndexName,
query: aiQuery,
size,
debug,
}),
getAutocompleteSearchResults({
indexName: generalIndexName,
query: generalQuery,
size,
debug,
}),
])
if (process.env.NODE_ENV !== 'development') {
searchCacheControl(res)
setFastlySurrogateKey(res, SURROGATE_ENUMS.MANUAL)
}
const results: CombinedAutocompleteSearchResponse = {
aiAutocomplete: {
meta: aiSearchResults.meta,
hits: aiSearchResults.hits,
},
generalAutocomplete: {
meta: generalSearchResults.meta,
hits: generalSearchResults.hits,
},
}
// If both queries are empty, cache the results
if (isEmptyQuery) {
autocompleteCache.set(EMPTY_QUERY_CACHE_KEY, {
timestamp: Date.now(),
data: results,
})
}
res.status(200).json(results)
} catch (error) {
await handleGetSearchResultsError(
req,
res,
error,
JSON.stringify(
{
indexName: { aiIndexName, generalIndexName },
query: { aiQuery, generalQuery },
size,
},
null,
2,
),
)
}
}

View File

@@ -3,6 +3,7 @@
For general search (client searches on docs.github.com) we use the middleware in ./general-search-middleware to get the search results For general search (client searches on docs.github.com) we use the middleware in ./general-search-middleware to get the search results
*/ */
// TODO: Move the routes implementations in this files to lib/routes so you can at-a-glance see all of the routes without the implementation logic
import express, { Request, Response } from 'express' import express, { Request, Response } from 'express'
import FailBot from '@/observability/lib/failbot.js' import FailBot from '@/observability/lib/failbot.js'
@@ -16,13 +17,14 @@ import { getAutocompleteSearchResults } from '@/search/lib/get-elasticsearch-res
import { getAISearchAutocompleteResults } from '@/search/lib/get-elasticsearch-results/ai-search-autocomplete' import { getAISearchAutocompleteResults } from '@/search/lib/get-elasticsearch-results/ai-search-autocomplete'
import { getSearchFromRequestParams } from '@/search/lib/search-request-params/get-search-from-request-params' import { getSearchFromRequestParams } from '@/search/lib/search-request-params/get-search-from-request-params'
import { getGeneralSearchResults } from '@/search/lib/get-elasticsearch-results/general-search' import { getGeneralSearchResults } from '@/search/lib/get-elasticsearch-results/general-search'
import { createRateLimiter } from '#src/shielding/middleware/rate-limit.js' import { combinedAutocompleteRoute } from '@/search/lib/routes/combined-autocomplete-route'
import { createRateLimiter } from '@/shielding/middleware/rate-limit.js'
const router = express.Router() const router = express.Router()
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
router.use(createRateLimiter(10)) // just 1 worker in dev so 10 requests per minute allowed router.use(createRateLimiter(10)) // just 1 worker in dev so 10 requests per minute allowed
} else if (process.env.NODE_ENV === 'production') { } else if (process.env.NODE_ENV === 'production') {
router.use(createRateLimiter(1)) // 1 * 25 requests per minute for prod router.use(createRateLimiter(30)) // 30 requests per minute allowed
} }
router.get('/legacy', (req: Request, res: Response) => { router.get('/legacy', (req: Request, res: Response) => {
@@ -69,7 +71,7 @@ router.get(
const { const {
indexName, indexName,
validationErrors, validationErrors,
searchParams: { query, size }, searchParams: { query, size, debug },
} = getSearchFromRequestParams(req, 'generalAutocomplete') } = getSearchFromRequestParams(req, 'generalAutocomplete')
if (validationErrors.length) { if (validationErrors.length) {
return res.status(400).json(validationErrors[0]) return res.status(400).json(validationErrors[0])
@@ -79,6 +81,7 @@ router.get(
indexName, indexName,
query, query,
size, size,
debug,
} }
try { try {
const { meta, hits } = await getAutocompleteSearchResults(options) const { meta, hits } = await getAutocompleteSearchResults(options)
@@ -98,11 +101,18 @@ router.get(
router.get( router.get(
'/ai-search-autocomplete/v1', '/ai-search-autocomplete/v1',
catchMiddlewareError(async (req: Request, res: Response) => { catchMiddlewareError(async (req: Request, res: Response) => {
// If no query is provided, we want to return the top 5 most popular terms
// This is a special case for AI search autocomplete
// So we use `force` to allow the query to be empty without the usual validation error
let force = {} as any
if (!req.query.query) {
force.query = ''
}
const { const {
indexName, indexName,
validationErrors, validationErrors,
searchParams: { query, size }, searchParams: { query, size, debug },
} = getSearchFromRequestParams(req, 'aiSearchAutocomplete') } = getSearchFromRequestParams(req, 'aiSearchAutocomplete', force)
if (validationErrors.length) { if (validationErrors.length) {
return res.status(400).json(validationErrors[0]) return res.status(400).json(validationErrors[0])
} }
@@ -111,6 +121,7 @@ router.get(
indexName, indexName,
query, query,
size, size,
debug,
} }
try { try {
const { meta, hits } = await getAISearchAutocompleteResults(getResultOptions) const { meta, hits } = await getAISearchAutocompleteResults(getResultOptions)
@@ -127,7 +138,20 @@ router.get(
}), }),
) )
async function handleGetSearchResultsError(req: Request, res: Response, error: any, options: any) { // Route used by our frontend to fetch ai & general autocomplete search results in a single request
router.get(
'/combined-autocomplete/v1',
catchMiddlewareError(async (req: Request, res: Response) => {
combinedAutocompleteRoute(req, res)
}),
)
export async function handleGetSearchResultsError(
req: Request,
res: Response,
error: any,
options: any,
) {
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
console.error(`Error calling getSearchResults(${options})`, error) console.error(`Error calling getSearchResults(${options})`, error)
} else { } else {
@@ -137,7 +161,7 @@ async function handleGetSearchResultsError(req: Request, res: Response, error: a
res.status(500).json({ error: error.message }) res.status(500).json({ error: error.message })
} }
// Redirects for latest versions // Redirects search routes to their latest versions
router.get('/', (req: Request, res: Response) => { router.get('/', (req: Request, res: Response) => {
res.redirect(307, req.originalUrl.replace('/search', '/search/v1')) res.redirect(307, req.originalUrl.replace('/search', '/search/v1'))
}) })
@@ -153,4 +177,11 @@ router.get('/ai-search-autocomplete', (req: Request, res: Response) => {
) )
}) })
router.get('/combined-autocomplete', (req: Request, res: Response) => {
res.redirect(
307,
req.originalUrl.replace('/search/combined-autocomplete', '/search/combined-autocomplete/v1'),
)
})
export default router export default router

View File

@@ -8,7 +8,7 @@ import {
} from 'src/frame/components/context/MainContext' } from 'src/frame/components/context/MainContext'
import { DefaultLayout } from 'src/frame/components/DefaultLayout' import { DefaultLayout } from 'src/frame/components/DefaultLayout'
import { SearchContext } from 'src/search/components/context/SearchContext' import { SearchContext } from 'src/search/components/context/SearchContext'
import { Search } from 'src/search/components/index' import { Search } from 'src/search/components/results/index'
import { SearchOnReqObject } from 'src/search/types' import { SearchOnReqObject } from 'src/search/types'
import type { SearchContextT } from 'src/search/components/types' import type { SearchContextT } from 'src/search/components/types'

View File

@@ -66,7 +66,7 @@ describeIfElasticsearchURL('search/ai-search-autocomplete v1 middleware', () =>
const sp = new URLSearchParams() const sp = new URLSearchParams()
sp.set('query', 'fo') sp.set('query', 'fo')
sp.set('version', 'never-heard-of') sp.set('version', 'never-heard-of')
const res = await get(`${aiSearchEndpoint}?{sp}`) const res = await get(`${aiSearchEndpoint}?${sp}`)
expect(res.statusCode).toBe(400) expect(res.statusCode).toBe(400)
expect(JSON.parse(res.body).error).toBeTruthy() expect(JSON.parse(res.body).error).toBeTruthy()
}) })
@@ -134,31 +134,24 @@ describeIfElasticsearchURL('search/ai-search-autocomplete v1 middleware', () =>
const res = await get(getSearchEndpointWithParams(sp)) const res = await get(getSearchEndpointWithParams(sp))
expect(res.statusCode).toBe(200) expect(res.statusCode).toBe(200)
const results = JSON.parse(res.body) as AutocompleteSearchResponse const results = JSON.parse(res.body) as AutocompleteSearchResponse
console.log(JSON.stringify(results, null, 2))
const hit = results.hits[0] const hit = results.hits[0]
expect(hit.term).toBe('How do I clone a repository?') expect(hit.term).toBe('How do I clone a repository?')
expect(hit.highlights).toBeTruthy() expect(hit.highlights).toBeTruthy()
expect(hit.highlights[0]).toBe('How do I <mark>clone</mark> a repository?') expect(hit.highlights[0]).toBe('How do I <mark>clone</mark> a repository?')
}) })
test('invalid query', async () => { test('support empty query', async () => {
const sp = new URLSearchParams() const sp = new URLSearchParams()
// No query at all // No query at all
{ {
const res = await get(getSearchEndpointWithParams(sp)) const res = await get(getSearchEndpointWithParams(sp))
expect(res.statusCode).toBe(400) expect(res.statusCode).toBe(200)
} }
// Empty query // Empty query
{ {
sp.set('query', '') sp.set('query', '')
const res = await get(getSearchEndpointWithParams(sp)) const res = await get(getSearchEndpointWithParams(sp))
expect(res.statusCode).toBe(400) expect(res.statusCode).toBe(200)
}
// Empty when trimmed
{
sp.set('query', ' ')
const res = await get(getSearchEndpointWithParams(sp))
expect(res.statusCode).toBe(400)
} }
}) })
}) })

View File

@@ -0,0 +1,170 @@
/**
* To be able to run these tests you need to index the fixtures!
* And you need to have an Elasticsearch URL to connect to for the server.
*
* To index the fixtures, run:
*
* ELASTICSEARCH_URL=http://localhost:9200 npm run index-test-fixtures
*
* This will replace any "real" Elasticsearch indexes you might have so
* once you're done working on vitest tests you need to index real
* content again.
*/
import { expect, test, vi } from 'vitest'
import { describeIfElasticsearchURL } from '@/tests/helpers/conditional-runs.js'
import { get } from '@/tests/helpers/e2etest-ts'
import type { CombinedAutocompleteSearchResponse } from '@/search/types'
if (!process.env.ELASTICSEARCH_URL) {
console.warn(
'None of the API search middleware tests are run because ' +
"the environment variable 'ELASTICSEARCH_URL' is currently not set.",
)
}
const combinedSearchEndpoint = '/api/search/combined-autocomplete/v1'
const getSearchEndpointWithParams = (searchParams: URLSearchParams) =>
`${combinedSearchEndpoint}?${searchParams}`
// This suite only runs if $ELASTICSEARCH_URL is set.
describeIfElasticsearchURL('search/combined-autocomplete v1 middleware', () => {
vi.setConfig({ testTimeout: 60 * 1000 })
test('basic search', async () => {
const sp = new URLSearchParams()
sp.set('query', 'how do I')
const res = await get(getSearchEndpointWithParams(sp))
expect(res.statusCode).toBe(200)
const results = JSON.parse(res.body) as CombinedAutocompleteSearchResponse
// For aiAutocomplete
expect(results.aiAutocomplete.meta).toBeTruthy()
expect(results.aiAutocomplete.meta.found.value).toBeGreaterThanOrEqual(1)
expect(results.aiAutocomplete.meta.found.relation).toBeTruthy()
expect(results.aiAutocomplete.hits).toBeTruthy()
const aiHit = results.aiAutocomplete.hits[0]
expect(aiHit.term).toBe('How do I clone a repository?')
expect(aiHit.highlights).toBeTruthy()
expect(aiHit.highlights[0]).toBe('<mark>How do I</mark> clone a repository?')
// For generalAutocomplete
expect(results.generalAutocomplete.meta).toBeTruthy()
expect(results.generalAutocomplete.meta.found.value).toBeGreaterThanOrEqual(1)
expect(results.generalAutocomplete.meta.found.relation).toBeTruthy()
expect(results.generalAutocomplete.hits).toBeTruthy()
const generalHit = results.generalAutocomplete.hits[0]
expect(generalHit.term).toBe('inputs')
expect(generalHit.highlights).toBeTruthy()
expect(generalHit.highlights[0]).toBe('<mark>inputs</mark>')
// Check that it can be cached at the CDN
expect(res.headers['set-cookie']).toBeUndefined()
expect(res.headers['cache-control']).toContain('public')
expect(res.headers['cache-control']).toMatch(/max-age=[1-9]/)
expect(res.headers['surrogate-control']).toContain('public')
expect(res.headers['surrogate-control']).toMatch(/max-age=[1-9]/)
expect(res.headers['surrogate-key']).toBe('manual-purge')
})
test('invalid version', async () => {
const sp = new URLSearchParams()
sp.set('query', 'rest')
sp.set('version', 'never-heard-of')
const res = await get(getSearchEndpointWithParams(sp))
expect(res.statusCode).toBe(400)
expect(JSON.parse(res.body).error).toBeTruthy()
})
test('variations on version name', async () => {
const sp = new URLSearchParams()
sp.set('query', 'rest')
const versions = ['enterprise-cloud', 'ghec', 'fpt', 'free-pro-team@latest']
for (const version of versions) {
sp.set('version', version)
const res = await get(getSearchEndpointWithParams(sp))
expect(res.statusCode).toBe(200)
}
})
test('invalid language', async () => {
const sp = new URLSearchParams()
sp.set('query', 'rest')
sp.set('language', 'xx')
const res = await get(getSearchEndpointWithParams(sp))
expect(res.statusCode).toBe(400)
expect(JSON.parse(res.body).error).toBeTruthy()
})
test('only english supported', async () => {
const sp = new URLSearchParams()
sp.set('query', 'rest')
sp.set('language', 'ja')
const res = await get(getSearchEndpointWithParams(sp))
expect(res.statusCode).toBe(400)
expect(JSON.parse(res.body).error).toBeTruthy()
})
test('autocomplete term search', async () => {
const sp = new URLSearchParams()
sp.set('query', 'rest')
const res = await get(getSearchEndpointWithParams(sp))
expect(res.statusCode).toBe(200)
const results = JSON.parse(res.body) as CombinedAutocompleteSearchResponse
// aiAutocomplete results
const aiHits = results.aiAutocomplete.hits
expect(aiHits.length).toBeGreaterThanOrEqual(2)
expect(aiHits[0].term).toBe(
'How do I manage OAuth app access restrictions for my organization?',
)
expect(aiHits[0].highlights[0]).toBe(
'How do I manage OAuth app access <mark>restrictions</mark> for my organization?',
)
expect(aiHits[1].term).toBe('How do I test my SSH connection to GitHub?')
expect(aiHits[1].highlights[0]).toBe('How do I <mark>test</mark> my SSH connection to GitHub?')
// generalAutocomplete results
const generalHits = results.generalAutocomplete.hits
expect(generalHits.length).toBeGreaterThanOrEqual(3)
expect(generalHits[0].term).toBe('rest')
expect(generalHits[0].highlights[0]).toBe('<mark>rest</mark>')
expect(generalHits[1].term).toBe('rest api')
expect(generalHits[1].highlights[0]).toBe('<mark>rest</mark> api')
expect(generalHits[2].term).toBe('rest api endpoints')
expect(generalHits[2].highlights[0]).toBe('<mark>rest</mark> api endpoints')
})
test('empty query returns default results', async () => {
const sp = new URLSearchParams()
// No query at all
{
const res = await get(getSearchEndpointWithParams(sp))
expect(res.statusCode).toBe(200)
const results = JSON.parse(res.body) as CombinedAutocompleteSearchResponse
expect(results).toBeTruthy()
}
// Empty query
{
sp.set('query', '')
const res = await get(getSearchEndpointWithParams(sp))
expect(res.statusCode).toBe(200)
const results = JSON.parse(res.body) as CombinedAutocompleteSearchResponse
expect(results).toBeTruthy()
}
// Empty when trimmed
{
sp.set('query', ' ')
const res = await get(getSearchEndpointWithParams(sp))
expect(res.statusCode).toBe(200)
const results = JSON.parse(res.body) as CombinedAutocompleteSearchResponse
expect(results).toBeTruthy()
}
})
})

View File

@@ -0,0 +1,147 @@
import { expect, test, describe } from 'vitest'
import { fixIncompleteMarkdown } from '@/search/components/helpers/fix-incomplete-markdown'
// Unit tests for the `fixIncompleteMarkdown` function
describe('fixIncompleteMarkdown', () => {
test('should close unclosed bold syntax with double asterisks', () => {
const input = 'This is **bold text'
const expected = 'This is **bold text**'
const result = fixIncompleteMarkdown(input)
expect(result).toBe(expected)
})
test('should close unclosed bold syntax with double underscores', () => {
const input = 'This is __bold text'
const expected = 'This is __bold text__'
const result = fixIncompleteMarkdown(input)
expect(result).toBe(expected)
})
test('should close unclosed italics syntax with single asterisk', () => {
const input = 'This is *italic text'
const expected = 'This is *italic text*'
const result = fixIncompleteMarkdown(input)
expect(result).toBe(expected)
})
test('should close unclosed italics syntax with single underscore', () => {
const input = 'This is _italic text'
const expected = 'This is _italic text_'
const result = fixIncompleteMarkdown(input)
expect(result).toBe(expected)
})
test('should close unclosed bold and italics syntax', () => {
const input = 'This is ***bold and italic text'
const expected = 'This is ***bold and italic text***'
const result = fixIncompleteMarkdown(input)
expect(result).toBe(expected)
})
test('should close unclosed link syntax without URL', () => {
const input = 'This is a [link text'
const expected = 'This is a [link text]'
const result = fixIncompleteMarkdown(input)
expect(result).toBe(expected)
})
test('should close unclosed link syntax with URL', () => {
const input = 'This is a [link text](https://example.com'
const expected = 'This is a [link text](https://example.com)'
const result = fixIncompleteMarkdown(input)
expect(result).toBe(expected)
})
test('should close unclosed inline code syntax', () => {
const input = 'This is `inline code'
const expected = 'This is `inline code`'
const result = fixIncompleteMarkdown(input)
expect(result).toBe(expected)
})
test('should close unclosed code block syntax', () => {
const input = 'Here is some code:\n```\nconst x = 10;'
const expected = 'Here is some code:\n```\nconst x = 10;\n```'
const result = fixIncompleteMarkdown(input)
expect(result).toBe(expected)
})
test('should handle nested markdown elements', () => {
const input = 'This is **bold and _italic text'
const expected = 'This is **bold and _italic text_**'
const result = fixIncompleteMarkdown(input)
expect(result).toBe(expected)
})
test('should not alter complete markdown', () => {
const input = 'This is **bold text** and *italic text*'
const expected = 'This is **bold text** and *italic text*'
const result = fixIncompleteMarkdown(input)
expect(result).toBe(expected)
})
test('should handle multiline input with unclosed link', () => {
const input =
'Start of text [link text](https://example.com) and **bold text**\n\nI am a new paragraph with *italic text*\n\nThis is the end of the text, with a [link to the end'
const expected =
'Start of text [link text](https://example.com) and **bold text**\n\nI am a new paragraph with *italic text*\n\nThis is the end of the text, with a [link to the end]'
const result = fixIncompleteMarkdown(input)
expect(result).toBe(expected)
})
test('should close unclosed strikethrough syntax', () => {
const input = 'This is ~~strikethrough text'
const expected = 'This is ~~strikethrough text~~'
const result = fixIncompleteMarkdown(input)
expect(result).toBe(expected)
})
test('should close unclosed images syntax', () => {
const input = '![Alt text]('
const expected = '![Alt text]()'
const result = fixIncompleteMarkdown(input)
expect(result).toBe(expected)
})
test('should handle unclosed nested emphasis', () => {
const input = 'Some _italic and **bold text'
const expected = 'Some _italic and **bold text**_'
const result = fixIncompleteMarkdown(input)
expect(result).toBe(expected)
})
test('should handle unclosed code blocks with language specified', () => {
const input = '```javascript\nconsole.log("Hello, world!");'
const expected = '```javascript\nconsole.log("Hello, world!");\n```'
const result = fixIncompleteMarkdown(input)
expect(result).toBe(expected)
})
test('should not alter headings', () => {
const input = '### Heading level 3'
const expected = '### Heading level 3'
const result = fixIncompleteMarkdown(input)
expect(result).toBe(expected)
})
test('should not alter incomplete horizontal rules', () => {
const input = 'Some text\n---'
const expected = 'Some text\n---'
const result = fixIncompleteMarkdown(input)
expect(result).toBe(expected)
})
test('should handle incomplete tables', () => {
const input = '| Header1 | Header2 |\n|---------|---------|\n| Row1Col1'
const expected = '| Header1 | Header2 |\n|---------|---------|\n| Row1Col1 | |'
const result = fixIncompleteMarkdown(input)
expect(result).toBe(expected)
})
test('should handle unclosed emphasis with tildes', () => {
const input = 'This is ~tilde emphasis'
const expected = 'This is ~tilde emphasis~'
const result = fixIncompleteMarkdown(input)
expect(result).toBe(expected)
})
})

View File

@@ -1,4 +1,5 @@
import type { SearchTotalHits } from '@elastic/elasticsearch/lib/api/types' import type { SearchTotalHits } from '@elastic/elasticsearch/lib/api/types'
import type { import type {
AdditionalIncludes, AdditionalIncludes,
ComputedSearchQueryParamsMap, ComputedSearchQueryParamsMap,
@@ -20,6 +21,11 @@ export interface AutocompleteSearchResponse {
hits: AutocompleteSearchHit[] hits: AutocompleteSearchHit[]
} }
export interface CombinedAutocompleteSearchResponse {
aiAutocomplete: AutocompleteSearchResponse
generalAutocomplete: AutocompleteSearchResponse
}
// Response to middleware /search route // Response to middleware /search route
export interface SearchOnReqObject<Type extends SearchTypes> { export interface SearchOnReqObject<Type extends SearchTypes> {
searchParams: ComputedSearchQueryParamsMap[Type] searchParams: ComputedSearchQueryParamsMap[Type]
@@ -52,7 +58,7 @@ export type GeneralSearchHit = GeneralSearchHitWithoutIncludes & {
[key in AdditionalIncludes]?: string [key in AdditionalIncludes]?: string
} }
interface AutocompleteSearchHit { export interface AutocompleteSearchHit {
term?: string term?: string
highlights: string[] highlights: string[]
} }

View File

@@ -28,8 +28,12 @@ const RECOGNIZED_KEYS_BY_ANY = new Set([
'tool', 'tool',
// When apiVersion isn't the only one. E.g. ?apiVersion=XXX&tool=vscode // When apiVersion isn't the only one. E.g. ?apiVersion=XXX&tool=vscode
'apiVersion', 'apiVersion',
// Search // Search results page
'query', 'query',
// Any page, Search Overlay
'search-overlay-input',
'search-overlay-open',
'search-overlay-ask-ai',
// The drop-downs on "Webhook events and payloads" // The drop-downs on "Webhook events and payloads"
'actionType', 'actionType',
// Used by the tracking middleware // Used by the tracking middleware