From 57414e85f28fd5ab420f0bef757edb878de8f096 Mon Sep 17 00:00:00 2001 From: Dylan Tientcheu Date: Tue, 29 Apr 2025 11:09:17 +0200 Subject: [PATCH] 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 | 343 ++++++++++++++++-- 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, 1029 insertions(+), 43 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 155ca0b0..b4e35fb4 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 ce6d17a1..9e11b773 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 9f32181e..7b99ccde 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 2a923f24..825bdec8 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 8eab794c..2be406a2 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} + + ))} +
+
-

{titleText}

-

{helpText}

); } + +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 ( + + ); +} + +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 917247f2..beae74f4 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 00000000..b8b0c155 --- /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 5068b032..90bfec3d 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 f0f2bbaa..0e710ba9 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 ae236883..cd3e602b 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 00000000..824deada --- /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 e69de29b..5bc6cdb0 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 17d1f5fc..10460f5b 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"