UNPKG

docusaurus-openai-search

Version:

AI-powered search plugin for Docusaurus - extends Algolia search with intelligent keyword generation and RAG-based answers

553 lines (552 loc) 30.6 kB
import React, { useCallback, useEffect, useRef, useState, useMemo } from 'react'; import { createPortal } from 'react-dom'; import { DocSearchButton, useDocSearchKeyboardEvents } from '@docsearch/react'; import Link from '@docusaurus/Link'; import { useHistory } from '@docusaurus/router'; import { isRegexpStringMatch, useSearchLinkCreator } from '@docusaurus/theme-common'; import { useAlgoliaContextualFacetFilters, useSearchResultUrlProcessor } from '@docusaurus/theme-search-algolia/client'; import Translate from '@docusaurus/Translate'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import { AISearchModal } from './AISearchModal'; import { ErrorBoundary } from './ErrorBoundary'; import { createLogger } from '../utils'; import algoliasearch from 'algoliasearch'; import { DEFAULT_CONFIG } from '../config/defaults'; import '@docsearch/css'; import '../styles.css'; // Import DocSearchModal dynamically to reduce initial bundle size let DocSearchModal = null; // Default translations const defaultTranslations = { button: { buttonText: 'Search', buttonAriaLabel: 'Search', }, modal: { searchBox: { resetButtonTitle: 'Clear the query', resetButtonAriaLabel: 'Clear the query', cancelButtonText: 'Cancel', cancelButtonAriaLabel: 'Cancel', }, startScreen: { recentSearchesTitle: 'Recent', noRecentSearchesText: 'No recent searches', saveRecentSearchButtonTitle: 'Save this search', removeRecentSearchButtonTitle: 'Remove this search from history', favoriteSearchesTitle: 'Favorite', removeFavoriteSearchButtonTitle: 'Remove this search from favorites', }, errorScreen: { titleText: 'Unable to fetch results', helpText: 'You might want to check your network connection.', }, footer: { selectText: 'to select', selectKeyAriaLabel: 'Enter key', navigateText: 'to navigate', navigateUpKeyAriaLabel: 'Arrow up', navigateDownKeyAriaLabel: 'Arrow down', closeText: 'to close', closeKeyAriaLabel: 'Escape key', searchByText: 'Search by', }, noResultsScreen: { noResultsText: 'No results for', suggestedQueryText: 'Try searching for', reportMissingResultsText: 'Believe this query should return results?', reportMissingResultsLinkText: 'Let us know.', }, }, }; async function importDocSearchModalIfNeeded() { if (DocSearchModal) { return Promise.resolve(); } return Promise.all([ import('@docsearch/react/modal'), import('@docsearch/react/style'), import('../styles.css'), ]).then(([{ DocSearchModal: Modal }]) => { DocSearchModal = Modal; }); } function useNavigator({ externalUrlRegex }) { const history = useHistory(); return { navigate({ itemUrl }) { if (externalUrlRegex && isRegexpStringMatch(externalUrlRegex, itemUrl)) { window.location.href = itemUrl; } else { history.push(itemUrl); } }, }; } function useSearchClient() { const { siteMetadata } = useDocusaurusContext(); return useCallback((searchClient) => { searchClient.addAlgoliaAgent('docusaurus', siteMetadata.docusaurusVersion); return searchClient; }, [siteMetadata.docusaurusVersion]); } function useTransformItems({ transformItems }) { const processSearchResultUrl = useSearchResultUrlProcessor(); return useCallback((items) => { const transformedItems = items.map((item) => ({ ...item, url: processSearchResultUrl(item.url), })); return transformItems ? transformItems(transformedItems) : transformedItems; }, [processSearchResultUrl, transformItems]); } function useSearchParameters({ contextualSearch, searchParameters }) { const contextualSearchFacetFilters = useAlgoliaContextualFacetFilters(); let facetFilters = searchParameters?.facetFilters || []; if (contextualSearch && contextualSearchFacetFilters.length > 0) { facetFilters = Array.isArray(facetFilters[0]) ? [...facetFilters, contextualSearchFacetFilters] : [facetFilters, contextualSearchFacetFilters].flat(); } return { ...searchParameters, facetFilters, }; } const ResultsFooter = React.memo(({ state, onClose, seeAllResultsText }) => { const createSearchLink = useSearchLinkCreator(); return (React.createElement("div", { className: "ai-search-footer" }, React.createElement("div", { className: "ai-search-footer-left" }, React.createElement(Link, { to: createSearchLink(state.query), onClick: onClose, className: "ai-search-see-all" }, seeAllResultsText ? (seeAllResultsText.replace('{count}', state.context.nbHits)) : (React.createElement(Translate, { id: "theme.SearchBar.seeAll", values: { count: state.context.nbHits } }, 'See all {count} results')))))); }); /** * Docusaurus AI Search component * P4-001: Memoized to prevent unnecessary re-renders */ export const DocusaurusAISearch = React.memo(function DocusaurusAISearch({ themeConfig, aiConfig }) { const { appId, apiKey, indexName, contextualSearch = false, externalUrlRegex, searchParameters, transformItems, searchPagePath, placeholder, translations } = themeConfig.algolia; // Initialize logger with enableLogging config useEffect(() => { createLogger(aiConfig?.enableLogging || false); }, [aiConfig?.enableLogging]); const navigator = useNavigator({ externalUrlRegex }); const computedSearchParameters = useSearchParameters({ contextualSearch, searchParameters }); const computedTransformItems = useTransformItems({ transformItems }); const transformSearchClient = useSearchClient(); const searchContainer = useRef(null); const searchButtonRef = useRef(null); const [isOpen, setIsOpen] = useState(false); const [initialQuery, setInitialQuery] = useState(undefined); const [showAIModal, setShowAIModal] = useState(false); const [aiQuery, setAiQuery] = useState(''); const [searchResults, setSearchResults] = useState([]); // Merge button translations with AI config const buttonTranslations = useMemo(() => { const aiButtonText = aiConfig?.ui?.searchButtonText; const aiButtonAriaLabel = aiConfig?.ui?.searchButtonAriaLabel; return { buttonText: aiButtonText || translations?.button?.buttonText || defaultTranslations.button.buttonText, buttonAriaLabel: aiButtonAriaLabel || translations?.button?.buttonAriaLabel || defaultTranslations.button.buttonAriaLabel, }; }, [aiConfig?.ui, translations?.button]); // Get search input placeholder const searchPlaceholder = useMemo(() => { return aiConfig?.ui?.searchInputPlaceholder || placeholder || 'Search docs'; }, [aiConfig?.ui?.searchInputPlaceholder, placeholder]); // Create Algolia search client const searchClient = useMemo(() => algoliasearch(appId, apiKey), [appId, apiKey]); // Create algoliaConfig object for AI modal const algoliaConfig = useMemo(() => ({ searchClient, indexName }), [searchClient, indexName]); const prepareSearchContainer = useCallback(() => { if (!searchContainer.current) { const divElement = document.createElement('div'); divElement.className = 'docusaurus-openai-search'; searchContainer.current = divElement; document.body.insertBefore(divElement, document.body.firstChild); } }, []); // Cleanup search container on unmount useEffect(() => { return () => { if (searchContainer.current && searchContainer.current.parentNode) { searchContainer.current.parentNode.removeChild(searchContainer.current); searchContainer.current = null; } }; }, []); const openModal = useCallback(() => { prepareSearchContainer(); importDocSearchModalIfNeeded().then(() => setIsOpen(true)); }, [prepareSearchContainer]); const closeModal = useCallback(() => { setIsOpen(false); if (searchButtonRef.current) { searchButtonRef.current.focus(); } setInitialQuery(undefined); }, []); const handleAskAI = useCallback((query, results) => { setAiQuery(query); setSearchResults(results); setShowAIModal(true); if (aiConfig?.onAIQuery) { aiConfig.onAIQuery(query, true); } }, [aiConfig]); // P4-001: Memoize AI modal close handler to prevent unnecessary re-renders const closeAIModal = useCallback(() => { setShowAIModal(false); }, []); const handleInput = useCallback((event) => { if (event.key === 'f' && (event.metaKey || event.ctrlKey)) { return; } event.preventDefault(); setInitialQuery(event.key); openModal(); }, [openModal]); // P4-001: Memoize hitComponent to prevent unnecessary re-renders const hitComponent = useCallback(({ hit, children }) => (React.createElement(Link, { to: hit.url }, children)), []); const resultsFooterComponent = useCallback(({ state }) => React.createElement(ResultsFooter, { state: state, onClose: closeModal, seeAllResultsText: aiConfig?.ui?.seeAllResultsText }), [closeModal, aiConfig?.ui?.seeAllResultsText]); useDocSearchKeyboardEvents({ isOpen, onOpen: openModal, onClose: closeModal, onInput: handleInput, searchButtonRef, }); useEffect(() => { if (isOpen && aiConfig?.enabled !== false) { const timer = setTimeout(() => { const originalInput = document.querySelector('.DocSearch-Input'); const searchDropdown = document.querySelector('.DocSearch-Dropdown'); if (!originalInput) return; // Create our own input that looks exactly like DocSearch's const customInput = originalInput.cloneNode(false); customInput.removeAttribute('maxlength'); customInput.removeAttribute('maxLength'); customInput.className = originalInput.className; customInput.placeholder = aiConfig?.ui?.searchInputPlaceholder || originalInput.placeholder; // Hide the original input and insert our custom one originalInput.style.display = 'none'; originalInput.parentNode?.insertBefore(customInput, originalInput.nextSibling); // Store the full value let fullValue = originalInput.value || ''; customInput.value = fullValue; // Sync our input with DocSearch's state const syncToDocSearch = (value) => { // Update the hidden original input const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set; if (nativeInputValueSetter) { nativeInputValueSetter.call(originalInput, value); } // Trigger React's onChange const inputEvent = new InputEvent('input', { bubbles: true, cancelable: true, data: value, inputType: 'insertText' }); originalInput.dispatchEvent(inputEvent); // Also trigger change event const changeEvent = new Event('change', { bubbles: true }); originalInput.dispatchEvent(changeEvent); }; // Handle input events on our custom input customInput.addEventListener('input', (e) => { fullValue = customInput.value; syncToDocSearch(fullValue); }); // Handle all other events ['keydown', 'keyup', 'keypress', 'focus', 'blur', 'paste', 'cut'].forEach(eventType => { customInput.addEventListener(eventType, (e) => { // Don't forward Enter key on keydown if there are search results and AI is enabled if (eventType === 'keydown' && e.key === 'Enter' && customInput.value.trim().length > 0 && document.querySelector('.DocSearch-Hit') && aiConfig?.enabled !== false) { // Let our handleKeyDown function handle this return; } // Forward the event to the original input const clonedEvent = new e.constructor(eventType, { bubbles: e.bubbles, cancelable: e.cancelable, view: e.view, detail: e.detail, key: e.key, code: e.code, keyCode: e.keyCode, which: e.which, altKey: e.altKey, ctrlKey: e.ctrlKey, metaKey: e.metaKey, shiftKey: e.shiftKey, clipboardData: e.clipboardData, }); originalInput.dispatchEvent(clonedEvent); }); }); // Focus our custom input when the modal opens customInput.focus(); // Clean up when modal closes const cleanup = () => { customInput.remove(); originalInput.style.display = ''; }; const modalElement = document.querySelector('.DocSearch-Modal'); if (modalElement) { const modalObserver = new MutationObserver(() => { if (!document.contains(modalElement)) { cleanup(); modalObserver.disconnect(); } }); modalObserver.observe(modalElement.parentElement || document.body, { childList: true }); } // Now set up the AI button with access to the full query const searchInput = customInput; // Use our custom input for all operations if (searchInput && searchDropdown) { let aiButtonAdded = false; let observer = null; let typingTimer = null; const addAiButton = (query) => { if (!query || query.trim().length === 0) { return; } const existingButton = document.querySelector('.ai-search-header'); if (existingButton) { existingButton.remove(); } const aiButton = document.createElement('div'); aiButton.className = 'ai-search-header'; const buttonText = aiConfig?.ui?.aiButtonText?.replace('{query}', query) || DEFAULT_CONFIG.ui.aiButtonText.replace('{query}', query); const buttonAriaLabel = aiConfig?.ui?.aiButtonAriaLabel || DEFAULT_CONFIG.ui.aiButtonAriaLabel; aiButton.innerHTML = ` <button class="ai-search-button-header" aria-label="${buttonAriaLabel}"> <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"> <path d="M12 2c-4.4 0-8 3.6-8 8 0 2.8 1.5 5.3 3.7 6.7.1.1.2.2.3.2V20c0 1.1.9 2 2 2h4c1.1 0 2-.9 2-2v-3.1c.1 0 .2-.1.3-.2 2.2-1.4 3.7-3.9 3.7-6.7 0-4.4-3.6-8-8-8zm2 18h-4v-1h4v1zm0-3h-4v-1h4v1zm.5-3.6c-.2.2-.3.3-.5.4V15h-4v-1.2c-.2-.1-.3-.2-.5-.4C8 12.2 7 10.2 7 9c0-2.8 2.2-5 5-5s5 2.2 5 5c0 1.2-1 3.2-2.5 4.4z" /> </svg> ${buttonText} </button> `; const resultsContainer = document.querySelector('.DocSearch-Dropdown-Container'); if (resultsContainer && resultsContainer.parentNode) { resultsContainer.parentNode.insertBefore(aiButton, resultsContainer); aiButtonAdded = true; const button = aiButton.querySelector('button'); if (button) { button.addEventListener('click', (e) => { e.preventDefault(); const currentQuery = searchInput.value.trim(); const searchResultItems = Array.from(document.querySelectorAll('.DocSearch-Hit')).map((hit) => { const anchor = hit.querySelector('a'); const titleEl = hit.querySelector('.DocSearch-Hit-title'); const pathEl = hit.querySelector('.DocSearch-Hit-path'); let snippet = ''; const contentEls = hit.querySelectorAll('.DocSearch-Hit-content mark'); contentEls.forEach((mark) => { snippet += mark.textContent + ' ... '; }); return { url: anchor?.href || '', hierarchy: { lvl0: titleEl?.textContent || '', lvl1: pathEl?.textContent || '', }, content: snippet || '', _snippetResult: { content: { value: snippet || '', }, }, // Add required fields for InternalDocSearchHit objectID: `result-${Math.random().toString(36).substring(2)}`, type: 'lvl1', _highlightResult: {}, }; }); handleAskAI(currentQuery, searchResultItems); closeModal(); }); } } }; const handleMutationObserver = () => { const hasResults = document.querySelector('.DocSearch-Hit'); const query = searchInput.value.trim(); if (hasResults && query.length > 0 && !aiButtonAdded) { addAiButton(query); } }; observer = new MutationObserver(handleMutationObserver); observer.observe(searchDropdown, { childList: true, subtree: true, attributes: false, characterData: false, }); const handleNewSearch = () => { setTimeout(() => { const hasResults = document.querySelector('.DocSearch-Hit'); const query = searchInput.value.trim(); if (hasResults && query.length > 0) { addAiButton(query); } else if (query.length === 0) { const existingButton = document.querySelector('.ai-search-header'); if (existingButton) { existingButton.remove(); aiButtonAdded = false; } } }, 100); }; const doneTyping = () => { aiButtonAdded = false; handleNewSearch(); }; const handleKeyUp = () => { if (typingTimer) clearTimeout(typingTimer); typingTimer = setTimeout(doneTyping, 500); }; const handleKeyDown = (e) => { if (typingTimer) clearTimeout(typingTimer); if (e.key === 'Enter' && searchInput.value.trim().length > 0) { const hasResults = document.querySelector('.DocSearch-Hit'); if (hasResults && aiConfig?.enabled !== false) { e.preventDefault(); e.stopPropagation(); const searchResultItems = Array.from(document.querySelectorAll('.DocSearch-Hit')).map((hit) => { const hierarchy = {}; hit.querySelectorAll('.DocSearch-Hit-title, .DocSearch-Hit-path, .DocSearch-Hit-contentTitle') .forEach((el) => { const level = el.className.includes('title') ? 'lvl1' : el.className.includes('path') ? 'lvl0' : 'content'; hierarchy[level] = el.textContent || ''; }); const url = hit.querySelector('a')?.getAttribute('href'); return { hierarchy, url, _snippetResult: { content: { value: hit.querySelector('.DocSearch-Hit-content')?.textContent || '' } } }; }); handleAskAI(searchInput.value.trim(), searchResultItems); closeModal(); } } }; searchInput.addEventListener('click', handleNewSearch); searchInput.addEventListener('keyup', handleKeyUp); searchInput.addEventListener('keydown', handleKeyDown); return () => { if (observer) { observer.disconnect(); } searchInput.removeEventListener('click', handleNewSearch); searchInput.removeEventListener('keyup', handleKeyUp); searchInput.removeEventListener('keydown', handleKeyDown); if (typingTimer) { clearTimeout(typingTimer); } }; } }, 200); return () => clearTimeout(timer); } }, [isOpen, handleAskAI, closeModal, aiConfig]); useEffect(() => { let linkAdded = false; let linkElement = null; if (appId && typeof document !== 'undefined') { const existingLink = document.querySelector(`link[href="https://${appId}-dsn.algolia.net"]`); if (!existingLink) { linkElement = document.createElement('link'); linkElement.rel = 'preconnect'; linkElement.href = `https://${appId}-dsn.algolia.net`; linkElement.crossOrigin = 'anonymous'; document.head.appendChild(linkElement); linkAdded = true; } } return () => { if (linkAdded && linkElement && linkElement.parentNode) { linkElement.parentNode.removeChild(linkElement); } }; }, [appId]); // Apply custom button styling and keyboard shortcut visibility useEffect(() => { if (typeof document !== 'undefined') { // Small delay to ensure button is rendered const timer = setTimeout(() => { const searchButton = searchButtonRef.current; if (searchButton) { // Update button text if custom text is provided if (aiConfig?.ui?.searchButtonText) { const placeholderElement = searchButton.querySelector('.DocSearch-Button-Placeholder'); if (placeholderElement) { placeholderElement.textContent = aiConfig.ui.searchButtonText; } } // Add custom class name if provided if (aiConfig?.ui?.searchButtonClassName) { searchButton.classList.add(aiConfig.ui.searchButtonClassName); } // Handle keyboard shortcut visibility if (aiConfig?.ui?.showSearchButtonShortcut === false) { // Hide the keyboard shortcut hint const shortcutElement = searchButton.querySelector('.DocSearch-Button-Keys'); if (shortcutElement) { shortcutElement.style.display = 'none'; } } } }, 100); return () => clearTimeout(timer); } }, [aiConfig?.ui?.searchButtonText, aiConfig?.ui?.searchButtonClassName, aiConfig?.ui?.showSearchButtonShortcut]); return (React.createElement("div", { className: "docusaurus-openai-search" }, aiConfig?.ui?.useCustomSearchButton ? (React.createElement("button", { ref: searchButtonRef, onClick: openModal, onMouseOver: importDocSearchModalIfNeeded, onFocus: importDocSearchModalIfNeeded, onTouchStart: importDocSearchModalIfNeeded, className: `DocSearch DocSearch-Button ${aiConfig?.ui?.searchButtonClassName || ''}`, "aria-label": buttonTranslations.buttonAriaLabel }, React.createElement("span", { className: "DocSearch-Button-Container" }, React.createElement("svg", { className: "DocSearch-Search-Icon", width: "20", height: "20", viewBox: "0 0 20 20" }, React.createElement("path", { d: "M14.386 14.386l4.0877 4.0877-4.0877-4.0877c-2.9418 2.9419-7.7115 2.9419-10.6533 0-2.9419-2.9418-2.9419-7.7115 0-10.6533 2.9418-2.9419 7.7115-2.9419 10.6533 0 2.9419 2.9418 2.9419 7.7115 0 10.6533z", stroke: "currentColor", fill: "none", fillRule: "evenodd", strokeLinecap: "round", strokeLinejoin: "round" })), React.createElement("span", { className: "DocSearch-Button-Placeholder" }, aiConfig?.ui?.searchButtonText || 'Search')), aiConfig?.ui?.showSearchButtonShortcut !== false && (React.createElement("span", { className: "DocSearch-Button-Keys" }, React.createElement("kbd", { className: "DocSearch-Button-Key" }, typeof window !== 'undefined' && window.navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl'), React.createElement("kbd", { className: "DocSearch-Button-Key" }, "K"))))) : (React.createElement(DocSearchButton, { onTouchStart: importDocSearchModalIfNeeded, onFocus: importDocSearchModalIfNeeded, onMouseOver: importDocSearchModalIfNeeded, onClick: openModal, ref: searchButtonRef, translations: buttonTranslations })), isOpen && DocSearchModal && searchContainer.current && createPortal(React.createElement(ErrorBoundary, { componentName: "DocSearch Modal", enableLogging: aiConfig?.enableLogging, onError: (error, errorInfo) => { console.error('[DocusaurusAISearch] DocSearch Modal Error:', error); // Close modal on error to prevent stuck state closeModal(); }, maxRetries: 1 }, React.createElement(DocSearchModal, { onClose: closeModal, initialScrollY: window.scrollY, initialQuery: initialQuery, navigator: navigator, transformItems: computedTransformItems, hitComponent: hitComponent, transformSearchClient: transformSearchClient, ...(searchPagePath && { resultsFooterComponent, }), placeholder: searchPlaceholder, translations: translations?.modal ?? defaultTranslations.modal, searchParameters: computedSearchParameters, indexName: indexName, apiKey: apiKey, appId: appId })), searchContainer.current), showAIModal && createPortal(React.createElement("div", { className: "docusaurus-openai-search" }, React.createElement(ErrorBoundary, { componentName: "AI Search Modal", enableLogging: aiConfig?.enableLogging, onError: (error, errorInfo) => { console.error('[DocusaurusAISearch] AI Modal Error:', error); if (aiConfig?.onAIQuery) { aiConfig.onAIQuery(aiQuery, false); } }, maxRetries: 2 }, React.createElement(AISearchModal, { query: aiQuery, onClose: closeAIModal, searchResults: searchResults, config: aiConfig, themeConfig: themeConfig, algoliaConfig: algoliaConfig }))), document.body))); });