+ )}
+
+ {/* 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 */}
+
+ >
+ )
+}
diff --git a/src/frame/components/page-header/OldHeaderSearchAndWidgets.module.scss b/src/frame/components/page-header/OldHeaderSearchAndWidgets.module.scss
new file mode 100644
index 000000000000..87a6e495e4de
--- /dev/null
+++ b/src/frame/components/page-header/OldHeaderSearchAndWidgets.module.scss
@@ -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;
+ }
+}
diff --git a/src/frame/components/page-header/OldHeaderSearchAndWidgets.tsx b/src/frame/components/page-header/OldHeaderSearchAndWidgets.tsx
new file mode 100644
index 000000000000..3f8a2c049593
--- /dev/null
+++ b/src/frame/components/page-header/OldHeaderSearchAndWidgets.tsx
@@ -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 (
+
+ )}
+
+ setIsSearchOpen(!isSearchOpen)}
+ aria-label="Open Search Bar"
+ aria-expanded={isSearchOpen ? 'true' : 'false'}
+ icon={SearchIcon}
+ />
+ 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 */}
+
- )
-}
-
-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.
-
- {/* Deliberately empty */}
-
- )
-}
diff --git a/src/search/components/helpers/ai-search-links-json.ts b/src/search/components/helpers/ai-search-links-json.ts
new file mode 100644
index 000000000000..00a6fea51702
--- /dev/null
+++ b/src/search/components/helpers/ai-search-links-json.ts
@@ -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]
+}
diff --git a/src/search/components/helpers/execute-search-actions.ts b/src/search/components/helpers/execute-search-actions.ts
new file mode 100644
index 000000000000..e07e97a87bf0
--- /dev/null
+++ b/src/search/components/helpers/execute-search-actions.ts
@@ -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 || [],
+ }
+}
diff --git a/src/search/components/helpers/fix-incomplete-markdown.ts b/src/search/components/helpers/fix-incomplete-markdown.ts
new file mode 100644
index 000000000000..c4b1fcde4930
--- /dev/null
+++ b/src/search/components/helpers/fix-incomplete-markdown.ts
@@ -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')
+}
diff --git a/src/search/components/hooks/useAISearchAutocomplete.ts b/src/search/components/hooks/useAISearchAutocomplete.ts
new file mode 100644
index 000000000000..192ad669b1bc
--- /dev/null
+++ b/src/search/components/hooks/useAISearchAutocomplete.ts
@@ -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
+}
+
+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
+
+// 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({
+ aiAutocompleteOptions: [],
+ })
+ const [searchLoading, setSearchLoading] = useState(true)
+ const [searchError, setSearchError] = useState(false)
+
+ // Support for aborting in-flight requests (e.g. user starts typing while a request is still pending)
+ const abortControllerRef = useRef(null)
+
+ // Debounce to prevent requests while user is (quickly) typing
+ const debouncedFetchRef = useRef | 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,
+ }
+}
diff --git a/src/search/components/useBreakpoint.ts b/src/search/components/hooks/useBreakpoint.ts
similarity index 100%
rename from src/search/components/useBreakpoint.ts
rename to src/search/components/hooks/useBreakpoint.ts
diff --git a/src/search/components/hooks/useLocalStorageCache.ts b/src/search/components/hooks/useLocalStorageCache.ts
new file mode 100644
index 000000000000..02353d3f4877
--- /dev/null
+++ b/src/search/components/hooks/useLocalStorageCache.ts
@@ -0,0 +1,152 @@
+import { useCallback } from 'react'
+
+interface CachedItem {
+ 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(
+ 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
+ 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 = { 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
diff --git a/src/search/components/useMediaQuery.ts b/src/search/components/hooks/useMediaQuery.ts
similarity index 100%
rename from src/search/components/useMediaQuery.ts
rename to src/search/components/hooks/useMediaQuery.ts
diff --git a/src/search/components/useNumberFormatter.ts b/src/search/components/hooks/useNumberFormatter.ts
similarity index 100%
rename from src/search/components/useNumberFormatter.ts
rename to src/search/components/hooks/useNumberFormatter.ts
diff --git a/src/search/components/usePage.ts b/src/search/components/hooks/usePage.ts
similarity index 100%
rename from src/search/components/usePage.ts
rename to src/search/components/hooks/usePage.ts
diff --git a/src/search/components/useQuery.ts b/src/search/components/hooks/useQuery.ts
similarity index 91%
rename from src/search/components/useQuery.ts
rename to src/search/components/hooks/useQuery.ts
index 0f17b4b51966..7e7a1631adaf 100644
--- a/src/search/components/useQuery.ts
+++ b/src/search/components/hooks/useQuery.ts
@@ -19,7 +19,7 @@ export const useQuery = (): QueryInfo => {
}
}
-function parseDebug(debug: string | Array | undefined) {
+export function parseDebug(debug: string | Array | undefined) {
if (debug === '') {
// E.g. `?query=foo&debug` should be treated as truthy
return true
diff --git a/src/search/components/input/AskAIResults.module.scss b/src/search/components/input/AskAIResults.module.scss
new file mode 100644
index 000000000000..f798e65600dd
--- /dev/null
+++ b/src/search/components/input/AskAIResults.module.scss
@@ -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;
+}
diff --git a/src/search/components/input/AskAIResults.tsx b/src/search/components/input/AskAIResults.tsx
new file mode 100644
index 000000000000..1c343830dac4
--- /dev/null
+++ b/src/search/components/input/AskAIResults.tsx
@@ -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([] as Source[])
+ const [initialLoading, setInitialLoading] = useState(true)
+ const [responseLoading, setResponseLoading] = useState(false)
+ const eventGroupId = useRef('')
+ const disclaimerRef = useRef(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)
+
+ // 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 (
+