1
0
Fork 0

feat(v4): ask ai context

This commit is contained in:
Dylan Tientcheu 2025-04-29 11:09:17 +02:00
parent 5ab4d3d215
commit 57414e85f2
14 changed files with 1029 additions and 43 deletions

View file

@ -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}
/>

View file

@ -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);

View file

@ -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%;
}
}

View file

@ -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",

View file

@ -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<ScreenStateProps<InternalDocSearchHit>, '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 (
<div className="DocSearch-AskAiScreen">
<div className="DocSearch-Screen-Icon">
<svg
xmlns="http://www.w3.org/2000/svg"
width="140"
height="140"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="0.75"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-sparkles-icon lucide-sparkles"
>
<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z" />
<path d="M20 3v4" />
<path d="M22 5h-4" />
<path d="M4 17v2" />
<path d="M5 18H3" />
</svg>
<div className="DocSearch-AskAiScreen DocSearch-AskAiScreen-Container">
<div className="DocSearch-AskAiScreen-Header">
<div className="DocSearch-Screen-Icon">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-sparkles-icon lucide-sparkles"
>
<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z" />
<path d="M20 3v4" />
<path d="M22 5h-4" />
<path d="M4 17v2" />
<path d="M5 18H3" />
</svg>
</div>
<p className="DocSearch-AskAi-Disclaimer">{disclaimerText}</p>
</div>
<div className="DocSearch-AskAiScreen-Body">
<div className="DocSearch-AskAiScreen-Response-Container">
<div className="DocSearch-AskAiScreen-Response">
<p className="DocSearch-AskAiScreen-Query">{displayedQuery}</p>
{error && <p className="DocSearch-AskAiScreen-Error">{error.message}</p>}
<div
className={`DocSearch-AskAiScreen-Answer ${
loadingStatus === 'streaming' ? 'DocSearch-AskAiScreen-Answer--streaming' : ''
}`}
>
{(loadingStatus === 'streaming' || loadingStatus === 'idle') && (
<MemoizedMarkdown content={displayedAnswer} id="ask-ai-answer" />
)}
{loadingStatus === 'loading' && (
<div className="DocSearch-AskAiScreen-Streaming-Loader">
<PulseLoader />
</div>
)}
</div>
<div className="DocSearch-AskAiScreen-Answer-Footer">
{loadingStatus === 'idle' && displayedAnswer.length > 0 && (
<div className="DocSearch-AskAiScreen-Actions">
<CopyButton onClick={() => navigator.clipboard.writeText(displayedAnswer)} />
<LikeButton />
<DislikeButton />
</div>
)}
</div>
</div>
<div className="DocSearch-AskAiScreen-RelatedSources">
<p className="DocSearch-AskAiScreen-RelatedSources-Title">{relatedSourcesText}</p>
{context.length === 0 &&
loadingStatus === 'loading' &&
// eslint-disable-next-line react/no-array-index-key
Array.from({ length: 3 }).map((_, index) => <SkeletonSource key={index} />)}
{context.length > 0 &&
context.map((source) => (
<a
key={source.objectID}
href={source.url || source.objectID || '#'}
className="DocSearch-AskAiScreen-RelatedSources-Item-Link"
>
<RelatedSourceIcon />
<span>{source.title || source.url || source.objectID}</span>
</a>
))}
</div>
</div>
</div>
<p className="DocSearch-Title">{titleText}</p>
<p className="DocSearch-Help">{helpText}</p>
</div>
);
}
function SkeletonSource(): JSX.Element {
return (
<div className="DocSearch-AskAiScreen-SkeletonSource">
<RelatedSourceIcon />
<div className="DocSearch-AskAiScreen-SkeletonSource-Text" />
</div>
);
}
function RelatedSourceIcon(): JSX.Element {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="4" x2="20" y1="9" y2="9" />
<line x1="4" x2="20" y1="15" y2="15" />
<line x1="10" x2="8" y1="3" y2="21" />
<line x1="16" x2="14" y1="3" y2="21" />
</svg>
);
}
function PulseLoader(): JSX.Element {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="0" fill="currentColor">
<animate
id="svgSpinnersPulseMultiple0"
fill="freeze"
attributeName="r"
begin="0;svgSpinnersPulseMultiple2.end"
calcMode="spline"
dur="1.2s"
keySplines=".52,.6,.25,.99"
values="0;11"
></animate>
<animate
fill="freeze"
attributeName="opacity"
begin="0;svgSpinnersPulseMultiple2.end"
calcMode="spline"
dur="1.2s"
keySplines=".52,.6,.25,.99"
values="1;0"
></animate>
</circle>
<circle cx="12" cy="12" r="0" fill="currentColor">
<animate
id="svgSpinnersPulseMultiple1"
fill="freeze"
attributeName="r"
begin="svgSpinnersPulseMultiple0.begin+0.2s"
calcMode="spline"
dur="1.2s"
keySplines=".52,.6,.25,.99"
values="0;11"
></animate>
<animate
fill="freeze"
attributeName="opacity"
begin="svgSpinnersPulseMultiple0.begin+0.2s"
calcMode="spline"
dur="1.2s"
keySplines=".52,.6,.25,.99"
values="1;0"
></animate>
</circle>
<circle cx="12" cy="12" r="0" fill="currentColor">
<animate
id="svgSpinnersPulseMultiple2"
fill="freeze"
attributeName="r"
begin="svgSpinnersPulseMultiple0.begin+0.4s"
calcMode="spline"
dur="1.2s"
keySplines=".52,.6,.25,.99"
values="0;11"
></animate>
<animate
fill="freeze"
attributeName="opacity"
begin="svgSpinnersPulseMultiple0.begin+0.4s"
calcMode="spline"
dur="1.2s"
keySplines=".52,.6,.25,.99"
values="1;0"
></animate>
</circle>
</svg>
);
}
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 (
<button
type="button"
className={`DocSearch-AskAiScreen-ActionButton DocSearch-AskAiScreen-CopyButton ${
isCopied ? 'DocSearch-AskAiScreen-CopyButton--copied' : ''
}`}
disabled={isCopied} // disable button briefly after copy
onClick={handleClick}
>
{isCopied ? (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-check-icon lucide-check"
>
<path d="M20 6 9 17l-5-5" />
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-copy-icon lucide-copy"
>
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</svg>
)}
</button>
);
}
function LikeButton(): JSX.Element {
// @todo: implement like button
return (
<button type="button" className="DocSearch-AskAiScreen-ActionButton DocSearch-AskAiScreen-LikeButton">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-thumbs-up-icon lucide-thumbs-up"
>
<path d="M7 10v12" />
<path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z" />
</svg>
</button>
);
}
function DislikeButton(): JSX.Element {
// @todo: implement dislike button
return (
<button type="button" className="DocSearch-AskAiScreen-ActionButton DocSearch-AskAiScreen-DislikeButton">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-thumbs-down-icon lucide-thumbs-down"
>
<path d="M17 14V2" />
<path d="M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88Z" />
</svg>
</button>
);
}

