diff --git a/Directory.Packages.props b/Directory.Packages.props index da5e2015f..7ddf27714 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -70,4 +70,4 @@ - \ No newline at end of file + diff --git a/src/Elastic.Documentation.Site/Assets/custom-elements.ts b/src/Elastic.Documentation.Site/Assets/custom-elements.ts index 5aa96ff4c..e24d57208 100644 --- a/src/Elastic.Documentation.Site/Assets/custom-elements.ts +++ b/src/Elastic.Documentation.Site/Assets/custom-elements.ts @@ -1 +1,2 @@ +import './web-components/SearchOrAskAi/SearchOrAskAi' import './web-components/VersionDropdown' diff --git a/src/Elastic.Documentation.Site/Assets/eui-icons-cache.ts b/src/Elastic.Documentation.Site/Assets/eui-icons-cache.ts new file mode 100644 index 000000000..b8ed475a9 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/eui-icons-cache.ts @@ -0,0 +1,40 @@ +import { icon as EuiIconVisualizeApp } from '@elastic/eui/es/components/icon/assets/app_visualize' +import { icon as EuiIconArrowDown } from '@elastic/eui/es/components/icon/assets/arrow_down' +import { icon as EuiIconArrowLeft } from '@elastic/eui/es/components/icon/assets/arrow_left' +import { icon as EuiIconArrowRight } from '@elastic/eui/es/components/icon/assets/arrow_right' +import { icon as EuiIconCheck } from '@elastic/eui/es/components/icon/assets/check' +import { icon as EuiIconCopyClipboard } from '@elastic/eui/es/components/icon/assets/copy_clipboard' +import { icon as EuiIconCross } from '@elastic/eui/es/components/icon/assets/cross' +import { icon as EuiIconDocument } from '@elastic/eui/es/components/icon/assets/document' +import { icon as EuiIconError } from '@elastic/eui/es/components/icon/assets/error' +import { icon as EuiIconFaceHappy } from '@elastic/eui/es/components/icon/assets/face_happy' +import { icon as EuiIconFaceSad } from '@elastic/eui/es/components/icon/assets/face_sad' +import { icon as EuiIconNewChat } from '@elastic/eui/es/components/icon/assets/new_chat' +import { icon as EuiIconRefresh } from '@elastic/eui/es/components/icon/assets/refresh' +import { icon as EuiIconSearch } from '@elastic/eui/es/components/icon/assets/search' +import { icon as EuiIconSparkles } from '@elastic/eui/es/components/icon/assets/sparkles' +import { icon as EuiIconTrash } from '@elastic/eui/es/components/icon/assets/trash' +import { icon as EuiIconUser } from '@elastic/eui/es/components/icon/assets/user' +import { icon as EuiIconWrench } from '@elastic/eui/es/components/icon/assets/wrench' +import { appendIconComponentCache } from '@elastic/eui/es/components/icon/icon' + +appendIconComponentCache({ + newChat: EuiIconNewChat, + arrowDown: EuiIconArrowDown, + arrowLeft: EuiIconArrowLeft, + arrowRight: EuiIconArrowRight, + document: EuiIconDocument, + search: EuiIconSearch, + trash: EuiIconTrash, + user: EuiIconUser, + wrench: EuiIconWrench, + visualizeApp: EuiIconVisualizeApp, + check: EuiIconCheck, + sparkles: EuiIconSparkles, + cross: EuiIconCross, + copyClipboard: EuiIconCopyClipboard, + faceHappy: EuiIconFaceHappy, + faceSad: EuiIconFaceSad, + refresh: EuiIconRefresh, + error: EuiIconError, +}) diff --git a/src/Elastic.Documentation.Site/Assets/main.ts b/src/Elastic.Documentation.Site/Assets/main.ts index d1902e30d..5371fcf6c 100644 --- a/src/Elastic.Documentation.Site/Assets/main.ts +++ b/src/Elastic.Documentation.Site/Assets/main.ts @@ -10,7 +10,6 @@ import { initTabs } from './tabs' import { initTocNav } from './toc-nav' import 'htmx-ext-head-support' import 'htmx-ext-preload' -import 'htmx.org' import { $, $$ } from 'select-dom' import { UAParser } from 'ua-parser-js' diff --git a/src/Elastic.Documentation.Site/Assets/markdown/code.css b/src/Elastic.Documentation.Site/Assets/markdown/code.css index 5d11e9237..62652d91b 100644 --- a/src/Elastic.Documentation.Site/Assets/markdown/code.css +++ b/src/Elastic.Documentation.Site/Assets/markdown/code.css @@ -1,155 +1,157 @@ @layer components { - .highlight { - @apply mt-4; - } - - pre { - @apply grid; - code { - @apply text-grey-30 overflow-x-auto rounded-none border-0 p-6! text-sm; - background-color: #22272e; - mix-blend-mode: unset; - line-height: 1.5em; - word-break: normal; - } - code:first-child { - @apply rounded-t-sm; + .markdown-content { + .highlight { + @apply mt-4; } - code:last-child { - @apply rounded-b-sm; - } - code.language-apiheader { - @apply border-b-grey-80 border-b-1; - } - } - pre code .code-callout { - @apply ml-1; - user-select: none; - &::after { - content: attr(data-index); + pre { + @apply grid; + code { + @apply text-grey-30 overflow-x-auto rounded-none border-0 p-6! text-sm; + background-color: #22272e; + mix-blend-mode: unset; + line-height: 1.5em; + word-break: normal; + } + code:first-child { + @apply rounded-t-sm; + } + code:last-child { + @apply rounded-b-sm; + } + code.language-apiheader { + @apply border-b-grey-80 border-b-1; + } } - } - ol.code-callouts { - li { - @apply relative pl-1; - counter-increment: code-callout-counter; - list-style-type: none; + pre code .code-callout { + @apply ml-1; + user-select: none; + &::after { + content: attr(data-index); + } } - li::before { - content: counter(code-callout-counter); - position: absolute; - top: 1px; - left: calc(-1 * var(--spacing) * 6); + ol.code-callouts { + li { + @apply relative pl-1; + counter-increment: code-callout-counter; + list-style-type: none; + } + + li::before { + content: counter(code-callout-counter); + position: absolute; + top: 1px; + left: calc(-1 * var(--spacing) * 6); + } } - } - pre code .code-callout .hljs-number { - @apply text-white!; - } + pre code .code-callout .hljs-number { + @apply text-white!; + } - pre code .code-callout, - ol.code-callouts li::before { - @apply bg-blue-elastic inline-flex size-4.5 items-center justify-center rounded-full font-mono text-xs! text-white!; - } + pre code .code-callout, + ol.code-callouts li::before { + @apply bg-blue-elastic inline-flex size-4.5 items-center justify-center rounded-full font-mono text-xs! text-white!; + } - code { - @apply bg-grey-10 border-grey-20 rounded-xs border-1 font-mono; - font-size: 0.875em; - line-height: 1.4em; - padding-left: 0.2em; - padding-right: 0.2em; - text-decoration: inherit; - font-weight: inherit; - mix-blend-mode: multiply; - word-break: break-word; - } + code { + @apply bg-grey-10 border-grey-20 rounded-xs border-1 font-mono; + font-size: 0.875em; + line-height: 1.4em; + padding-left: 0.2em; + padding-right: 0.2em; + text-decoration: inherit; + font-weight: inherit; + mix-blend-mode: multiply; + word-break: break-word; + } - .hljs-built_in, - .hljs-selector-tag, - .hljs-section, - .hljs-link { - color: var(--color-blue-elastic-70); - } + .hljs-built_in, + .hljs-selector-tag, + .hljs-section, + .hljs-link { + color: var(--color-blue-elastic-70); + } - .hljs-keyword { - color: var(--color-blue-elastic-70); - } + .hljs-keyword { + color: var(--color-blue-elastic-70); + } - .hljs { - color: var(--color-blue-elastic-30) !important; - } - .hljs-subst { - color: var(--color-purple-60); - } - .hljs-function { - color: var(--color-purple-60); - } + .hljs { + color: var(--color-blue-elastic-30) !important; + } + .hljs-subst { + color: var(--color-purple-60); + } + .hljs-function { + color: var(--color-purple-60); + } - .hljs-title, - .hljs-title.function, - .hljs-attr, - .hljs-meta-keyword { - color: var(--color-yellow-50); - } + .hljs-title, + .hljs-title.function, + .hljs-attr, + .hljs-meta-keyword { + color: var(--color-yellow-50); + } - .hljs-string { - color: var(--color-green-50); - } - .hljs-operator { - color: var(--color-yellow-50); - } + .hljs-string { + color: var(--color-green-50); + } + .hljs-operator { + color: var(--color-yellow-50); + } - .hljs-meta, - .hljs-name, - .hljs-bullet, - .hljs-addition, - .hljs-template-tag, - .hljs-template-variable { - color: var(--color-yellow-50); - } + .hljs-meta, + .hljs-name, + .hljs-bullet, + .hljs-addition, + .hljs-template-tag, + .hljs-template-variable { + color: var(--color-yellow-50); + } - .hljs-type, - .hljs-symbol { - color: var(--color-teal-50); - } - .hljs-variable { - color: var(--color-purple-50); - } + .hljs-type, + .hljs-symbol { + color: var(--color-teal-50); + } + .hljs-variable { + color: var(--color-purple-50); + } - .hljs-comment, - .hljs-quote, - .hljs-deletion { - color: var(--color-grey-70); - } + .hljs-comment, + .hljs-quote, + .hljs-deletion { + color: var(--color-grey-70); + } - .hljs-punctuation { - color: var(--color-grey-50); - font-weight: bold; - } + .hljs-punctuation { + color: var(--color-grey-50); + font-weight: bold; + } - .hljs-keyword, - .hljs-selector-tag, - .hljs-literal, - .hljs-title, - .hljs-section, - .hljs-doctag, - .hljs-type, - .hljs-name, - .hljs-strong { - font-weight: bold; - } + .hljs-keyword, + .hljs-selector-tag, + .hljs-literal, + .hljs-title, + .hljs-section, + .hljs-doctag, + .hljs-type, + .hljs-name, + .hljs-strong { + font-weight: bold; + } - .hljs-literal { - color: var(--color-pink-50); - } - .hljs-number { - color: var(--color-red-50); - } + .hljs-literal { + color: var(--color-pink-50); + } + .hljs-number { + color: var(--color-red-50); + } - .hljs-emphasis { - font-style: italic; + .hljs-emphasis { + font-style: italic; + } } } diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAiAnswer.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAiAnswer.tsx new file mode 100644 index 000000000..9a4bdc508 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAiAnswer.tsx @@ -0,0 +1,179 @@ +/** @jsxImportSource @emotion/react */ +import { useAskAiTerm } from './search.store' +import { LlmGatewayMessage, useLlmGateway } from './useLlmGateway' +import { + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiMarkdownFormat, + EuiPanel, + EuiSpacer, + EuiText, + EuiButtonIcon, + EuiToolTip, + useEuiTheme, + EuiCallOut, +} from '@elastic/eui' +import { css } from '@emotion/react' +import * as React from 'react' +import { useEffect, useRef, useState, useMemo } from 'react' +import { v4 as uuidv4 } from 'uuid' + +// Helper function to accumulate AI message content +const getAccumulatedContent = (messages: LlmGatewayMessage[]) => { + return messages + .filter((m) => m.type === 'ai_message' || m.type === 'ai_message_chunk') + .map((m) => m.data.content) + .join('') +} + +export const AskAiAnswer = () => { + const { euiTheme } = useEuiTheme() + const question = useAskAiTerm() + const threadId = useMemo(() => uuidv4(), [question]) + const [isLoading, setLoading] = useState(true) + const [isAnswerFinished, setAnswerFinished] = useState(false) + + const scrollRef = useRef(null) + + const { messages, error, retry, sendQuestion } = useLlmGateway({ + threadId: threadId, + onMessage: (message) => { + switch (message.type) { + case 'agent_start': + setLoading(true) + setAnswerFinished(false) + break + case 'agent_end': + setAnswerFinished(true) + setLoading(false) + break + } + }, + onError: () => { + setLoading(false) + setAnswerFinished(true) + }, + }) + + useEffect(() => { + if (question.trim()) { + sendQuestion(question).catch(() => { + // Send error with APM agent RUM? + }) + } + }, [question, sendQuestion]) + + return ( + + + + {getAccumulatedContent(messages)} + +
+ {isAnswerFinished && ( + <> + + + + + + + + + + + + + + + { + setAnswerFinished(false) + retry() + }} + /> + + + + + )} + {!error && isLoading && ( + <> + {messages.filter( + (m) => + m.type === 'ai_message' || + m.type === 'ai_message_chunk' + ).length > 0 && } + + + + + + + Generating... + + + + + )} +
+ {error && ( + <> + + +

+ The Elastic Docs AI Assistant encountered an + error. Please try again. +

+
+ + )} +
+
+ ) +} diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAiSuggestions.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAiSuggestions.tsx new file mode 100644 index 000000000..e76f9f2f3 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAiSuggestions.tsx @@ -0,0 +1,64 @@ +/** @jsxImportSource @emotion/react */ +import { useSearchActions, useSearchTerm } from './search.store' +import { EuiButton, EuiSpacer, EuiText, useEuiTheme } from '@elastic/eui' +import { css } from '@emotion/react' +import * as React from 'react' + +export interface AskAiSuggestion { + question: string +} + +interface Props { + suggestions: AskAiSuggestion[] +} + +export const AskAiSuggestions = (props: Props) => { + const searchTerm = useSearchTerm() + const { setSearchTerm, submitAskAiTerm } = useSearchActions() + const { euiTheme } = useEuiTheme() + const buttonCss = css` + border: none; + & > span { + justify-content: flex-start; + } + svg { + color: ${euiTheme.colors.textSubdued}; + } + ` + return ( + <> + Ask Elastic Docs AI Assistant + + {searchTerm && ( + { + submitAskAiTerm(searchTerm) + }} + > + {searchTerm} + + )} + {props.suggestions.map((suggestion, index) => ( + { + setSearchTerm(suggestion.question) + submitAskAiTerm(suggestion.question) + }} + > + {suggestion.question} + + ))} + + ) +} diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/SearchOrAskAi.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/SearchOrAskAi.tsx new file mode 100644 index 000000000..89895b17a --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/SearchOrAskAi.tsx @@ -0,0 +1,14 @@ +import { SearchOrAskAiButton } from './SearchOrAskAiButton' +import r2wc from '@r2wc/react-to-web-component' +import * as React from 'react' +import { StrictMode } from 'react' + +const SearchOrAskAi = () => { + return ( + + + + ) +} + +customElements.define('search-or-ask-ai', r2wc(SearchOrAskAi)) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/SearchOrAskAiButton.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/SearchOrAskAiButton.tsx new file mode 100644 index 000000000..0006f0845 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/SearchOrAskAiButton.tsx @@ -0,0 +1,92 @@ +import '../../eui-icons-cache' +import { SearchOrAskAiModal } from './SearchOrAskAiModal' +import { useModalActions, useModalIsOpen } from './modal.store' +import { useSearchActions, useSearchTerm } from './search.store' +import { + EuiButton, + EuiPortal, + EuiOverlayMask, + EuiFocusTrap, + EuiPanel, + EuiTextTruncate, + EuiText, +} from '@elastic/eui' +import { css } from '@emotion/react' +import * as React from 'react' +import { useEffect } from 'react' + +export const SearchOrAskAiButton = () => { + const searchTerm = useSearchTerm() + const { clearSearchTerm } = useSearchActions() + const isModalOpen = useModalIsOpen() + const { openModal, closeModal, toggleModal } = useModalActions() + + const positionCss = css` + position: absolute; + left: 50%; + transform: translateX(-50%); + top: 48px; + width: 90ch; + max-width: 100%; + ` + + useEffect(() => { + const handleKeydown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + clearSearchTerm() + closeModal() + } + if ((event.metaKey || event.ctrlKey) && event.key === 'k') { + event.preventDefault() + toggleModal() + } + } + window.addEventListener('keydown', handleKeydown) + return () => { + window.removeEventListener('keydown', handleKeydown) + } + }, []) + + return ( + <> + + + + {searchTerm ? ( + + ) : ( + 'Search or ask AI' + )} + + + + ⌘K + + + {isModalOpen && ( + + + + + + + + + + )} + + ) +} diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/SearchOrAskAiModal.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/SearchOrAskAiModal.tsx new file mode 100644 index 000000000..2580133af --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/SearchOrAskAiModal.tsx @@ -0,0 +1,66 @@ +import { AskAiAnswer } from './AskAiAnswer' +import { Suggestions } from './Suggestions' +import { useAskAiTerm, useSearchActions, useSearchTerm } from './search.store' +import { + EuiFieldSearch, + EuiPanel, + EuiSpacer, + EuiBetaBadge, + EuiText, + EuiHorizontalRule, +} from '@elastic/eui' +import { css } from '@emotion/react' +import * as React from 'react' + +/** @jsxImportSource @emotion/react */ + +export const SearchOrAskAiModal = () => { + const searchTerm = useSearchTerm() + const askAiTerm = useAskAiTerm() + const { setSearchTerm, submitAskAiTerm } = useSearchActions() + + return ( + + setSearchTerm(e.target.value)} + onSearch={(e) => { + submitAskAiTerm(e) + }} + isClearable + /> + + {askAiTerm ? : } + +
+ + + + This feature is in beta. Got feedback? We'd love to hear it! + +
+
+ ) +} diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/SearchSuggestions.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/SearchSuggestions.tsx new file mode 100644 index 000000000..e8e54c61a --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/SearchSuggestions.tsx @@ -0,0 +1,112 @@ +/** @jsxImportSource @emotion/react */ +import { useModalActions } from './modal.store' +import { + EuiButton, + EuiSpacer, + EuiText, + EuiTextTruncate, + useEuiTheme, +} from '@elastic/eui' +import { css } from '@emotion/react' +import htmx from 'htmx.org' +import * as React from 'react' +import { useEffect } from 'react' + +export interface Suggestion { + title: string + url: string +} + +interface Props { + suggestions: Suggestion[] +} + +export const SearchSuggestions = (props: Props) => { + return ( + <> + Suggested pages + + {props.suggestions.map((suggestion) => ( +