diff --git a/src/Elastic.Documentation.ServiceDefaults/Logging/CondensedConsoleLogger.cs b/src/Elastic.Documentation.ServiceDefaults/Logging/CondensedConsoleLogger.cs index ef00eabff..a69842c76 100644 --- a/src/Elastic.Documentation.ServiceDefaults/Logging/CondensedConsoleLogger.cs +++ b/src/Elastic.Documentation.ServiceDefaults/Logging/CondensedConsoleLogger.cs @@ -27,6 +27,9 @@ public override void Write( : now.ToString("[yyyy-MM-ddTHH:mm:ss.fffZ] ", System.Globalization.CultureInfo.InvariantCulture); textWriter.WriteLine($"{nowString}{logLevel}::{ShortCategoryName(categoryName)}:: {message}"); + + if (logEntry.Exception is { } exception) + textWriter.WriteLine(exception.ToString()); } private static string GetLogLevel(LogLevel logLevel) => logLevel switch diff --git a/src/Elastic.Documentation.Site/Assets/web-components/AskAi/AskAi.tsx b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/AskAi.tsx index 5c225d2e7..f72e0602e 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/AskAi/AskAi.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/AskAi.tsx @@ -1,15 +1,9 @@ import '../../eui-icons-cache' import { sharedQueryClient } from '../shared/queryClient' import { ElasticAiAssistantButton } from './ElasticAiAssistantButton' -import { - useAskAiModalActions, - useAskAiModalIsOpen, - useFlyoutWidth, -} from './askAi.modal.store' +import { AskAiFlyoutBodyContent, useAskAiFlyout } from './useAskAiFlyout' import { EuiFlyout, - EuiFlyoutBody, - EuiLoadingSpinner, EuiProvider, euiShadow, euiShadowHover, @@ -17,41 +11,12 @@ import { } from '@elastic/eui' import { css } from '@emotion/react' import r2wc from '@r2wc/react-to-web-component' -import { QueryClientProvider, useQuery } from '@tanstack/react-query' -import { useEffect, Suspense, lazy, StrictMode } from 'react' - -// Lazy load the modal component -const LazyAskAiModal = lazy(() => - import('./AskAiModal').then((module) => ({ - default: module.AskAiModal, - })) -) +import { QueryClientProvider } from '@tanstack/react-query' +import { StrictMode } from 'react' -const AskAiButton = () => { - const isModalOpen = useAskAiModalIsOpen() - const { openModal, closeModal, setFlyoutWidth } = useAskAiModalActions() - const flyoutWidth = useFlyoutWidth() - const euiThemeContext = useEuiTheme() +const fabContainerCss = (euiThemeContext: ReturnType) => { const { euiTheme } = euiThemeContext - - const { data: isApiAvailable } = useQuery({ - queryKey: ['api-health'], - queryFn: async () => { - const response = await fetch('/docs/_api/v1/', { method: 'POST' }) - return response.ok - }, - staleTime: 60 * 60 * 1000, // 60 minutes - retry: false, - }) - - const loadingCss = css` - display: flex; - justify-content: center; - align-items: center; - padding: 2rem; - ` - - const fabContainerCss = css` + return css` position: fixed; bottom: ${euiTheme.size.xxxxl}; right: ${euiTheme.size.xxxxl}; @@ -69,67 +34,45 @@ const AskAiButton = () => { transform: translateY(0); } ` +} - useEffect(() => { - const handleKeydown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - event.preventDefault() - closeModal() - } - // Cmd+; to open Ask AI flyout - if ( - (event.metaKey || event.ctrlKey) && - event.code === 'Semicolon' - ) { - event.preventDefault() - openModal() - } - } - window.addEventListener('keydown', handleKeydown) - return () => window.removeEventListener('keydown', handleKeydown) - }, [openModal, closeModal]) +const AskAiButton = () => { + const euiThemeContext = useEuiTheme() + const { + isApiAvailable, + isModalOpen, + openModal, + closeModal, + setFlyoutWidth, + flyoutWidth, + } = useAskAiFlyout() if (!isApiAvailable) { return null } - let flyout - if (isModalOpen) { - flyout = ( - - -
- - -
- } - > - - - -
-
- ) - } + const flyout = isModalOpen ? ( + + + + ) : null return ( <> {!isModalOpen && ( -
+
{ ) } -const backgroundWrapperCss = css` - position: relative; - min-height: 100%; - - &::before { - content: ''; - position: absolute; - top: 38px; - left: 0; - right: 0; - bottom: 0; - background-color: #e5e5f7; - pointer-events: none; - z-index: 0; - - opacity: 0.4; - background-image: radial-gradient(#444cf7 0.5px, #ffffff 0.5px); - background-size: 10px 10px; - - mask-image: linear-gradient( - to bottom, - rgba(0, 0, 0, 1) 0%, - rgba(0, 0, 0, 1) 10%, - rgba(0, 0, 0, 0.5) 20%, - rgba(0, 0, 0, 0) 30% - ); - -webkit-mask-image: linear-gradient( - to bottom, - rgba(0, 0, 0, 1) 0%, - rgba(0, 0, 0, 1) 10%, - rgba(0, 0, 0, 0.5) 20%, - rgba(0, 0, 0, 0) 30% - ); - } - - & > * { - position: relative; - z-index: 1; - } -` - const AskAi = () => { return ( diff --git a/src/Elastic.Documentation.Site/Assets/web-components/AskAi/AskAiHeaderButton.tsx b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/AskAiHeaderButton.tsx new file mode 100644 index 000000000..9383bce81 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/AskAiHeaderButton.tsx @@ -0,0 +1,63 @@ +import '../../eui-icons-cache' +import { ElasticAiAssistantButton } from './ElasticAiAssistantButton' +import { AskAiFlyoutBodyContent, useAskAiFlyout } from './useAskAiFlyout' +import { EuiFlyout, EuiPortal, EuiThemeProvider } from '@elastic/eui' +import { css } from '@emotion/react' + +const headerButtonWrapperCss = css` + display: flex; + align-items: center; + margin-left: 8px; +` + +export const AskAiHeaderButton = () => { + const { + isApiAvailable, + isModalOpen, + openModal, + closeModal, + setFlyoutWidth, + flyoutWidth, + } = useAskAiFlyout() + + if (!isApiAvailable) { + return null + } + + return ( + <> + + + Ask AI + + + + {isModalOpen && ( + + + + + + + + )} + + ) +} diff --git a/src/Elastic.Documentation.Site/Assets/web-components/AskAi/AskAiSuggestions.tsx b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/AskAiSuggestions.tsx index e897633e1..2b702e39e 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/AskAi/AskAiSuggestions.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/AskAiSuggestions.tsx @@ -1,50 +1,19 @@ +import { askAiConfig } from './askAi.config' import { useChatActions } from './chat.store' import { useIsAskAiCooldownActive } from './useAskAiCooldown' import { EuiButton, EuiText, useEuiTheme, EuiSpacer } from '@elastic/eui' import { css } from '@emotion/react' import { useMemo } from 'react' -export interface AskAiSuggestion { - question: string -} - -// Comprehensive list of AI suggestion questions -const ALL_SUGGESTIONS: AskAiSuggestion[] = [ - { question: 'How do I set up a data stream in Elasticsearch?' }, - { question: 'What are the best practices for indexing performance?' }, - { question: 'How can I create a dashboard in Kibana?' }, - { question: 'What is the difference between a keyword and text field?' }, - { question: 'How do I configure machine learning jobs?' }, - { question: 'What are aggregations and how do I use them?' }, - { question: 'How do I set up Elasticsearch security and authentication?' }, - { question: 'What are the different types of Elasticsearch queries?' }, - { question: 'How do I monitor cluster health and performance?' }, - { - question: - 'What is the Elastic Stack and how do the components work together?', - }, - { question: 'How do I create and manage Elasticsearch indices?' }, - { question: 'What are the best practices for Elasticsearch mapping?' }, - { question: 'How do I set up log shipping with Beats?' }, - { question: 'What is APM and how do I use it for application monitoring?' }, - { question: 'How do I create custom visualizations in Kibana?' }, - { question: 'What are Elasticsearch snapshots and how do I use them?' }, - { question: 'How do I configure cross-cluster search?' }, - { - question: - 'What are the different Elasticsearch node types and their roles?', - }, -] - export const AskAiSuggestions = () => { const { submitQuestion } = useChatActions() const disabled = useIsAskAiCooldownActive() const { euiTheme } = useEuiTheme() - // Randomly select 3 questions from the comprehensive list const selectedSuggestions = useMemo(() => { - // Shuffle the array and take first 3 - const shuffled = [...ALL_SUGGESTIONS].sort(() => Math.random() - 0.5) + const shuffled = [...askAiConfig.suggestions].sort( + () => Math.random() - 0.5 + ) return shuffled.slice(0, 3) }, []) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/AskAi/Chat.tsx b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/Chat.tsx index 5dcf30954..24f428a4f 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/AskAi/Chat.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/Chat.tsx @@ -4,6 +4,7 @@ import { ChatMessageList } from './ChatMessageList' import { InfoBanner } from './InfoBanner' import { LegalDisclaimer } from './LegalDisclaimer' import AiIcon from './ai-icon.svg' +import { askAiConfig } from './askAi.config' import { useAskAiModalActions } from './askAi.modal.store' import { ChatMessage, @@ -74,14 +75,8 @@ export const Chat = () => { } - title={

Hi! I'm the Elastic Docs AI Assistant

} - body={ -

- I'm here to help you find answers about Elastic, - powered entirely by our technical documentation. - How can I help? -

- } + title={

Hi! I'm the {askAiConfig.assistantName}

} + body={

{askAiConfig.assistantDescription}

} />
) : !hasHydrated ? ( @@ -178,7 +173,7 @@ const ChatHeader = () => { font-weight: ${euiTheme.font.weight.medium}; `} > - Elastic Docs AI Assistant + {askAiConfig.assistantName}
()( chatMessages: [], totalMessageCount: 0, // Other state - aiProvider: 'LlmGateway', // Default to LLM Gateway + aiProvider: askAiConfig.defaultAiProvider, messageFeedback: {}, hasHydrated: false, // Will be set to true after IndexedDB hydration scrollPosition: 0, @@ -633,7 +637,7 @@ if (typeof window !== 'undefined') { let conversations: Record = {} let activeConversationId: ConversationId | null = null - let aiProvider: AiProvider = 'LlmGateway' + let aiProvider: AiProvider = askAiConfig.defaultAiProvider let scrollPosition = 0 let inputValue = '' @@ -647,8 +651,11 @@ if (typeof window !== 'undefined') { persistedState.activeConversationId ?? null scrollPosition = persistedState.scrollPosition ?? 0 inputValue = persistedState.inputValue ?? '' - // Use stored aiProvider as fallback (will be overridden by conversation data if available) - aiProvider = persistedState.aiProvider ?? 'LlmGateway' + // For Codex always use AgentBuilder; ignore persisted value from Assembler + aiProvider = askAiConfig.forceAiProvider + ? askAiConfig.defaultAiProvider + : (persistedState.aiProvider ?? + askAiConfig.defaultAiProvider) } // Phase 2: Load active conversation's data (if any) @@ -662,6 +669,7 @@ if (typeof window !== 'undefined') { chatMessages = loaded.chatMessages totalMessageCount = loaded.totalMessageCount messageFeedback = loaded.messageFeedback + // loadConversationData already returns AgentBuilder for Codex aiProvider = loaded.aiProvider } } diff --git a/src/Elastic.Documentation.Site/Assets/web-components/AskAi/useAskAiFlyout.tsx b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/useAskAiFlyout.tsx new file mode 100644 index 000000000..933adfeef --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/AskAi/useAskAiFlyout.tsx @@ -0,0 +1,123 @@ +import { + useAskAiModalActions, + useAskAiModalIsOpen, + useFlyoutWidth, +} from './askAi.modal.store' +import { EuiFlyoutBody, EuiLoadingSpinner } from '@elastic/eui' +import { css } from '@emotion/react' +import { useQuery } from '@tanstack/react-query' +import { useEffect, Suspense, lazy } from 'react' + +const LazyAskAiModal = lazy(() => + import('./AskAiModal').then((module) => ({ + default: module.AskAiModal, + })) +) + +const loadingCss = css` + display: flex; + justify-content: center; + align-items: center; + padding: 2rem; +` + +const backgroundWrapperCss = css` + position: relative; + min-height: 100%; + + &::before { + content: ''; + position: absolute; + top: 38px; + left: 0; + right: 0; + bottom: 0; + background-color: #e5e5f7; + pointer-events: none; + z-index: 0; + + opacity: 0.4; + background-image: radial-gradient(#444cf7 0.5px, #ffffff 0.5px); + background-size: 10px 10px; + + mask-image: linear-gradient( + to bottom, + rgba(0, 0, 0, 1) 0%, + rgba(0, 0, 0, 1) 10%, + rgba(0, 0, 0, 0.5) 20%, + rgba(0, 0, 0, 0) 30% + ); + -webkit-mask-image: linear-gradient( + to bottom, + rgba(0, 0, 0, 1) 0%, + rgba(0, 0, 0, 1) 10%, + rgba(0, 0, 0, 0.5) 20%, + rgba(0, 0, 0, 0) 30% + ); + } + + & > * { + position: relative; + z-index: 1; + } +` + +export const AskAiFlyoutBodyContent = () => ( + +
+ + +
+ } + > + + +
+ +) + +export const useAskAiFlyout = () => { + const isModalOpen = useAskAiModalIsOpen() + const { openModal, closeModal, setFlyoutWidth } = useAskAiModalActions() + const flyoutWidth = useFlyoutWidth() + + const { data: isApiAvailable } = useQuery({ + queryKey: ['api-health'], + queryFn: async () => { + const response = await fetch('/docs/_api/v1/', { method: 'POST' }) + return response.ok + }, + staleTime: 60 * 60 * 1000, // 60 minutes + retry: false, + }) + + useEffect(() => { + const handleKeydown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault() + closeModal() + } + // Cmd+; to open Ask AI flyout + if ( + (event.metaKey || event.ctrlKey) && + event.code === 'Semicolon' + ) { + event.preventDefault() + openModal() + } + } + window.addEventListener('keydown', handleKeydown) + return () => window.removeEventListener('keydown', handleKeydown) + }, [openModal, closeModal]) + + return { + isApiAvailable: isApiAvailable ?? false, + isModalOpen, + openModal, + closeModal, + setFlyoutWidth, + flyoutWidth, + } +} diff --git a/src/Elastic.Documentation.Site/Assets/web-components/CodexHeader/Header.tsx b/src/Elastic.Documentation.Site/Assets/web-components/CodexHeader/Header.tsx index 2a04b8a75..fc8c244ae 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/CodexHeader/Header.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/CodexHeader/Header.tsx @@ -1,14 +1,9 @@ import '../../eui-icons-cache' +import { AskAiHeaderButton } from '../AskAi/AskAiHeaderButton' import { NavigationSearch } from '../NavigationSearch/NavigationSearch' import { useHtmxContainer } from '../shared/htmx/useHtmxContainer' import { sharedQueryClient } from '../shared/queryClient' -import { - EuiHeader, - EuiHeaderLogo, - EuiHeaderSectionItemButton, - EuiIcon, - EuiProvider, -} from '@elastic/eui' +import { EuiHeader, EuiHeaderLogo, EuiProvider } from '@elastic/eui' import r2wc from '@r2wc/react-to-web-component' import { QueryClientProvider } from '@tanstack/react-query' import { useRef } from 'react' @@ -51,9 +46,10 @@ export const Header = ({ title, logoHref }: Props) => { size="s" placeholder="Search" />, - - - , + , + // + // + // , ], }, ]} diff --git a/src/Elastic.Documentation.Site/Assets/web-components/shared/askAiStreamClient.ts b/src/Elastic.Documentation.Site/Assets/web-components/shared/askAiStreamClient.ts index 3437e49f7..292372255 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/shared/askAiStreamClient.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/shared/askAiStreamClient.ts @@ -4,6 +4,7 @@ */ import { logWarn } from '../../telemetry/logging' import { AskAiEvent, AskAiEventSchema } from '../AskAi/AskAiEvent' +import { askAiConfig } from '../AskAi/askAi.config' import { ApiError, createApiErrorFromResponse, @@ -17,6 +18,8 @@ import { export type AiProvider = 'AgentBuilder' | 'LlmGateway' +const defaultAiProvider: AiProvider = askAiConfig.defaultAiProvider + const API_ENDPOINT = '/docs/_api/v1/ask-ai/stream' /** @@ -52,7 +55,7 @@ export async function startAskAiStream(options: StreamOptions): Promise { const { message, conversationId, - aiProvider = 'LlmGateway', + aiProvider = defaultAiProvider, signal, callbacks, } = options