From 57414e85f28fd5ab420f0bef757edb878de8f096 Mon Sep 17 00:00:00 2001 From: Dylan Tientcheu Date: Tue, 29 Apr 2025 11:09:17 +0200 Subject: [PATCH 1/6] feat(v4): ask ai context --- examples/demo-askai/src/App.tsx | 2 +- packages/docsearch-css/src/_variables.css | 6 +- packages/docsearch-css/src/modal.css | 311 ++++++++++++++++- packages/docsearch-react/package.json | 3 +- packages/docsearch-react/src/AskAiScreen.tsx | 329 ++++++++++++++++-- packages/docsearch-react/src/DocSearch.tsx | 11 +- .../docsearch-react/src/DocSearchModal.tsx | 11 +- .../docsearch-react/src/MemoizedMarkdown.tsx | 33 ++ .../docsearch-react/src/NoResultsScreen.tsx | 2 +- packages/docsearch-react/src/ScreenState.tsx | 4 +- packages/docsearch-react/src/SearchBox.tsx | 2 + .../docsearch-react/src/lib/genAiClient.ts | 158 +++++++++ packages/docsearch-react/src/useAskAi.ts | 176 ++++++++++ yarn.lock | 10 + 14 files changed, 1022 insertions(+), 36 deletions(-) create mode 100644 packages/docsearch-react/src/MemoizedMarkdown.tsx create mode 100644 packages/docsearch-react/src/lib/genAiClient.ts diff --git a/examples/demo-askai/src/App.tsx b/examples/demo-askai/src/App.tsx index 155ca0b04..b4e35fb45 100644 --- a/examples/demo-askai/src/App.tsx +++ b/examples/demo-askai/src/App.tsx @@ -12,7 +12,7 @@ function App(): JSX.Element { indexName="beta-react" appId="betaHAXPMHIMMC" apiKey="8b00405cba281a7d800ccec393e9af24" - datasourceId="crawler_rag_beta-react-rag" + dataSourceId="crawler_rag_beta-react-rag" promptId="crawler_rag_beta-react-rag" insights={true} /> diff --git a/packages/docsearch-css/src/_variables.css b/packages/docsearch-css/src/_variables.css index ce6d17a1a..9e11b773e 100644 --- a/packages/docsearch-css/src/_variables.css +++ b/packages/docsearch-css/src/_variables.css @@ -3,7 +3,9 @@ :root { --docsearch-primary-color: rgb(0, 61, 255); --docsearch-subtle-color: rgb(214, 214, 231); - --docsearch-text-color: rgba(35, 38, 59, 1); + --docsearch-text-color: #36395a; + --docsearch-error-color: #ef5350; + --docsearch-success-color: #e8f5e9; --docsearch-secondary-text-color: rgba(90, 94, 154, 1); --docsearch-background-color: rgb(245, 245, 250); --docsearch-spacing: 12px; @@ -54,6 +56,8 @@ html[data-theme='dark'] { --docsearch-text-color: rgba(196, 199, 220, 1); --docsearch-secondary-text-color: rgba(182, 183, 213, 1); --docsearch-subtle-color: rgba(33, 33, 57, 1); + --docsearch-error-color: #ef5350; + --docsearch-success-color: rgba(67, 160, 71, 0.2); --docsearch-highlight-color: rgba(69, 122, 255, 1); --docsearch-focus-color: rgb(154, 200, 255); --docsearch-background-color: rgba(54, 57, 90, 1); diff --git a/packages/docsearch-css/src/modal.css b/packages/docsearch-css/src/modal.css index 9f32181ef..7b99ccde8 100644 --- a/packages/docsearch-css/src/modal.css +++ b/packages/docsearch-css/src/modal.css @@ -552,6 +552,10 @@ svg.DocSearch-Hit-Select-Icon { height: 80%; } +.DocSearch-NoResults--withAskAi { + height: 70%; +} + .DocSearch-StartScreen { height: 100%; } @@ -737,6 +741,298 @@ assistive tech users */ text-overflow: ellipsis; } +@keyframes fade-in { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +/* ask ai screen specific styles */ +.DocSearch-AskAiScreen-Container { + display: flex; + flex-direction: column; + text-align: left; + justify-content: flex-start; + padding: 0; + width: 100%; + height: 100%; + gap: 0; +} + +.DocSearch-AskAiScreen-Header { + display: flex; + flex-direction: row; + align-items: center; + gap: 12px; + font-size: 0.65em; + font-weight: 300; + padding: 1em 0.4em; +} + +.DocSearch-AskAi-Disclaimer { + padding: 0; + margin: 0; +} + +.DocSearch-AskAiScreen-Body { + display: flex; + flex-direction: column; + gap: 24px; + width: 100%; + padding: 6px; + overflow-y: auto; +} + +.DocSearch-AskAiScreen-Response-Container { + display: flex; + flex-direction: row; + gap: 8px; +} + +.DocSearch-AskAiScreen-Response { + display: flex; + flex-direction: column; + width: 70%; + gap: 16px; + font-size: 0.8em; + background: var(--docsearch-hit-background); + padding: 24px; + color: var(--docsearch-text-color); + border-radius: 4px; + align-self: flex-start; +} + +.DocSearch-AskAiScreen-Query { + font-size: 1.2em; + font-weight: 600; + margin: 0; +} + +.DocSearch-AskAiScreen-Answer { + line-height: 1.5; + font-weight: 400; + color: var(--docsearch-secondary-text-color); + margin: 0; +} + +.DocSearch-AskAiScreen-Answer--streaming > * { + animation: fade-in 0.3s ease-in-out; +} + +.DocSearch-AskAiScreen-Answer-Footer { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.DocSearch-AskAiScreen-Actions { + display: flex; + flex-direction: row; + gap: 12px; + margin-left: auto; +} + +.DocSearch-AskAiScreen-ActionButton { + display: flex; + align-items: center; + justify-content: center; + border: none; + background: none; + padding: 4px; + margin: 0; + cursor: pointer; + transition: background-color 0.2s ease; + border-radius: 4px; +} + +.DocSearch-AskAiScreen-ActionButton:hover { + background: var(--docsearch-hit-highlight-color); +} + +.DocSearch-AskAiScreen-ActionButton svg { + width: 20px; + height: 20px; + stroke-width: 1.5; + color: var(--docsearch-icon-color); +} + +.DocSearch-AskAiScreen-CopyButton--copied { + background-color: var(--docsearch-success-color); + cursor: default; +} + +.DocSearch-AskAiScreen-Error { + color: var(--docsearch-error-color); + font-size: 0.8em; + font-weight: 400; + margin: 0; +} + +.DocSearch-AskAiScreen-RelatedSources { + display: flex; + flex-direction: column; + width: 30%; + gap: 8px; +} + +.DocSearch-AskAiScreen-RelatedSources-Title { + font-size: 0.7em; + font-weight: 400; + color: var(--docsearch-text-color); + margin: 0; +} + +.DocSearch-AskAiScreen-RelatedSources-Item-Link { + display: flex; + align-items: center; + gap: 4px; + padding: 12px 6px; + background: var(--docsearch-hit-background); + border-radius: 4px; + color: var(--docsearch-text-color); + font-size: 0.75em; + text-decoration: none; + transition: background-color 0.2s ease; +} + +.DocSearch-AskAiScreen-RelatedSources-Item-Link svg { + flex-shrink: 0; + color: var(--docsearch-icon-color); + stroke-width: 1.2; +} + +.DocSearch-AskAiScreen-RelatedSources-Item-Link span { + flex: 1 1 0; + min-width: 0; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.DocSearch-AskAiScreen-RelatedSources-Item-Link:hover { + background: var(--docsearch-hit-highlight-color); +} + +.DocSearch-Markdown-Content { + color: var(--docsearch-text-color); + line-height: 1.5; +} + +.DocSearch-Markdown-Content p:last-child { + margin-bottom: 0; +} + +.DocSearch-Markdown-Content p:first-child { + margin-top: 0; +} + +.DocSearch-Markdown-Content code { + background-color: var(--docsearch-key-background); + color: var(--docsearch-text-color); + padding: 0.2em 0.4em; + margin: 0; + border-radius: 3px; + font-family: monospace; +} + +.DocSearch-Markdown-Content pre { + background-color: var(--docsearch-key-background); + color: var(--docsearch-text-color); + padding: 1em; + border-radius: 3px; + overflow-x: auto; +} + +.DocSearch-Markdown-Content pre code { + background-color: transparent; + color: inherit; + padding: 0; + margin: 0; + font-size: inherit; + border-radius: 0; + white-space: pre-wrap; + word-wrap: break-word; +} + +.DocSearch-Markdown-Content h1, +.DocSearch-Markdown-Content h2, +.DocSearch-Markdown-Content h3, +.DocSearch-Markdown-Content h4, +.DocSearch-Markdown-Content h5, +.DocSearch-Markdown-Content h6 { + color: var(--docsearch-text-color); + margin-top: 1em; + margin-bottom: 0.5em; + font-weight: 600; +} + +.DocSearch-Markdown-Content ul, +.DocSearch-Markdown-Content ol { + color: var(--docsearch-text-color); + margin-bottom: 1em; +} + +.DocSearch-Markdown-Content li { + color: var(--docsearch-text-color); + margin-bottom: 0.25em; +} + +.DocSearch-Markdown-Content a { + color: var(--docsearch-highlight-color); + text-decoration: none; +} + +.DocSearch-Markdown-Content a:hover { + text-decoration: underline; +} + +/* skeleton source styles */ +.DocSearch-AskAiScreen-SkeletonSource { + display: flex; + align-items: center; + gap: 4px; + padding: 6px; + background: var(--docsearch-hit-background); + border-radius: 4px; + height: 32px; /* match item link height roughly */ +} + +.DocSearch-AskAiScreen-SkeletonSource-Icon { + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--docsearch-muted-color); + opacity: 0.4; + animation: pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +.DocSearch-AskAiScreen-SkeletonSource-Text { + flex: 1 1 0; + height: 12px; + background: var(--docsearch-muted-color); + border-radius: 4px; + opacity: 0.4; + animation: pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite; + animation-delay: 0.2s; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 0.4; + } + 50% { + opacity: 0.8; + } +} + /* Responsive */ @media (max-width: 768px) { :root { @@ -796,14 +1092,17 @@ assistive tech users */ .DocSearch-Hit-Tree { display: none; } -} -@keyframes fade-in { - 0% { - opacity: 0; + .DocSearch-AskAiScreen-Response-Container { + flex-direction: column; + gap: 24px; } - 100% { - opacity: 1; + .DocSearch-AskAiScreen-Response { + width: 100%; + } + + .DocSearch-AskAiScreen-RelatedSources { + width: 100%; } } diff --git a/packages/docsearch-react/package.json b/packages/docsearch-react/package.json index 2a923f24f..825bdec8b 100644 --- a/packages/docsearch-react/package.json +++ b/packages/docsearch-react/package.json @@ -39,7 +39,8 @@ "@algolia/autocomplete-core": "1.18.1", "@algolia/autocomplete-preset-algolia": "1.18.1", "@docsearch/css": "3.9.0", - "algoliasearch": "^5.14.2" + "algoliasearch": "^5.14.2", + "marked": "^15.0.11" }, "devDependencies": { "@rollup/plugin-replace": "6.0.2", diff --git a/packages/docsearch-react/src/AskAiScreen.tsx b/packages/docsearch-react/src/AskAiScreen.tsx index 8eab794c1..2be406a2d 100644 --- a/packages/docsearch-react/src/AskAiScreen.tsx +++ b/packages/docsearch-react/src/AskAiScreen.tsx @@ -1,41 +1,328 @@ -import React, { type JSX } from 'react'; +import React, { type JSX, useState, useEffect } from 'react'; + +import { MemoizedMarkdown } from './MemoizedMarkdown'; +import type { ScreenStateProps } from './ScreenState'; +import type { InternalDocSearchHit } from './types'; +import { useAskAi } from './useAskAi'; export type AskAiScreenTranslations = Partial<{ titleText: string; - helpText: string; + disclaimerText: string; + relatedSourcesText: string; }>; -type AskAiScreenProps = { +type AskAiScreenProps = Omit, 'translations'> & { translations?: AskAiScreenTranslations; }; -// @todo: ask ai screen -export function AskAiScreen({ translations = {} }: AskAiScreenProps): JSX.Element { - const { titleText = 'Welcome to Ask AI', helpText = 'Ask me anything about your documentation.' } = translations; +export function AskAiScreen({ translations = {}, ...props }: AskAiScreenProps): JSX.Element { + const { + titleText = 'How to run a crawl?', + disclaimerText = 'Answers are generated using artificial intelligence. This is an experimental technology, and information may occasionally be incorrect or misleading.', + relatedSourcesText = 'Related Sources', + } = translations; + + const genAiClient = props.genAiClient; + if (!genAiClient) { + throw new Error('You have to provide credentials to use the Ask AI feature.\nSee documentation:'); + } + + const { ask, messages, currentResponse, loadingStatus, context, error } = useAskAi({ genAiClient }); + + // if we have no messages and a query, and are not loading/streaming, we can use it as the initial query + if (messages.length === 0 && props.state.query && loadingStatus === 'idle') { + ask({ query: props.state.query }); + } + + // determine the initial query to display + const displayedQuery = messages.find((m) => m.role === 'user')?.content || titleText; + + // select the content to display based on the status + const displayedAnswer = + loadingStatus === 'streaming' ? currentResponse : messages.find((m) => m.role === 'assistant')?.content || ''; + return ( -
-
+
+
+
+ + + + + + + +
+

{disclaimerText}

+
+
+
+
+

{displayedQuery}

+ {error &&

{error.message}

} +
+ {(loadingStatus === 'streaming' || loadingStatus === 'idle') && ( + + )} + {loadingStatus === 'loading' && ( +
+ +
+ )} +
+
+ {loadingStatus === 'idle' && displayedAnswer.length > 0 && ( +
+ navigator.clipboard.writeText(displayedAnswer)} /> + + +
+ )} +
+
+
+

{relatedSourcesText}

+ {context.length === 0 && + loadingStatus === 'loading' && + // eslint-disable-next-line react/no-array-index-key + Array.from({ length: 3 }).map((_, index) => )} + {context.length > 0 && + context.map((source) => ( + + + {source.title || source.url || source.objectID} + + ))} +
+
+
+
+ ); +} + +function SkeletonSource(): JSX.Element { + return ( +
+ +
+
+ ); +} + +function RelatedSourceIcon(): JSX.Element { + return ( + + + + + + + ); +} + +function PulseLoader(): JSX.Element { + return ( + + + + + + + + + + + + + + + ); +} + +function CopyButton({ onClick }: { onClick: () => void }): JSX.Element { + const [isCopied, setIsCopied] = useState(false); + + useEffect(() => { + if (isCopied) { + const timer = setTimeout(() => { + setIsCopied(false); + }, 1500); // reset after 1.5 seconds + return (): void => clearTimeout(timer); + } + return undefined; + }, [isCopied]); + + const handleClick = (): void => { + onClick(); + setIsCopied(true); + }; + + return ( +
-

{titleText}

-

{helpText}

-
+ ) : ( + + + + + )} + + ); +} + +function LikeButton(): JSX.Element { + // @todo: implement like button + return ( + + ); +} + +function DislikeButton(): JSX.Element { + // @todo: implement dislike button + return ( + ); } diff --git a/packages/docsearch-react/src/DocSearch.tsx b/packages/docsearch-react/src/DocSearch.tsx index 917247f21..beae74f4b 100644 --- a/packages/docsearch-react/src/DocSearch.tsx +++ b/packages/docsearch-react/src/DocSearch.tsx @@ -26,7 +26,7 @@ export interface DocSearchProps { appId: string; apiKey: string; indexName: string; - datasourceId?: string; + dataSourceId?: string; promptId?: string; placeholder?: string; searchParameters?: SearchParamsObject; @@ -49,8 +49,14 @@ export function DocSearch(props: DocSearchProps): JSX.Element { const [initialQuery, setInitialQuery] = React.useState(props?.initialQuery || undefined); const [isAskAiActive, setIsAskAiActive] = React.useState(false); + let currentPlaceholder = + props?.translations?.modal?.searchBox?.placeholderText || props?.placeholder || 'Search docs'; + if (isAskAiActive) { + currentPlaceholder = props?.translations?.modal?.searchBox?.placeholderTextAskAi || 'Ask another question...'; + } + // check if the instance is configured to handle ask ai - const canHandleAskAi = Boolean(props?.datasourceId && props?.promptId); + const canHandleAskAi = Boolean(props?.dataSourceId && props?.promptId); const onAskAiToggle = React.useCallback( (askAitoggle: boolean) => { @@ -94,6 +100,7 @@ export function DocSearch(props: DocSearchProps): JSX.Element { createPortal( ({ key: `__DOCSEARCH_FAVORITE_SEARCHES__${indexName}`, @@ -422,7 +429,6 @@ export function DocSearchModal({ const askItem: InternalDocSearchHit = { type: 'askAI', query, - // placeholders (dummy data) url_without_anchor: '', objectID: `ask-ai-button`, content: null, @@ -617,6 +623,7 @@ export function DocSearchModal({ getMissingResultsUrl={getMissingResultsUrl} isAskAiActive={isAskAiActive} canHandleAskAi={canHandleAskAi} + genAiClient={genAiClient} onAskAiToggle={onAskAiToggle} onItemClick={(item, event) => { // if the item is askAI, do nothing diff --git a/packages/docsearch-react/src/MemoizedMarkdown.tsx b/packages/docsearch-react/src/MemoizedMarkdown.tsx new file mode 100644 index 000000000..b8b0c155b --- /dev/null +++ b/packages/docsearch-react/src/MemoizedMarkdown.tsx @@ -0,0 +1,33 @@ +import { marked, type Token } from 'marked'; +import React, { memo, useMemo, type FC } from 'react'; + +function parseMarkdownIntoHTMLBlocks(md: string): string[] { + const tokens = marked.lexer(md); + return tokens.map((token: Token) => + marked.parser([token], { + gfm: true, + breaks: true, + }), + ); +} + +const HTMLBlock: FC<{ html: string; key: string }> = ({ html, key }) => ( +
+); + +const MemoizedHTMLBlock = memo(HTMLBlock, (prev, next) => prev.html === next.html); +MemoizedHTMLBlock.displayName = 'MemoizedHTMLBlock'; + +export const MemoizedMarkdown = memo(({ content, id }: { content: string; id: string }) => { + const htmlBlocks = useMemo(() => parseMarkdownIntoHTMLBlocks(content), [content]); + + return ( +
+ {htmlBlocks.map((html, i) => ( + // eslint-disable-next-line react/no-array-index-key + + ))} +
+ ); +}); +MemoizedMarkdown.displayName = 'MemoizedMarkdown'; diff --git a/packages/docsearch-react/src/NoResultsScreen.tsx b/packages/docsearch-react/src/NoResultsScreen.tsx index 5068b032f..90bfec3d7 100644 --- a/packages/docsearch-react/src/NoResultsScreen.tsx +++ b/packages/docsearch-react/src/NoResultsScreen.tsx @@ -25,7 +25,7 @@ export function NoResultsScreen({ translations = {}, ...props }: NoResultsScreen const searchSuggestions: string[] | undefined = props.state.context.searchSuggestions as string[]; return ( -
+
diff --git a/packages/docsearch-react/src/ScreenState.tsx b/packages/docsearch-react/src/ScreenState.tsx index f0f2bbaae..0e710ba9b 100644 --- a/packages/docsearch-react/src/ScreenState.tsx +++ b/packages/docsearch-react/src/ScreenState.tsx @@ -6,6 +6,7 @@ import { AskAiScreen } from './AskAiScreen'; import type { DocSearchProps } from './DocSearch'; import type { ErrorScreenTranslations } from './ErrorScreen'; import { ErrorScreen } from './ErrorScreen'; +import type { GenAiClient } from './lib/genAiClient'; import type { NoResultsScreenTranslations } from './NoResultsScreen'; import { NoResultsScreen } from './NoResultsScreen'; import type { ResultsScreenTranslations } from './ResultsScreen'; @@ -36,6 +37,7 @@ export interface ScreenStateProps hitComponent: DocSearchProps['hitComponent']; indexName: DocSearchProps['indexName']; disableUserPersonalization: boolean; + genAiClient: GenAiClient | null; resultsFooterComponent: DocSearchProps['resultsFooterComponent']; translations: ScreenStateTranslations; getMissingResultsUrl?: DocSearchProps['getMissingResultsUrl']; @@ -44,7 +46,7 @@ export interface ScreenStateProps export const ScreenState = React.memo( ({ translations = {}, ...props }: ScreenStateProps) => { if (props.isAskAiActive && props.canHandleAskAi) { - return ; + return ; } if (props.state?.status === 'error') { diff --git a/packages/docsearch-react/src/SearchBox.tsx b/packages/docsearch-react/src/SearchBox.tsx index ae236883b..cd3e602b2 100644 --- a/packages/docsearch-react/src/SearchBox.tsx +++ b/packages/docsearch-react/src/SearchBox.tsx @@ -11,6 +11,8 @@ export type SearchBoxTranslations = Partial<{ clearButtonAriaLabel: string; closeButtonText: string; closeButtonAriaLabel: string; + placeholderText: string; + placeholderTextAskAi: string; searchInputLabel: string; backToKeywordSearchButtonText: string; backToKeywordSearchButtonAriaLabel: string; diff --git a/packages/docsearch-react/src/lib/genAiClient.ts b/packages/docsearch-react/src/lib/genAiClient.ts new file mode 100644 index 000000000..824deadaf --- /dev/null +++ b/packages/docsearch-react/src/lib/genAiClient.ts @@ -0,0 +1,158 @@ +export interface AskAiResponse { + response: string; + additionalFilters: string[]; + context: Array<{ title?: string; url?: string; objectID: string }>; + conversationID: string; + createdAt: string; + query: string; + metadata: Record; +} + +export interface GenAiClientOptions { + dataSourceId?: string; + promptId?: string; +} + +export interface GenAiClient { + appId: string; + apiKey: string; + dataSourceId?: string; + promptId?: string; + fetchAskAiResponse: (params: Omit) => Promise; +} + +const BASE_URL = 'https://generative-ai.algolia.com'; + +export function algoliaGenAiToolkit(appId: string, apiKey: string, options: GenAiClientOptions): GenAiClient { + const client: Omit = { + appId, + apiKey, + ...options, + }; + + return { + ...client, + fetchAskAiResponse: (params) => + fetchAskAiResponseFunction({ + ...params, + genAiClient: client as GenAiClient, + }), + }; +} + +export interface FetchAskAiResponseParams { + query: string; + genAiClient: GenAiClient; + additionalFilters?: Record; + onUpdate: (chunk: AskAiResponse) => void; + onComplete?: () => void; + onError?: (error: Error) => void; +} + +async function fetchAskAiResponseFunction({ + query, + genAiClient, + additionalFilters, + onUpdate, + onComplete, + onError, +}: FetchAskAiResponseParams): Promise { + const { appId, apiKey, dataSourceId, promptId } = genAiClient; + let finalResponse: AskAiResponse | null = null; + + // Helper function to process a single SSE line + function processSseLine( + line: string, + context: string, // 'chunk' or 'final chunk' for error messages + ): AskAiResponse | null { + if (!line.startsWith('data:')) { + return null; + } + const jsonString = line.substring(5).trim(); + if (!jsonString) { + return null; + } + try { + const chunk = JSON.parse(jsonString) as AskAiResponse; + onUpdate(chunk); + return chunk; + } catch (e) { + if (onError) { + onError(e instanceof Error ? e : new Error(`failed to parse sse ${context}`)); + } + return null; + } + } + + try { + const response = await fetch(`${BASE_URL}/generate/response`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Algolia-Application-Id': appId, + 'X-Algolia-API-Key': apiKey, + }, + body: JSON.stringify({ + query, + dataSourceId, + promptId, + additionalFilters, + stream: true, + }), + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`Ask AI request failed with status ${response.status}: ${errorBody}`); + } + + if (!response.body) { + throw new Error('Response body is null'); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + // eslint-disable-next-line no-constant-condition + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + buffer += decoder.decode(value, { stream: true }); + + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + const chunk = processSseLine(line, 'chunk'); + if (chunk) { + finalResponse = chunk; + } + } + } + + // Process any remaining data in the buffer + const finalChunk = processSseLine(buffer, 'final chunk'); + if (finalChunk) { + finalResponse = finalChunk; + } + + if (onComplete) { + onComplete(); + } + + if (!finalResponse) { + throw new Error('No valid response was received'); + } + + return finalResponse; + } catch (error) { + if (onError) { + onError(error instanceof Error ? error : new Error('unknown stream error')); + } + throw error; + } +} diff --git a/packages/docsearch-react/src/useAskAi.ts b/packages/docsearch-react/src/useAskAi.ts index e69de29bb..5bc6cdb08 100644 --- a/packages/docsearch-react/src/useAskAi.ts +++ b/packages/docsearch-react/src/useAskAi.ts @@ -0,0 +1,176 @@ +import { useState, useCallback, useMemo } from 'react'; + +import { algoliaGenAiToolkit, type AskAiResponse, type GenAiClient, type GenAiClientOptions } from './lib/genAiClient'; + +type LoadingStatus = 'error' | 'idle' | 'loading' | 'streaming'; + +interface Message { + id: string; + role: 'assistant' | 'user'; + content: string; +} + +interface UseAskAiState { + messages: Message[]; + currentResponse: string; + additionalFilters: string[]; + context: AskAiResponse['context']; + conversationID: string | null; + loadingStatus: LoadingStatus; + error: Error | null; +} + +interface UseAskAiParams { + genAiClient: GenAiClient; +} + +interface AskParams { + query: string; + additionalFilters?: Record; +} + +interface UseAskAiReturn { + messages: Message[]; + currentResponse: string; + additionalFilters: string[]; + context: AskAiResponse['context']; + conversationID: string | null; + loadingStatus: LoadingStatus; + error: Error | null; + ask: (params: AskParams) => Promise; + resetState: () => void; +} + +/** + * Hook for interacting with Algolia's Generative AI API. + * + * @param params - Configuration options. + * @param params.genAiClient - The GenAI client instance. + * @returns State and functions for interacting with the AI. + */ +export function useAskAi({ genAiClient }: UseAskAiParams): UseAskAiReturn { + const initialState = useMemo( + () => ({ + messages: [], + currentResponse: '', + additionalFilters: [], + context: [], + conversationID: null, + loadingStatus: 'idle', + error: null, + }), + [], + ); + + const [state, setState] = useState(initialState); + + // reset state + const resetState = useCallback(() => { + setState(initialState); + }, [initialState]); + + // ask ai request + const ask = useCallback( + async ({ query, additionalFilters }: AskParams) => { + // generate a unique id for the user message + const userMessageId = crypto.randomUUID(); + + // Add user message to the conversation + setState((prevState) => ({ + ...prevState, + messages: [...prevState.messages, { role: 'user', content: query, id: userMessageId }], + currentResponse: '', + additionalFilters: [], + context: [], + loadingStatus: 'loading', + error: null, + })); + + try { + await genAiClient.fetchAskAiResponse({ + query, + additionalFilters, + onUpdate: (chunk) => { + // update state incrementally as data streams in + setState((prevState) => ({ + ...prevState, + currentResponse: chunk.response, + additionalFilters: chunk.additionalFilters, + context: chunk.context, + conversationID: chunk.conversationID, + loadingStatus: 'streaming', + })); + }, + onComplete: () => { + // generate a unique id for the assistant message + const assistantMessageId = crypto.randomUUID(); + + // add the completed assistant message to the conversation + setState((prevState) => ({ + ...prevState, + messages: [ + ...prevState.messages, + { role: 'assistant', content: prevState.currentResponse, id: assistantMessageId }, + ], + loadingStatus: 'idle', // stream finished successfully + })); + }, + onError: (error) => { + // handle errors during the stream + setState((prevState) => ({ + ...prevState, + loadingStatus: 'error', + error, + })); + }, + }); + } catch (error) { + setState((prevState) => ({ + ...prevState, + loadingStatus: 'error', + error: error instanceof Error ? error : new Error('unknown fetch error'), + })); + } + }, + [genAiClient], + ); + + return { + ...state, + ask, + resetState, + }; +} + +/** Function signature for transforming the GenAI client. */ +export type DocSearchTransformGenAiClient = (genAiClient: GenAiClient) => GenAiClient; + +/** + * Hook to create and memoize an Algolia Generative AI client instance. + * + * @param appId - Your Algolia Application ID. + * @param apiKey - Your Algolia API Key. + * @param options - GenAI client options (dataSourceId, promptID). + * @param transformGenAiClient - Optional function to modify the client instance. + * @returns A memoized GenAI client instance. + */ +export function useGenAiClient( + appId: string, + apiKey: string, + options: GenAiClientOptions, + transformGenAiClient: DocSearchTransformGenAiClient = (client) => client, +): GenAiClient | null { + const genAiClient = useMemo(() => { + if (!options.dataSourceId || !options.promptId) { + return null; + } + const client = algoliaGenAiToolkit(appId, apiKey, options); + + // note: Currently, the genAiClient doesn't have a built-in `addAlgoliaAgent` method like the search client. + // if needed in the future, agent logic would be added here. + + return transformGenAiClient(client); + }, [appId, apiKey, options, transformGenAiClient]); + + return genAiClient; +} diff --git a/yarn.lock b/yarn.lock index 17d1f5fcf..10460f5b6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2226,6 +2226,7 @@ __metadata: "@testing-library/jest-dom": "npm:6.6.3" "@testing-library/react": "npm:16.2.0" algoliasearch: "npm:^5.14.2" + marked: "npm:^15.0.11" nodemon: "npm:^3.1.0" vitest: "npm:3.0.2" peerDependencies: @@ -14317,6 +14318,15 @@ __metadata: languageName: node linkType: hard +"marked@npm:^15.0.11": + version: 15.0.11 + resolution: "marked@npm:15.0.11" + bin: + marked: bin/marked.js + checksum: 10c0/d532db4955c1f2ac6efc65a644725e9e12e7944cb6af40c7148baecfd3b3c2f3564229b3daf12d2125635466448fb9b367ce52357be3aea0273e3d152efdbdcf + languageName: node + linkType: hard + "math-intrinsics@npm:^1.1.0": version: 1.1.0 resolution: "math-intrinsics@npm:1.1.0" From b94072a4fdc134c0305d8acd03159631a85f8dfd Mon Sep 17 00:00:00 2001 From: Dylan Tientcheu Date: Tue, 29 Apr 2025 11:24:03 +0200 Subject: [PATCH 2/6] fix: a few stuff --- packages/docsearch-react/src/AskAiScreen.tsx | 4 ++-- packages/docsearch-react/src/DocSearch.tsx | 11 ++++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/docsearch-react/src/AskAiScreen.tsx b/packages/docsearch-react/src/AskAiScreen.tsx index 2be406a2d..592595cf1 100644 --- a/packages/docsearch-react/src/AskAiScreen.tsx +++ b/packages/docsearch-react/src/AskAiScreen.tsx @@ -17,13 +17,13 @@ type AskAiScreenProps = Omit, 'translatio export function AskAiScreen({ translations = {}, ...props }: AskAiScreenProps): JSX.Element { const { - titleText = 'How to run a crawl?', disclaimerText = 'Answers are generated using artificial intelligence. This is an experimental technology, and information may occasionally be incorrect or misleading.', relatedSourcesText = 'Related Sources', } = translations; const genAiClient = props.genAiClient; if (!genAiClient) { + // @todo: add a link to the documentation throw new Error('You have to provide credentials to use the Ask AI feature.\nSee documentation:'); } @@ -35,7 +35,7 @@ export function AskAiScreen({ translations = {}, ...props }: AskAiScreenProps): } // determine the initial query to display - const displayedQuery = messages.find((m) => m.role === 'user')?.content || titleText; + const displayedQuery = messages.find((m) => m.role === 'user')?.content || 'No query provided'; // select the content to display based on the status const displayedAnswer = diff --git a/packages/docsearch-react/src/DocSearch.tsx b/packages/docsearch-react/src/DocSearch.tsx index beae74f4b..7abd05b13 100644 --- a/packages/docsearch-react/src/DocSearch.tsx +++ b/packages/docsearch-react/src/DocSearch.tsx @@ -51,13 +51,18 @@ export function DocSearch(props: DocSearchProps): JSX.Element { let currentPlaceholder = props?.translations?.modal?.searchBox?.placeholderText || props?.placeholder || 'Search docs'; - if (isAskAiActive) { - currentPlaceholder = props?.translations?.modal?.searchBox?.placeholderTextAskAi || 'Ask another question...'; - } // check if the instance is configured to handle ask ai const canHandleAskAi = Boolean(props?.dataSourceId && props?.promptId); + if (canHandleAskAi) { + currentPlaceholder = props?.translations?.modal?.searchBox?.placeholderText || 'Search docs or ask AI a question'; + } + + if (isAskAiActive) { + currentPlaceholder = props?.translations?.modal?.searchBox?.placeholderTextAskAi || 'Ask another question...'; + } + const onAskAiToggle = React.useCallback( (askAitoggle: boolean) => { setIsAskAiActive(askAitoggle); From d0df487d4d5a69850e372d0872e0526a2add5958 Mon Sep 17 00:00:00 2001 From: Dylan Tientcheu Date: Tue, 6 May 2025 11:07:39 +0200 Subject: [PATCH 3/6] feat: recently asked & conversations (#2590) --- packages/docsearch-css/src/modal.css | 36 +- packages/docsearch-react/src/AskAiScreen.tsx | 125 +++---- .../docsearch-react/src/DocSearchModal.tsx | 335 ++++++++++-------- .../docsearch-react/src/MemoizedMarkdown.tsx | 4 +- packages/docsearch-react/src/Results.tsx | 53 +-- packages/docsearch-react/src/ScreenState.tsx | 7 +- packages/docsearch-react/src/StartScreen.tsx | 39 +- .../src/icons/SparklesIcon.tsx | 23 ++ packages/docsearch-react/src/icons/index.ts | 1 + .../docsearch-react/src/lib/genAiClient.ts | 3 + .../docsearch-react/src/stored-searches.ts | 78 ++-- .../src/types/DocSearchState.ts | 1 - .../src/types/StoredDocSearchHit.ts | 3 + packages/docsearch-react/src/useAskAi.ts | 117 ++++-- packages/docsearch-react/src/utils/storage.ts | 89 +++++ 15 files changed, 567 insertions(+), 347 deletions(-) create mode 100644 packages/docsearch-react/src/icons/SparklesIcon.tsx create mode 100644 packages/docsearch-react/src/utils/storage.ts diff --git a/packages/docsearch-css/src/modal.css b/packages/docsearch-css/src/modal.css index 7b99ccde8..99cfb641f 100644 --- a/packages/docsearch-css/src/modal.css +++ b/packages/docsearch-css/src/modal.css @@ -783,14 +783,13 @@ assistive tech users */ flex-direction: column; gap: 24px; width: 100%; - padding: 6px; - overflow-y: auto; } .DocSearch-AskAiScreen-Response-Container { display: flex; flex-direction: row; - gap: 8px; + gap: 16px; + margin-bottom: 16px; } .DocSearch-AskAiScreen-Response { @@ -799,6 +798,7 @@ assistive tech users */ width: 70%; gap: 16px; font-size: 0.8em; + margin-bottom: 8px; background: var(--docsearch-hit-background); padding: 24px; color: var(--docsearch-text-color); @@ -823,6 +823,13 @@ assistive tech users */ animation: fade-in 0.3s ease-in-out; } +.DocSearch-AskAiScreen-ThinkingDots { + font-size: 0.7em; + font-weight: 400; + color: var(--docsearch-secondary-text-color); + margin: 0; +} + .DocSearch-AskAiScreen-Answer-Footer { display: flex; flex-direction: row; @@ -878,20 +885,35 @@ assistive tech users */ display: flex; flex-direction: column; width: 30%; - gap: 8px; + gap: 4px; } .DocSearch-AskAiScreen-RelatedSources-Title { font-size: 0.7em; font-weight: 400; color: var(--docsearch-text-color); + padding: 6px 0; margin: 0; } +.DocSearch-AskAiScreen-RelatedSources-NoResults { + font-size: 0.8rem; + font-weight: 400; + margin: 0; + color: var(--docsearch-text-color); +} + +.DocSearch-AskAiScreen-RelatedSources-Error { + font-size: 0.8rem; + font-weight: 400; + margin: 0; + color: var(--docsearch-error-color); +} + .DocSearch-AskAiScreen-RelatedSources-Item-Link { display: flex; align-items: center; - gap: 4px; + gap: 6px; padding: 12px 6px; background: var(--docsearch-hit-background); border-radius: 4px; @@ -1026,10 +1048,10 @@ assistive tech users */ @keyframes pulse { 0%, 100% { - opacity: 0.4; + opacity: 0.3; } 50% { - opacity: 0.8; + opacity: 0.6; } } diff --git a/packages/docsearch-react/src/AskAiScreen.tsx b/packages/docsearch-react/src/AskAiScreen.tsx index 592595cf1..1204c25eb 100644 --- a/packages/docsearch-react/src/AskAiScreen.tsx +++ b/packages/docsearch-react/src/AskAiScreen.tsx @@ -3,36 +3,31 @@ import React, { type JSX, useState, useEffect } from 'react'; import { MemoizedMarkdown } from './MemoizedMarkdown'; import type { ScreenStateProps } from './ScreenState'; import type { InternalDocSearchHit } from './types'; -import { useAskAi } from './useAskAi'; export type AskAiScreenTranslations = Partial<{ titleText: string; disclaimerText: string; relatedSourcesText: string; + thinkingText: string; }>; type AskAiScreenProps = Omit, 'translations'> & { translations?: AskAiScreenTranslations; + conversationId?: string | null; }; -export function AskAiScreen({ translations = {}, ...props }: AskAiScreenProps): JSX.Element { +export function AskAiScreen({ translations = {}, ...props }: AskAiScreenProps): JSX.Element | null { + if (!props.askAiState) { + return null; + } + const { disclaimerText = 'Answers are generated using artificial intelligence. This is an experimental technology, and information may occasionally be incorrect or misleading.', relatedSourcesText = 'Related Sources', + thinkingText = 'Thinking', } = translations; - const genAiClient = props.genAiClient; - if (!genAiClient) { - // @todo: add a link to the documentation - throw new Error('You have to provide credentials to use the Ask AI feature.\nSee documentation:'); - } - - const { ask, messages, currentResponse, loadingStatus, context, error } = useAskAi({ genAiClient }); - - // if we have no messages and a query, and are not loading/streaming, we can use it as the initial query - if (messages.length === 0 && props.state.query && loadingStatus === 'idle') { - ask({ query: props.state.query }); - } + const { messages, currentResponse, loadingStatus, context, error } = props.askAiState; // determine the initial query to display const displayedQuery = messages.find((m) => m.role === 'user')?.content || 'No query provided'; @@ -81,7 +76,7 @@ export function AskAiScreen({ translations = {}, ...props }: AskAiScreenProps): )} {loadingStatus === 'loading' && (
- +
)}
@@ -112,6 +107,14 @@ export function AskAiScreen({ translations = {}, ...props }: AskAiScreenProps): {source.title || source.url || source.objectID} ))} + {context.length === 0 && loadingStatus === 'idle' && ( +

No related sources found

+ )} + {context.length === 0 && loadingStatus === 'error' && ( +

+ Error loading related sources. Please try again. +

+ )}
@@ -119,6 +122,28 @@ export function AskAiScreen({ translations = {}, ...props }: AskAiScreenProps): ); } +function ThinkingDots({ thinkingText }: { thinkingText: string }): JSX.Element { + const [dots, setDots] = useState(''); + + useEffect(() => { + const interval = setInterval(() => { + setDots((prevDots) => { + if (prevDots === '...') return ''; + return prevDots + '.'; + }); + }, 500); + + return (): void => clearInterval(interval); + }, []); + + return ( +

+ {thinkingText} + {dots} +

+ ); +} + function SkeletonSource(): JSX.Element { return (
@@ -148,76 +173,6 @@ function RelatedSourceIcon(): JSX.Element { ); } -function PulseLoader(): JSX.Element { - return ( - - - - - - - - - - - - - - - ); -} - function CopyButton({ onClick }: { onClick: () => void }): JSX.Element { const [isCopied, setIsCopied] = useState(false); diff --git a/packages/docsearch-react/src/DocSearchModal.tsx b/packages/docsearch-react/src/DocSearchModal.tsx index 0a81b3014..d0e2f18f5 100644 --- a/packages/docsearch-react/src/DocSearchModal.tsx +++ b/packages/docsearch-react/src/DocSearchModal.tsx @@ -16,9 +16,10 @@ import type { ScreenStateTranslations } from './ScreenState'; import { ScreenState } from './ScreenState'; import type { SearchBoxTranslations } from './SearchBox'; import { SearchBox } from './SearchBox'; -import { createStoredSearches } from './stored-searches'; -import type { DocSearchHit, DocSearchState, InternalDocSearchHit, StoredDocSearchHit } from './types'; -import { useGenAiClient } from './useAskAi'; +import { createStoredConversations, createStoredSearches } from './stored-searches'; +import type { DocSearchHit, DocSearchState, InternalDocSearchHit, StoredAskAiState, StoredDocSearchHit } from './types'; +import type { AskAiState } from './useAskAi'; +import { useAskAi, useGenAiClient } from './useAskAi'; import { useSearchClient } from './useSearchClient'; import { useTouchEvents } from './useTouchEvents'; import { useTrapFocus } from './useTrapFocus'; @@ -43,18 +44,27 @@ export type DocSearchModalProps = DocSearchProps & { * Helper function to build sources when there is no query * useful for recent searches and favorite searches. */ -const buildNoQuerySources = ( - recentSearches: ReturnType, - favoriteSearches: ReturnType, - saveRecentSearch: (item: InternalDocSearchHit) => void, - onClose: () => void, - disableUserPersonalization: boolean, -): Array> => { +type BuildNoQuerySourcesOptions = { + recentSearches: ReturnType; + favoriteSearches: ReturnType; + saveRecentSearch: (item: InternalDocSearchHit) => void; + onClose: () => void; + disableUserPersonalization: boolean; + canHandleAskAi: boolean; +}; + +const buildNoQuerySources = ({ + recentSearches, + favoriteSearches, + saveRecentSearch, + onClose, + disableUserPersonalization, +}: BuildNoQuerySourcesOptions): Array> => { if (disableUserPersonalization) { return []; } - return [ + const sources: Array> = [ { sourceId: 'recentSearches', onSelect({ item, event }): void { @@ -86,6 +96,8 @@ const buildNoQuerySources = ( }, }, ]; + + return sources; }; type BuildQuerySourcesState = Pick, 'context'>; @@ -107,7 +119,7 @@ const buildQuerySources = async ({ appId, apiKey, maxResultsPerGroup, - transformItems = identity, // default to identity if not provided + transformItems = identity, saveRecentSearch, onClose, }: { @@ -119,15 +131,15 @@ const buildQuerySources = async ({ indexName: string; searchParameters: DocSearchProps['searchParameters']; snippetLength: React.MutableRefObject; - insights: boolean; // ensure boolean + insights: boolean; appId?: string; apiKey?: string; maxResultsPerGroup?: number; - transformItems?: DocSearchProps['transformItems']; // prop can be undefined + transformItems?: DocSearchProps['transformItems']; saveRecentSearch: (item: InternalDocSearchHit) => void; onClose: () => void; }): Promise>> => { - const insightsActive = insights; // already boolean + const insightsActive = insights; try { const { results } = await searchClient.search({ @@ -277,7 +289,6 @@ export function DocSearchModal({ isOpen: false, activeItemId: null, status: 'idle', - isAskAiActive, }); const containerRef = React.useRef(null); @@ -296,6 +307,17 @@ export function DocSearchModal({ dataSourceId, promptId, }); + if (!genAiClient && canHandleAskAi) { + throw new Error('Something went wrong while initializing the Ask AI feature.'); + } + + // storage + const conversations = React.useRef( + createStoredConversations({ + key: `__DOCSEARCH_ASKAI_CONVERSATIONS__${indexName}`, + limit: 10, + }), + ).current; const favoriteSearches = React.useRef( createStoredSearches({ key: `__DOCSEARCH_FAVORITE_SEARCHES__${indexName}`, @@ -311,6 +333,21 @@ export function DocSearchModal({ }), ).current; + // askAI + const askAiState = useAskAi({ + genAiClient: genAiClient!, + conversations, + }); + + const handleAskAiToggle = React.useCallback( + (toggle: boolean, query: string) => { + onAskAiToggle(toggle); + askAiState.ask?.({ query }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [onAskAiToggle], + ); + const saveRecentSearch = React.useCallback( function saveRecentSearch(item: InternalDocSearchHit) { if (disableUserPersonalization) { @@ -347,145 +384,145 @@ export function DocSearchModal({ [state.context.algoliaInsightsPlugin], ); - const autocomplete = React.useMemo( - () => - createAutocomplete, React.MouseEvent, React.KeyboardEvent>( - { - id: 'docsearch', - // we don't want to focus on the AskAI hit by default - defaultActiveItemId: canHandleAskAi ? 1 : 0, - placeholder, - openOnFocus: true, - initialState: { - query: initialQuery, - context: { - searchSuggestions: [], - }, - }, - insights: Boolean(insights), - navigator, - onStateChange(props) { - const nextState = props.state; - setState((prevState) => { - // to avoid flickering, we ignore the update from autocomplete-core - // when the query just went empty, status is idle, collections are empty, - // and we weren't already loading/stalled. - const queryWentEmpty = prevState.query !== '' && nextState.query === ''; - const statusIsIdle = nextState.status === 'idle'; - const collectionsAreEmpty = !(nextState.collections?.some((c) => c.items.length > 0) ?? false); - const wasNotLoading = prevState.status !== 'loading' && prevState.status !== 'stalled'; - - if (queryWentEmpty && statusIsIdle && collectionsAreEmpty && wasNotLoading) { - return prevState; - } - - // otherwise, merge state as usual - return { - ...prevState, - ...nextState, - }; - }); - }, - getSources({ query, state: sourcesState, setContext, setStatus }) { - if (!query) { - return buildNoQuerySources( - recentSearches, - favoriteSearches, - saveRecentSearch, - onClose, - disableUserPersonalization, - ); - } - - const querySourcesState: BuildQuerySourcesState = { context: sourcesState.context }; - - // Algolia sources - const algoliaSourcesPromise = buildQuerySources({ - query, - state: querySourcesState, - setContext, - setStatus, - searchClient, - indexName, - searchParameters, - snippetLength, - insights: Boolean(insights), - appId, - apiKey, - maxResultsPerGroup, - transformItems, - saveRecentSearch, - onClose, - }); - - // AskAI source - const askAiSource: Array> = canHandleAskAi + const autocompleteRef = + React.useRef< + ReturnType< + typeof createAutocomplete< + InternalDocSearchHit, + React.FormEvent, + React.MouseEvent, + React.KeyboardEvent + > + > + >(undefined); + + if (!autocompleteRef.current) { + autocompleteRef.current = createAutocomplete< + InternalDocSearchHit, + React.FormEvent, + React.MouseEvent, + React.KeyboardEvent + >({ + id: 'docsearch', + // we don't want to focus on the AskAI hit by default + defaultActiveItemId: canHandleAskAi ? 1 : 0, + placeholder, + openOnFocus: true, + initialState: { + query: initialQuery, + context: { + searchSuggestions: [], + }, + }, + insights: Boolean(insights), + navigator, + onStateChange(props) { + setState(props.state); + }, + getSources({ query, state: sourcesState, setContext, setStatus }) { + if (isAskAiActive) { + // when Ask AI screen is active, don't render any autocomplete sources + return []; + } + if (!query) { + const noQuerySources = buildNoQuerySources({ + recentSearches, + favoriteSearches, + saveRecentSearch, + onClose, + disableUserPersonalization, + canHandleAskAi, + }); + + const recentConversationSource: Array> = + canHandleAskAi ? [ { - sourceId: 'askAI', + sourceId: 'recentConversations', getItems(): InternalDocSearchHit[] { - // return a single item representing the Ask AI action - // placeholder data matching the InternalDocSearchHit structure - const askItem: InternalDocSearchHit = { - type: 'askAI', - query, - url_without_anchor: '', - objectID: `ask-ai-button`, - content: null, - url: '', - anchor: null, - hierarchy: { - lvl0: 'Ask AI', // Or contextually relevant - lvl1: query, - lvl2: null, - lvl3: null, - lvl4: null, - lvl5: null, - lvl6: null, - }, - _highlightResult: {} as any, - _snippetResult: {} as any, - __docsearch_parent: null, - }; - return [askItem]; + return conversations.getAll() as unknown as InternalDocSearchHit[]; }, onSelect({ item }): void { - if (item.type === 'askAI') { - onAskAiToggle(true); + if (item.askState) { + handleAskAiToggle(true, item.askState.query); } }, }, ] : []; + return [...noQuerySources, ...recentConversationSource]; + } - // Combine Algolia results (once resolved) with the Ask AI source - return algoliaSourcesPromise.then((algoliaSources) => { - return [...askAiSource, ...algoliaSources]; - }); - }, - }, - ), - [ - indexName, - searchParameters, - maxResultsPerGroup, - searchClient, - onClose, - saveRecentSearch, - initialQuery, - placeholder, - navigator, - transformItems, - disableUserPersonalization, - insights, - appId, - apiKey, - favoriteSearches, - recentSearches, - canHandleAskAi, - onAskAiToggle, - ], - ); + const querySourcesState: BuildQuerySourcesState = { context: sourcesState.context }; + + // Algolia sources + const algoliaSourcesPromise = buildQuerySources({ + query, + state: querySourcesState, + setContext, + setStatus, + searchClient, + indexName, + searchParameters, + snippetLength, + insights: Boolean(insights), + appId, + apiKey, + maxResultsPerGroup, + transformItems, + saveRecentSearch, + onClose, + }); + + // AskAI source + const askAiSource: Array> = canHandleAskAi + ? [ + { + sourceId: 'askAI', + getItems(): InternalDocSearchHit[] { + // return a single item representing the Ask AI action + // placeholder data matching the InternalDocSearchHit structure + const askItem: InternalDocSearchHit = { + type: 'askAI', + query, + url_without_anchor: '', + objectID: `ask-ai-button`, + content: null, + url: '', + anchor: null, + hierarchy: { + lvl0: 'Ask AI', // Or contextually relevant + lvl1: query, + lvl2: null, + lvl3: null, + lvl4: null, + lvl5: null, + lvl6: null, + }, + _highlightResult: {} as any, + _snippetResult: {} as any, + __docsearch_parent: null, + }; + return [askItem]; + }, + onSelect({ item }): void { + if (item.type === 'askAI' && item.query) { + handleAskAiToggle(true, item.query); + } + }, + }, + ] + : []; + + // Combine Algolia results (once resolved) with the Ask AI source + return algoliaSourcesPromise.then((algoliaSources) => { + return [...askAiSource, ...algoliaSources]; + }); + }, + }); + } + + const autocomplete = autocompleteRef.current; const { getEnvironmentProps, getRootProps, refresh } = autocomplete; @@ -618,17 +655,19 @@ export function DocSearchModal({ disableUserPersonalization={disableUserPersonalization} recentSearches={recentSearches} favoriteSearches={favoriteSearches} + conversations={conversations} inputRef={inputRef} translations={screenStateTranslations} getMissingResultsUrl={getMissingResultsUrl} isAskAiActive={isAskAiActive} canHandleAskAi={canHandleAskAi} - genAiClient={genAiClient} + askAiState={askAiState} onAskAiToggle={onAskAiToggle} onItemClick={(item, event) => { - // if the item is askAI, do nothing - if (item.type === 'askAI') { - onAskAiToggle(true); + // if the item is askAI toggle the screen + if (item.type === 'askAI' && item.query) { + handleAskAiToggle(true, item.query); + event.preventDefault(); return; } diff --git a/packages/docsearch-react/src/MemoizedMarkdown.tsx b/packages/docsearch-react/src/MemoizedMarkdown.tsx index b8b0c155b..a2dd10dd5 100644 --- a/packages/docsearch-react/src/MemoizedMarkdown.tsx +++ b/packages/docsearch-react/src/MemoizedMarkdown.tsx @@ -11,9 +11,7 @@ function parseMarkdownIntoHTMLBlocks(md: string): string[] { ); } -const HTMLBlock: FC<{ html: string; key: string }> = ({ html, key }) => ( -
-); +const HTMLBlock: FC<{ html: string }> = ({ html }) =>
; const MemoizedHTMLBlock = memo(HTMLBlock, (prev, next) => prev.html === next.html); MemoizedHTMLBlock.displayName = 'MemoizedHTMLBlock'; diff --git a/packages/docsearch-react/src/Results.tsx b/packages/docsearch-react/src/Results.tsx index d7717a38b..f28e06597 100644 --- a/packages/docsearch-react/src/Results.tsx +++ b/packages/docsearch-react/src/Results.tsx @@ -2,6 +2,7 @@ import type { AutocompleteApi, AutocompleteState, BaseItem } from '@algolia/auto import React, { type JSX } from 'react'; import type { DocSearchProps } from './DocSearch'; +import { SparklesIcon } from './icons/SparklesIcon'; import { Snippet } from './Snippet'; import type { InternalDocSearchHit, StoredDocSearchHit } from './types'; @@ -32,7 +33,20 @@ export function Results(props: ResultsProps
    - + +
+ + ); + } + + if (props.collection.source.sourceId === 'recentConversations') { + return ( +
+
{props.title}
+
    + {props.collection.items.map((item, index) => { + return ; + })}
); @@ -115,6 +129,12 @@ function Result({
)} + {item.type === 'askAI' && ( +
+ +
+ )} + {item.hierarchy[item.type] && (item.type === 'lvl2' || item.type === 'lvl3' || @@ -141,41 +161,20 @@ function Result({ ); } -interface AskAiResultProps extends ResultsProps { +interface AskAiButtonProps extends ResultsProps { item: TItem; translations?: ResultsTranslations; } -function AskAiResult({ +function AskAiButton({ item, getItemProps, onItemClick, translations, collection, -}: AskAiResultProps): JSX.Element { +}: AskAiButtonProps): JSX.Element { const { askAiPlaceholder = 'Ask AI: ' } = translations || {}; - const icon = ( - - - - - - - - ); - return (
  • ({ >
    -
    {icon}
    +
    + +
    {askAiPlaceholder} "{item.query || ''}" diff --git a/packages/docsearch-react/src/ScreenState.tsx b/packages/docsearch-react/src/ScreenState.tsx index 0e710ba9b..a727dbb29 100644 --- a/packages/docsearch-react/src/ScreenState.tsx +++ b/packages/docsearch-react/src/ScreenState.tsx @@ -6,7 +6,6 @@ import { AskAiScreen } from './AskAiScreen'; import type { DocSearchProps } from './DocSearch'; import type { ErrorScreenTranslations } from './ErrorScreen'; import { ErrorScreen } from './ErrorScreen'; -import type { GenAiClient } from './lib/genAiClient'; import type { NoResultsScreenTranslations } from './NoResultsScreen'; import { NoResultsScreen } from './NoResultsScreen'; import type { ResultsScreenTranslations } from './ResultsScreen'; @@ -14,7 +13,8 @@ import { ResultsScreen } from './ResultsScreen'; import type { StartScreenTranslations } from './StartScreen'; import { StartScreen } from './StartScreen'; import type { StoredSearchPlugin } from './stored-searches'; -import type { InternalDocSearchHit, StoredDocSearchHit } from './types'; +import type { InternalDocSearchHit, StoredAskAiState, StoredDocSearchHit } from './types'; +import type { AskAiState } from './useAskAi'; export type ScreenStateTranslations = Partial<{ errorScreen: ErrorScreenTranslations; @@ -29,6 +29,7 @@ export interface ScreenStateProps state: AutocompleteState; recentSearches: StoredSearchPlugin; favoriteSearches: StoredSearchPlugin; + conversations: StoredSearchPlugin; onItemClick: (item: InternalDocSearchHit, event: KeyboardEvent | MouseEvent) => void; onAskAiToggle: (toggle: boolean) => void; isAskAiActive: boolean; @@ -37,7 +38,7 @@ export interface ScreenStateProps hitComponent: DocSearchProps['hitComponent']; indexName: DocSearchProps['indexName']; disableUserPersonalization: boolean; - genAiClient: GenAiClient | null; + askAiState?: AskAiState; resultsFooterComponent: DocSearchProps['resultsFooterComponent']; translations: ScreenStateTranslations; getMissingResultsUrl?: DocSearchProps['getMissingResultsUrl']; diff --git a/packages/docsearch-react/src/StartScreen.tsx b/packages/docsearch-react/src/StartScreen.tsx index ec73865cd..16ec5b4d3 100644 --- a/packages/docsearch-react/src/StartScreen.tsx +++ b/packages/docsearch-react/src/StartScreen.tsx @@ -1,6 +1,6 @@ import React, { type JSX } from 'react'; -import { RecentIcon, CloseIcon, StarIcon, SearchIcon } from './icons'; +import { RecentIcon, CloseIcon, StarIcon, SearchIcon, SparklesIcon } from './icons'; import { Results } from './Results'; import type { ScreenStateProps } from './ScreenState'; import type { InternalDocSearchHit } from './types'; @@ -12,6 +12,8 @@ export type StartScreenTranslations = Partial<{ removeRecentSearchButtonTitle: string; favoriteSearchesTitle: string; removeFavoriteSearchButtonTitle: string; + recentConversationsTitle: string; + removeRecentConversationButtonTitle: string; }>; type StartScreenProps = Omit, 'translations'> & { @@ -22,12 +24,15 @@ type StartScreenProps = Omit, 'translatio export function StartScreen({ translations = {}, ...props }: StartScreenProps): JSX.Element | null { const { recentSearchesTitle = 'Recent', - noRecentSearchesText = 'Make a search to see results', + noRecentSearchesText = 'Search results will appear here', saveRecentSearchButtonTitle = 'Save this search', removeRecentSearchButtonTitle = 'Remove this search from history', favoriteSearchesTitle = 'Favorite', removeFavoriteSearchButtonTitle = 'Remove this search from favorites', + recentConversationsTitle = 'Recently asked', + removeRecentConversationButtonTitle = 'Remove this conversation from history', } = translations; + if (props.state.status === 'idle' && props.hasCollections === false) { if (props.disableUserPersonalization) { return null; @@ -128,6 +133,36 @@ export function StartScreen({ translations = {}, ...props }: StartScreenProps):
    )} /> + + ( +
    + +
    + )} + renderAction={({ item, runDeleteTransition }) => ( +
    + +
    + )} + />
    ); } diff --git a/packages/docsearch-react/src/icons/SparklesIcon.tsx b/packages/docsearch-react/src/icons/SparklesIcon.tsx new file mode 100644 index 000000000..dac9ef260 --- /dev/null +++ b/packages/docsearch-react/src/icons/SparklesIcon.tsx @@ -0,0 +1,23 @@ +import React, { type JSX } from 'react'; + +export function SparklesIcon(): JSX.Element { + return ( + + + + + + + + ); +} diff --git a/packages/docsearch-react/src/icons/index.ts b/packages/docsearch-react/src/icons/index.ts index b3886e023..e3df831a6 100644 --- a/packages/docsearch-react/src/icons/index.ts +++ b/packages/docsearch-react/src/icons/index.ts @@ -1,5 +1,6 @@ export * from './GoToExternalIcon'; export * from './LoadingIcon'; +export * from './SparklesIcon'; export * from './RecentIcon'; export * from './CloseIcon'; export * from './SearchIcon'; diff --git a/packages/docsearch-react/src/lib/genAiClient.ts b/packages/docsearch-react/src/lib/genAiClient.ts index 824deadaf..6081a3923 100644 --- a/packages/docsearch-react/src/lib/genAiClient.ts +++ b/packages/docsearch-react/src/lib/genAiClient.ts @@ -43,6 +43,7 @@ export function algoliaGenAiToolkit(appId: string, apiKey: string, options: GenA export interface FetchAskAiResponseParams { query: string; genAiClient: GenAiClient; + conversationId?: string | null; additionalFilters?: Record; onUpdate: (chunk: AskAiResponse) => void; onComplete?: () => void; @@ -53,6 +54,7 @@ async function fetchAskAiResponseFunction({ query, genAiClient, additionalFilters, + conversationId, onUpdate, onComplete, onError, @@ -97,6 +99,7 @@ async function fetchAskAiResponseFunction({ dataSourceId, promptId, additionalFilters, + conversationId, stream: true, }), }); diff --git a/packages/docsearch-react/src/stored-searches.ts b/packages/docsearch-react/src/stored-searches.ts index 0cfe28f47..57cf2fdc5 100644 --- a/packages/docsearch-react/src/stored-searches.ts +++ b/packages/docsearch-react/src/stored-searches.ts @@ -1,40 +1,5 @@ -import type { DocSearchHit, StoredDocSearchHit } from './types'; - -function isLocalStorageSupported(): boolean { - const key = '__TEST_KEY__'; - - try { - localStorage.setItem(key, ''); - localStorage.removeItem(key); - - return true; - } catch { - return false; - } -} - -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -function createStorage(key: string) { - if (isLocalStorageSupported() === false) { - return { - setItem(): void {}, - getItem(): TItem[] { - return []; - }, - }; - } - - return { - setItem(item: TItem[]): void { - return window.localStorage.setItem(key, JSON.stringify(item)); - }, - getItem(): TItem[] { - const item = window.localStorage.getItem(key); - - return item ? JSON.parse(item) : []; - }, - }; -} +import type { DocSearchHit, StoredAskAiState, StoredDocSearchHit } from './types'; +import { createStorage } from './utils/storage'; type CreateStoredSearchesOptions = { key: string; @@ -79,3 +44,42 @@ export function createStoredSearches({ }, }; } + +export function createStoredConversations({ + key, + limit = 5, +}: CreateStoredSearchesOptions): StoredSearchPlugin { + const storage = createStorage(key); + let items = storage.getItem().slice(0, limit); + + return { + add(item: TItem): void { + const { askState } = item; + + // check if this query is already saved + // @todo: this is a bit of a hack, we should be able to use + // conversationId to identify. + const isQueryAlreadySaved = items.findIndex( + (x) => + x.objectID === askState?.conversationId || x.askState?.messages[0].content === askState?.messages[0].content, + ); + + if (isQueryAlreadySaved > -1) { + items[isQueryAlreadySaved] = item; + } else { + items.unshift(item); + items = items.slice(0, limit); + } + + storage.setItem(items); + }, + getAll(): TItem[] { + return items; + }, + remove(item: TItem): void { + items = items.filter((x) => x.objectID !== item.objectID); + + storage.setItem(items); + }, + }; +} diff --git a/packages/docsearch-react/src/types/DocSearchState.ts b/packages/docsearch-react/src/types/DocSearchState.ts index 291d989ba..c48d09433 100644 --- a/packages/docsearch-react/src/types/DocSearchState.ts +++ b/packages/docsearch-react/src/types/DocSearchState.ts @@ -13,5 +13,4 @@ interface DocSearchContext extends AutocompleteContext { export interface DocSearchState extends AutocompleteState { context: DocSearchContext; - isAskAiActive: boolean; } diff --git a/packages/docsearch-react/src/types/StoredDocSearchHit.ts b/packages/docsearch-react/src/types/StoredDocSearchHit.ts index fecd79413..3b34f8dc8 100644 --- a/packages/docsearch-react/src/types/StoredDocSearchHit.ts +++ b/packages/docsearch-react/src/types/StoredDocSearchHit.ts @@ -1,3 +1,6 @@ +import type { AskAiState } from '../useAskAi'; + import type { DocSearchHit } from './DocSearchHit'; export type StoredDocSearchHit = Omit; +export type StoredAskAiState = Omit & { askState?: AskAiState }; diff --git a/packages/docsearch-react/src/useAskAi.ts b/packages/docsearch-react/src/useAskAi.ts index 5bc6cdb08..ba2a800bb 100644 --- a/packages/docsearch-react/src/useAskAi.ts +++ b/packages/docsearch-react/src/useAskAi.ts @@ -1,6 +1,8 @@ -import { useState, useCallback, useMemo } from 'react'; +import { useState, useCallback, useMemo, useRef } from 'react'; import { algoliaGenAiToolkit, type AskAiResponse, type GenAiClient, type GenAiClientOptions } from './lib/genAiClient'; +import type { StoredSearchPlugin } from './stored-searches'; +import type { StoredAskAiState } from './types'; type LoadingStatus = 'error' | 'idle' | 'loading' | 'streaming'; @@ -10,18 +12,24 @@ interface Message { content: string; } -interface UseAskAiState { +export interface AskAiState { messages: Message[]; currentResponse: string; + query: string; additionalFilters: string[]; context: AskAiResponse['context']; - conversationID: string | null; + conversationId: string | null; loadingStatus: LoadingStatus; error: Error | null; + // optional just to make the type flexible + ask?: (params: AskParams) => Promise; + reset?: () => void; + restoreConversation?: (conversation: StoredAskAiState) => void; } interface UseAskAiParams { genAiClient: GenAiClient; + conversations: StoredSearchPlugin; } interface AskParams { @@ -29,51 +37,60 @@ interface AskParams { additionalFilters?: Record; } -interface UseAskAiReturn { - messages: Message[]; - currentResponse: string; - additionalFilters: string[]; - context: AskAiResponse['context']; - conversationID: string | null; - loadingStatus: LoadingStatus; - error: Error | null; - ask: (params: AskParams) => Promise; - resetState: () => void; -} - /** * Hook for interacting with Algolia's Generative AI API. * * @param params - Configuration options. * @param params.genAiClient - The GenAI client instance. + * @param params.conversations - The conversations storage ref to store the AI responses. * @returns State and functions for interacting with the AI. */ -export function useAskAi({ genAiClient }: UseAskAiParams): UseAskAiReturn { - const initialState = useMemo( +export function useAskAi({ genAiClient, conversations }: UseAskAiParams): AskAiState { + const initialState = useMemo( () => ({ messages: [], currentResponse: '', + query: '', additionalFilters: [], context: [], - conversationID: null, - loadingStatus: 'idle', + conversationId: null, + loadingStatus: 'idle' as const, error: null, }), [], ); - const [state, setState] = useState(initialState); + const [state, setState] = useState>(initialState); + const didAddConversationRef = useRef(false); - // reset state - const resetState = useCallback(() => { + // reset state function + const reset = useCallback(() => { setState(initialState); + didAddConversationRef.current = false; }, [initialState]); + const restoreConversation = useCallback( + (conversation: StoredAskAiState) => { + setState(conversation.askState ?? initialState); + didAddConversationRef.current = true; + }, + [initialState], + ); + // ask ai request const ask = useCallback( async ({ query, additionalFilters }: AskParams) => { + // if there's no conversationid, empty the messages + if (!state.conversationId) { + setState((prevState) => ({ + ...prevState, + messages: [], + })); + } + // generate a unique id for the user message const userMessageId = crypto.randomUUID(); + const newConversationId = state.conversationId ?? crypto.randomUUID(); // Add user message to the conversation setState((prevState) => ({ @@ -82,6 +99,7 @@ export function useAskAi({ genAiClient }: UseAskAiParams): UseAskAiReturn { currentResponse: '', additionalFilters: [], context: [], + query, loadingStatus: 'loading', error: null, })); @@ -90,6 +108,7 @@ export function useAskAi({ genAiClient }: UseAskAiParams): UseAskAiReturn { await genAiClient.fetchAskAiResponse({ query, additionalFilters, + // conversationId: newConversationId, onUpdate: (chunk) => { // update state incrementally as data streams in setState((prevState) => ({ @@ -97,23 +116,50 @@ export function useAskAi({ genAiClient }: UseAskAiParams): UseAskAiReturn { currentResponse: chunk.response, additionalFilters: chunk.additionalFilters, context: chunk.context, - conversationID: chunk.conversationID, loadingStatus: 'streaming', })); }, onComplete: () => { - // generate a unique id for the assistant message const assistantMessageId = crypto.randomUUID(); - // add the completed assistant message to the conversation - setState((prevState) => ({ - ...prevState, - messages: [ - ...prevState.messages, - { role: 'assistant', content: prevState.currentResponse, id: assistantMessageId }, - ], - loadingStatus: 'idle', // stream finished successfully - })); + setState((prevState) => { + const newState = { + ...prevState, + messages: [ + ...prevState.messages, + { role: 'assistant' as const, content: prevState.currentResponse, id: assistantMessageId }, + ], + loadingStatus: 'idle' as const, + conversationId: prevState.conversationId ?? newConversationId, + }; + + if (!didAddConversationRef.current) { + conversations.add({ + query: newState.messages[0].content, + objectID: newConversationId, + + // dummy content to make it a valid hit + content: null, + hierarchy: { + lvl0: 'askAI', + lvl1: newState.messages[0].content, + lvl2: null, + lvl3: null, + lvl4: null, + lvl5: null, + lvl6: null, + }, + type: 'askAI', + url: '', + url_without_anchor: '', + anchor: '', + askState: newState, + }); + didAddConversationRef.current = true; + } + + return newState; + }); }, onError: (error) => { // handle errors during the stream @@ -132,13 +178,14 @@ export function useAskAi({ genAiClient }: UseAskAiParams): UseAskAiReturn { })); } }, - [genAiClient], + [genAiClient, conversations, state], ); return { ...state, ask, - resetState, + reset, + restoreConversation, }; } diff --git a/packages/docsearch-react/src/utils/storage.ts b/packages/docsearch-react/src/utils/storage.ts new file mode 100644 index 000000000..b941f4405 --- /dev/null +++ b/packages/docsearch-react/src/utils/storage.ts @@ -0,0 +1,89 @@ +/** + * Checks if local storage is available and usable. + */ +export function isLocalStorageSupported(): boolean { + const key = '__TEST_KEY__'; + try { + localStorage.setItem(key, ''); + localStorage.removeItem(key); + return true; + } catch { + return false; + } +} + +/** + * Creates a simple storage interface for arrays using localstorage. + * Provides basic getitem and setitem functionality. + * Falls back to a no-op implementation if localstorage is not supported.. + * + * @template titem The type of items to store. + * @param key - The localstorage key to use. + * @returns An object with setitem and getitem methods. + */ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function createStorage(key: string) { + if (isLocalStorageSupported() === false) { + return { + setItem(): void {}, + getItem(): TItem[] { + return []; + }, + }; + } + + return { + setItem(item: TItem[]): void { + return window.localStorage.setItem(key, JSON.stringify(item)); + }, + getItem(): TItem[] { + const item = window.localStorage.getItem(key); + return item ? JSON.parse(item) : []; + }, + }; +} + +/** + * Creates a simple storage interface for a single object using localstorage. + * Provides basic getitem, setitem, and removeitem functionality. + * Falls back to a no-op implementation if localstorage is not supported. + * + * @template titem The type of the object to store. + * @param key - The localstorage key to use. + * @returns An object with setitem, getitem, and removeitem methods. + */ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function createObjectStorage(key: string) { + if (isLocalStorageSupported() === false) { + return { + setItem(_item: TItem | null): void {}, + getItem(): TItem | null { + return null; + }, + removeItem(): void {}, + }; + } + + return { + setItem(item: TItem | null): void { + if (item === null) { + window.localStorage.removeItem(key); + } else { + window.localStorage.setItem(key, JSON.stringify(item)); + } + }, + getItem(): TItem | null { + const item = window.localStorage.getItem(key); + try { + return item ? (JSON.parse(item) as TItem) : null; + } catch { + // handle potential JSON parsing errors, e.g., corrupted data + window.localStorage.removeItem(key); // clear corrupted data + return null; + } + }, + removeItem(): void { + window.localStorage.removeItem(key); + }, + }; +} From 9979d6e4d8b94cd5631cd2197dd0586cfa452d8f Mon Sep 17 00:00:00 2001 From: Dylan Tientcheu Date: Mon, 12 May 2025 23:01:44 +0200 Subject: [PATCH 4/6] fix: conversational flow --- packages/docsearch-css/src/modal.css | 2 +- packages/docsearch-react/src/AskAiScreen.tsx | 305 +++++++++++++----- .../docsearch-react/src/DocSearchModal.tsx | 45 +-- packages/docsearch-react/src/ScreenState.tsx | 4 +- packages/docsearch-react/src/SearchBox.tsx | 38 ++- .../docsearch-react/src/lib/genAiClient.ts | 7 +- packages/docsearch-react/src/useAskAi.ts | 34 +- 7 files changed, 292 insertions(+), 143 deletions(-) diff --git a/packages/docsearch-css/src/modal.css b/packages/docsearch-css/src/modal.css index 99cfb641f..9341fd14c 100644 --- a/packages/docsearch-css/src/modal.css +++ b/packages/docsearch-css/src/modal.css @@ -824,7 +824,7 @@ assistive tech users */ } .DocSearch-AskAiScreen-ThinkingDots { - font-size: 0.7em; + font-size: 0.8em; font-weight: 400; color: var(--docsearch-secondary-text-color); margin: 0; diff --git a/packages/docsearch-react/src/AskAiScreen.tsx b/packages/docsearch-react/src/AskAiScreen.tsx index 1204c25eb..81b896e9d 100644 --- a/packages/docsearch-react/src/AskAiScreen.tsx +++ b/packages/docsearch-react/src/AskAiScreen.tsx @@ -1,9 +1,26 @@ -import React, { type JSX, useState, useEffect } from 'react'; +import React, { type JSX, useState, useEffect, useMemo } from 'react'; +import { SparklesIcon } from './icons'; +import type { AskAiResponse } from './lib/genAiClient'; import { MemoizedMarkdown } from './MemoizedMarkdown'; import type { ScreenStateProps } from './ScreenState'; import type { InternalDocSearchHit } from './types'; +interface Message { + id: string; + role: 'assistant' | 'user'; + content: string; + context?: AskAiResponse['context']; +} + +type LoadingStatus = 'error' | 'idle' | 'loading' | 'streaming'; + +interface AskAiStateForScreen { + messages: Message[]; + loadingStatus: LoadingStatus; + error: Error | null; +} + export type AskAiScreenTranslations = Partial<{ titleText: string; disclaimerText: string; @@ -11,111 +28,223 @@ export type AskAiScreenTranslations = Partial<{ thinkingText: string; }>; -type AskAiScreenProps = Omit, 'translations'> & { +type AskAiScreenProps = Omit, 'askAiState' | 'translations'> & { + askAiState: AskAiStateForScreen; translations?: AskAiScreenTranslations; - conversationId?: string | null; }; -export function AskAiScreen({ translations = {}, ...props }: AskAiScreenProps): JSX.Element | null { - if (!props.askAiState) { +interface AskAiScreenHeaderProps { + disclaimerText: string; +} + +interface Exchange { + id: string; + userMessage: Message; + assistantMessage: Message | null; +} + +function AskAiScreenHeader({ disclaimerText }: AskAiScreenHeaderProps): JSX.Element { + return ( +
    +
    + +
    +

    {disclaimerText}

    +
    + ); +} + +interface AskAiExchangeCardProps { + exchange: Exchange; + isLastExchange: boolean; + loadingStatus: LoadingStatus; + error: Error | null; + translations: Required; + globalHasHadAssistantResponse: boolean; +} + +function AskAiExchangeCard({ + exchange, + isLastExchange, + loadingStatus, + error, + translations, + globalHasHadAssistantResponse, +}: AskAiExchangeCardProps): JSX.Element { + const { userMessage, assistantMessage } = exchange; + + const showLoadingIndicator = isLastExchange && loadingStatus === 'loading'; + const isStreaming = isLastExchange && loadingStatus === 'streaming'; + const showError = isLastExchange && loadingStatus === 'error' && error; + const showActions = !isLastExchange || (isLastExchange && loadingStatus === 'idle' && Boolean(assistantMessage)); + const contextToDisplay = assistantMessage?.context || []; + + return ( +
    +
    +
    +

    {userMessage.content}

    +
    +
    +
    + {showLoadingIndicator && ( +
    + +
    + )} + {(isStreaming || assistantMessage?.content) && ( + + )} + {showError &&

    {error.message}

    } +
    +
    +
    + +
    +
    + + {/* Sources for this exchange */} + +
    + ); +} + +interface AskAiScreenFooterActionsProps { + showActions: boolean; + latestAssistantMessageContent: string | null; +} + +function AskAiScreenFooterActions({ + showActions, + latestAssistantMessageContent, +}: AskAiScreenFooterActionsProps): JSX.Element | null { + if (!showActions || !latestAssistantMessageContent) { return null; } + return ( +
    + navigator.clipboard.writeText(latestAssistantMessageContent)} /> + + +
    + ); +} + +interface AskAiSourcesPanelProps { + contextToDisplay: AskAiResponse['context']; + loadingStatus: LoadingStatus; + relatedSourcesText: string; + hasHadAssistantResponse: boolean; + isExchangeLoading: boolean; +} +function AskAiSourcesPanel({ + contextToDisplay, + loadingStatus, + relatedSourcesText, + hasHadAssistantResponse, + isExchangeLoading, +}: AskAiSourcesPanelProps): JSX.Element { + return ( +
    +

    {relatedSourcesText}

    + {contextToDisplay.length > 0 && + contextToDisplay.map((source) => ( + + + {source.title || source.url || source.objectID} + + ))} + {contextToDisplay.length === 0 && + loadingStatus === 'loading' && + !hasHadAssistantResponse && + isExchangeLoading && + // eslint-disable-next-line react/no-array-index-key + Array.from({ length: 3 }).map((_, index) => )} + + {contextToDisplay.length === 0 && + (loadingStatus === 'idle' || loadingStatus === 'streaming') && + hasHadAssistantResponse && + !isExchangeLoading && ( +

    No related sources for the latest answer.

    + )} + {contextToDisplay.length === 0 && loadingStatus === 'error' && ( +

    Could not load related sources.

    + )} +
    + ); +} + +export function AskAiScreen({ translations = {}, ...props }: AskAiScreenProps): JSX.Element | null { const { disclaimerText = 'Answers are generated using artificial intelligence. This is an experimental technology, and information may occasionally be incorrect or misleading.', relatedSourcesText = 'Related Sources', thinkingText = 'Thinking', + titleText = 'Ask AI', } = translations; - const { messages, currentResponse, loadingStatus, context, error } = props.askAiState; + const finalTranslations: Required = { + titleText, + disclaimerText, + relatedSourcesText, + thinkingText, + }; + + const { messages, loadingStatus, error } = props.askAiState; - // determine the initial query to display - const displayedQuery = messages.find((m) => m.role === 'user')?.content || 'No query provided'; + // Group messages into exchanges (user + assistant pairs) + const exchanges: Exchange[] = useMemo(() => { + const grouped: Exchange[] = []; + for (let i = 0; i < messages.length; i++) { + if (messages[i].role === 'user') { + const userMessage = messages[i]; + const assistantMessage = messages[i + 1]?.role === 'assistant' ? messages[i + 1] : null; + grouped.push({ id: userMessage.id, userMessage, assistantMessage }); + if (assistantMessage) { + i++; + } + } + } + return grouped; + }, [messages]); - // select the content to display based on the status - const displayedAnswer = - loadingStatus === 'streaming' ? currentResponse : messages.find((m) => m.role === 'assistant')?.content || ''; + const globalHasHadAssistantResponse = messages.some((m) => m.role === 'assistant'); return (
    -
    -
    - - - - - - - -
    -

    {disclaimerText}

    -
    +
    -
    -
    -

    {displayedQuery}

    - {error &&

    {error.message}

    } -
    - {(loadingStatus === 'streaming' || loadingStatus === 'idle') && ( - - )} - {loadingStatus === 'loading' && ( -
    - -
    - )} -
    -
    - {loadingStatus === 'idle' && displayedAnswer.length > 0 && ( -
    - navigator.clipboard.writeText(displayedAnswer)} /> - - -
    - )} -
    -
    -
    -

    {relatedSourcesText}

    - {context.length === 0 && - loadingStatus === 'loading' && - // eslint-disable-next-line react/no-array-index-key - Array.from({ length: 3 }).map((_, index) => )} - {context.length > 0 && - context.map((source) => ( - - - {source.title || source.url || source.objectID} - - ))} - {context.length === 0 && loadingStatus === 'idle' && ( -

    No related sources found

    - )} - {context.length === 0 && loadingStatus === 'error' && ( -

    - Error loading related sources. Please try again. -

    - )} -
    +
    + {exchanges + .slice() + .reverse() + .map((exchange, index) => ( + + ))}
    diff --git a/packages/docsearch-react/src/DocSearchModal.tsx b/packages/docsearch-react/src/DocSearchModal.tsx index d0e2f18f5..2be38163f 100644 --- a/packages/docsearch-react/src/DocSearchModal.tsx +++ b/packages/docsearch-react/src/DocSearchModal.tsx @@ -339,15 +339,6 @@ export function DocSearchModal({ conversations, }); - const handleAskAiToggle = React.useCallback( - (toggle: boolean, query: string) => { - onAskAiToggle(toggle); - askAiState.ask?.({ query }); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [onAskAiToggle], - ); - const saveRecentSearch = React.useCallback( function saveRecentSearch(item: InternalDocSearchHit) { if (disableUserPersonalization) { @@ -396,17 +387,23 @@ export function DocSearchModal({ > >(undefined); + const handleAskAiToggle = React.useCallback( + (toggle: boolean, query: string) => { + onAskAiToggle(toggle); + // clear the query + if (autocompleteRef.current) { + autocompleteRef.current.setQuery(''); + } + askAiState.ask?.({ query }); + }, + [onAskAiToggle, askAiState], + ); + if (!autocompleteRef.current) { - autocompleteRef.current = createAutocomplete< - InternalDocSearchHit, - React.FormEvent, - React.MouseEvent, - React.KeyboardEvent - >({ + autocompleteRef.current = createAutocomplete({ id: 'docsearch', // we don't want to focus on the AskAI hit by default defaultActiveItemId: canHandleAskAi ? 1 : 0, - placeholder, openOnFocus: true, initialState: { query: initialQuery, @@ -608,12 +605,18 @@ export function DocSearchModal({ }; }, []); + // Refresh the autocomplete results when ask ai is toggled off + // helps return to the previous ac state and start screen + React.useEffect(() => { + if (!isAskAiActive) { + autocomplete.refresh(); + } + }, [isAskAiActive, autocomplete]); + return (
    { + handleAskAiToggle(true, query); + }} /> diff --git a/packages/docsearch-react/src/ScreenState.tsx b/packages/docsearch-react/src/ScreenState.tsx index a727dbb29..9e5cbbef4 100644 --- a/packages/docsearch-react/src/ScreenState.tsx +++ b/packages/docsearch-react/src/ScreenState.tsx @@ -46,8 +46,8 @@ export interface ScreenStateProps export const ScreenState = React.memo( ({ translations = {}, ...props }: ScreenStateProps) => { - if (props.isAskAiActive && props.canHandleAskAi) { - return ; + if (props.isAskAiActive && props.canHandleAskAi && props.askAiState) { + return ; } if (props.state?.status === 'error') { diff --git a/packages/docsearch-react/src/SearchBox.tsx b/packages/docsearch-react/src/SearchBox.tsx index cd3e602b2..f363e4f9d 100644 --- a/packages/docsearch-react/src/SearchBox.tsx +++ b/packages/docsearch-react/src/SearchBox.tsx @@ -25,6 +25,8 @@ interface SearchBoxProps inputRef: RefObject; onClose: () => void; onAskAiToggle: (toggle: boolean) => void; + onAskAgain: (query: string) => void; + placeholder: string; isAskAiActive: boolean; isFromSelection: boolean; translations?: SearchBoxTranslations; @@ -56,6 +58,32 @@ export function SearchBox({ translations = {}, ...props }: SearchBoxProps): JSX. } }, [props.isFromSelection, props.inputRef]); + const baseInputProps = props.getInputProps({ + inputElement: props.inputRef.current!, + autoFocus: props.autoFocus, + maxLength: MAX_QUERY_SIZE, + }); + + const blockedKeys = new Set(['ArrowUp', 'ArrowDown', 'Enter']); + const origOnKeyDown = baseInputProps.onKeyDown; + + const inputProps = { + ...baseInputProps, + onKeyDown: (e: React.KeyboardEvent): void => { + // block these up, down, enter listeners when AskAI is active + if (props.isAskAiActive && blockedKeys.has(e.key)) { + // enter key asks another question + if (e.key === 'Enter' && props.state.query) { + props.onAskAgain(props.state.query); + } + e.preventDefault(); + e.stopPropagation(); + return; + } + origOnKeyDown?.(e); + }, + }; + return ( <>
    - +
    diff --git a/packages/docsearch-css/src/modal.css b/packages/docsearch-css/src/modal.css index 9341fd14c..ec589b971 100644 --- a/packages/docsearch-css/src/modal.css +++ b/packages/docsearch-css/src/modal.css @@ -787,15 +787,14 @@ assistive tech users */ .DocSearch-AskAiScreen-Response-Container { display: flex; - flex-direction: row; - gap: 16px; + flex-direction: column; margin-bottom: 16px; } .DocSearch-AskAiScreen-Response { display: flex; flex-direction: column; - width: 70%; + width: 100%; gap: 16px; font-size: 0.8em; margin-bottom: 8px; @@ -884,7 +883,15 @@ assistive tech users */ .DocSearch-AskAiScreen-RelatedSources { display: flex; flex-direction: column; - width: 30%; + width: 100%; + gap: 4px; +} + +.DocSearch-AskAiScreen-RelatedSources-List { + display: flex; + flex-direction: row; + flex-wrap: wrap; + width: 100%; gap: 4px; } @@ -938,6 +945,12 @@ assistive tech users */ white-space: nowrap; } +.DocSearch-AskAiScreen-ExchangesList { + gap: 24px; + display: flex; + flex-direction: column; +} + .DocSearch-AskAiScreen-RelatedSources-Item-Link:hover { background: var(--docsearch-hit-highlight-color); } @@ -1117,7 +1130,6 @@ assistive tech users */ .DocSearch-AskAiScreen-Response-Container { flex-direction: column; - gap: 24px; } .DocSearch-AskAiScreen-Response { diff --git a/packages/docsearch-react/rollup.config-1747292357362.cjs b/packages/docsearch-react/rollup.config-1747292357362.cjs new file mode 100644 index 000000000..dabbb5224 --- /dev/null +++ b/packages/docsearch-react/rollup.config-1747292357362.cjs @@ -0,0 +1,81 @@ +'use strict'; + +Object.defineProperty(exports, '__esModule', { value: true }); + +var replace = require('@rollup/plugin-replace'); +var pluginBabel = require('@rollup/plugin-babel'); +var json = require('@rollup/plugin-json'); +var resolve = require('@rollup/plugin-node-resolve'); +var terser = require('@rollup/plugin-terser'); +var rollupPluginDts = require('rollup-plugin-dts'); +var filesize = require('rollup-plugin-filesize'); +var child_process = require('child_process'); +var pkg = require('./package.json'); + +const plugins = [ + replace({ + preventAssignment: true, + __DEV__: JSON.stringify(process.env.NODE_ENV === 'development'), + }), + json(), + resolve({ + extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'], + browser: true, + }), + pluginBabel.babel({ + babelHelpers: 'bundled', + exclude: 'node_modules/**', + extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'], + rootMode: 'upward', + }), + terser(), + filesize({ + showMinifiedSize: false, + showGzippedSize: true, + }), +]; + +const typesConfig = { + input: 'dist/esm/types/index.d.ts', + output: [{ file: 'dist/esm/index.d.ts', format: 'es' }], + plugins: [rollupPluginDts.dts()], +}; + +function getBundleBanner(pkg) { + const lastCommitHash = child_process.execSync('git rev-parse --short HEAD').toString().trim(); + const version = process.env.SHIPJS ? pkg.version : `${pkg.version} (UNRELEASED ${lastCommitHash})`; + const authors = '© Algolia, Inc. and contributors'; + + return `/*! ${pkg.name} ${version} | MIT License | ${authors} | ${pkg.homepage} */`; +} + +var rollup_config = [ + { + input: 'src/index.ts', + external: ['react', 'react-dom'], + output: [ + { + globals: { + react: 'React', + 'react-dom': 'ReactDOM', + }, + file: 'dist/umd/index.js', + format: 'umd', + sourcemap: true, + name: pkg.name, + banner: getBundleBanner(pkg), + }, + { dir: 'dist/esm', format: 'es' }, + ], + plugins: [ + ...plugins, + replace({ + preventAssignment: true, + 'process.env.NODE_ENV': JSON.stringify('production'), + }), + ], + }, + typesConfig, +]; + +exports.default = rollup_config; diff --git a/packages/docsearch-react/src/AskAiScreen.tsx b/packages/docsearch-react/src/AskAiScreen.tsx index 81b896e9d..f15b57f22 100644 --- a/packages/docsearch-react/src/AskAiScreen.tsx +++ b/packages/docsearch-react/src/AskAiScreen.tsx @@ -10,6 +10,7 @@ interface Message { id: string; role: 'assistant' | 'user'; content: string; + urls?: Array<{ url: string; title?: string }>; context?: AskAiResponse['context']; } @@ -77,7 +78,7 @@ function AskAiExchangeCard({ const isStreaming = isLastExchange && loadingStatus === 'streaming'; const showError = isLastExchange && loadingStatus === 'error' && error; const showActions = !isLastExchange || (isLastExchange && loadingStatus === 'idle' && Boolean(assistantMessage)); - const contextToDisplay = assistantMessage?.context || []; + const urlsToDisplay = assistantMessage?.urls || []; return (
    @@ -110,13 +111,20 @@ function AskAiExchangeCard({
    {/* Sources for this exchange */} - + {urlsToDisplay.length > 0 || + (urlsToDisplay.length === 0 && + loadingStatus === 'loading' && + !globalHasHadAssistantResponse && + isLastExchange && + (loadingStatus === 'loading' || loadingStatus === 'streaming')) ? ( + + ) : null}
    ); } @@ -143,7 +151,7 @@ function AskAiScreenFooterActions({ } interface AskAiSourcesPanelProps { - contextToDisplay: AskAiResponse['context']; + urlsToDisplay: Array<{ url: string; title?: string }>; loadingStatus: LoadingStatus; relatedSourcesText: string; hasHadAssistantResponse: boolean; @@ -151,7 +159,7 @@ interface AskAiSourcesPanelProps { } function AskAiSourcesPanel({ - contextToDisplay, + urlsToDisplay, loadingStatus, relatedSourcesText, hasHadAssistantResponse, @@ -160,32 +168,36 @@ function AskAiSourcesPanel({ return (

    {relatedSourcesText}

    - {contextToDisplay.length > 0 && - contextToDisplay.map((source) => ( - - - {source.title || source.url || source.objectID} - - ))} - {contextToDisplay.length === 0 && +
    + {urlsToDisplay.length > 0 && + urlsToDisplay.map((link) => ( + + + {link.title || link.url} + + ))} +
    + {urlsToDisplay.length === 0 && loadingStatus === 'loading' && !hasHadAssistantResponse && isExchangeLoading && // eslint-disable-next-line react/no-array-index-key Array.from({ length: 3 }).map((_, index) => )} - {contextToDisplay.length === 0 && + {urlsToDisplay.length === 0 && (loadingStatus === 'idle' || loadingStatus === 'streaming') && hasHadAssistantResponse && !isExchangeLoading && ( -

    No related sources for the latest answer.

    +

    no related sources for the latest answer.

    )} - {contextToDisplay.length === 0 && loadingStatus === 'error' && ( -

    Could not load related sources.

    + {urlsToDisplay.length === 0 && loadingStatus === 'error' && ( +

    could not load related sources.

    )}
    ); diff --git a/packages/docsearch-react/src/useAskAi.ts b/packages/docsearch-react/src/useAskAi.ts index 16ccd8297..046c3bd80 100644 --- a/packages/docsearch-react/src/useAskAi.ts +++ b/packages/docsearch-react/src/useAskAi.ts @@ -11,6 +11,7 @@ interface Message { role: 'assistant' | 'user'; content: string; context?: AskAiResponse['context']; + urls?: Array<{ url: string; title?: string }>; } export interface AskAiState { @@ -96,7 +97,7 @@ export function useAskAi({ genAiClient, conversations }: UseAskAiParams): AskAiS messages: [ ...(prevState.conversationId ? prevState.messages : []), // keep history if we have a conversationId { id: userMessageId, role: 'user', content: query }, - { id: assistantMessageId, role: 'assistant', content: '', context: [] }, + { id: assistantMessageId, role: 'assistant', content: '', context: [], urls: [] }, ], loadingStatus: 'loading', query, @@ -112,7 +113,14 @@ export function useAskAi({ genAiClient, conversations }: UseAskAiParams): AskAiS setState((prevState) => ({ ...prevState, messages: prevState.messages.map((m) => - m.id === assistantMessageId ? { ...m, content: chunk.response, context: chunk.context } : m, + m.id === assistantMessageId + ? { + ...m, + content: chunk.response, + context: chunk.context, + urls: extractLinksFromText(chunk.response), + } + : m, ), loadingStatus: 'streaming', })); @@ -215,3 +223,37 @@ export function useGenAiClient( return genAiClient; } + +// utility to extract links (markdown and bare urls) from a string +function extractLinksFromText(text: string): Array<{ url: string; title?: string }> { + // match [title](url) and bare urls + const markdownLinkRegex = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g; + const urlRegex = /https?:\/\/[^\s)]+/g; + const links: Array<{ url: string; title?: string }> = []; + const seen = new Set(); + + // extract markdown links first + let match; + while ((match = markdownLinkRegex.exec(text)) !== null) { + let url = match[2]; + const title = match[1]; + // trim trailing punctuation + url = url.replace(/[).,;!?]+$/, ''); + if (!seen.has(url)) { + links.push({ url, title }); + seen.add(url); + } + } + + // extract bare urls + while ((match = urlRegex.exec(text)) !== null) { + let url = match[0]; + url = url.replace(/[).,;!?]+$/, ''); + if (!seen.has(url)) { + links.push({ url }); + seen.add(url); + } + } + + return links; +}