View file

@ -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<string | undefined>(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(
<DocSearchModal
{...props}
placeholder={currentPlaceholder}
initialScrollY={window.scrollY}
initialQuery={initialQuery}
translations={props?.translations?.modal}

View file

@ -18,6 +18,7 @@ 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 { useSearchClient } from './useSearchClient';
import { useTouchEvents } from './useTouchEvents';
import { useTrapFocus } from './useTrapFocus';
@ -245,8 +246,10 @@ const buildQuerySources = async ({
export function DocSearchModal({
appId,
apiKey,
dataSourceId,
promptId,
indexName,
placeholder = 'Search docs',
placeholder,
searchParameters,
maxResultsPerGroup,
onClose = noop,
@ -289,6 +292,10 @@ export function DocSearchModal({
const initialQuery = React.useRef(initialQueryFromProp || initialQueryFromSelection).current;
const searchClient = useSearchClient(appId, apiKey, transformSearchClient);
const genAiClient = useGenAiClient(appId, apiKey, {
dataSourceId,
promptId,
});
const favoriteSearches = React.useRef(
createStoredSearches<StoredDocSearchHit>({
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

View file

@ -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 }) => (
<div dangerouslySetInnerHTML={{ __html: html }} key={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 (
<div className="DocSearch-Markdown-Content">
{htmlBlocks.map((html, i) => (
// eslint-disable-next-line react/no-array-index-key
<MemoizedHTMLBlock key={`${id}-block-${i}`} html={html} />
))}
</div>
);
});
MemoizedMarkdown.displayName = 'MemoizedMarkdown';

View file

@ -25,7 +25,7 @@ export function NoResultsScreen({ translations = {}, ...props }: NoResultsScreen
const searchSuggestions: string[] | undefined = props.state.context.searchSuggestions as string[];
return (
<div className="DocSearch-NoResults">
<div className={`DocSearch-NoResults ${props.canHandleAskAi ? 'DocSearch-NoResults--withAskAi' : ''}`}>
<div className="DocSearch-Screen-Icon">
<NoResultsIcon />
</div>

View file

@ -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<TItem extends BaseItem>
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<TItem extends BaseItem>
export const ScreenState = React.memo(
({ translations = {}, ...props }: ScreenStateProps<InternalDocSearchHit>) => {
if (props.isAskAiActive && props.canHandleAskAi) {
return <AskAiScreen translations={translations?.askAiScreen} />;
return <AskAiScreen {...props} translations={translations?.askAiScreen} />;
}
if (props.state?.status === 'error') {

View file

@ -11,6 +11,8 @@ export type SearchBoxTranslations = Partial<{
clearButtonAriaLabel: string;
closeButtonText: string;
closeButtonAriaLabel: string;
placeholderText: string;
placeholderTextAskAi: string;
searchInputLabel: string;
backToKeywordSearchButtonText: string;
backToKeywordSearchButtonAriaLabel: string;

View file

@ -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<string, any>;
}
export interface GenAiClientOptions {
dataSourceId?: string;
promptId?: string;
}
export interface GenAiClient {
appId: string;
apiKey: string;
dataSourceId?: string;
promptId?: string;
fetchAskAiResponse: (params: Omit<FetchAskAiResponseParams, 'genAiClient'>) => Promise<AskAiResponse>;
}
const BASE_URL = 'https://generative-ai.algolia.com';
export function algoliaGenAiToolkit(appId: string, apiKey: string, options: GenAiClientOptions): GenAiClient {
const client: Omit<GenAiClient, 'fetchAskAiResponse'> = {
appId,
apiKey,
...options,
};
return {
...client,
fetchAskAiResponse: (params) =>
fetchAskAiResponseFunction({
...params,
genAiClient: client as GenAiClient,
}),
};
}
export interface FetchAskAiResponseParams {
query: string;
genAiClient: GenAiClient;
additionalFilters?: Record<string, any>;
onUpdate: (chunk: AskAiResponse) => void;
onComplete?: () => void;
onError?: (error: Error) => void;
}
async function fetchAskAiResponseFunction({
query,
genAiClient,
additionalFilters,
onUpdate,
onComplete,
onError,
}: FetchAskAiResponseParams): Promise<AskAiResponse> {
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;
}
}

View file

@ -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<string, any>;
}
interface UseAskAiReturn {
messages: Message[];
currentResponse: string;
additionalFilters: string[];
context: AskAiResponse['context'];
conversationID: string | null;
loadingStatus: LoadingStatus;
error: Error | null;
ask: (params: AskParams) => Promise<void>;
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<UseAskAiState>(
() => ({
messages: [],
currentResponse: '',
additionalFilters: [],
context: [],
conversationID: null,
loadingStatus: 'idle',
error: null,
}),
[],
);
const [state, setState] = useState<UseAskAiState>(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;
}

View file

@ -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"