feat(v4): ask ai context
This commit is contained in:
parent
5ab4d3d215
commit
57414e85f2
14 changed files with 1029 additions and 43 deletions
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
33
packages/docsearch-react/src/MemoizedMarkdown.tsx
Normal file
33
packages/docsearch-react/src/MemoizedMarkdown.tsx
Normal 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';
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ export type SearchBoxTranslations = Partial<{
|
|||
clearButtonAriaLabel: string;
|
||||
closeButtonText: string;
|
||||
closeButtonAriaLabel: string;
|
||||
placeholderText: string;
|
||||
placeholderTextAskAi: string;
|
||||
searchInputLabel: string;
|
||||
backToKeywordSearchButtonText: string;
|
||||
backToKeywordSearchButtonAriaLabel: string;
|
||||
|
|
|
|||
158
packages/docsearch-react/src/lib/genAiClient.ts
Normal file
158
packages/docsearch-react/src/lib/genAiClient.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
10
yarn.lock
10
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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue