docusaurus-openai-search
Version:
AI-powered search plugin for Docusaurus - extends Algolia search with intelligent keyword generation and RAG-based answers
871 lines (869 loc) • 54.7 kB
JavaScript
import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react';
import { createLogger, SearchOrchestrator, ResponseCache } from '../utils';
import { ModalCleanupUtils, RefCleanupUtils, useCleanup } from '../utils/cleanup';
import { useErrorBoundary } from '../components/ErrorBoundary';
import '../styles.css';
import { DEFAULT_CONFIG } from '../config/defaults';
// Cache for loaded components
let markdownComponents = null;
let markdownLoadPromise = null;
// P4-002: Lazy load markdown dependencies
async function loadMarkdownDependencies() {
if (markdownComponents) {
return markdownComponents;
}
if (markdownLoadPromise) {
return markdownLoadPromise;
}
markdownLoadPromise = Promise.all([
import('react-markdown'),
import('remark-gfm'),
import('rehype-raw'),
import('prism-react-renderer')
]).then(([reactMarkdown, remarkGfm, rehypeRaw, prismRenderer]) => {
const components = {
ReactMarkdown: reactMarkdown.default,
remarkGfm: remarkGfm.default,
rehypeRaw: rehypeRaw.default,
Highlight: prismRenderer.Highlight,
prismThemes: prismRenderer.themes
};
markdownComponents = components;
return components;
});
return markdownLoadPromise;
}
/**
* Custom code block renderer for ReactMarkdown
* P4-002: Updated to work with lazy-loaded components
*/
const CodeBlock = React.memo(({ node, inline, className, children, components, ...props }) => {
const match = /language-(\w+)/.exec(className || '');
const language = match && match[1] ? match[1] : '';
const code = String(children).replace(/\n$/, '');
if (!inline && language && components) {
const { Highlight, prismThemes } = components;
return (React.createElement(Highlight, { theme: props.theme || prismThemes.github, code: code, language: language }, ({ className, style, tokens, getLineProps, getTokenProps }) => (React.createElement("pre", { className: className, style: style }, tokens.map((line, i) => (React.createElement("div", { key: i, ...getLineProps({ line }) }, line.map((token, key) => (React.createElement("span", { key: key, ...getTokenProps({ token }) }))))))))));
}
return (React.createElement("code", { className: className, ...props }, children));
});
/**
* Modal component that displays AI-generated answers to search queries
*/
export function AISearchModal({ query, onClose, searchResults, config, themeConfig, algoliaConfig }) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [answer, setAnswer] = useState(null);
const [formattedAnswer, setFormattedAnswer] = useState('');
const [retrievedContent, setRetrievedContent] = useState([]);
const [searchStep, setSearchStep] = useState(null);
const [fetchFailed, setFetchFailed] = useState(false);
const [isRetrying, setIsRetrying] = useState(false);
const [queryAnalysis, setQueryAnalysis] = useState(null);
const [aiCallCount, setAiCallCount] = useState(0);
const [isFromCache, setIsFromCache] = useState(false);
const [isGeneratingAnswer, setIsGeneratingAnswer] = useState(false);
// Week 2 Enhancement: Validation state for confidence indicators
const [validation, setValidation] = useState(null);
// P4-002: State for lazy-loaded markdown components
const [markdownComponentsLoaded, setMarkdownComponentsLoaded] = useState(null);
const [markdownLoading, setMarkdownLoading] = useState(false);
// Reference to the markdown container
const markdownRef = useRef(null);
// Reference to search orchestrator
const orchestratorRef = useRef(null);
// Ref to track if answer generation has started to prevent duplicates
const answerGenerationStartedRef = useRef(false);
// Cache instance
const cache = ResponseCache.getInstance();
// Cleanup hook for component
const { registerCleanup, cleanupComponent } = useCleanup('ai-search-modal');
// P3-001: Error boundary hook for enhanced error handling
const { createErrorHandler } = useErrorBoundary();
// Stage 2: Multi-source search state
const [multiSourceResults, setMultiSourceResults] = useState(null);
// Use useMemo to ensure these values are stable and don't change after mount
const isMultiSourceEnabled = useMemo(() => !!config?.features?.multiSource, [config?.features?.multiSource]);
const multiSourceConfig = useMemo(() => {
if (!config?.features?.multiSource)
return null;
const msConfig = {};
if (config.features.multiSource.github?.repo) {
msConfig.github = {
repository: config.features.multiSource.github.repo
};
}
if (config.features.multiSource.blog?.url) {
msConfig.blog = { url: config.features.multiSource.blog.url };
}
if (config.features.multiSource.changelog?.url) {
msConfig.changelog = { url: config.features.multiSource.changelog.url };
}
return msConfig;
}, [config?.features?.multiSource]);
// Week 6: Conversational memory state
const [sessionId, setSessionId] = useState(null);
const [conversationHistory, setConversationHistory] = useState([]);
// Stage 3: Fine-tuned model enhancement state
const [enhancement, setEnhancement] = useState(null);
// Use useMemo for conversational memory enabled
const isConversationalMemoryEnabled = useMemo(() => !!config?.features?.conversationalMemory?.enabled, [config?.features?.conversationalMemory?.enabled]);
// Week 6: Query Intelligence
const [searchTime] = useState(Date.now());
// Initialize logger with enableLogging config
useEffect(() => {
createLogger(config?.enableLogging || false);
}, [config?.enableLogging]);
// P4-002: Load markdown components when modal opens
useEffect(() => {
if (!markdownComponentsLoaded && !markdownLoading) {
setMarkdownLoading(true);
loadMarkdownDependencies()
.then((components) => {
setMarkdownComponentsLoaded(components);
setMarkdownLoading(false);
})
.catch((error) => {
console.error('[AI Search Modal] Failed to load markdown components:', error);
setMarkdownLoading(false);
});
}
}, [markdownComponentsLoaded, markdownLoading]);
// Get the backend URL from config
const backendUrl = config?.backend?.url;
// console.log('[AI Search Modal] Backend URL:', backendUrl);
// P4-001 & P4-002: Memoize Prism theme computation with lazy-loaded components
const prismTheme = useMemo(() => {
// P4-002: Return null if markdown components aren't loaded yet
if (!markdownComponentsLoaded?.prismThemes) {
return null;
}
const isDarkTheme = typeof document !== 'undefined' &&
document.documentElement.dataset.theme === 'dark';
if (themeConfig?.prism) {
if (isDarkTheme &&
themeConfig.prism.darkTheme &&
typeof themeConfig.prism.darkTheme === 'object' &&
'plain' in themeConfig.prism.darkTheme &&
'styles' in themeConfig.prism.darkTheme) {
return themeConfig.prism.darkTheme;
}
else if (themeConfig.prism.theme &&
typeof themeConfig.prism.theme === 'object' &&
'plain' in themeConfig.prism.theme &&
'styles' in themeConfig.prism.theme) {
return themeConfig.prism.theme;
}
}
// Default to standard themes if custom ones aren't available
return isDarkTheme ? markdownComponentsLoaded.prismThemes.vsDark : markdownComponentsLoaded.prismThemes.github;
}, [themeConfig?.prism, markdownComponentsLoaded?.prismThemes]);
// P4-001: Memoize modal texts to prevent object recreation on every render
const modalTexts = useMemo(() => ({
modalTitle: config?.ui?.modalTitle || DEFAULT_CONFIG.ui.modalTitle,
loadingText: config?.ui?.loadingText || DEFAULT_CONFIG.ui.loadingText,
errorText: config?.ui?.errorText || DEFAULT_CONFIG.ui.errorText,
retryButtonText: config?.ui?.retryButtonText || DEFAULT_CONFIG.ui.retryButtonText,
footerText: config?.ui?.footerText || DEFAULT_CONFIG.ui.footerText,
retrievingText: config?.ui?.retrievingText || DEFAULT_CONFIG.ui.retrievingText,
generatingText: config?.ui?.generatingText || DEFAULT_CONFIG.ui.generatingText,
questionPrefix: config?.ui?.questionPrefix || DEFAULT_CONFIG.ui.questionPrefix,
searchKeywordsLabel: config?.ui?.searchKeywordsLabel || DEFAULT_CONFIG.ui.searchKeywordsLabel,
documentsFoundLabel: config?.ui?.documentsFoundLabel || DEFAULT_CONFIG.ui.documentsFoundLabel,
documentsMoreText: config?.ui?.documentsMoreText || DEFAULT_CONFIG.ui.documentsMoreText,
sourcesHeaderText: config?.ui?.sourcesHeaderText || DEFAULT_CONFIG.ui.sourcesHeaderText,
searchLinksHelpText: config?.ui?.searchLinksHelpText || DEFAULT_CONFIG.ui.searchLinksHelpText,
closeButtonAriaLabel: config?.ui?.closeButtonAriaLabel || DEFAULT_CONFIG.ui.closeButtonAriaLabel,
cachedResponseText: config?.ui?.cachedResponseText || DEFAULT_CONFIG.ui.cachedResponseText,
documentsAnalyzedText: config?.ui?.documentsAnalyzedText || DEFAULT_CONFIG.ui.documentsAnalyzedText,
searchResultsOnlyText: config?.ui?.searchResultsOnlyText || DEFAULT_CONFIG.ui.searchResultsOnlyText,
noDocumentsFoundError: config?.ui?.noDocumentsFoundError || DEFAULT_CONFIG.ui.noDocumentsFoundError,
noSearchResultsError: config?.ui?.noSearchResultsError || DEFAULT_CONFIG.ui.noSearchResultsError,
}), [config?.ui]);
// P2-001: Enhanced retry handler with proper ref reset and safety checks
const handleRetry = useCallback(() => {
// Reset all states
setIsRetrying(true);
setError(null);
setLoading(true);
setSearchStep(null);
setRetrievedContent([]);
setAnswer(null);
setIsGeneratingAnswer(false);
// P2-001: Enhanced ref reset with safety checks
try {
// Ensure answerGenerationStartedRef is properly reset
if (answerGenerationStartedRef && answerGenerationStartedRef.current !== undefined) {
answerGenerationStartedRef.current = false;
}
// P3-002: Reset orchestrator and cancel pending operations
if (orchestratorRef.current) {
// Cancel any ongoing operations before retry
if (typeof orchestratorRef.current.cancelAllOperations === 'function') {
orchestratorRef.current.cancelAllOperations();
}
// Recreate orchestrator to ensure clean state
orchestratorRef.current = null;
if (backendUrl && config) {
orchestratorRef.current = new SearchOrchestrator(config, (step) => setSearchStep(step));
console.log('[AI Search Modal] Orchestrator recreated for retry');
}
}
}
catch (error) {
console.error('[AI Search Modal] Error during retry ref reset:', error);
}
// Set a small delay to ensure UI updates before retrying
setTimeout(() => {
setIsRetrying(false);
// The useEffects will handle the retry
}, 100);
}, []);
// P2-001: Enhanced close handler with comprehensive ref reset and safety checks
const handleClose = useCallback(() => {
// Pre-close safety checks
try {
// Ensure answerGenerationStartedRef is properly reset with safety check
if (answerGenerationStartedRef && answerGenerationStartedRef.current !== undefined) {
answerGenerationStartedRef.current = false;
}
// P3-002: Stop any ongoing orchestrator operations
if (orchestratorRef.current) {
// Cancel all pending operations before clearing reference
if (typeof orchestratorRef.current.cancelAllOperations === 'function') {
orchestratorRef.current.cancelAllOperations();
}
orchestratorRef.current = null;
}
// Reset refs on modal close with null checks
RefCleanupUtils.clearRefs(markdownRef, orchestratorRef, answerGenerationStartedRef);
// Reset all state to initial values
ModalCleanupUtils.cleanupModal({
refs: [markdownRef, orchestratorRef, answerGenerationStartedRef],
states: [
{ setter: setLoading, initialValue: true },
{ setter: setError, initialValue: null },
{ setter: setAnswer, initialValue: null },
{ setter: setFormattedAnswer, initialValue: '' },
{ setter: setRetrievedContent, initialValue: [] },
{ setter: setSearchStep, initialValue: null },
{ setter: setFetchFailed, initialValue: false },
{ setter: setIsRetrying, initialValue: false },
{ setter: setQueryAnalysis, initialValue: null },
{ setter: setAiCallCount, initialValue: 0 },
{ setter: setIsFromCache, initialValue: false },
{ setter: setIsGeneratingAnswer, initialValue: false }
]
});
}
catch (error) {
// Safety net - log error but don't prevent modal from closing
console.error('[AI Search Modal] Error during close cleanup:', error);
}
finally {
// Always call the original onClose regardless of cleanup success
onClose();
}
}, [onClose]);
// P2-001: Enhanced ref initialization and orchestrator setup with safety checks
useEffect(() => {
// Ensure answerGenerationStartedRef is properly initialized
if (answerGenerationStartedRef.current === undefined || answerGenerationStartedRef.current === null) {
answerGenerationStartedRef.current = false;
}
// Initialize orchestrator with safety checks - only create if it doesn't exist
if (backendUrl && config && !orchestratorRef.current) {
console.log('[AI Search Modal] Creating orchestrator with config:', {
backendUrl,
hasConfig: !!config,
configBackendUrl: config?.backend?.url
});
orchestratorRef.current = new SearchOrchestrator(config, (step) => setSearchStep(step));
console.log('[AI Search Modal] Orchestrator created');
}
// Don't cleanup orchestrator on dependency changes - only on unmount
return () => {
// Only cleanup on actual unmount, not on dependency changes
};
}, [backendUrl]); // Remove config from dependencies to prevent recreating orchestrator
// P2-001: Enhanced component lifecycle management with ref monitoring
useEffect(() => {
// Register cleanup tasks with enhanced ref management
registerCleanup('refs', () => {
RefCleanupUtils.clearRefs(markdownRef, orchestratorRef, answerGenerationStartedRef);
}, 10);
registerCleanup('orchestrator', () => {
if (orchestratorRef.current) {
// P3-002: Enhanced orchestrator cleanup with operation cancellation
if (typeof orchestratorRef.current.cancelAllOperations === 'function') {
orchestratorRef.current.cancelAllOperations();
}
orchestratorRef.current = null;
}
}, 5);
registerCleanup('answerGeneration', () => {
// Ensure answer generation ref is properly reset
if (answerGenerationStartedRef && answerGenerationStartedRef.current !== undefined) {
answerGenerationStartedRef.current = false;
}
}, 8);
return () => {
// Clear state on unmount with enhanced safety
try {
cleanupComponent();
// Also ensure orchestrator is cleaned up on unmount
if (orchestratorRef.current) {
orchestratorRef.current.cancelAllOperations();
orchestratorRef.current = null;
}
}
catch (error) {
console.error('[AI Search Modal] Error during component cleanup:', error);
}
};
}, []); // Empty dependency array - only run once on mount
// P2-001: Monitor ref state changes for debugging and safety
useEffect(() => {
// Optional: Add development-only ref state monitoring
if (process.env.NODE_ENV === 'development' && config?.enableLogging) {
const checkRefs = () => {
console.debug('[AI Search Modal] Ref states:', {
markdownRef: !!markdownRef.current,
orchestratorRef: !!orchestratorRef.current,
answerGenerationStarted: answerGenerationStartedRef.current
});
};
// Initial check
checkRefs();
}
}, [config?.enableLogging]);
// Week 6: Get session ID from orchestrator when it's ready
useEffect(() => {
if (orchestratorRef.current && isConversationalMemoryEnabled) {
const sessionId = orchestratorRef.current.getSessionId();
if (sessionId) {
setSessionId(sessionId);
}
}
}, [isConversationalMemoryEnabled]);
// Week 6: Load conversation history when session is available
useEffect(() => {
if (sessionId && orchestratorRef.current && isConversationalMemoryEnabled) {
orchestratorRef.current.getConversationHistory()
.then(history => {
setConversationHistory(history);
})
.catch(error => {
console.error('Failed to load conversation history:', error);
});
}
}, [sessionId, isConversationalMemoryEnabled]);
// Stage 2 & Week 6: Enhanced fetchContent function with multi-source and conversational support
const previousQueryRef = useRef('');
useEffect(() => {
const errorHandler = createErrorHandler('AISearchModal-fetchContent');
let isCancelled = false; // P3-002: Race condition protection
// Check if query actually changed
const queryChanged = previousQueryRef.current !== query;
previousQueryRef.current = query;
async function fetchContent() {
// console.log('[AI Search Modal] fetchContent called', {
// query,
// hasAnswer: !!answer,
// retrievedContentLength: retrievedContent.length,
// loading,
// isRetrying
// });
if (!query) {
setLoading(false);
return;
}
// Skip if we're retrying
if (isRetrying) {
// console.log('[AI Search Modal] Skipping - isRetrying is true');
return;
}
// Skip if we already have content and an answer
if (retrievedContent.length > 0 && answer) {
// console.log('[AI Search Modal] Skipping - already have content and answer');
setLoading(false);
return;
}
// P3-002: Check if this effect instance was cancelled
if (isCancelled) {
// console.log('[AI Search Modal] Skipping - isCancelled is true');
return;
}
try {
setFetchFailed(false);
// Check cache first if enabled
const enableCaching = config?.enableCaching ?? DEFAULT_CONFIG.enableCaching;
const cacheTTL = config?.cacheTTL || DEFAULT_CONFIG.cacheTTL;
if (enableCaching) {
const cached = cache.getCached(query, cacheTTL);
if (cached) {
if (cached.response) {
// P3-002: Check for race condition before setting state
if (isCancelled)
return;
// Full cache hit - we have everything
setAnswer(cached.response);
setRetrievedContent(cached.documents || []);
if (cached.queryAnalysis && typeof cached.queryAnalysis === 'object') {
setQueryAnalysis(cached.queryAnalysis);
}
setIsFromCache(true);
setLoading(false);
// Track cached response
if (config?.onAIQuery) {
config.onAIQuery(query, true);
}
return;
}
}
}
// Use AI search with the new orchestrator
// console.log('[AI Search Modal] Checking orchestrator:', {
// hasOrchestratorRef: !!orchestratorRef,
// hasOrchestratorCurrent: !!orchestratorRef.current,
// hasAlgoliaConfig: !!algoliaConfig,
// algoliaConfigKeys: algoliaConfig ? Object.keys(algoliaConfig) : []
// });
if (orchestratorRef.current && algoliaConfig) {
// console.log('[AI Search Modal] Using orchestrator search', {
// isConversationalMemoryEnabled,
// isMultiSourceEnabled,
// hasMultiSourceConfig: !!multiSourceConfig
// });
let result;
// Week 6: Use conversational search if enabled
if (isConversationalMemoryEnabled) {
// console.log('[AI Search Modal] Using conversational search');
result = await orchestratorRef.current.performConversationalAISearch(query, algoliaConfig.searchClient, algoliaConfig.indexName);
// P3-002: Check for race condition after async operation
if (isCancelled)
return;
// Set conversational search results
setAnswer(result.answer);
setRetrievedContent(result.documents);
if (result.sessionId) {
setSessionId(result.sessionId);
}
// Set validation and query analysis data
if (result.validation) {
setValidation(result.validation);
}
if (result.queryAnalysis) {
setQueryAnalysis(result.queryAnalysis);
}
// Stage 3: Set enhancement data
if (result.enhancement) {
setEnhancement(result.enhancement);
}
}
// Stage 2: Use multi-source search if enabled and conversational is not
else if (isMultiSourceEnabled && multiSourceConfig) {
// console.log('[AI Search Modal] Using multi-source search');
result = await orchestratorRef.current.performMultiSourceAISearch(query, algoliaConfig.searchClient, algoliaConfig.indexName, multiSourceConfig);
// P3-002: Check for race condition after async operation
if (isCancelled)
return;
// Set multi-source results
setMultiSourceResults(result);
setAnswer(result.answer);
// Convert multi-source results to DocumentContent for backward compatibility
const documents = result.sources.map((source) => ({
url: source.url,
title: source.title,
content: source.content
}));
setRetrievedContent(documents);
// Set validation data
if (result.validation) {
setValidation(result.validation);
}
}
else {
// Use traditional single-source search
// console.log('[AI Search Modal] Using traditional single-source search');
try {
// console.log('[AI Search Modal] Calling performAISearch...');
result = await orchestratorRef.current.performAISearch(query, algoliaConfig.searchClient, algoliaConfig.indexName);
// console.log('[AI Search Modal] performAISearch returned:', result);
}
catch (orchestratorError) {
console.error('[AI Search Modal] performAISearch error:', orchestratorError);
throw orchestratorError;
}
// P3-002: Check for race condition after async operation
if (isCancelled) {
// console.log('[AI Search Modal] Aborted after performAISearch - isCancelled is true');
return;
}
if (result.documents.length === 0) {
throw new Error(modalTexts.noDocumentsFoundError);
}
// console.log('[AI Search Modal] Setting state after performAISearch');
setRetrievedContent(result.documents);
setAnswer(result.answer);
// Week 2 Enhancement: Capture validation data from backend
if (result.validation) {
setValidation(result.validation);
}
// Week 3 Enhancement: Capture query analysis data
if (result.queryAnalysis) {
setQueryAnalysis(result.queryAnalysis);
}
// Stage 3: Capture enhancement data
if (result.enhancement) {
setEnhancement(result.enhancement);
}
}
// console.log('[AI Search Modal] Search completed, result:', {
// hasAnswer: !!result?.answer,
// answerLength: result?.answer?.length,
// documentsCount: result?.documents?.length || result?.sources?.length
// });
if (!result || !result.answer) {
console.error('[AI Search Modal] Invalid result from orchestrator:', result);
throw new Error('Search completed but no answer was returned');
}
try {
// Cache the complete response if caching is enabled
if (enableCaching) {
cache.set(query, result.answer, '', result.documents || result.sources);
}
// Track successful AI query
if (config?.onAIQuery) {
config.onAIQuery(query, true);
}
// console.log('[AI Search Modal] About to set loading to false');
setLoading(false);
// console.log('[AI Search Modal] Loading set to false');
}
catch (err) {
console.error('[AI Search Modal] Error in post-search processing:', err);
setLoading(false); // Ensure loading is set to false even if there's an error
throw err; // Re-throw to be caught by outer error handler
}
}
else {
// Fallback to original search behavior
if (searchResults.length === 0) {
throw new Error(modalTexts.noDocumentsFoundError);
}
setSearchStep({
step: 'documents-found',
message: modalTexts.retrievingText,
progress: 50
});
// Simple content extraction from search results
const maxDocs = 5; // Backend will handle the actual document limit
const documents = searchResults.slice(0, maxDocs).map((result) => {
let content = '';
// Build content from hierarchy
if (result.hierarchy) {
const levels = ['lvl0', 'lvl1', 'lvl2', 'lvl3', 'lvl4', 'lvl5'];
levels.forEach(level => {
const value = result.hierarchy[level];
if (value) {
content += `${value}\n`;
}
});
}
// Add snippet
if (result._snippetResult?.content?.value) {
content += `\n${result._snippetResult.content.value}`;
}
// Add full content if available
if (result.content) {
content += `\n${result.content}`;
}
return {
url: result.url || '',
title: result.hierarchy?.lvl1 || result.hierarchy?.lvl0 || 'Document',
content: content || 'No content available'
};
});
// P3-002: Check for race condition before setting fallback data
if (isCancelled)
return;
setRetrievedContent(documents);
}
}
catch (err) {
// P3-001: Enhanced error handling with error boundary integration
console.error('[AISearchModal] Error in fetchContent:', err);
setSearchStep(null);
// Determine if this is a critical error that should trigger error boundary
const isCriticalError = err instanceof TypeError ||
err.message?.includes('network') ||
err.message?.includes('fetch') ||
err.name === 'AbortError';
if (isCriticalError) {
// For critical errors, trigger error boundary
errorHandler(err, { context: 'fetchContent', query });
}
else {
// For non-critical errors, show user-friendly message
setError(`Unable to find relevant documentation: ${err.message || 'Unknown error'}. Please try a different search query.`);
setLoading(false);
// Track failed AI query if function is provided
if (config?.onAIQuery) {
config.onAIQuery(query, false);
}
}
}
}
fetchContent();
// P3-002: Cleanup function to prevent race conditions
return () => {
// Only cancel if query is changing
if (queryChanged) {
// console.log('[AI Search Modal] fetchContent cleanup - query changed, setting isCancelled to true');
isCancelled = true;
// Cancel orchestrator operations if they're running
if (orchestratorRef.current && typeof orchestratorRef.current.cancelAllOperations === 'function') {
orchestratorRef.current.cancelAllOperations();
}
}
};
}, [query, searchResults.length, algoliaConfig?.indexName]); // Remove config-based dependencies that can change
// P3-001: Enhanced markdown response handling with error protection
useEffect(() => {
const errorHandler = createErrorHandler('AISearchModal-markdownProcessing');
try {
if (answer) {
let markdownContent = answer;
// Add source references if we have retrieved content
if (retrievedContent.length > 0) {
const sourcesMarkdown = `
---
**${modalTexts.sourcesHeaderText}**
${retrievedContent.slice(0, 5).map((doc, idx) => `${idx + 1}. [${doc.title}](${doc.url})`).join('\n')}`;
markdownContent += sourcesMarkdown;
}
setFormattedAnswer(markdownContent);
}
}
catch (err) {
console.error('[AISearchModal] Error processing markdown:', err);
// For markdown processing errors, fallback to plain text
if (answer) {
setFormattedAnswer(answer);
}
}
}, [answer, modalTexts.sourcesHeaderText, retrievedContent, createErrorHandler]);
// P3-001: Enhanced DOM manipulation with error protection
useEffect(() => {
try {
if (markdownRef.current && !loading && !error) {
// Find all blockquotes
const blockquotes = markdownRef.current.querySelectorAll('blockquote');
blockquotes.forEach(blockquote => {
try {
// Find the first strong tag in the blockquote
const firstStrong = blockquote.querySelector('p:first-child strong:first-child');
if (firstStrong) {
const text = firstStrong.textContent?.trim().toLowerCase();
// Add appropriate class based on content
if (text === 'note' || text === 'info') {
blockquote.classList.add('note');
}
else if (text === 'tip') {
blockquote.classList.add('tip');
}
else if (text === 'warning') {
blockquote.classList.add('warning');
}
else if (text === 'danger' || text === 'caution') {
blockquote.classList.add('danger');
}
}
}
catch (blockquoteError) {
console.warn('[AISearchModal] Error processing blockquote:', blockquoteError);
// Continue with other blockquotes even if one fails
}
});
}
}
catch (err) {
console.error('[AISearchModal] Error in DOM manipulation:', err);
// DOM manipulation errors are non-critical, just log and continue
}
}, [loading, error, formattedAnswer]);
// Create the classes for the modal based on Docusaurus theme variables
const modalClasses = {
overlay: [
'ai-modal-overlay',
themeConfig?.colorMode?.respectPrefersColorScheme ? 'respect-color-scheme' : '',
].filter(Boolean).join(' '),
content: [
'ai-modal-content',
themeConfig?.hideableSidebar ? 'hideable-sidebar' : '',
].filter(Boolean).join(' ')
};
// Stage 2: Source type indicator component
const SourceTypeIndicator = ({ source }) => {
const getSourceIcon = (sourceType) => {
switch (sourceType) {
case 'documentation':
return '📚';
case 'github':
return '🐙';
case 'blog':
return '📝';
case 'changelog':
return '📋';
default:
return '📄';
}
};
const getSourceLabel = (sourceType) => {
switch (sourceType) {
case 'documentation':
return 'Documentation';
case 'github':
return 'GitHub';
case 'blog':
return 'Blog';
case 'changelog':
return 'Changelog';
default:
return 'Unknown';
}
};
return (React.createElement("span", { className: `ai-search-source-indicator ai-search-source-${source.source}` },
getSourceIcon(source.source),
" ",
getSourceLabel(source.source)));
};
// Updated sources section render function
const renderSources = () => {
const sourcesSection = (() => {
if (multiSourceResults && multiSourceResults.sources.length > 0) {
return (React.createElement("div", { className: "ai-search-sources-section" },
React.createElement("h3", { className: "ai-search-sources-title" }, "Sources"),
React.createElement("div", { className: "ai-search-aggregation-metrics" },
React.createElement("span", { className: "ai-search-total-sources" },
multiSourceResults.aggregationMetrics.totalSources,
" sources found"),
React.createElement("span", { className: "ai-search-confidence-score" },
"Confidence: ",
multiSourceResults.aggregationMetrics.confidenceScore,
"%")),
React.createElement("div", { className: "ai-search-sources-list" }, multiSourceResults.sources.slice(0, 8).map((source, idx) => (React.createElement("div", { key: idx, className: "ai-search-source-item" },
React.createElement(SourceTypeIndicator, { source: source }),
React.createElement("a", { href: source.url, target: "_blank", rel: "noopener noreferrer" }, source.title),
source.metadata.author && (React.createElement("span", { className: "ai-search-source-author" },
"by ",
source.metadata.author)),
source.metadata.timestamp && (React.createElement("span", { className: "ai-search-source-timestamp" }, new Date(source.metadata.timestamp).toLocaleDateString()))))))));
}
// Fallback to traditional sources display
if (retrievedContent.length > 0) {
return (React.createElement("div", { className: "ai-search-sources-section" },
React.createElement("h3", { className: "ai-search-sources-title" }, "Sources"),
React.createElement("div", { className: "ai-search-sources-list" }, retrievedContent.slice(0, 5).map((doc, idx) => (React.createElement("div", { key: idx, className: "ai-search-source-item" },
React.createElement(SourceTypeIndicator, { source: {
source: 'documentation',
title: doc.title,
url: doc.url,
content: doc.content,
metadata: { weight: 0.5 }
} }),
React.createElement("a", { href: doc.url, target: "_blank", rel: "noopener noreferrer" }, doc.title)))))));
}
return null;
})();
return (React.createElement(React.Fragment, null, sourcesSection));
};
// console.log('[AI Search Modal] Rendering with state:', {
// loading,
// hasError: !!error,
// hasAnswer: !!answer,
// formattedAnswerLength: formattedAnswer.length
// });
return (React.createElement("div", { className: modalClasses.overlay, onClick: (e) => {
if (e.target === e.currentTarget) {
handleClose();
}
} },
React.createElement("div", { className: modalClasses.content },
React.createElement("div", { className: "ai-modal-header" },
React.createElement("h3", null, modalTexts.modalTitle),
React.createElement("button", { className: "ai-modal-close", onClick: handleClose, "aria-label": modalTexts.closeButtonAriaLabel }, "\u00D7")),
React.createElement("div", { className: "ai-modal-body" },
React.createElement("div", { className: "ai-question" },
React.createElement("strong", null, modalTexts.questionPrefix),
" ",
query),
loading ? (React.createElement("div", { className: "ai-loading" },
React.createElement("div", { className: "ai-loading-spinner" }),
React.createElement("div", { className: "ai-loading-status" }, searchStep ? (React.createElement(React.Fragment, null,
React.createElement("div", { className: "ai-progress-bar" },
React.createElement("div", { className: "ai-progress-fill", style: { width: `${searchStep.progress}%` } })),
React.createElement("div", { className: "ai-step-message" }, searchStep.message),
searchStep.details && (React.createElement("div", { className: "ai-progress-details" },
searchStep.details.keywords && searchStep.details.keywords.length > 0 && (React.createElement("div", { className: "ai-keywords-section" },
React.createElement("strong", null, modalTexts.searchKeywordsLabel),
React.createElement("ul", { className: "ai-keywords-list" }, searchStep.details.keywords.map((keyword, idx) => (React.createElement("li", { key: idx, className: "ai-keyword-item" }, keyword)))))),
searchStep.details.documentsFound !== undefined && searchStep.details.documentsFound > 0 && (React.createElement("div", { className: "ai-documents-section" },
React.createElement("strong", null, modalTexts.documentsFoundLabel.replace('{count}', searchStep.details.documentsFound.toString())),
searchStep.details.documentLinks && searchStep.details.documentLinks.length > 0 && (React.createElement("ul", { className: "ai-document-links" },
searchStep.details.documentLinks.slice(0, 5).map((link, idx) => (React.createElement("li", { key: idx, className: "ai-document-link-item" },
React.createElement("a", { href: link, target: "_blank", rel: "noopener noreferrer" }, link.split('/').pop() || 'Document')))),
searchStep.details.documentLinks.length > 5 && (React.createElement("li", { className: "ai-document-link-more" }, modalTexts.documentsMoreText.replace('{count}', (searchStep.details.documentLinks.length - 5).toString()))))))))))) : (React.createElement("p", null, modalTexts.loadingText))))) : error ? (React.createElement("div", { className: "ai-error" },
React.createElement("div", { className: "alert alert--danger error-message" },
React.createElement("p", null, error)),
React.createElement("div", { className: "ai-error-actions" },
React.createElement("button", { className: "button button--primary ai-retry-button", onClick: handleRetry },
React.createElement("svg", { xmlns: "http://www.w3.org/2000/svg", width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" },
React.createElement("path", { d: "M21 2v6h-6" }),
React.createElement("path", { d: "M3 12a9 9 0 0 1 15-6.7L21 8" }),
React.createElement("path", { d: "M3 22v-6h6" }),
React.createElement("path", { d: "M21 12a9 9 0 0 1-15 6.7L3 16" })),
modalTexts.retryButtonText)),
searchResults.length > 0 && (React.createElement("div", { className: "ai-search-links" },
React.createElement("p", null, modalTexts.searchLinksHelpText),
React.createElement("ul", null, searchResults.slice(0, 3).map((result, idx) => (React.createElement("li", { key: idx },
React.createElement("a", { href: result.url, target: "_blank", rel: "noopener noreferrer" }, result.hierarchy?.lvl0 ||
result.hierarchy?.lvl1 ||
'Result ' + (idx + 1)))))))))) : (React.createElement("div", { className: "ai-answer" },
validation && (React.createElement("div", { className: "ai-validation-section" },
validation.confidence && (React.createElement("div", { className: `ai-confidence ai-confidence-${validation.confidence.toLowerCase()}` },
React.createElement("span", { className: "ai-confidence-label" }, "Confidence:"),
React.createElement("span", { className: "ai-confidence-value" }, validation.confidence))),
validation.qualityMetrics && (React.createElement("div", { className: "ai-quality-indicators" },
validation.qualityMetrics.hasCodeExamples && (React.createElement("span", { className: "ai-quality-badge ai-has-code" },
React.createElement("span", { className: "ai-badge-icon" }, "\uD83D\uDCBB"),
"Code Examples")),
validation.qualityMetrics.hasStepByStep && (React.createElement("span", { className: "ai-quality-badge ai-has-steps" },
React.createElement("span", { className: "ai-badge-icon" }, "\uD83D\uDCCB"),
"Step-by-Step")),
validation.hasSources && (React.createElement("span", { className: "ai-quality-badge ai-has-sources" },
React.createElement("span", { className: "ai-badge-icon" }, "\uD83D\uDD17"),
"Sources Cited")))),
enhancement && (React.createElement("div", { className: "ai-enhancement-indicators" },
enhancement.fineTunedModelUsed && (React.createElement("span", { className: "ai-enhancement-badge ai-finetuned-model" },
React.createElement("span", { className: "ai-badge-icon" }, "\uD83E\uDDE0"),
"Fine-Tuned Model")),
enhancement.recursiveEnhanced && (React.createElement("span", { className: "ai-enhancement-badge ai-recursive-enhanced" },
React.createElement("span", { className: "ai-badge-icon" }, "\uD83D\uDD04"),
"Recursively Enhanced")),
enhancement.documentsAnalyzed && enhancement.documentsAnalyzed > 0 && (React.createElement("span", { className: "ai-enhancement-badge ai-docs-analyzed" },
React.createElement("span", { className: "ai-badge-icon" }, "\uD83D\uDCDA"),
enhancement.documentsAnalyzed,
" Documents Analyzed")))))),
React.createElement("div", { className: "ai-response" }, validation?.isNotFound ? (React.createElement("div", { className: "ai-not-found" },
React.createElement("div", { className: "alert alert--info" },
React.createElement("div", { className: "ai-response-text markdown-body", ref: markdownRef }, markdownComponentsLoaded ? (React.createElement(markdownComponentsLoaded.ReactMarkdown, { remarkPlugins: [markdownComponentsLoaded.remarkGfm], rehypePlugins: [markdownComponentsLoaded.rehypeRaw], components: {
// Override pre rendering to avoid nesting
// @ts-ignore - The type definition for pre component in ReactMarkdown is complex
pre: ({ children }) => children,
// @ts-ignore - The type definition for code component in ReactMarkdown is complex
code: (codeProps) => {
const { className, children } = codeProps;
// Check if this is a code block or inline code
const match = /language-(\w+)/.exec(className || '